Public/Get-NSPEntraPairingReport.ps1

function Get-NSPEntraPairingReport {
    <#
    .SYNOPSIS
        Sorts every pair of a queue into one reconciliation category. Offline.
    .DESCRIPTION
        Categories: ConfirmedLinked, ExistingLinked, AwaitingSync, NeedsCheck, Failed, Blocked, Ready,
        Skipped, Flagged, Deferred, NeedsReview, Ambiguous, NotFound, Undecided and Diagnostic.
 
        ConfirmedLinked needs a revalidation after the write: Sync-NSPEntraPairingQueue is what sets
        it. Blocked and Ready come from -Preflight, so without one a selected pair reads Selected.
 
        -CsvPath and -JsonPath save the rows. Both refuse a path inside a Git checkout, because the
        report holds personal data.
    .EXAMPLE
        Get-NSPEntraPairingReport -Queue $queue -Preflight $check | Group-Object Category
    .EXAMPLE
        Get-NSPEntraPairingReport -Queue $queue -CsvPath 'D:\Secure\pairing-report.csv'
    #>

    [CmdletBinding()]
    param(
        [Parameter(Mandatory)]$Queue,
        $Preflight,
        [string]$CsvPath,
        [string]$JsonPath
    )

    if ($CsvPath) { Assert-NSPEntraPairingStatePath -Path $CsvPath }
    if ($JsonPath) { Assert-NSPEntraPairingStatePath -Path $JsonPath }

    $checks = @{}
    if ($Preflight) { foreach ($item in @($Preflight.Items)) { $checks[[string]$item.CloudObjectId] = $item } }

    $rows = foreach ($pair in @($Queue.pairs)) {
        $check = $checks[[string]$pair.cloudObjectId]
        $category =
        if ($pair.executionState -eq 'linked') { 'ConfirmedLinked' }
        elseif ($pair.status -eq 'Linked') { 'ExistingLinked' }
        elseif ($pair.executionState -eq 'applied') { 'AwaitingSync' }
        elseif ($pair.executionState -in @('writing', 'unconfirmed', 'different', 'unknown')) { 'NeedsCheck' }
        elseif ($pair.executionState -eq 'failed') { 'Failed' }
        elseif ($pair.decision -eq 'selected') {
            if ($null -eq $check) { 'Selected' } elseif ($check.Outcome -eq 'Blocked') { 'Blocked' } else { 'Ready' }
        }
        elseif ($pair.decision -eq 'skipped') { 'Skipped' }
        elseif ($pair.decision -eq 'flagged') { 'Flagged' }
        elseif ($pair.decision -eq 'deferred') { 'Deferred' }
        elseif ($pair.decision -eq 'needs-review') { 'NeedsReview' }
        elseif ($pair.status -in @('Ambiguous', 'OneToMany')) { 'Ambiguous' }
        elseif ($pair.status -eq 'NotFound') { 'NotFound' }
        elseif ($pair.status -eq 'Suggested') { 'Undecided' }
        else { 'Diagnostic' }

        [pscustomobject]@{
            PSTypeName = 'NSP.M365.UserPairing.ReportRow'
            Category = $category
            CloudObjectId = $pair.cloudObjectId
            CloudUserPrincipalName = $pair.cloudUpnAtReview
            Status = $pair.status
            Decision = $pair.decision
            ExecutionState = $pair.executionState
            AdObjectGuid = $pair.adObjectGuid
            AdDistinguishedName = $pair.adDistinguishedNameAtReview
            Reasons = @(if ($null -ne $check) { $check.Blocks }) -join '; '
            Note = $pair.note
        }
    }
    $rows = @($rows)

    $utf8 = New-Object Text.UTF8Encoding($false)
    if ($JsonPath) { [IO.File]::WriteAllText($JsonPath, (ConvertTo-Json -InputObject $rows -Depth 4), $utf8) }
    if ($CsvPath) {
        $text = 'Category', 'CloudObjectId', 'CloudUserPrincipalName', 'Status', 'Decision', 'ExecutionState', 'AdObjectGuid', 'AdDistinguishedName', 'Reasons', 'Note'
        $safe = foreach ($row in $rows) {
            $cells = [ordered]@{}
            foreach ($name in $text) { $cells[$name] = ConvertTo-NSPEntraPairingCsvCell $row.$name }
            [pscustomobject]$cells
        }
        [IO.File]::WriteAllText($CsvPath, ((@($safe) | ConvertTo-Csv -NoTypeInformation) -join "`r`n"), $utf8)
    }
    $rows
}