Orders/Translate.ps1

# From one Pilot order record to the order object Write.ps1 stages: the map applied row by row, the skip
# reason when a required value is blank, one stop per place with its freight lines.

# a partial address from Pilot still stages: each missing part is marked so dispatch sees what to finish
function Complete-PilotAddress {
  param([Parameter(Mandatory)]$Address)
  if (-not (@($Address.address1, $Address.city, $Address.state, $Address.zip) -match '\S')) { return $Address }
  foreach ($part in @{ address1 = 'MISSING'; city = 'MISSING'; zip = 'MISSING'; state = '??' }.GetEnumerator()) {
    if ([string]::IsNullOrWhiteSpace([string]$Address.($part.Key))) { $Address.($part.Key) = $part.Value }
  }
  $Address
}

function Resolve-PilotPath($Context, [string]$Path) {
  $value = $Context
  foreach ($part in $Path.Split('.')) { $value = $value.$part }
  $value
}

function Test-PilotCheck([string]$Name, $Value) {
  $check = $script:PilotOrderChecks[$Name]
  if ($check -is [scriptblock]) { & $check $Value } else { [string]$Value -match $check }
}

# the first check in map order that fails, as the skip reason, naming every item that failed it
function Get-PilotMapReason($Rows, [object[]]$Contexts) {
  foreach ($row in $Rows) {
    foreach ($path in @($row.src)) {
      $bad = @($Contexts | Where-Object { -not (Test-PilotCheck $row.check (Resolve-PilotPath $_ $path)) })
      if ($bad.Count) {
        $items = @($bad | ForEach-Object { $_.item.dispatchOrderItemId })
        return 'no {0}{1}' -f $path.Split('.')[-1], $(if ($bad[0].ContainsKey('item')) { ' on item ' + ($items -join ', ') })
      }
    }
  }
}

# one model object from a scope's rows, as the writer reads it
function New-PilotMapObject([string]$Scope, $Context) {
  $object = [ordered]@{}
  foreach ($row in @($script:PilotOrderMap | Where-Object dst -like "$Scope.*")) {
    $values = @(foreach ($path in @($row.src)) { Resolve-PilotPath $Context $path })
    $value = $null
    if ($row.ContainsKey('value')) { $value = $row.value }
    elseif ($row.format) { $value = $row.format -f $values }
    else { foreach ($candidate in $values) { if ("$candidate" -match '\S') { $value = $candidate; break } } }
    if ("$value" -notmatch '\S' -and $row.ContainsKey('default')) { $value = $row.default }
    if ($row.cast) { $value = $value -as ($row.cast -as [type]) }
    $object[$row.dst.Split('.')[1]] = $value
  }
  [pscustomobject]$object
}

function ConvertFrom-PilotOrder {
  param(
    [Parameter(Mandatory)]$Record,
    $Cfg,
    [Parameter(Mandatory)]$References
  )

  $reason = Get-PilotOrderSkipReason $Record
  if ($reason) { throw "Pilot order $($Record.dispatchOrderId): $reason." }
  $order = New-PilotMapObject order @{ order = $Record; cfg = $Cfg }
  $items = @($Record.dispatchOrderItems | Where-Object { -not $_.isDeleted })
  # the reference rows this order used, kept with it when it is staged
  $used = [ordered]@{ terminals = @{}; locations = @{}; contracts = @{} }
  $stops = [ordered]@{}
  foreach ($item in $items) {
    $context = @{ order = $Record; item = $item; cfg = $Cfg }
    # the lists only fill in names and addresses; TMW maps each stop by its id. an id their list lacks
    # keeps the id, and the stop comes over with no address for ops to finish in EDI
    foreach ($list in $script:PilotReferenceLists) {
      $id = [string]$item.($list.id)
      $context[$list.name] = $References."$($list.name)s"[$id]
      if ($context[$list.name]) { $used["$($list.name)s"][$id] = $context[$list.name] }
    }
    $freight = New-PilotMapObject freight $context
    # one stop per place, its freight lines in item order
    foreach ($scope in 'pickup', 'delivery') {
      $stop = Complete-PilotAddress (New-PilotMapObject $scope $context)
      $key = '{0}|{1}' -f $stop.type, $stop.locationCode
      if (-not $stops.Contains($key)) {
        $stops[$key] = $stop | Add-Member -PassThru -NotePropertyMembers @{ earliest = $order.deliveryWindowStart; latest = $order.deliveryWindowEnd; freight = [Collections.Generic.List[object]]::new() }
      }
      $stops[$key].freight.Add($freight)
    }
  }

  $order | Add-Member -PassThru -NotePropertyMembers @{
    stops = @($stops.Values | Where-Object type -eq 'PU') + @($stops.Values | Where-Object type -eq 'DR')
    raw = $Record
    references = [pscustomobject]$used
  }
}

# why a Pilot order is not staged this run, or nothing for a good load. the map's checked rows run in
# list order, with the checks that span fields (status, the scheduled window, active items) between the
# order rows and the item rows. it reads only the order itself, so it runs before anything else is read
function Get-PilotOrderSkipReason {
  param([Parameter(Mandatory)]$Record, [int[]]$Include = @(2), $Window)
  $rows = @($script:PilotOrderMap | Where-Object check)
  $reason = Get-PilotMapReason @($rows | Where-Object dst -like 'order.*') @(@{ order = $Record })
  if ($reason) { return $reason }
  $status = 0
  if (-not [int]::TryParse([string]$Record.dispatchOrderStatusTypeId, [ref]$status) -or $status -ne 2 -or $status -notin $Include) { return "status $($Record.dispatchOrderStatusTypeId) $($Record.dispatchOrderStatusTypeName)".Trim() }
  if ([string]::IsNullOrWhiteSpace([string]$Record.scheduledStartDateTime) -or [string]::IsNullOrWhiteSpace([string]$Record.scheduledEndDateTime)) { return 'no scheduled window' }
  $dates = @()
  foreach ($field in 'scheduledStartDateTime', 'scheduledEndDateTime') {
    $value = $Record.$field
    $date = [datetime]::MinValue
    if ($value -is [datetime]) { $date = $value }
    elseif (-not [datetime]::TryParse([string]$value, [Globalization.CultureInfo]::InvariantCulture, [Globalization.DateTimeStyles]::None, [ref]$date)) { return 'invalid scheduled window' }
    $dates += $date
  }
  if ([DateTimeOffset]$dates[1] -le [DateTimeOffset]$dates[0]) { return 'scheduled window ends before it starts' }
  if ($Window.exact) {
    $scheduled = [DateTimeOffset]$dates[0]
    if ($scheduled -lt [DateTimeOffset]$Window.start -or $scheduled -ge [DateTimeOffset]$Window.end) { return 'scheduled start outside poll window' }
  }
  $items = @($Record.dispatchOrderItems | Where-Object { -not $_.isDeleted })
  if (-not $items.Count) { return 'no active items' }
  Get-PilotMapReason @($rows | Where-Object dst -notlike 'order.*') @($items | ForEach-Object { @{ order = $Record; item = $_ } })
}