Public/Get-SPMigrationReport.ps1

function Get-SPMigrationReport {
    <#
    .SYNOPSIS
        Produit un rapport exploitable à partir de l'état et des journaux NDJSON.
 
    .DESCRIPTION
        Le rapport est calculé à partir du magasin d'état structuré, jamais par
        correspondance de chaînes dans un fichier de log — c'est précisément ce
        que faisait la version précédente, avec des compteurs faux dès qu'un
        libellé changeait.
 
        Deux sorties : un objet PowerShell (chaînable, testable) et, si -HtmlPath
        est fourni, une page HTML autonome à transmettre au client.
 
    .PARAMETER StatePath
        Fichier d'état NDJSON produit par Invoke-SPMigration.
 
    .PARAMETER LogPath
        Journal NDJSON complémentaire (optionnel) pour les statistiques de throttling.
 
    .PARAMETER HtmlPath
        Chemin d'écriture du rapport HTML.
 
    .EXAMPLE
        Get-SPMigrationReport -StatePath .\Logs\prod.state.ndjson -HtmlPath .\Logs\rapport.html
    #>

    [CmdletBinding()]
    [OutputType([pscustomobject])]
    param(
        [Parameter(Mandatory, Position = 0)][string]$StatePath,
        [string]$LogPath,
        [string]$HtmlPath
    )

    if (-not (Test-Path -LiteralPath $StatePath)) {
        throw "Fichier d'état introuvable : $StatePath"
    }

    # Dernier événement par clé : l'état final de chaque unité de travail
    $latest = [ordered]@{}
    foreach ($line in [System.IO.File]::ReadLines($StatePath)) {
        if ([string]::IsNullOrWhiteSpace($line)) { continue }
        try {
            $record = $line | ConvertFrom-Json
            $latest[$record.key] = $record
        }
        catch { continue }
    }

    $units = @($latest.Values)
    $byStatus = $units | Group-Object status

    $failures = @($units | Where-Object status -eq 'Failed' | ForEach-Object {
            [pscustomobject]@{
                Source = $_.sourceUrl
                Target = $_.targetUrl
                Error = $_.PSObject.Properties['error'] ? $_.error : $null
                Time = $_.ts
            }
        })

    $durations = @($units | Where-Object {
            $_.status -eq 'Completed' -and $_.PSObject.Properties['durationMs']
        } | ForEach-Object { [double]$_.durationMs })

    $throttle = $null
    if ($LogPath -and (Test-Path -LiteralPath $LogPath)) {
        $throttleEvents = 0
        $throttleWaitMs = 0
        foreach ($line in [System.IO.File]::ReadLines($LogPath)) {
            if ([string]::IsNullOrWhiteSpace($line)) { continue }
            try {
                $e = $line | ConvertFrom-Json
                if ($e.PSObject.Properties['throttled'] -and $e.throttled) {
                    $throttleEvents++
                    if ($e.PSObject.Properties['delayMs']) { $throttleWaitMs += [int]$e.delayMs }
                }
            }
            catch { continue }
        }
        $throttle = [pscustomobject]@{
            Events = $throttleEvents
            WaitedSec = [math]::Round($throttleWaitMs / 1000, 1)
        }
    }

    $completed = @($units | Where-Object status -eq 'Completed').Count
    $running = @($units | Where-Object status -eq 'Running').Count
    $submitted = @($units | Where-Object status -eq 'Submitted').Count

    # Un rapport ne doit jamais présenter une absence de mesure comme une mesure.
    # « Durée moyenne : 0 s » se lisait comme « instantané » alors que rien
    # n'avait été chronométré, et une carte verte sur zéro élément transféré
    # laissait conclure à une migration réussie.
    $alertes = [System.Collections.Generic.List[string]]::new()
    if ($units.Count -eq 0) {
        $alertes.Add("Aucune unité dans le fichier d'état : ce rapport ne mesure rien.")
    }
    elseif ($completed -eq 0) {
        $alertes.Add("RIEN N'A ÉTÉ TRANSFÉRÉ : aucune unité terminée sur $($units.Count).")
    }
    if ($running) {
        $alertes.Add("$running unité(s) restée(s) en cours : exécution interrompue, résultat incomplet.")
    }
    if ($submitted) {
        $alertes.Add(
            "$submitted travail(aux) SOUMIS et non confirmés : le service les a acceptés, " +
            "rien ne prouve qu'il les ait exécutés."
        )
    }
    if (-not $durations.Count -and $completed) {
        $alertes.Add('Aucune durée mesurée : les temps affichés sont « non mesuré », pas zéro.')
    }

    $report = [pscustomobject]@{
        GeneratedAt = (Get-Date).ToString('o')
        StatePath = (Resolve-Path $StatePath).Path
        TotalUnits = $units.Count
        Completed = $completed
        Failed = $failures.Count
        Skipped = @($units | Where-Object status -eq 'Skipped').Count
        Running = $running
        Submitted = $submitted
        SuccessRate = if ($units.Count) { [math]::Round(($completed / $units.Count) * 100, 1) } else { $null }
        MeasuredUnits = $durations.Count
        AvgDurationMs = if ($durations.Count) { [math]::Round(($durations | Measure-Object -Average).Average, 0) } else { $null }
        MaxDurationMs = if ($durations.Count) { [math]::Round(($durations | Measure-Object -Maximum).Maximum, 0) } else { $null }
        NothingTransferred = ($completed -eq 0)
        Alerts = $alertes.ToArray()
        Throttling = $throttle
        StatusBreak = @($byStatus | ForEach-Object { [pscustomobject]@{ Status = $_.Name; Count = $_.Count } })
        Failures = $failures
    }

    foreach ($a in $alertes) {
        Write-SPMLog -Level WARNING -Operation 'Report' -Message $a
    }

    if ($HtmlPath) {
        $dir = Split-Path -Parent $HtmlPath
        if ($dir -and -not (Test-Path -LiteralPath $dir)) { New-Item -ItemType Directory -Path $dir -Force | Out-Null }
        Write-SPMHtmlReport -Report $report -Path $HtmlPath
        Write-SPMLog -Level SUCCESS -Operation 'Report' -Message "Rapport HTML écrit : $HtmlPath"
    }

    return $report
}