PropertyBox/Write-SRxOperationTerm.ps1

<#
.SYNOPSIS
    Jobs Phase 7 - isolated worker: create or delete a Job template
    ('operation' term) in the O365 Termstore under the Entra App Registration
    identity (PnP CSOM, app-only certificate), then update the local
    termSet.xml + JSON copies (Sync-SRxLocalTermStructure).
    Same bootstrap / result protocol as Write-SRxTermBatch.ps1:
      result file + "__JSON__" line = { Success; Conflict; Data; Error; TermId; Pid; Ended }
      Data = { Action; TermId; ParentTermId; Name; Props; SyncWarning }
#>

[CmdletBinding()]
param(
    [Parameter(Mandatory)] [string]$RootPath,
    [Parameter(Mandatory)] [ValidateSet('New', 'Delete')] [string]$Action,
    [Parameter(Mandatory=$false)] [string]$TermId = '',
    [Parameter(Mandatory=$false)] [string]$ParentTermId = '',
    [Parameter(Mandatory=$false)] [AllowEmptyString()] [string]$Name = '',
    [Parameter(Mandatory=$false)] [string]$DesiredSetJson = '{}',
    [Parameter(Mandatory)] [string]$ResultPath
)

$ErrorActionPreference = 'Stop'

function Write-SRxWorkerResult {
    param([bool]$Success, $Data, [string]$ErrorText)
    $payload = [pscustomobject]@{
        Success  = $Success
        Conflict = $false
        Data     = $Data
        Error    = $ErrorText
        TermId   = $TermId
        Pid      = $PID
        Ended    = (Get-Date).ToString('o')
    }
    $json = $payload | ConvertTo-Json -Depth 25 -Compress
    try {
        $dir = Split-Path -Parent $ResultPath
        if ($dir -and -not (Test-Path $dir)) { New-Item -ItemType Directory -Path $dir -Force | Out-Null }
        Set-Content -LiteralPath $ResultPath -Value $json -Encoding UTF8
    } catch { }
    Write-Host ("__JSON__" + $json)
}

function ConvertFrom-SRxJsonToHashtable {
    param([string]$Json)
    $ht = @{}
    if ([string]::IsNullOrWhiteSpace($Json)) { return $ht }
    $obj = $Json | ConvertFrom-Json
    foreach ($p in $obj.PSObject.Properties) { $ht[$p.Name] = [string]$p.Value }
    return $ht
}

try {
    $Name = ([string]$Name).Trim()
    $externalScript = Join-Path $RootPath 'loadmodule.ps1'
    . $externalScript
    if ($null -ne $LoadModule) { $LoadModule.Invoke("SRxCore") | Out-Null }
    Initialize-SRxEnv -LoadModule2 $LoadModule -RootPath $RootPath | Out-Null
    Connect-SRxSPOService
    if ($null -eq $global:SRxEnv -or $null -eq $global:SRxEnv.Tenancy) {
        throw "global:SRxEnv or global:SRxEnv.Tenancy is null after bootstrap."
    }
    try { Import-Module (Join-Path $PSScriptRoot 'SRxPropertyBox.psm1') -DisableNameChecking -Force -ErrorAction SilentlyContinue } catch { }

    $connection = Get-SRxConnection -siteUrl $global:SRxEnv.Tenancy.AdminUrl -Termstore -Supress
    if (-not $connection) { throw "Failed to establish app-only PnP connection to the Termstore." }

    $desired = ConvertFrom-SRxJsonToHashtable -Json $DesiredSetJson

    if ($Action -eq 'New') {
        if ([string]::IsNullOrEmpty($ParentTermId)) { throw 'ParentTermId is required' }
        if ([string]::IsNullOrEmpty($Name)) { throw 'Name is required' }
        if ([string]::IsNullOrEmpty($TermId)) { $TermId = [guid]::NewGuid().ToString() }
        $parent = Get-PnPTerm -Identity ([GUID]$ParentTermId) -Connection $connection -ErrorAction Stop
        Get-PnPProperty -ClientObject $parent -Property Id, Name -Connection $connection | Out-Null
        $new = $parent.CreateTerm($Name, 1033, [GUID]$TermId)
        foreach ($k in $desired.Keys) { $new.SetCustomProperty($k, [string]$desired[$k]) }
        Invoke-PnPQuery -Connection $connection -ErrorAction Stop
    }
    else {
        if ([string]::IsNullOrEmpty($TermId)) { throw 'TermId is required' }
        $term = Get-PnPTerm -Identity ([GUID]$TermId) -Connection $connection -ErrorAction Stop
        Get-PnPProperty -ClientObject $term -Property Id, Name -Connection $connection | Out-Null
        $term.DeleteObject()
        Invoke-PnPQuery -Connection $connection -ErrorAction Stop
    }

    # ---- local termSet.xml + JSON (off the UI thread, like the property batch)
    $syncWarning = $null
    try {
        if (Get-Command Sync-SRxLocalTermStructure -ErrorAction SilentlyContinue) {
            $sync = Sync-SRxLocalTermStructure -Action $Action -TermId $TermId -ParentTermId $ParentTermId -Name $Name -Raw $desired
            if (-not $sync.Success) { $syncWarning = "Local sync failed: $($sync.Error)" }
        } else { $syncWarning = "Sync-SRxLocalTermStructure not available in worker; local cache not updated." }
    } catch { $syncWarning = "Local sync threw: $($_.Exception.Message)" }

    $data = [pscustomobject]@{
        Action       = $Action
        TermId       = $TermId
        ParentTermId = $ParentTermId
        Name         = $Name
        Props        = $desired
        SyncWarning  = $syncWarning
    }
    Write-SRxWorkerResult -Success $true -Data $data -ErrorText $null
    exit 0
}
catch {
    $msg = $_.Exception.Message
    if ($_.Exception.InnerException) { $msg += " | inner: " + $_.Exception.InnerException.Message }
    Write-SRxWorkerResult -Success $false -Data $null -ErrorText $msg
    exit 1
}