Delivery.ps1

Set-StrictMode -Version Latest

$RequestCodes = @{
  hold      = 0
  create    = 10
  update    = 20
  save_bol  = 30
  save_drop = 40
  status    = 90
  cancel    = 99
}

$StageCodes = @{
  assigned        = 10
  driving_to_load = 20
  arrived_at_load = 30
  loading         = 40
  driving_to_drop = 50
  arrived_at_drop = 60
  dropping        = 70
  completed_drop  = 80
  complete        = 90
}

$StateNames = @{
  X00 = 'pending'
  X40 = 'rejected'
  X80 = 'reconciled'
  X90 = 'sent'
}

function Get-RequestJson($request) {
  if ($request.PSObject.Properties['payload_json']) { return $request.payload_json }
  ConvertTo-Json -InputObject $request.payload -Depth 12 -Compress
}

function ConvertTo-CompactJson($json) {
  ConvertTo-Json -InputObject (ConvertFrom-Json $json -Depth 64 -DateKind String -NoEnumerate) -Depth 64 -Compress
}

function Get-RequestHash($baseUrl, $request, $tenant, $destinationTenant) {
  $json = Get-RequestJson $request
  $bytes = [Text.Encoding]::UTF8.GetBytes("$($baseUrl.TrimEnd('/'))|$Tenant|$DestinationTenant|$($request.path)|$json")
  [Convert]::ToHexString([Security.Cryptography.SHA256]::HashData($bytes)).ToLower()
}

function Get-MessageKey($request) {
  if ($request.PSObject.Properties['message_key']) { return $request.message_key }
  switch ($request.kind) {
    save_bol  { "save_bol|$($request.payload.bol_number)|$($request.payload.terminal.source_id)" }
    save_drop { "save_drop|$($request.payload.site.source_id)" }
    default   { $request.kind }
  }
}

function Write-DeliveryItem($path, $item) {
  $temp = "$path.tmp"
  try {
    $item | ConvertTo-Json -Depth 16 | Set-Content -LiteralPath $temp -Encoding utf8 -ErrorAction Stop
    [IO.File]::Move($temp, $path, $true)
  }
  finally {
    if (Test-Path -LiteralPath $temp) { Remove-Item -LiteralPath $temp }
  }
}

function Get-ReceiptKey($data) {
  "$($data.base_url)|$($data.tenant)|$($data.destination_tenant)|$($data.order_number)|$($data.message_key)"
}

function Get-CreatedKey($data) {
  "$($data.base_url)|$($data.tenant)|$($data.destination_tenant)|$($data.order_number)"
}

function Get-DeliveryIndex($cacheDir, [switch]$Prune) {
  $legacy = @{}
  $terminal = @{}
  $pendingByHash = @{}
  $pending = [Collections.Generic.List[object]]::new()
  $receipts = @{}
  $created = @{}
  $terminalItems = [Collections.Generic.List[object]]::new()
  $oldFormat = $false

  foreach ($file in @(Get-ChildItem $cacheDir -File | Sort-Object Name)) {
    if ($file.Extension -eq '.cache') {
      $oldFormat = $true
      $legacy[$file.BaseName] = $true
      continue
    }
    if ($file.Name -notmatch '\.X(?<state>00|40|80|90)\.(?<hash>[0-9a-f]{64})\.json$') { continue }
    $state = $Matches.state
    $item = [pscustomobject]@{
      file = $file.FullName
      data = Get-Content $file.FullName -Raw | ConvertFrom-Json -Depth 64 -DateKind String
    }
    # Match receipts from before numeric padding was removed.
    $compact = [pscustomobject]@{ path = $item.data.path; payload_json = ConvertTo-CompactJson (Get-RequestJson $item.data) }
    $item | Add-Member hashes @($item.data.hash, (Get-RequestHash $item.data.base_url $compact $item.data.tenant $item.data.destination_tenant))
    if (-not $item.data.PSObject.Properties['payload_json']) { $oldFormat = $true }
    if ($state -ne '00') {
      $terminalItems.Add($item)
      if ($item.data.kind -eq 'create' -and $state -eq '90' -and $item.data.status -eq 'synced') {
        $created[(Get-CreatedKey $item.data)] = $item
      }
      $key = Get-ReceiptKey $item.data
      if ($receipts.ContainsKey($key)) {
        $old = $receipts[$key]
        foreach ($hash in $old.hashes) { $terminal.Remove($hash) }
      }
      $receipts[$key] = $item
      foreach ($hash in $item.hashes) { $terminal[$hash] = $true }
      continue
    }
    $pendingByHash[$item.data.hash] = $item
    $pending.Add($item)
  }

  if ($Prune) {
    $keep = @{}
    foreach ($item in @($receipts.Values) + @($created.Values)) { $keep[$item.file] = $true }
    foreach ($item in $terminalItems) {
      if (-not $keep.ContainsKey($item.file)) { Remove-Item -LiteralPath $item.file }
    }
  }

  foreach ($item in @($pending)) {
    if (-not @($item.hashes.Where({$terminal.ContainsKey($_)})).Count) { continue }
    $pendingByHash.Remove($item.data.hash)
    $null = $pending.Remove($item)
    if ($Prune) { Remove-Item -LiteralPath $item.file }
  }

  [pscustomobject]@{
    legacy = $legacy
    terminal = $terminal
    pending_by_hash = $pendingByHash
    pending = $pending
    receipts = $receipts
    created = $created
    old_format = $oldFormat
  }
}

function New-DeliveryItem($order, $request, $hash, $messageKey, $baseUrl, $cacheDir, $tenant, $destinationTenant, $stateCode = 'X00', $status = $null, $response = $null) {
  $updated = [datetime]$order.updated_date
  $stage = if ($request.kind -eq 'cancel') { 99 } else { [int]$StageCodes["$($order.progress)"] }
  $requestCode = [int]$RequestCodes[$request.kind]
  $stamp = $updated.ToString('yyyyMMddTHHmmssfff')
  $file = Join-Path $cacheDir "$stamp.$($order.order_number).S$($stage.ToString('00')).R$($requestCode.ToString('00')).$stateCode.$hash.json"
  $data = [pscustomobject][ordered]@{
    order_number = $order.order_number
    source_updated = $updated.ToString('yyyy-MM-ddTHH:mm:ss.fff')
    stage_code = $stage
    request_code = $requestCode
    state = $StateNames[$stateCode]
    message_key = $messageKey
    hash = $hash
    base_url = $baseUrl.TrimEnd('/')
    tenant = $Tenant
    destination_tenant = $DestinationTenant
    kind = $request.kind
    path = $request.path
    payload_json = Get-RequestJson $request
    attempted_at = $null
    http = $null
    status = $status
    response = $response
  }
  [pscustomobject]@{ file = $file; data = $data }
}

function Get-CrossroadsDeliverySummary($cacheDir = (Join-Path $PWD 'cache')) {
  $files = @(if (Test-Path -LiteralPath $cacheDir) { Get-ChildItem -LiteralPath $cacheDir -File -Force -ErrorAction Stop })
  $counts = @{ X00 = 0; X40 = 0; X80 = 0; X90 = 0 }
  $bytes = 0L
  $oldest = $null
  $cursor = $null
  foreach ($file in $files) {
    $bytes += $file.Length
    if ($file.Name -match '^(\d{8}T\d{9})\..+\.S\d+\.R\d+\.(X00|X40|X80|X90)\.[0-9a-f]{64}\.json$') {
      $stamp, $state = $Matches[1], $Matches[2]
      $counts[$state]++
      if ($state -eq 'X00' -and ($null -eq $oldest -or $stamp -lt $oldest)) { $oldest = $stamp }
    }
    elseif ($file.Name -match '^(\d{8}T\d{9})\.cursor$') {
      if ($null -eq $cursor -or $Matches[1] -gt $cursor) { $cursor = $Matches[1] }
    }
  }
  [pscustomobject][ordered]@{
    Pending = $counts.X00
    Rejected = $counts.X40
    Reconciled = $counts.X80
    Sent = $counts.X90
    TotalFiles = $files.Count
    SizeMB = [math]::Round($bytes / 1MB, 2)
    OldestPendingSourceUpdate = if ($oldest) { [datetime]::ParseExact($oldest, 'yyyyMMddTHHmmssfff', [Globalization.CultureInfo]::InvariantCulture) } else { $null }
    Cursor = if ($cursor) { [datetime]::ParseExact($cursor, 'yyyyMMddTHHmmssfff', [Globalization.CultureInfo]::InvariantCulture) } else { $null }
  }
}

function Initialize-CrossroadsDelivery($cacheDir = (Join-Path $PWD 'cache')) {
  $null = Initialize-Delivery $cacheDir
}

function Initialize-Delivery($cacheDir) {
  New-Item -ItemType Directory -Path $cacheDir -Force | Out-Null
  $index = Get-DeliveryIndex $cacheDir -Prune
  foreach ($item in @($index.receipts.Values)) {
    if ($item.data.state -ne 'rejected' -or $item.data.status -ne 'pending' -or $item.data.http -lt 200 -or $item.data.http -ge 300) { continue }
    $key = Get-ReceiptKey $item.data
    if (@($index.pending.Where({(Get-ReceiptKey $_.data) -eq $key -and [datetime]$_.data.source_updated -ge [datetime]$item.data.source_updated})).Count) { continue }
    $old = $item.file
    $item.file = $old -replace '\.X40\.', '.X00.'
    $item.data.state = 'pending'
    Write-DeliveryItem $item.file $item.data
    Remove-Item -LiteralPath $old
    $index.receipts.Remove($key)
    foreach ($hash in $item.hashes) { $index.terminal.Remove($hash) }
    $index.pending.Add($item)
    $index.pending_by_hash[$item.data.hash] = $item
  }
  $protected = @{}
  foreach ($item in $index.created.Values) { $protected[$item.file] = $true }
  $required = @{}
  foreach ($item in $index.pending) {
    if (-not $item.data.PSObject.Properties['requires']) { continue }
    foreach ($hash in @($item.data.requires)) { if ($hash) { $required[$hash] = $true } }
  }
  foreach ($item in $index.receipts.Values) {
    if (@($item.hashes.Where({$required.ContainsKey($_)})).Count) { $protected[$item.file] = $true }
  }
  $purge = @(Get-ChildItem $cacheDir -File | Where-Object {
    ($_.Extension -eq '.cache' -or $_.Name -match '\.X(40|80|90)\.[0-9a-f]{64}\.json$') -and -not $protected.ContainsKey($_.FullName)
  })
  Push-Location $cacheDir
  try {
    if ($purge.Count) { $null = Clear-Files ([pscustomobject]@{ keepdays = 1; purgefiles = $purge.Name -join ',' }) }
  }
  finally {
    Pop-Location
  }
  if ($purge.Count) { Get-DeliveryIndex $cacheDir -Prune } else { $index }
}

function Get-CrossroadsDeliveryCursor($cacheDir = (Join-Path $PWD 'cache')) {
  $cursor = @(Get-ChildItem $cacheDir -Filter '*.cursor' -File | Sort-Object Name | Select-Object -Last 1)
  if ($cursor.Count -eq 0) { return }
  [datetime]::ParseExact($cursor[0].BaseName, 'yyyyMMddTHHmmssfff', [Globalization.CultureInfo]::InvariantCulture)
}

function Set-CrossroadsDeliveryCursor($cacheDir = (Join-Path $PWD 'cache'), $current, $rows) {
  $latest = @($rows.updated_date | ForEach-Object { [datetime]$_ } | Sort-Object)[-1]
  if ($null -eq $current -or $latest -gt $current) {
    $stamp = $latest.ToString('yyyyMMddTHHmmssfff')
    $null > (Join-Path $cacheDir "$stamp.cursor")
    Get-ChildItem $cacheDir -Filter '*.cursor' -File |
      Where-Object BaseName -ne $stamp |
      Remove-Item
    return $latest
  }
  $current
}

function Add-CrossroadsDelivery($orders,
  [Parameter(Mandatory)] [ValidateNotNullOrWhiteSpace()] [string]$baseUrl,
  $cacheDir = (Join-Path $PWD 'cache'), $persist,
  [Parameter(Mandatory)] [ValidateNotNullOrEmpty()] [string]$Tenant,
  [Parameter(Mandatory)] [ValidateNotNullOrEmpty()] [string]$DestinationTenant) {
  Add-Delivery $orders $baseUrl $cacheDir $persist $Tenant $DestinationTenant (Get-DeliveryIndex $cacheDir)
}

function Add-Delivery($orders, $baseUrl, $cacheDir, $persist, $Tenant, $DestinationTenant, $index) {
  if (@($orders | Group-Object order_number | Where-Object Count -gt 1).Count) {
    throw 'Supply one complete latest snapshot per order in each batch.'
  }
  $baseUrl = $baseUrl.TrimEnd('/')
  $staged = [Collections.Generic.List[object]]::new()

  foreach ($order in $orders) {
    if ($null -eq $order.updated_date) { throw "Crossroads: $($order.order_number) has no updated date" }
    $updated = [datetime]$order.updated_date
    $requests = @(foreach ($request in @($order.requests)) {
      $hash = Get-RequestHash $baseUrl $request $Tenant $DestinationTenant
      $priorHash = $hash
      # Old receipts used normalized object JSON, not the SQL string.
      if ($index.old_format -and $request.PSObject.Properties['payload_json']) {
        $prior = [pscustomobject]@{ path = $request.path; payload = ConvertFrom-Json $request.payload_json -Depth 64 -DateKind String }
        $priorHash = Get-RequestHash $baseUrl $prior $Tenant $DestinationTenant
      }
      [pscustomobject]@{
        request = $request
        hash = $hash
        prior_hash = $priorHash
        key = Get-MessageKey $request
      }
    })
    $current = @{}
    foreach ($item in $requests) { $current[$item.key] = @($item.hash, $item.prior_hash) }

    if ($persist) {
      foreach ($old in @($index.pending.Where({
        "$($_.data.order_number)" -eq "$($order.order_number)" -and
        $_.data.base_url.TrimEnd('/') -eq $baseUrl -and
        $_.data.tenant -ceq $Tenant -and
        $_.data.destination_tenant -ceq $DestinationTenant -and
        [datetime]$_.data.source_updated -le $updated
      }))) {
        if ($current.ContainsKey($old.data.message_key) -and $current[$old.data.message_key] -contains $old.data.hash) { continue }
        Remove-Item -LiteralPath $old.file
        $index.pending_by_hash.Remove($old.data.hash)
        $null = $index.pending.Remove($old)
      }
    }

    $cancel = @($order.requests | Where-Object kind -eq 'cancel').Count -gt 0
    if (-not [string]::IsNullOrWhiteSpace($order.hold) -and -not $cancel) {
      $request = [pscustomobject]@{
        kind = 'hold'
        path = $null
        payload = [pscustomobject][ordered]@{
          order_number = $order.order_number
          reason = $order.hold
        }
      }
      $hash = Get-RequestHash $baseUrl $request $Tenant $DestinationTenant
      if (-not $index.terminal.ContainsKey($hash)) {
        $item = New-DeliveryItem $order $request $hash 'hold' $baseUrl $cacheDir $Tenant $DestinationTenant 'X40' 'held' ([pscustomobject]@{ source = 'local'; message = $order.hold })
        if ($persist) { Write-DeliveryItem $item.file $item.data }
        $index.terminal[$hash] = $true
        $staged.Add($item)
      }
    }

    $updates = @($requests | Where-Object {$_.request.kind -eq 'update'} | ForEach-Object hash)
    $details = @($requests | Where-Object {$_.request.kind -in @('save_bol','save_drop')} | ForEach-Object hash)
    foreach ($requestItem in $requests) {
      $completion = $requestItem.request.kind -eq 'status' -and $order.progress -eq 'complete'
      $dependent = $requestItem.request.kind -in @('save_bol','save_drop','status')
      $requires = @($updates)
      if ($completion) { $requires += $details }
      $hashes = @($requestItem.hash, $requestItem.prior_hash)
      if (@($hashes.Where({ $index.terminal.ContainsKey($_) -or ($requestItem.request.kind -eq 'create' -and $index.legacy.ContainsKey($_)) })).Count) { continue }
      $pendingHash = $hashes.Where({$index.pending_by_hash.ContainsKey($_)}, 'First')
      if ($pendingHash.Count) {
        $item = $index.pending_by_hash[$pendingHash[0]]
        if ($dependent -and [datetime]$item.data.source_updated -le $updated) {
          $item.data | Add-Member requires $requires -Force
          if ($persist) { Write-DeliveryItem $item.file $item.data }
        }
        $staged.Add($item)
        continue
      }

      $item = New-DeliveryItem $order $requestItem.request $requestItem.hash $requestItem.key $baseUrl $cacheDir $Tenant $DestinationTenant
      if ($dependent) { $item.data | Add-Member requires $requires }
      if ($persist) { Write-DeliveryItem $item.file $item.data }
      $index.pending_by_hash[$requestItem.hash] = $item
      $index.pending.Add($item)
      $staged.Add($item)
    }
  }
  @($staged)
}

function Set-DeliveryResult($item, $stateCode, $http, $status, $response, $index) {
  $item.data.attempted_at = (Get-Date).ToUniversalTime().ToString('o')
  $item.data.http = $http
  $item.data.status = $status
  $item.data.response = $response
  $item.data.state = $StateNames[$stateCode]
  $destination = $item.file -replace '\.X00\.', ".$stateCode."
  Write-DeliveryItem $destination $item.data
  if ($stateCode -eq 'X00') { return }
  Remove-Item -LiteralPath $item.file
  $item.file = $destination
  $key = Get-ReceiptKey $item.data
  $createdKey = Get-CreatedKey $item.data
  if ($index.receipts.ContainsKey($key)) {
    $old = $index.receipts[$key]
    $confirmation = $index.created[$createdKey]
    if ($old.file -ne $destination -and ($null -eq $confirmation -or $old.file -ne $confirmation.file)) {
      Remove-Item -LiteralPath $old.file
    }
  }
  $index.receipts[$key] = $item
  if ($item.data.kind -eq 'create' -and $stateCode -eq 'X90' -and $status -eq 'synced') {
    if ($index.created.ContainsKey($createdKey) -and $index.created[$createdKey].file -ne $destination) {
      Remove-Item -LiteralPath $index.created[$createdKey].file
    }
    $index.created[$createdKey] = $item
  }
}

function Get-DeliveryResponse($response, $kind, $orderNumber) {
  $http = if ($null -eq $response.http) { 0 } else { [int]$response.http }
  $parseError = if ($response.PSObject.Properties['parse_error']) { $response.parse_error } else { $null }
  $responseText = if ($null -eq $response.data) { '' } else { ConvertTo-Json -InputObject $response.data -Depth 12 -Compress }
  $responseStatus = if ($null -ne $response.data -and $response.data.PSObject.Properties['status']) { "$($response.data.status)" } else { '' }
  $errorCode = $response.data
  foreach ($field in @('log', 'detail', 'error')) {
    $errorCode = if ($null -ne $errorCode -and $errorCode.PSObject.Properties[$field]) { $errorCode.$field } else { $null }
  }
  $accepted = -not $parseError -and $http -ge 200 -and $http -lt 300
  $duplicate = -not $parseError -and $kind -eq 'create' -and ($accepted -or $http -eq 422) -and $(
    if ($errorCode) { $errorCode -eq 'request.order_already_exists' }
    else {
      $http -eq 422 -and $null -ne $response.data -and $response.data.PSObject.Properties['detail'] -and
      $response.data.detail -ceq "Duplicate order: An order with number '$orderNumber' already exists for this tenant."
    }
  )
  $alreadyApplied = $duplicate -or (
    -not $parseError -and $kind -eq 'update' -and $responseText -match '(?i)order (?:has )?already been updated'
  )
  $unconfirmedCreate = $accepted -and $kind -eq 'create' -and [string]::IsNullOrWhiteSpace($responseStatus)
  $sent = $accepted -and -not $unconfirmedCreate -and ([string]::IsNullOrWhiteSpace($responseStatus) -or $responseStatus -eq 'synced')
  $wrappedRetry = $responseText -match '(?i)too many requests|error code:\s*(?:408|429|5\d\d)\b|internal server error|timed? out|temporar(?:y|ily) unavailable'
  $retryable = ($accepted -and $responseStatus -eq 'pending') -or $unconfirmedCreate -or ($parseError -and $http -ge 200 -and $http -lt 300) -or $http -eq 0 -or $http -in @(401, 403, 408, 429) -or $http -ge 500 -or ($http -ge 300 -and $http -lt 400) -or $wrappedRetry
  $rejected = -not $sent -and -not $alreadyApplied -and -not $retryable
  $stateCode = if ($alreadyApplied) { 'X80' } elseif ($sent) { 'X90' } elseif ($rejected) { 'X40' } else { 'X00' }
  $status = if ($duplicate) { 'duplicate' }
    elseif ($alreadyApplied) { 'already_applied' }
    elseif ($parseError) { 'invalid_response' }
    elseif ($unconfirmedCreate) { 'unconfirmed' }
    elseif (-not [string]::IsNullOrWhiteSpace($responseStatus)) { $responseStatus }
    elseif ($rejected) { 'rejected' }
    else { 'pending' }
  $message = if ($null -ne $response.data -and $response.data.PSObject.Properties['message']) { "$($response.data.message)" } else { '' }
  $errorMessage = if ($stateCode -in @('X80', 'X90')) { '' }
    elseif ($parseError) { "$parseError" }
    elseif (-not [string]::IsNullOrWhiteSpace($message)) { $message }
    else { $responseText }
  [pscustomobject]@{ http = $http; state_code = $stateCode; status = $status; error_code = $errorCode; error = $errorMessage }
}

function Confirm-DestinationCreation($item, $token, $cacheDir, $index) {
  $data = $item.data
  $request = [pscustomobject]@{ kind='create'; path='/v1/order/get'; payload=@{order_number="$($data.order_number)"} }
  $read = Invoke-CrossroadsRequest -BaseUrl $data.base_url -Path $request.path -Body $request.payload `
    -Token $token -Tenant $data.tenant -DestinationTenant $data.destination_tenant -ReadOnly
  if ($read.http -lt 200 -or $read.http -ge 300 -or $null -eq $read.data) { return $false }
  $body = $read.data
  if (-not $body.PSObject.Properties['status'] -or $body.status -ne 'synced' -or
      -not $body.PSObject.Properties['origin_order'] -or -not $body.PSObject.Properties['destination_order']) { return $false }
  $origin = $body.origin_order
  $destination = $body.destination_order
  if ($null -eq $origin -or $null -eq $destination -or
      -not $origin.PSObject.Properties['origin_order_number'] -or
      -not $destination.PSObject.Properties['origin_order_number'] -or
      -not $destination.PSObject.Properties['destination_order_number']) { return $false }
  if ("$($origin.origin_order_number)" -cne "$($data.order_number)" -or
      "$($destination.origin_order_number)" -cne "$($data.order_number)" -or
      [string]::IsNullOrWhiteSpace("$($destination.destination_order_number)")) { return $false }
  $order = [pscustomobject]@{order_number=$data.order_number; updated_date=$data.source_updated; progress='assigned'}
  $hash = Get-RequestHash $data.base_url $request $data.tenant $data.destination_tenant
  $proof = New-DeliveryItem $order $request $hash 'creation_confirmation' $data.base_url $cacheDir $data.tenant $data.destination_tenant 'X90' 'synced' `
    ([pscustomobject]@{verified_by='order_get';destination_order_number=$destination.destination_order_number})
  Write-DeliveryItem $proof.file $proof.data
  $index.created[(Get-CreatedKey $data)] = $proof
  return $true
}

function Send-CrossroadsDelivery(
  [Parameter(Mandatory)] [ValidateNotNullOrWhiteSpace()] [string]$baseUrl,
  $clientId, $clientSecret, $cacheDir = (Join-Path $PWD 'cache'),
  [Parameter(Mandatory)] [ValidateNotNullOrEmpty()] [string]$Tenant,
  [Parameter(Mandatory)] [ValidateNotNullOrEmpty()] [string]$DestinationTenant) {
  $baseUrl = $baseUrl.TrimEnd('/')
  $index = Get-DeliveryIndex $cacheDir
  $pending = @($index.pending.Where({
    $_.data.base_url.TrimEnd('/') -eq $baseUrl -and
    $_.data.tenant -ceq $Tenant -and
    $_.data.destination_tenant -ceq $DestinationTenant
  }))
  if ($pending.Count -eq 0) { return }
  if ([string]::IsNullOrWhiteSpace($clientId) -or [string]::IsNullOrWhiteSpace($clientSecret)) {
    throw 'Crossroads: missing credentials'
  }

  $token = $null
  foreach ($group in ($pending | Group-Object { $_.data.order_number })) {
    $blocked = $false
    $created = $false
    $confirmed = $index.created.ContainsKey((Get-CreatedKey $group.Group[0].data))
    $creates = @($group.Group.Where({$_.data.kind -eq 'create'}))
    if (-not $confirmed -and ($creates.Count -eq 0 -or @($creates.Where({$_.data.attempted_at})).Count)) {
      if (-not $token) { $token = Get-CrossroadsToken -BaseUrl $baseUrl -TokenPath '/auth/token' -ClientId $clientId -ClientSecret $clientSecret -GrantType 'password' }
      $confirmed = Confirm-DestinationCreation $group.Group[0] $token $cacheDir $index
    }
    $reported = $false
    $update = @(@($group.Group) + @($index.receipts.Values) | Where-Object {
      $_.data.kind -eq 'update' -and (Get-CreatedKey $_.data) -ceq (Get-CreatedKey $group.Group[0].data)
    } | Sort-Object {$_.data.source_updated}) | Select-Object -Last 1
    foreach ($item in @($group.Group | Sort-Object { if ($_.data.kind -eq 'create') { 0 } else { 1 } }, { $_.data.stage_code }, { $_.data.request_code }, file)) {
      if ($blocked) { continue }
      if ($item.data.kind -ne 'create' -and -not $confirmed) {
        if (-not $reported) {
          [pscustomobject]@{ order_number = $item.data.order_number; kind = 'create'; http = $null; ok = $false; synced = $false; state = 'pending'; status = 'waiting_for_create'; error = 'Dependent requests await confirmed destination creation.' }
          $reported = $true
        }
        continue
      }
      if ($item.data.kind -eq 'update' -and $created) {
        Set-DeliveryResult $item 'X90' $null 'not_required' $null $index
        [pscustomobject]@{ order_number = $item.data.order_number; kind = $item.data.kind; http = $null; ok = $true; synced = $true; state = 'sent'; status = 'not_required'; error = '' }
        continue
      }
      $completion = $item.data.kind -eq 'status' -and $item.data.stage_code -eq 90
      if ($item.data.kind -in @('save_bol','save_drop','status')) {
        # Older pending details did not record their update prerequisite.
        if (-not $item.data.PSObject.Properties['requires'] -and -not $completion -and $update) {
          $item.data | Add-Member requires @($update.data.hash)
          Write-DeliveryItem $item.file $item.data
        }
        $sent = @{}
        foreach ($receipt in $index.receipts.Values) {
          if ($receipt.data.state -ne 'sent') { continue }
          $sent[$receipt.data.hash] = $true
          if ($receipt.PSObject.Properties['hashes']) {
            foreach ($hash in $receipt.hashes) { $sent[$hash] = $true }
          }
        }
        if (($completion -and -not $item.data.PSObject.Properties['requires']) -or
            ($item.data.PSObject.Properties['requires'] -and @($item.data.requires.Where({-not $sent.ContainsKey($_)})).Count)) {
          $status = if ($completion) { 'waiting_for_details' } else { 'waiting_for_update' }
          [pscustomobject]@{ order_number=$item.data.order_number; kind=$item.data.kind; http=$null; ok=$false; synced=$false; state='pending'; status=$status; error='Request awaits confirmed prerequisite delivery.' }
          continue
        }
      }

      if (-not $token) {
        $token = Get-CrossroadsToken -BaseUrl $baseUrl -TokenPath '/auth/token' `
          -ClientId $clientId -ClientSecret $clientSecret -GrantType 'password'
      }
      $response = Invoke-CrossroadsRequest -BaseUrl $baseUrl -Path $item.data.path `
        -Body (Get-RequestJson $item.data) -RawJson -Token $token -Tenant $item.data.tenant `
        -DestinationTenant $item.data.destination_tenant -AllowWrite
      $result = Get-DeliveryResponse $response $item.data.kind $item.data.order_number
      Set-DeliveryResult $item $result.state_code $result.http $result.status $response.data $index
      if ($item.data.kind -eq 'create' -and $result.state_code -eq 'X90' -and $result.status -eq 'synced') { $created = $true; $confirmed = $true }
      $blocked = $result.state_code -eq 'X00' -or ($item.data.kind -eq 'create' -and -not $confirmed)
      $synced = $result.state_code -in @('X80', 'X90')
      [pscustomobject]@{
        order_number = $item.data.order_number
        kind = $item.data.kind
        http = $result.http
        ok = $synced
        synced = $synced
        state = $StateNames[$result.state_code]
        status = $result.status
        error_code = $result.error_code
        error = $result.error
      }
    }
  }
}