Files
autodesk/tools/plugin-usage/Build-Plant3DPluginUsageAggregate.ps1
T

40 lines
23 KiB
PowerShell
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
param(
[string]$SourceRoot = 'C:\dev\TEMP\CheckPlugin',
[string]$SheetName = 'Plant3D',
[string]$OutputDirectory = 'C:\dev\autodesk\migration\audits\plugin-usage-plant3d-aggregate-20260731'
)
$ErrorActionPreference='Stop'; Add-Type -AssemblyName System.IO.Compression.FileSystem
$mainNs='http://schemas.openxmlformats.org/spreadsheetml/2006/main';$relNs='http://schemas.openxmlformats.org/officeDocument/2006/relationships';$pkgNs='http://schemas.openxmlformats.org/package/2006/relationships';$utf8=New-Object Text.UTF8Encoding($false);$utf8Bom=New-Object Text.UTF8Encoding($true)
New-Item -ItemType Directory -Force -Path (Join-Path $OutputDirectory 'evidence')|Out-Null
function XmlEntry($z,$name){$e=$z.GetEntry($name.TrimStart('/'));if(!$e){return $null};$r=[IO.StreamReader]::new($e.Open());try{return [xml]$r.ReadToEnd()}finally{$r.Dispose()}}
function Col($ref){$s=([regex]::Match($ref,'^[A-Z]+')).Value;$n=0;foreach($x in $s.ToCharArray()){$n=$n*26+([int][char]$x-64)};$n}
function HNorm($v){if($null -eq $v){return ''};(($v.Trim().ToLowerInvariant())-replace '[\s_\-\.]+','')}
function PNorm($v){if($null -eq $v){return ''};(($v.Trim()-replace '(?i)\.(dll|exe)$','').Trim().ToUpperInvariant())}
function Shared($z){$x=XmlEntry $z 'xl/sharedStrings.xml';$a=[Collections.Generic.List[string]]::new();if(!$x){return $a};$n=New-Object Xml.XmlNamespaceManager($x.NameTable);$n.AddNamespace('m',$mainNs);foreach($i in $x.SelectNodes('//m:si',$n)){$a.Add((($i.SelectNodes('.//m:t',$n)|% InnerText)-join ''))};$a}
function CellText($c,$n,$ss){if(!$c){return ''};$i=$c.SelectSingleNode('./m:is',$n);if($i){return (($i.SelectNodes('.//m:t',$n)|% InnerText)-join '')};$v=$c.SelectSingleNode('./m:v',$n);if(!$v){return ''};$s=$v.InnerText;if([string]$c.t -eq 's'){$j=0;if([int]::TryParse($s,[ref]$j)-and $j-lt $ss.Count){return $ss[$j]}};$s}
function DateStyles($z){$h=[Collections.Generic.HashSet[int]]::new();14..22|%{[void]$h.Add($_)};$x=XmlEntry $z 'xl/styles.xml';if(!$x){return $h};$n=New-Object Xml.XmlNamespaceManager($x.NameTable);$n.AddNamespace('m',$mainNs);$custom=@{};foreach($f in $x.SelectNodes('//m:numFmts/m:numFmt',$n)){$custom[[int]$f.numFmtId]=[string]$f.formatCode};$i=0;foreach($xf in $x.SelectNodes('//m:cellXfs/m:xf',$n)){$id=[int]$xf.numFmtId;if(($custom.ContainsKey($id)-and $custom[$id]-match 'yy|yyyy|mm|dd|hh|ss')-or($id-ge 14-and $id-le 22)){[void]$h.Add($i)};$i++};$h}
function DateValue($text,$numeric,$style,$styles){if([string]::IsNullOrWhiteSpace($text)){return $null};if($numeric-and $styles.Contains($style)){$d=0.0;if([double]::TryParse($text,[Globalization.NumberStyles]::Float,[Globalization.CultureInfo]::InvariantCulture,[ref]$d)){return [pscustomobject]@{Date=[DateTime]::FromOADate($d);Rule='EXCEL_SERIAL_DATE'}}};$v=($text.Trim()-replace '[Tt]',' '-replace '\s+',' ');$fmts='dd.MM.yyyy HH:mm:ss','dd.MM.yyyy HH:mm','dd.MM.yyyy','yyyy-MM-dd HH:mm:ss','yyyy-MM-dd','yyyy MM dd HH:mm:ss','yyyy MM dd','dd/MM/yyyy HH:mm:ss','dd/MM/yyyy';foreach($f in $fmts){$d=[DateTime]::MinValue;if([DateTime]::TryParseExact($v,$f,[Globalization.CultureInfo]::InvariantCulture,[Globalization.DateTimeStyles]::AllowWhiteSpaces,[ref]$d)){return [pscustomobject]@{Date=$d;Rule=if($f-match 'H|s'){'TEXT_DATETIME'}else{'DATE_ONLY'}}}};$d=[DateTime]::MinValue;if([DateTime]::TryParse($v,[Globalization.CultureInfo]::GetCultureInfo('ru-RU'),[Globalization.DateTimeStyles]::AllowWhiteSpaces,[ref]$d)){return [pscustomobject]@{Date=$d;Rule=if($v-match ':'){'TEXT_DATETIME'}else{'DATE_ONLY'}}};$null}
function Sheet($z,$wanted){$w=XmlEntry $z 'xl/workbook.xml';$r=XmlEntry $z 'xl/_rels/workbook.xml.rels';$n=New-Object Xml.XmlNamespaceManager($w.NameTable);$n.AddNamespace('m',$mainNs);$n.AddNamespace('r',$relNs);$rn=New-Object Xml.XmlNamespaceManager($r.NameTable);$rn.AddNamespace('p',$pkgNs);foreach($s in $w.SelectNodes('//m:sheet',$n)){if(([string]$s.name).Trim().ToLowerInvariant() -eq $wanted.Trim().ToLowerInvariant()){$id=$s.GetAttribute('id',$relNs);$rel=$r.SelectSingleNode("//p:Relationship[@Id='$id']",$rn);$t=([string]$rel.Target).TrimStart('/');if($t-notmatch '^xl/'){$t='xl/'+$t};return [pscustomobject]@{Name=[string]$s.name;Target=$t}}};$null}
function Rows($z,$target,$ss,$styles){$x=XmlEntry $z $target;$n=New-Object Xml.XmlNamespaceManager($x.NameTable);$n.AddNamespace('m',$mainNs);$out=[Collections.Generic.List[object]]::new();foreach($row in $x.SelectNodes('//m:sheetData/m:row',$n)){$cells=@{};foreach($c in $row.SelectNodes('./m:c',$n)){$col=Col ([string]$c.r);$text=CellText $c $n $ss;$num=([string]$c.t -eq ''-or[string]$c.t-eq'n');$st=if([string]$c.s){[int]$c.s}else{0};$cells[$col]=[pscustomobject]@{Text=$text;Date=DateValue $text $num $st $styles;Formula=[string]$c.f}};$out.Add([pscustomobject]@{Number=[int]$row.r;Cells=$cells})};$out}
function C($row,$col){if($row.Cells.ContainsKey($col)){return $row.Cells[$col].Text};''}
function Header($rows){$pa=@('плагин','plugin','pluginname','module','command');$da=@('дата','date','datetime','timestamp','launchdate','usage_date');$la=@('количествозапусков','launchcount','count','usagecount','runs');foreach($row in $rows|select -First 30){$m=@{};foreach($k in $row.Cells.Keys){$m[(HNorm (C $row $k))]=[int]$k};$p=$pa|?{$m.ContainsKey($_)}|select -First 1;$d=$da|?{$m.ContainsKey($_)}|select -First 1;$l=$la|?{$m.ContainsKey($_)}|select -First 1;if($p-and$d-and$l){return [pscustomobject]@{Row=$row;Plugin=$m[$p];Date=$m[$d];Count=$m[$l];Rule='HEADER_ALIASES'}}};$null}
function Hash($p){(Get-FileHash -LiteralPath $p -Algorithm SHA256).Hash}
function RowHash($id,$row,$p,$d,$c){$s=[Security.Cryptography.SHA256]::Create();try{([BitConverter]::ToString($s.ComputeHash([Text.Encoding]::UTF8.GetBytes("$id|$row|$p|$d|$c")))-replace '-','')}finally{$s.Dispose()}}
function Csv($rows,$path,$headers){if(@($rows).Count){$rows|Export-Csv -LiteralPath $path -NoTypeInformation -Encoding UTF8}else{[IO.File]::WriteAllText($path,($headers-join ',')+"`r`n",$utf8Bom)}}
$files=@(Get-ChildItem -LiteralPath $SourceRoot -Recurse -File -Filter '*.xlsx'|? Name -notlike '~$*'|Sort FullName);$ignore=@(Get-ChildItem -LiteralPath $SourceRoot -Recurse -File -Filter '~$*.xlsx' -ErrorAction SilentlyContinue).Count;$fileRows=[Collections.Generic.List[object]]::new();$dups=[Collections.Generic.List[object]]::new();$reject=[Collections.Generic.List[object]]::new();$raw=[Collections.Generic.List[object]]::new();$idx=0;$rawRows=0;$validPlugin=0;$validDate=0;$validCount=0;$computerPropertyHits=0
$propertyNames='ComputerName','MachineName','HostName';foreach($f in $files){$z=[IO.Compression.ZipFile]::OpenRead($f.FullName);try{foreach($part in 'docProps/core.xml','docProps/custom.xml','docProps/app.xml'){$e=$z.GetEntry($part);if($e){$r=[IO.StreamReader]::new($e.Open());$text=$r.ReadToEnd();$r.Dispose();foreach($name in $propertyNames){if($text -match ('(?i)'+$name)){$computerPropertyHits++}}}}}finally{$z.Dispose()}}
$byHash=@{};foreach($f in $files){$idx++;$id='source-{0:D4}.xlsx'-f $idx;$h=Hash $f.FullName;if(!$byHash.ContainsKey($h)){$byHash[$h]=[pscustomobject]@{File=$f;Id=$id;Hash=$h;Canonical=$true}}else{$dups.Add([pscustomobject]@{SourceId=$id;CanonicalSourceId=$byHash[$h].Id;SHA256=$h;Status='DUPLICATE_WORKBOOK'})}}
$idx=0;foreach($f in $files){$idx++;$id='source-{0:D4}.xlsx'-f $idx;$h=Hash $f.FullName;$canonical=($byHash[$h].Id -eq $id);$before=Get-Item -LiteralPath $f.FullName;$plant=$false;$sheetActual='';$rowCount=0;$colCount=0;$result='DUPLICATE_WORKBOOK';$rejBefore=$reject.Count;$headerText='';if(!$canonical){$fileRows.Add([pscustomobject]@{SourceId=$id;SHA256=$h;HasPlant3DSheet='UNKNOWN_DUPLICATE';ActualSheetName='';RowCount=0;ColumnCount=0;Headers='';ParseResult='DUPLICATE_WORKBOOK';RejectedRows=0});continue};try{$z=[IO.Compression.ZipFile]::OpenRead($f.FullName);try{$sh=Sheet $z $SheetName;if(!$sh){$reject.Add([pscustomobject]@{SourceId=$id;SourceSheet='';SourceRow='';ReasonCode='SHEET_NOT_FOUND';DetectedPlugin='';DetectedDate='';DetectedLaunchCount='';RowHash=(RowHash $id 0 '' '' '')});$result='SHEET_NOT_FOUND';$fileRows.Add([pscustomobject]@{SourceId=$id;SHA256=$h;HasPlant3DSheet=$false;ActualSheetName='';RowCount=0;ColumnCount=0;Headers='';ParseResult=$result;RejectedRows=1});continue};$plant=$true;$sheetActual=$sh.Name;$ss=Shared $z;$styles=DateStyles $z;$rr=Rows $z $sh.Target $ss $styles;$rowCount=$rr.Count;$colCount=if($rr.Count){($rr|%{if($_.Cells.Count){($_.Cells.Keys|measure -Maximum).Maximum}else{0}}|measure -Maximum).Maximum}else{0};$hd=Header $rr;if(!$hd){$reject.Add([pscustomobject]@{SourceId=$id;SourceSheet=$sheetActual;SourceRow='';ReasonCode='HEADER_NOT_FOUND';DetectedPlugin='';DetectedDate='';DetectedLaunchCount='';RowHash=(RowHash $id 0 '' '' '')});$result='HEADER_NOT_FOUND';continue};$headerText=(($hd.Row.Cells.Keys|Sort|%{C $hd.Row $_})-join ';');foreach($row in $rr|? Number -gt $hd.Row.Number){$plugin=(C $row $hd.Plugin).Trim();$dateText=(C $row $hd.Date).Trim();$countText=(C $row $hd.Count).Trim();if(!$plugin-and !$dateText-and !$countText){continue};$rawRows++;$pNorm=PNorm $plugin;$di=$row.Cells[$hd.Date].Date;$num=0.0;$countOk=[double]::TryParse(($countText-replace ',','.'),[Globalization.NumberStyles]::Float,[Globalization.CultureInfo]::InvariantCulture,[ref]$num);$reason='';if(!$plugin){$reason='PLUGIN_NOT_FOUND'}else{$validPlugin++};if(!$dateText){$reason=if($reason){'AMBIGUOUS_RECORD'}else{'DATE_NOT_FOUND'}}elseif(!$di){$reason=if($reason){'AMBIGUOUS_RECORD'}else{'INVALID_DATE'}}else{$validDate++};if(!$countText){$reason=if($reason){'AMBIGUOUS_RECORD'}else{'INVALID_LAUNCH_COUNT'}}elseif(!$countOk-or [double]::IsNaN($num)-or [double]::IsInfinity($num)-or $num-lt 0-or $num-ne [math]::Truncate($num)){$reason=if($reason){'AMBIGUOUS_RECORD'}else{'INVALID_LAUNCH_COUNT'}}else{$validCount++};if($reason){$reject.Add([pscustomobject]@{SourceId=$id;SourceSheet=$sheetActual;SourceRow=$row.Number;ReasonCode=$reason;DetectedPlugin=$plugin;DetectedDateTime=$dateText;DetectedLaunchCount=$countText;RowHash=(RowHash $id $row.Number $plugin $dateText $countText)});continue};$d=$di.Date;$raw.Add([pscustomobject]@{PluginName=$plugin;PluginNameNormalized=$pNorm;UsageDate=$d.ToString('yyyy-MM-dd');UsageMonth=$d.ToString('yyyy-MM');UsageYear=$d.ToString('yyyy');LaunchCount=[int64]$num;SourceId=$id;SourceRow=$row.Number;ParseRule=$di.Rule})}}finally{$z.Dispose()};$result='PARSED'}catch{$reject.Add([pscustomobject]@{SourceId=$id;SourceSheet=$sheetActual;SourceRow='';ReasonCode='READ_ERROR';DetectedPlugin='';DetectedDateTime='';DetectedLaunchCount='';RowHash=(RowHash $id 0 '' '' '')});$result='READ_ERROR'}$after=Get-Item -LiteralPath $f.FullName;if($before.Length-ne $after.Length-or $before.LastWriteTimeUtc-ne $after.LastWriteTimeUtc-or(Hash $f.FullName)-ne $h){throw "Source modified: $id"};$fileRows.Add([pscustomobject]@{SourceId=$id;SHA256=$h;HasPlant3DSheet=$plant;ActualSheetName=$sheetActual;RowCount=$rowCount;ColumnCount=$colCount;Headers=$headerText;ParseResult=$result;RejectedRows=($reject.Count-$rejBefore)})}
$dupCandidates=@($raw|Group-Object PluginNameNormalized,UsageDate,LaunchCount|? Count -gt 1);$plugin=[Collections.Generic.List[object]]::new();foreach($g in ($raw|Group PluginNameNormalized)){$a=@($g.Group);$byDay=@($a|Group UsageDate);$byMonth=@($a|Group UsageMonth);$month=$byMonth|Sort Count -Descending|Select -First 1;$plugin.Add([pscustomobject]@{PluginName=$a[0].PluginName;PluginNameNormalized=$g.Name;RecordCount=$a.Count;TotalLaunches=(($a.LaunchCount|Measure -Sum).Sum);FirstUsage=($a.UsageDate|Sort|Select -First 1);LastUsage=($a.UsageDate|Sort|Select -Last 1);ActiveDays=($a.UsageDate|Sort -Unique).Count;ActiveMonths=($a.UsageMonth|Sort -Unique).Count;AverageLaunchesPerActiveDay=[math]::Round((($a.LaunchCount|Measure -Sum).Sum/($a.UsageDate|Sort -Unique).Count),4);MaxLaunchesPerDay=(($byDay|%{($_.Group.LaunchCount|Measure -Sum).Sum}|Measure -Maximum).Maximum);MaxUsageMonth=$month.Name;MaxMonthLaunches=(($month.Group.LaunchCount|Measure -Sum).Sum);ShareOfAllLaunches=0})};$total=(($raw.LaunchCount|Measure -Sum).Sum);$plugin|%{$_.ShareOfAllLaunches=if($total){[math]::Round($_.TotalLaunches/$total,6)}else{0}};$plugin=@($plugin|Sort PluginName|Sort TotalLaunches -Descending)
$day=@($raw|Group PluginNameNormalized,UsageDate|%{$a=$_.Group;[pscustomobject]@{PluginName=$a[0].PluginName;UsageDate=$a[0].UsageDate;LaunchCount=(($a.LaunchCount|Measure -Sum).Sum);SourceRecordCount=$a.Count}}|Sort PluginName,UsageDate);$months= @($raw.UsageMonth|Sort -Unique);$monthRows=@();foreach($g in ($raw|Group PluginNameNormalized)){ $r=[ordered]@{Плагин=$g.Group[0].PluginName};foreach($m in $months){$r[$m]= (($g.Group|? UsageMonth -eq $m|% LaunchCount|Measure -Sum).Sum)};$monthRows+=[pscustomobject]$r }
$normalized=@();$n=0;foreach($x in $raw|Sort PluginNameNormalized,UsageDate,SourceId,SourceRow){$n++;$normalized+=[pscustomobject]@{RecordId=('REC-{0:D6}'-f $n);PluginName=$x.PluginName;PluginNameNormalized=$x.PluginNameNormalized;UsageDate=$x.UsageDate;UsageMonth=$x.UsageMonth;LaunchCount=$x.LaunchCount;SourceId=$x.SourceId;SourceRow=$x.SourceRow;ParseRule=$x.ParseRule}}
$quality=[pscustomobject]@{XlsxFound=$files.Count;TemporaryIgnored=$ignore;Plant3DSheets=($files.Count-($fileRows|? ParseResult -eq 'SHEET_NOT_FOUND').Count);CanonicalPlant3DSheets=($fileRows|? HasPlant3DSheet -eq $true).Count;WithoutPlant3D=($fileRows|? ParseResult -eq 'SHEET_NOT_FOUND').Count;UniqueWorkbooks=$byHash.Count;DuplicateWorkbooks=$dups.Count;RowsExamined=$rawRows;RowsWithValidPlugin=$validPlugin;RowsWithValidDate=$validDate;RowsWithValidLaunchCount=$validCount;ValidAggregateRows=$raw.Count;RejectedRows=$reject.Count;TotalLaunches=$total;UniquePlugins=($raw.PluginNameNormalized|Sort -Unique).Count;DateRange=if($raw.Count){"$(($raw.UsageDate|Sort|Select -First 1)) .. $(($raw.UsageDate|Sort|Select -Last 1))"}else{''};SourceModified='NO';ComputerFieldAvailable=$false;ExplicitComputerPropertyHits=$computerPropertyHits}
Csv $plugin (Join-Path $OutputDirectory 'plugin-summary.csv') 'PluginName,PluginNameNormalized,RecordCount,TotalLaunches,FirstUsage,LastUsage,ActiveDays,ActiveMonths,AverageLaunchesPerActiveDay,MaxLaunchesPerDay,MaxUsageMonth,MaxMonthLaunches,ShareOfAllLaunches';Csv $day (Join-Path $OutputDirectory 'plugin-day-matrix.csv') 'PluginName,UsageDate,LaunchCount,SourceRecordCount';Csv $monthRows (Join-Path $OutputDirectory 'plugin-month-matrix.csv') 'Плагин';Csv $normalized (Join-Path $OutputDirectory 'normalized-usage-records.csv') 'RecordId,PluginName,PluginNameNormalized,UsageDate,UsageMonth,LaunchCount,SourceId,SourceRow,ParseRule';Csv $dups (Join-Path $OutputDirectory 'duplicate-workbooks.csv') 'SourceId,CanonicalSourceId,SHA256,Status';Csv $reject (Join-Path $OutputDirectory 'rejected-records.csv') 'SourceId,SourceSheet,SourceRow,ReasonCode,DetectedPlugin,DetectedDate,DetectedLaunchCount,RowHash';Csv $fileRows (Join-Path $OutputDirectory 'source-inventory.csv') 'SourceId,SHA256,HasPlant3DSheet,ActualSheetName,RowCount,ColumnCount,Headers,ParseResult,RejectedRows';[IO.File]::WriteAllText((Join-Path $OutputDirectory 'summary.json'),($quality|ConvertTo-Json -Depth 3),$utf8)
[IO.File]::WriteAllText((Join-Path $OutputDirectory 'computer-data-availability.md'),("# Computer data availability`r`n`r`nThe source workbooks do not contain an explicit computer-name column. Explicit ComputerName/MachineName/HostName properties in core, custom and app properties: "+$computerPropertyHits+". The User field is not a reliable substitute and was not used. A plugin x computer matrix cannot be built from this XLSX set without an additional source: MachineName log, computer name in workbook name, explicit workbook property, User-to-computer mapping, or the source database."),$utf8)
function E($v){if($null -eq $v){return ''};[Security.SecurityElement]::Escape([string]$v)};function XCol($n){$s='';while($n-gt 0){$n--; $s=([char](65+($n%26)))+$s;$n=[math]::Floor($n/26)};$s};function SX($rows){$b=[Text.StringBuilder]::new();[void]$b.Append('<worksheet xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main"><sheetData>');$r=1;foreach($row in $rows){[void]$b.Append('<row r="'+$r+'">');$c=1;foreach($p in $row.PSObject.Properties){[void]$b.Append('<c r="'+(XCol $c)+$r+'" t="inlineStr"><is><t xml:space="preserve">'+(E $p.Value)+'</t></is></c>');$c++};[void]$b.Append('</row>');$r++};[void]$b.Append('</sheetData></worksheet>');$b.ToString()}
function XLSX($path,$sheets){$fs=[IO.File]::Create($path);$z=[IO.Compression.ZipArchive]::new($fs,[IO.Compression.ZipArchiveMode]::Create);try{$ct='<?xml version="1.0" encoding="UTF-8" standalone="yes"?><Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types"><Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/><Default Extension="xml" ContentType="application/xml"/><Override PartName="/xl/workbook.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet.main+xml"/>';for($i=1;$i-le $sheets.Count;$i++){$ct+='<Override PartName="/xl/worksheets/sheet'+$i+'.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.worksheet+xml"/>'};$ct+='</Types>';$root='<?xml version="1.0" encoding="UTF-8" standalone="yes"?><Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument" Target="xl/workbook.xml"/></Relationships>';$wb='<?xml version="1.0" encoding="UTF-8" standalone="yes"?><workbook xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships"><sheets>';$rels='<?xml version="1.0" encoding="UTF-8" standalone="yes"?><Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">';$i=1;foreach($name in $sheets.Keys){$wb+='<sheet name="'+(E $name)+'" sheetId="'+$i+'" r:id="rId'+$i+'"/>';$rels+='<Relationship Id="rId'+$i+'" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/worksheet" Target="worksheets/sheet'+$i+'.xml"/>';$i++};$wb+='</sheets></workbook>';$rels+='</Relationships>';$entries=[ordered]@{'[Content_Types].xml'=$ct;'_rels/.rels'=$root;'xl/workbook.xml'=$wb;'xl/_rels/workbook.xml.rels'=$rels};$i=1;foreach($name in $sheets.Keys){$entries['xl/worksheets/sheet'+$i+'.xml']=SX $sheets[$name];$i++};foreach($e in $entries.GetEnumerator()){$x=$z.CreateEntry($e.Key);$x.LastWriteTime=[DateTimeOffset]::new(2000,1,1,0,0,0,[TimeSpan]::Zero);$w=[IO.StreamWriter]::new($x.Open(),$utf8);try{$w.Write($e.Value)}finally{$w.Dispose()}}}finally{$z.Dispose();$fs.Dispose()}}
$sumRows=@([pscustomobject]@{Плагин='Плагин';'Количество записей'='Количество записей';'Количество запусков'='Количество запусков';'Первое использование'='Первое использование';'Последнее использование'='Последнее использование';'Активных дней'='Активных дней';'Активных месяцев'='Активных месяцев';'Среднее запусков на активный день'='Среднее запусков на активный день';'Максимум запусков за день'='Максимум запусков за день';'Месяц максимального использования'='Месяц максимального использования';'Запусков в максимальный месяц'='Запусков в максимальный месяц';'Доля от всех запусков'='Доля от всех запусков'})+@($plugin|%{[pscustomobject]@{Плагин=$_.PluginName;'Количество записей'=$_.RecordCount;'Количество запусков'=$_.TotalLaunches;'Первое использование'=$_.FirstUsage;'Последнее использование'=$_.LastUsage;'Активных дней'=$_.ActiveDays;'Активных месяцев'=$_.ActiveMonths;'Среднее запусков на активный день'=$_.AverageLaunchesPerActiveDay;'Максимум запусков за день'=$_.MaxLaunchesPerDay;'Месяц максимального использования'=$_.MaxUsageMonth;'Запусков в максимальный месяц'=$_.MaxMonthLaunches;'Доля от всех запусков'=$_.ShareOfAllLaunches}});$dayRows=@([pscustomobject]@{Плагин='Плагин';Дата='Дата';'Количество запусков'='Количество запусков';'Количество исходных строк'='Количество исходных строк'})+@($day|%{[pscustomobject]@{Плагин=$_.PluginName;Дата=$_.UsageDate;'Количество запусков'=$_.LaunchCount;'Количество исходных строк'=$_.SourceRecordCount}});$sourceRows=@([pscustomobject]@{Плагин='Плагин';Дата='Дата';'Количество запусков'='Количество запусков';'Анонимизированный источник'='Анонимизированный источник';'Исходная строка'='Исходная строка'})+@($normalized|%{[pscustomobject]@{Плагин=$_.PluginName;Дата=$_.UsageDate;'Количество запусков'=$_.LaunchCount;'Анонимизированный источник'=$_.SourceId;'Исходная строка'=$_.SourceRow}});$qualityRows=@($quality.PSObject|%{[pscustomobject]@{Показатель=$_.Name;Значение=$_.Value}});$sheets=[ordered]@{'Итог по плагинам'=$sumRows;'Плагин × месяц'=@([pscustomobject]@{Плагин='Плагин'})+@($monthRows);'Плагин × день'=$dayRows;'Исходные агрегаты'=$sourceRows;'Качество данных'=$qualityRows};XLSX (Join-Path $OutputDirectory 'plugin-usage-final.xlsx') $sheets
$readme="XLSX files: $($quality.XlsxFound)`r`nUnique workbooks by SHA-256: $($quality.UniqueWorkbooks)`r`nDuplicate workbooks: $($quality.DuplicateWorkbooks)`r`nRows examined: $($quality.RowsExamined)`r`nValid aggregate rows: $($quality.ValidAggregateRows)`r`nRejected rows: $($quality.RejectedRows)`r`nUnique plugins: $($quality.UniquePlugins)`r`nDate range: $($quality.DateRange)`r`nTotal launches: $($quality.TotalLaunches)`r`nMost used plugin: $($plugin|select -First 1 -Expand PluginName)`r`nMost active month: $((($raw|group UsageMonth)|sort Count -Descending|select -First 1).Name)`r`nComputer field available: NO`r`nComputer matrix generated: NO`r`n`r`nOnly Plant3D sheets were analyzed. Source files are anonymized as source-0001.xlsx; project numbers, names, users and paths are not published.`r`n`r`nThe semantic of File was not used for deduplication: rows with same plugin/date/count were retained because File may represent separate aggregate dimensions.`r`n`r`nRepeat: .\tools\plugin-usage\Build-Plant3DPluginUsageAggregate.ps1 -SourceRoot C:\dev\TEMP\CheckPlugin -SheetName Plant3D -OutputDirectory .\migration\audits\plugin-usage-plant3d-aggregate-20260731";[IO.File]::WriteAllText((Join-Path $OutputDirectory 'README.md'),$readme,$utf8)