Private/Compliance.ps1

<#
    Rétention, enregistrements et modération.
 
    Ces champs figuraient dans la liste de ceux qu'on ne copie jamais ET dans
    celle que le vérificateur ignore : la perte n'apparaissait donc dans aucun
    rapport. Un document sous étiquette « Conserver 7 ans » arrivait à
    destination sans étiquette, donc supprimable. Un document « En attente
    d'approbation » arrivait dans une bibliothèque sans modération, visible de
    tous. Rien ne le disait.
 
    Le principe retenu : ces états ne se copient pas comme une colonne — ils se
    RÉAPPLIQUENT après coup, et ce qui ne peut pas l'être doit être nommé.
#>


# Écrits par le service, jamais par une copie directe. Ils restent hors de
# Set-SPMItemMetadata : leur place est ici, sous un traitement propre.
$script:SPMComplianceField = @(
    '_ComplianceTag', '_ComplianceTagWrittenTime', '_ComplianceTagUserId',
    '_ComplianceFlags', '_IsRecord', '_ModerationStatus', '_ModerationComments'
)

function Get-SPMComplianceState {
    <#
    .SYNOPSIS
        Lit l'état de conformité d'un élément : étiquette, enregistrement, modération.
 
    .DESCRIPTION
        Tolérant à l'absence : une bibliothèque sans rétention ni modération ne
        porte simplement pas ces champs. Absent et « vide » se valent ici, mais
        ni l'un ni l'autre ne vaut « conforme » — c'est « rien à réappliquer ».
 
    .OUTPUTS
        Objet { Label, IsRecord, ModerationStatus, ModerationComments, HasAny }.
    #>

    [CmdletBinding()]
    [OutputType([pscustomobject])]
    param([Parameter(Mandatory)]$Item)

    $valeurs = $null
    if ($Item.PSObject.Properties['FieldValues']) { $valeurs = $Item.FieldValues }
    elseif ($Item -is [System.Collections.IDictionary]) { $valeurs = $Item }

    $lire = {
        param($nom)
        if ($null -eq $valeurs) { return $null }
        if ($valeurs -is [System.Collections.IDictionary] -and -not $valeurs.Contains($nom)) { return $null }
        try { return $valeurs[$nom] } catch { return $null }
    }

    $label = & $lire '_ComplianceTag'
    $record = & $lire '_IsRecord'
    $moderation = & $lire '_ModerationStatus'
    $commentaires = & $lire '_ModerationComments'

    if ("$label" -eq '') { $label = $null }
    if ("$commentaires" -eq '') { $commentaires = $null }

    # 0 = approuvé, c'est-à-dire l'état par défaut d'une bibliothèque sans
    # modération : le réappliquer n'apprendrait rien et écrirait pour rien.
    $moderationUtile = $null
    if ($null -ne $moderation -and "$moderation" -ne '' -and [int]$moderation -ne 0) {
        $moderationUtile = [int]$moderation
    }

    [pscustomobject]@{
        Label = $label
        IsRecord = [bool]$record
        ModerationStatus = $moderationUtile
        ModerationComments = $commentaires
        HasAny = [bool]($label -or $record -or $null -ne $moderationUtile)
    }
}

function Get-SPMComplianceInventory {
    <#
    .SYNOPSIS
        Compte, à la source, ce qui est étiqueté, déclaré enregistrement ou en modération.
 
    .DESCRIPTION
        Se mesure AVANT de partir. Découvrir en fin de migration qu'une centaine
        de documents ont perdu leur étiquette de rétention ne laisse aucune
        manœuvre : la destination est déjà peuplée, et l'étiquette ne se
        rattrape pas à l'aveugle.
 
    .OUTPUTS
        Objet { Inspected, Labeled, Records, Pending, Labels, Samples }.
    #>

    [CmdletBinding()]
    [OutputType([pscustomobject])]
    param(
        [Parameter(Mandatory)][string]$LibraryTitle,
        [Parameter(Mandatory)]$Connection,
        [int]$PageSize = 500
    )

    $items = @(Invoke-SPMWithRetry -Operation "compliance/scan/$LibraryTitle" -PassThruErrors -ScriptBlock {
            Get-PnPListItem -List $LibraryTitle -PageSize $PageSize `
                -Fields '_ComplianceTag', '_IsRecord', '_ModerationStatus', 'FileRef' `
                -Connection $Connection -ErrorAction Stop
        })

    $etiquetes = [System.Collections.Generic.List[object]]::new()
    $records = [System.Collections.Generic.List[object]]::new()
    $enAttente = 0
    $labels = [System.Collections.Generic.HashSet[string]]::new()

    foreach ($item in $items) {
        $etat = Get-SPMComplianceState -Item $item
        $chemin = $null
        try { $chemin = "$($item.FieldValues['FileRef'])" } catch { $chemin = '(chemin inconnu)' }

        if ($etat.Label) {
            [void]$labels.Add("$($etat.Label)")
            $etiquetes.Add([pscustomobject]@{ Path = $chemin; Label = "$($etat.Label)" })
        }
        if ($etat.IsRecord) { $records.Add([pscustomobject]@{ Path = $chemin }) }
        if ($null -ne $etat.ModerationStatus) { $enAttente++ }
    }

    [pscustomobject]@{
        Inspected = $items.Count
        Labeled = $etiquetes.Count
        Records = $records.Count
        Pending = $enAttente
        Labels = @($labels)
        # Nommer les premiers permet d'aller vérifier ; tout lister noierait le
        # constat. La troncature est dite, jamais silencieuse.
        Samples = @($etiquetes | Select-Object -First 10)
        RecordSamples = @($records | Select-Object -First 10)
    }
}

function Set-SPMItemCompliance {
    <#
    .SYNOPSIS
        Réapplique à destination l'étiquette et l'état de modération de la source.
 
    .DESCRIPTION
        Trois états, trois traitements distincts :
 
          * ÉTIQUETTE DE RÉTENTION — réappliquée par `Set-PnPListItem -Label`.
            C'est ce qui rend le document à nouveau non supprimable.
          * MODÉRATION — réécrite par SystemUpdate. Sans modération active à
            destination, l'écriture échoue : c'est la réplique des réglages de
            bibliothèque qui la rend possible, pas cette fonction.
          * ENREGISTREMENT — NON restaurable par ce chemin. Déclarer un
            enregistrement est une opération de gestion documentaire, pas une
            écriture de champ. On le NOMME plutôt que de le taire.
 
    .OUTPUTS
        Objet { Label, Moderation, Record, Problems }.
    #>

    [CmdletBinding()]
    [OutputType([pscustomobject])]
    param(
        [Parameter(Mandatory)]$SourceItem,
        [Parameter(Mandatory)]$TargetItem,
        [Parameter(Mandatory)][string]$LibraryTitle,
        [Parameter(Mandatory)]$Connection
    )

    $source = Get-SPMComplianceState -Item $SourceItem
    $etat = [ordered]@{
        Label = 'sans objet'
        Moderation = 'sans objet'
        Record = 'sans objet'
        Problems = @()
    }

    if (-not $source.HasAny) { return [pscustomobject]$etat }

    # ------------------------------------------------------------ étiquette
    if ($source.Label) {
        $nomEtiquette = "$($source.Label)"
        try {
            Invoke-SPMWithRetry -Operation 'Set-PnPListItem/label' -MaxRetries 1 -ScriptBlock {
                Set-PnPListItem -List $LibraryTitle -Identity $TargetItem.Id -Label $nomEtiquette `
                    -Connection $Connection -ErrorAction Stop
            } | Out-Null
            $etat.Label = "réappliquée ($nomEtiquette)"
        }
        catch {
            $message = ($_.Exception.Message -split [char]10)[0]
            $etat.Label = 'NON réappliquée'
            $etat.Problems += "étiquette « $nomEtiquette » non réappliquée — $message"
        }
    }

    # ----------------------------------------------------------- modération
    if ($null -ne $source.ModerationStatus) {
        $valeurs = @{ '_ModerationStatus' = $source.ModerationStatus }
        if ($source.ModerationComments) { $valeurs['_ModerationComments'] = $source.ModerationComments }

        $echecs = @(Set-SPMValueSet -LibraryTitle $LibraryTitle -ItemId $TargetItem.Id `
                -Values $valeurs -Connection $Connection -Operation 'moderation')

        if ($echecs.Count) {
            $etat.Moderation = 'NON réappliquée'
            $etat.Problems += 'état de modération non réappliqué — la modération est-elle active à destination ?'
        }
        else {
            $etat.Moderation = "réappliquée ($($source.ModerationStatus))"
        }
    }

    # -------------------------------------------------------- enregistrement
    if ($source.IsRecord) {
        # Le taire ferait passer pour migré un document qui a perdu son statut
        # d'enregistrement — exactement le genre de perte qu'un audit révèle
        # deux ans plus tard.
        $etat.Record = 'NON restauré'
        $etat.Problems += 'déclaré enregistrement à la source : à redéclarer à destination, aucune écriture ne le fait'
    }

    return [pscustomobject]$etat
}