Titan.ps1
|
function Get-TitanMap($path, $keyColumn, $valueColumn) { $map = @{} if (Test-Path $path) { foreach ($row in (Import-Csv $path)) { $key = "$($row.$keyColumn)".Trim() if ($key -ne '') { $map[$key] = "$($row.$valueColumn)".Trim() } } } return $map } function ConvertTo-TitanOrders($dt, $cfg) { $mapDir = $cfg.mappings $terminalMap = Get-TitanMap (Join-Path $mapDir 'map-tmw-titan-terminal.csv') 'tmw_tcn' 'titan_name' $terminalShipperMap = Get-TitanMap (Join-Path $mapDir 'map-tmw-titan-terminal.csv') 'tmw_code' 'titan_name' $fuelMap = Get-TitanMap (Join-Path $mapDir 'map-tmw-titan-fuel.csv') 'tmw_code' 'titan_name' $supplierMap = Get-TitanMap (Join-Path $mapDir 'map-tmw-titan-terminal-supplier.csv') 'tmw_code' 'titan_name' $statusMap = Get-TitanMap (Join-Path $mapDir 'map-tmw-titan-status.csv') 'tmw_code' 'titan_name' $locationMap = Get-TitanMap (Join-Path $mapDir 'map-tmw-titan-location.csv') 'tmw_code' 'titan_name' $locationIndex = @{} $ambiguousLocations = @{} foreach ($row in (Import-Csv (Join-Path $mapDir 'map-tmw-titan-location.csv'))) { $id = "$($row.titan_location_id)".Trim() if ($id.Length -lt 4) { continue } $name = "$($row.titan_name)".Trim() $state = [regex]::Match($name, ',\s*([A-Za-z]{2})\s*$') if (-not $state.Success) { continue } $key = $id.Substring($id.Length - 4) + '|' + $state.Groups[1].Value.ToUpper() if ($locationIndex.ContainsKey($key)) { $ambiguousLocations[$key] = $true } else { $locationIndex[$key] = $name } } $unmapped = @{} $flag = { param($dimension, $value) if ("$value".Trim() -eq '') { return } if (-not $unmapped.ContainsKey($dimension)) { $unmapped[$dimension] = @{} } $unmapped[$dimension]["$value".Trim()] = $true } $map = { param($values, $value, $dimension, $hold) $key = "$value".Trim() if ($values.ContainsKey($key)) { return $values[$key] } & $flag $dimension $key if ($hold) { return $null } return $key } $resolveLocation = { param($consignee, $state) $code = "$consignee".Trim() if ($locationMap.ContainsKey($code)) { return $locationMap[$code] } $digits = $code -replace '[^0-9]', '' if ($digits.Length -lt 1 -or $digits.Length -gt 4) { return $null } $key = $digits.PadLeft(4, '0') + '|' + "$state".Trim().ToUpper() if ($ambiguousLocations.ContainsKey($key)) { return $null } if ($locationIndex.ContainsKey($key)) { return $locationIndex[$key] } return $null } $resolveTerminal = { param($tcn, $shipper) $tcn = "$tcn".Trim() if ($tcn -ne '' -and $terminalMap.ContainsKey($tcn)) { return $terminalMap[$tcn] } $shipper = "$shipper".Trim() if ($shipper -ne '' -and $terminalShipperMap.ContainsKey($shipper)) { return $terminalShipperMap[$shipper] } & $flag 'terminal' $(if ($tcn -ne '') { $tcn } else { $shipper }) return '' } $orders = foreach ($row in $dt) { $location = & $resolveLocation $row.source_store $row.source_store_state if ([string]::IsNullOrWhiteSpace($location)) { & $flag 'location' "$($row.source_store)/$($row.source_store_state)" } $status = & $map $statusMap $row.source_status 'status' $true $delivered = ($status -eq 'Delivered') [PSCustomObject][ordered]@{ externalRefID = $row.externalRefID supplier = $cfg.supplier customer = $cfg.customer location = $location fuelType = & $map $fuelMap $row.source_cmd_code 'fuelType' $false deliveryQuantity = $row.deliveryQuantity deliveryETADate = $row.deliveryETADate deliveryETATime = $row.deliveryETATime status = $status trackingNumber = '' contractLoadingNumber = '' deliveredGross = if ($delivered) { $row.source_delivered_gross } else { $null } deliveredNet = if ($delivered) { $row.source_delivered_net } else { $null } deliveryStartDate = if ($delivered) { $row.source_dstart_date } else { $null } deliveryStartTime = if ($delivered) { $row.source_dstart_time } else { $null } deliveryEndDate = if ($delivered) { $row.source_dend_date } else { $null } deliveryEndTime = if ($delivered) { $row.source_dend_time } else { $null } bolNumber = if ($delivered) { $row.source_bol } else { $null } BolLiftDate = if ($delivered) { $row.source_bol_lift_date } else { $null } BolLiftTime = if ($delivered) { $row.source_bol_lift_time } else { $null } bolGross = if ($delivered) { $row.source_bol_gross } else { $null } bolNet = if ($delivered) { $row.source_bol_net } else { $null } terminalSupplier = & $map $supplierMap $row.source_supplier 'terminalSupplier' $false terminal = & $resolveTerminal $row.source_tcn $row.source_shipper } } foreach ($dimension in $unmapped.Keys) { $values = ($unmapped[$dimension].Keys | Sort-Object) -join ', ' Write-Warning "ConvertTo-TitanOrders: UNMAPPED $dimension -> [$values]" } return @($orders) } function Get-TitanToken($cfg) { $base = $cfg.titan.base_url $email = $cfg.titan.email $pass = $cfg.titan.password $appType = if ([string]::IsNullOrWhiteSpace($cfg.titan.app_type)) { 4 } else { [int]$cfg.titan.app_type } if ([string]::IsNullOrWhiteSpace($base) -or [string]::IsNullOrWhiteSpace($email) -or [string]::IsNullOrWhiteSpace($pass)) { Write-Host 'Get-TitanToken: missing base_url / email / password' return $null } $loginPath = $cfg.titan.login_path $body = @{ email = $email password = $pass isBuyerTPOCreated = $false appType = $appType } | ConvertTo-Json $response = Invoke-RestMethod -Method Post -Uri ($base.TrimEnd('/') + $loginPath) -Body $body ` -ContentType 'application/json' -TimeoutSec $cfg.titan.timeout_sec if ($response.statusCode -eq 0 -and -not [string]::IsNullOrWhiteSpace($response.token)) { Write-Host ('Get-TitanToken: ok (user {0})' -f $response.userName) return $response.token } Write-Host ('Get-TitanToken: login rejected - statusCode={0} msg={1}' -f $response.statusCode, $response.statusMessage) return $null } function ConvertTo-TitanJson($row) { $payload = [ordered]@{} foreach ($property in $row.PSObject.Properties) { $value = $property.Value if ($null -ne $value -and "$value".Trim() -ne '') { $payload[$property.Name] = $value } } return ([PSCustomObject]$payload | ConvertTo-Json -Depth 10 -Compress) } function Get-TitanCache { param( [Parameter(Mandatory)][string]$Directory, [Parameter(Mandatory)][ValidatePattern('^[a-zA-Z0-9_-]+$')][string]$Scope, [Parameter(Mandatory)][ValidateRange(1, 3650)][int]$KeepDays, [string]$LegacyDirectory ) New-Item -ItemType Directory -Path $Directory -Force | Out-Null $now = [datetime]::UtcNow $cutoff = $now.AddDays(-$KeepDays) $format = "yyyyMMdd'T'HHmmssfffffff'Z'" $prefix = [regex]::Escape($Scope) $index = @{} foreach ($file in @(Get-ChildItem -LiteralPath $Directory -Filter '*.cache' -File)) { if ($file.Name -notmatch "^(\d{8}T\d{13}Z)\.$prefix\.(ack|cutover)\.([A-Fa-f0-9]{64})\.cache$") { continue } $stamp = $Matches[1] $hash = $Matches[3] $at = [datetime]::ParseExact($stamp, $format, [cultureinfo]::InvariantCulture, [Globalization.DateTimeStyles]::AssumeUniversal -bor [Globalization.DateTimeStyles]::AdjustToUniversal) if ($at -lt $cutoff) { Remove-Item -LiteralPath $file.FullName } else { $index[$hash] = $true } } if ($LegacyDirectory -and (Test-Path -LiteralPath $LegacyDirectory)) { $migrated = 0 foreach ($file in @(Get-ChildItem -LiteralPath $LegacyDirectory -Filter '*.cache' -File)) { if ($file.Name -notmatch "^$prefix-([A-Fa-f0-9]{64})\.cache$") { continue } $hash = $Matches[1] if ($file.Length -ne 0) { throw "Legacy marker is not empty: $($file.Name)" } # Empty-file Git history cannot identify a later same-byte acknowledgement. if (-not $index.ContainsKey($hash)) { $path = Join-Path $Directory "$($now.ToString($format)).$Scope.cutover.$hash.cache" New-Item -ItemType File -Path $path -ErrorAction Stop | Out-Null $index[$hash] = $true } Remove-Item -LiteralPath $file.FullName -ErrorAction Stop $migrated++ } if ($migrated) { Write-Host "Cache: migrated $migrated $Scope markers at a retention cutover, not a delivery timestamp" } } return $index } function Send-TitanPayloads($payloads, $cfg) { $scope = "$($cfg.environment)-$($cfg.division)" $cacheDir = $cfg.cache.directory $index = Get-TitanCache -Directory $cacheDir -Scope $scope -KeepDays $cfg.keepdays -LegacyDirectory $cfg.cache.legacy_directory $uri = $cfg.titan.base_url.TrimEnd('/') + $cfg.titan.order_path $sha = [Security.Cryptography.SHA256]::Create() $token = $null try { $results = foreach ($json in $payloads) { $row = $json | ConvertFrom-Json $bytes = [Text.Encoding]::UTF8.GetBytes($json) $hash = [BitConverter]::ToString($sha.ComputeHash($bytes)) -replace '-' if ($index.ContainsKey($hash)) { continue } if ([string]::IsNullOrWhiteSpace($token)) { $token = Get-TitanToken $cfg if ([string]::IsNullOrWhiteSpace($token)) { throw 'Titan: TFX login failed' } } try { $response = Invoke-RestMethod -Method Post -Uri $uri -Body $json -ContentType 'application/json' ` -Headers @{ token = $token } -TimeoutSec $cfg.titan.timeout_sec $messages = if ($response.messages) { ($response.messages.message -join '; ') } else { '' } $ok = ($response.status -eq 0) if ($ok) { $stamp = [datetime]::UtcNow.ToString("yyyyMMdd'T'HHmmssfffffff'Z'") New-Item -ItemType File -Path (Join-Path $cacheDir "$stamp.$scope.ack.$hash.cache") -ErrorAction Stop | Out-Null $index[$hash] = $true } [PSCustomObject]@{ externalRefID = "$($row.externalRefID)" status = $row.status ok = $ok httpcode = if ($ok) { 200 } else { $response.status } error = $messages response = if ($null -ne $response) { $response | ConvertTo-Json -Depth 6 -Compress } else { '' } sent = $json } } catch { $body = "$($_.ErrorDetails.Message)" if ([string]::IsNullOrWhiteSpace($body)) { $body = "$($_.Exception.Message)" } [PSCustomObject]@{ externalRefID = "$($row.externalRefID)" status = $row.status ok = $false httpcode = $null error = $_.Exception.Message response = $body sent = $json } } } return @($results) } finally { $sha.Dispose() } } |