diff --git a/DataAgent/CAPABILITIES.md b/DataAgent/CAPABILITIES.md index 1a60d8e..a9ed604 100644 --- a/DataAgent/CAPABILITIES.md +++ b/DataAgent/CAPABILITIES.md @@ -30,6 +30,9 @@ owned options are the keys the adapter interprets itself. other arguments belong | `fmt/csv` | `Path`, `Encoding`, `StripQuotes` | `ConvertTo-Csv` | | `fmt/xlsx` | `Path` | `Export-Excel` | | `fmt/custom` | `Module`, `Path` | `ConvertTo-Custom` | +| `fmt/pdi` | `Path`, `Values`, `Key`, `Head`, `Type`, `H`, `D`, `I` | none | +| `fmt/x12-210` | `Path`, `Values`, `Key`, `Detail`, `Head`, `Invoice`, `Line`, `Close`, `Se`, `SeExtra`, `Tail` | none | +| `fmt/dtn` | `Path`, `Values`, `Head`, `Start`, `Open`, `Header`, `Detail`, `Line`, `Close`, `Tail` | none | | `dst/email` | none | `Send-FileViaEmail` | | `dst/sftp` | `connect`, `send`, `attempts`, `delay` | `New-SFTPSession`, `Set-SFTPItem` | | `dst/ftp` | `Uri`, `Credential` | `FtpWebRequest` | @@ -41,12 +44,15 @@ owned options are the keys the adapter interprets itself. other arguments belong - **csv format:** Path is the output; Encoding defaults to utf8NoBOM. all remaining arguments pass to ConvertTo-Csv, including Delimiter, UseQuotes and NoHeader. StripQuotes removes *every* double quote, including data. unquoted modes can lose meaning with commas/newlines. source column order is retained. - **xlsx:** all arguments pass to Export-Excel, including Path and layout options. an existing file at Path is replaced, never written into, so a shorter run leaves no rows from the last one. verify cells/layout, not ZIP hashes. AutoSize may need native support on non-Windows hosts. - **custom format bridge:** Module is a path to a module exporting `ConvertTo-Custom($dt)`. the bridge passes a DataTable and saves returned text to Path as UTF-8 without BOM. this bridge is for existing text converters, not binary ZIP output. +- **invoice formats:** pdi writes H/D/I records; x12-210 writes an interchange with one transaction per invoice; dtn takes one object per invoice with nested Detail records. baseline templates ship in fmt/invoice/*.psd1. Values supplies company identifiers, addresses, terms and other constants. every template or field selector can be overridden by its listed option. substitutions support `{field}`, `{a.b}`, `{field,-15}` and `{field:0.00}`. Values wins over row fields. missing fields become empty, values are not escaped or truncated, and an alignment width is a minimum. these are configured layouts, not general CSV quoting or X12/DTN certification. prove the receiver's exact bytes before adoption. x12-210 defaults ISA ids to 15-wide, YYMMDD, a 9-digit interchange number and matching IEA; GS/GE use the same unpadded group number. ST/SE and segment counts are generated; SeExtra permits an explicit legacy count offset. the default control number is the extracted hour-of-year convention, not uniqueness across jobs or reruns. Values.now and Values.control can supply a filing time and number instead. dtn Values.dtn supplies user/pass/site for the file's login block; keep those values in env: variables, not committed config. its Header and Line are column templates, so a numeric precision variant needs no alternate formatter. - **email:** one filename, one Send-FileViaEmail call with `file` supplied by the adapter. other arguments pass through, such as `cfg` and `contentType`. multiple filenames throw before sending, never fan out. use a filename in the job directory: the helper uses it as both path and attachment name. no body/multi-attachment API or new size policy is provided here. - **sftp:** connect is splatted to New-SFTPSession, send to Set-SFTPItem. the adapter supplies the session and local Path; the session closes even on failure. a connect that fails is tried again, up to `attempts` tries in all (3) `delay` seconds apart (60), and the last error is the one thrown. choose the intended host-key/overwrite policy. connection Force bypasses host-key validation; prefer verified trusted hosts. - **ftp / ftps:** independent scripts. Uri is a remote directory (`ftp://host/path/`); Credential is a PSCredential or .NET NetworkCredential. uploads replace normally. FTPS uses explicit TLS, not implicit FTPS. plain FTP sends credentials and data unencrypted. ## custom adapters +invoice substitutions without a format preserve PowerShell's string interpolation, including dates and numbers. alignment pads that string. an explicit `:format` instead formats the original typed value using .NET's current-culture formatting. + set `adapter` to a `.ps1` path, relative to the job directory or absolute. no registration or core edit. each script receives `-Data` and `-Options`: - source: return records on the success stream. keep progress off that stream. diff --git a/DataAgent/DataAgent.psd1 b/DataAgent/DataAgent.psd1 index 2eba2e5..70961aa 100644 --- a/DataAgent/DataAgent.psd1 +++ b/DataAgent/DataAgent.psd1 @@ -1,6 +1,6 @@ @{ RootModule = 'DataAgent.psm1' - ModuleVersion = '0.8.0' + ModuleVersion = '0.9.0' GUID = '32662c0b-0a5d-49ac-8c62-8f7ae05f79c0' Author = 'Roy Ashbrook' Copyright = '(c) 2026 Roy Ashbrook. MIT License.' @@ -10,13 +10,13 @@ CmdletsToExport = @() VariablesToExport = @() AliasesToExport = @() - FileList = @('DataAgent.psd1', 'DataAgent.psm1', 'LICENSE', 'CAPABILITIES.md', 'src/sql.ps1', 'src/csv.ps1', 'src/cosmos.ps1', 'fmt/csv.ps1', 'fmt/xlsx.ps1', 'fmt/custom.ps1', 'dst/email.ps1', 'dst/sftp.ps1', 'dst/ftp.ps1', 'dst/ftps.ps1') + FileList = @('DataAgent.psd1', 'DataAgent.psm1', 'LICENSE', 'CAPABILITIES.md', 'src/sql.ps1', 'src/csv.ps1', 'src/cosmos.ps1', 'fmt/csv.ps1', 'fmt/xlsx.ps1', 'fmt/custom.ps1', 'fmt/pdi.ps1', 'fmt/x12-210.ps1', 'fmt/dtn.ps1', 'fmt/invoice/template.ps1', 'fmt/invoice/pdi.psd1', 'fmt/invoice/x12-210.psd1', 'fmt/invoice/dtn.psd1', 'dst/email.ps1', 'dst/sftp.ps1', 'dst/ftp.ps1', 'dst/ftps.ps1') PrivateData = @{ PSData = @{ Tags = @('ETL', 'Data', 'CSV', 'Automation', 'PSEdition_Core', 'Windows', 'Linux', 'MacOS') LicenseUri = 'https://github.com/royashbrook/DataAgent/blob/main/LICENSE' ProjectUri = 'https://github.com/royashbrook/DataAgent' - ReleaseNotes = 'src/cosmos: a cosmos sql query over rest with the account key, from Query or an InputFile, with @day, @from and @to for the day a run reports, an optional freshness check, and the shaping a cosmos query cannot do (date formats, code maps, sort, dropped sort columns). No other change.' + ReleaseNotes = 'Add fmt/pdi, fmt/x12-210 and fmt/dtn invoice layout baselines with configurable templates and values. Plain substitutions preserve PowerShell interpolation; explicit formats retain typed .NET formatting. Runner, source, destination and dependency behavior is unchanged.' } } } diff --git a/DataAgent/fmt/dtn.ps1 b/DataAgent/fmt/dtn.ps1 new file mode 100644 index 0000000..aee72a2 --- /dev/null +++ b/DataAgent/fmt/dtn.ps1 @@ -0,0 +1,20 @@ +param($Data, [hashtable] $Options) +. "$PSScriptRoot/invoice/template.ps1" +$Options = Get-InvoiceOptions $Options dtn +$scope = @{} + $Options.Values +& { + foreach ($t in $Options.Head) { Expand-InvoiceLine $t $null $scope } + $scope.n = [int]$Options.Start + foreach ($invoice in $Data) { + foreach ($t in $Options.Open) { Expand-InvoiceLine $t $invoice $scope } + '"{0}"' -f (@(foreach ($f in $Options.Header) { Expand-InvoiceLine $f $invoice $scope }) -join '","') + $scope.invoice = $invoice + foreach ($detail in $invoice.($Options.Detail)) { + '"{0}"' -f (@(foreach ($f in $Options.Line) { Expand-InvoiceLine $f $detail $scope }) -join '","') + } + $scope.Remove('invoice') + foreach ($t in $Options.Close) { Expand-InvoiceLine $t $invoice $scope } + $scope.n++ + } + foreach ($t in $Options.Tail) { Expand-InvoiceLine $t $null $scope } +} | Set-Content -LiteralPath $Options.Path -Encoding utf8NoBOM diff --git a/DataAgent/fmt/invoice/dtn.psd1 b/DataAgent/fmt/invoice/dtn.psd1 new file mode 100644 index 0000000..f20ef6d --- /dev/null +++ b/DataAgent/fmt/invoice/dtn.psd1 @@ -0,0 +1,11 @@ +@{ + Values = @{} + Head = @('{dtn.user},{dtn.pass}', 'INF') + Start = 1001 + Open = @('C02,{n}, / / ,00:00,COMP,2,0001,S', '{dtn.site}', 'BEGIN-BINARY-DATA') + Header = @('INVH', '{sender_id}', '{ivh_invoicenumber}', '', '{ivh_billdate}', 'PR', '{ivh_billto}', '{ivh_consignee}', '{ivh_consignee}', '{po}', '{ShipperName}', '{ShipperAddress1}', '{ShipperAddress2}', '{ShipperCity}', '{ShipperState}', '{ShipperZip}', '{ConsigneeName}', '{ivh_consignee}', '{ConsigneeAddress1}', '{ConsigneeAddress2}', '{ConsigneeCity}', '{ConsigneeState}', '{ConsigneeZip}', 'UNK', '{terms}', '{ivh_totalcharge}', '{ivh_billdate}', '{ivh_totalcharge}', '0.00', '', '', '', '', '', '') + Detail = 'Detail' + Line = @('{RecordType}', '{bol}', '{ivd_description}', '{cmd_code}', 'G', '{ivd_quantity}', '{ivd_wgt}', '{ivd_quantity}', '{ivd_unit}', '{ivd_rate:0.0000}', '{ivd_charge:0.00}', '{invoice.ShipDate}', '{invoice.ShipTime}', '', '', '') + Close = @('END-BINARY-DATA') + Tail = @('') +} diff --git a/DataAgent/fmt/invoice/pdi.psd1 b/DataAgent/fmt/invoice/pdi.psd1 new file mode 100644 index 0000000..8fbb6e0 --- /dev/null +++ b/DataAgent/fmt/invoice/pdi.psd1 @@ -0,0 +1,9 @@ +@{ + Key = 'ivh_invoicenumber' + Head = 'ivh*' + Type = 'dori' + Values = @{} + H = '"H","{sender_id}","{ivh_invoicenumber}","{ivh_billdate}",,,,,,"{ivh_totalcharge}","N"' + D = '"D","{ivd_refnum}","{ivh_consignee}","{cmd_code}","{not_text}","{sender_id}",,"{ivd_quantity}","{ivd_wgt}","{ivd_rate}",,"{ivd_charge}",' + I = '"I","{cht_itemcode}","{ivd_charge}",' +} diff --git a/DataAgent/fmt/invoice/template.ps1 b/DataAgent/fmt/invoice/template.ps1 new file mode 100644 index 0000000..1d9b6da --- /dev/null +++ b/DataAgent/fmt/invoice/template.ps1 @@ -0,0 +1,30 @@ +# Shared substitution for the three invoice layouts. No expression evaluation: config supplies +# values and .NET format strings, not PowerShell. Alignment is minimum width, never truncation. +function Get-InvoiceOptions($Overrides, $Name) { + $options = Import-PowerShellDataFile "$PSScriptRoot/$Name.psd1" + foreach ($key in $Overrides.Keys) { $options[$key] = $Overrides[$key] } + $options +} + +function Get-InvoiceRows($Rows) { + if ($Rows[0] -is [Data.DataRow]) { $Rows | Select-Object $Rows[0].Table.Columns.ColumnName } + else { $Rows } +} + +function Expand-InvoiceLine([string] $Template, $Row, [hashtable] $Scope = @{}) { + $out = [Text.StringBuilder]::new() + $at = 0 + foreach ($m in [regex]::Matches($Template, '\{([\w.]+)(,-?\d+)?(?::([^}]+))?\}')) { + $null = $out.Append($Template, $at, $m.Index - $at) + $parts = $m.Groups[1].Value.Split('.') + $value = if ($Scope.ContainsKey($parts[0])) { $Scope[$parts[0]] } else { $Row.($parts[0]) } + foreach ($part in @($parts | Select-Object -Skip 1)) { $value = $value.$part } + $format = '{0' + $m.Groups[2].Value + if ($m.Groups[3].Success) { $format += ':' + $m.Groups[3].Value } + else { $value = "$value" } + $null = $out.Append(($format + '}') -f $value) + $at = $m.Index + $m.Length + } + $null = $out.Append($Template, $at, $Template.Length - $at) + $out.ToString() +} diff --git a/DataAgent/fmt/invoice/x12-210.psd1 b/DataAgent/fmt/invoice/x12-210.psd1 new file mode 100644 index 0000000..6bcad5a --- /dev/null +++ b/DataAgent/fmt/invoice/x12-210.psd1 @@ -0,0 +1,28 @@ +@{ + Key = 'b302' + Detail = @('l5*', 'l0*', 'l1*') + Values = @{} + Head = @( + 'ISA*00* *00* *02*{sender_id,-15}*ZZ*{receiver_id,-15}*{now:yyMMdd}*{now:HHmm}*U*00401*{control:000000000}*0*P*>' + 'GS*IM*{sender_id}*{receiver_id}*{now:yyyyMMdd}*{now:HHmm}*{control}*X*004010' + ) + Invoice = @( + 'ST*210*{st:000000000}' + 'B3**{b302}*{b303}*PP**{b306}*{b307}**{b309}*035*{sender_id}*{b312}' + 'N9*D*BM*{b303}' + 'N1*SH*{company_name}' + 'N3*{company_address}' + 'N4*{company_city}*{company_state}*{company_zip}' + 'N1*SF*{n102_sf}' + 'N3*{n301_sf}' + 'N4*{n401_sf}*{n402_sf}*{n403_sf}' + 'N1*CN*{n102_cn}*92*{n104_cn}' + 'N3*{n301_cn}' + 'N4*{n401_cn}*{n402_cn}*{n403_cn}' + ) + Line = @('LX*{lx:000}', 'L5*{lx:000}*{l502}*****{l507}', 'L0*{lx:000}***{l004}*PG*{l006}*G', 'L1*{lx:000}*{l102}*PG*{l104}') + Close = @('L3*{l301}*G***{b307}') + Se = 'SE*{se}*{st:000000000}' + SeExtra = 0 + Tail = @('GE*{invoices}*{control}', 'IEA*1*{control:000000000}') +} diff --git a/DataAgent/fmt/pdi.ps1 b/DataAgent/fmt/pdi.ps1 new file mode 100644 index 0000000..b35a00a --- /dev/null +++ b/DataAgent/fmt/pdi.ps1 @@ -0,0 +1,14 @@ +param($Data, [hashtable] $Options) +. "$PSScriptRoot/invoice/template.ps1" +$Options = Get-InvoiceOptions $Options pdi +$rows = @(Get-InvoiceRows $Data) +& { + foreach ($head in ($rows | Select-Object -Property $Options.Head -Unique | Sort-Object $Options.Key)) { + Expand-InvoiceLine $Options.H $head $Options.Values + $lines = @($rows | Where-Object -Property $Options.Key -EQ -Value $head.($Options.Key)) + foreach ($row in ($lines | Where-Object -Property $Options.Type -EQ -Value 'd')) { Expand-InvoiceLine $Options.D $row $Options.Values } + if ($Options.I) { + foreach ($row in ($lines | Where-Object -Property $Options.Type -EQ -Value 'i')) { Expand-InvoiceLine $Options.I $row $Options.Values } + } + } +} | Set-Content -LiteralPath $Options.Path -Encoding utf8NoBOM diff --git a/DataAgent/fmt/x12-210.ps1 b/DataAgent/fmt/x12-210.ps1 new file mode 100644 index 0000000..eb823ad --- /dev/null +++ b/DataAgent/fmt/x12-210.ps1 @@ -0,0 +1,30 @@ +param($Data, [hashtable] $Options) +. "$PSScriptRoot/invoice/template.ps1" +$Options = Get-InvoiceOptions $Options 'x12-210' +$rows = @(Get-InvoiceRows $Data) +$now = Get-Date +$scope = @{} + $Options.Values +$scope.now = $now +# Kept from the extracted daily-job convention, not a unique interchange-number service. +$scope.control = [int]('{0:00}{1:0000}' -f $now.ToString('yy'), ($now - [datetime]::new($now.Year, 1, 1)).TotalHours) +foreach ($key in 'now', 'control') { if ($Options.Values.ContainsKey($key)) { $scope[$key] = $Options.Values[$key] } } +& { + foreach ($t in $Options.Head) { Expand-InvoiceLine $t $null $scope } + $st = 0 + foreach ($invoice in ($rows | Select-Object -Property * -ExcludeProperty $Options.Detail -Unique)) { + $scope.st = ++$st + $segments = [Collections.Generic.List[string]]::new() + foreach ($t in $Options.Invoice) { $segments.Add((Expand-InvoiceLine $t $invoice $scope)) } + $lx = 0 + foreach ($line in ($rows | Where-Object -Property $Options.Key -EQ -Value $invoice.($Options.Key))) { + $scope.lx = ++$lx + foreach ($t in $Options.Line) { $segments.Add((Expand-InvoiceLine $t $line $scope)) } + } + foreach ($t in $Options.Close) { $segments.Add((Expand-InvoiceLine $t $invoice $scope)) } + $scope.se = $segments.Count + 1 + [int]$Options.SeExtra + $segments.Add((Expand-InvoiceLine $Options.Se $invoice $scope)) + $segments + } + $scope.invoices = $st + foreach ($t in $Options.Tail) { Expand-InvoiceLine $t $null $scope } +} | Set-Content -LiteralPath $Options.Path -Encoding utf8NoBOM diff --git a/README.md b/README.md index 3aba56d..0164df2 100644 --- a/README.md +++ b/README.md @@ -3,13 +3,13 @@ the repeated part of a feed job: get data, format it, send it. use the tools you already use. ```powershell -Install-Module DataAgent -RequiredVersion 0.8.0 -Scope CurrentUser +Install-Module DataAgent -RequiredVersion 0.9.0 -Scope CurrentUser ``` ## the job ```powershell -Import-Module DataAgent -RequiredVersion 0.8.0 +Import-Module DataAgent -RequiredVersion 0.9.0 Invoke-DataAgent "$PSScriptRoot/settings.json" ``` @@ -17,10 +17,12 @@ that is the whole job's `.ps1`. the run works in the settings file's folder. a s ## what it supports -read the [capability brief](DataAgent/CAPABILITIES.md) for the config contract, all ten built-in adapters, owned options versus helper pass-throughs, custom scripts, dependencies and limits. the same file ships in the gallery module, so an agent or person can inspect the installed contract without reading code or running a job. +read the [capability brief](DataAgent/CAPABILITIES.md) for the config contract, all thirteen built-in adapters, owned options versus helper pass-throughs, custom scripts, dependencies and limits. the same file ships in the gallery module, so an agent or person can inspect the installed contract without reading code or running a job. the runner sets location to the settings file's folder, the calling script's directory, or the config's `directory` (for a module that wraps the runner), logs to screen and `yyyyMMdd.log`, cleans up when configured, then gets, formats and sends. empty results send nothing. `-WhatIf` skips the whole run. custom adapters need no core edit. +[invoice-settings.json](examples/invoice-settings.json) shows a format-only PDI job. its input uses the fields named in [the baseline templates](DataAgent/fmt/invoice/). settings supply constants through Values and override only the templates or selectors that differ. pdi and x12-210 take flat rows; dtn takes invoice objects with nested Detail arrays. prove a receiver's exact layout before adding a destination. + **0.4 is a breaking change from 0.3.** use `Invoke-DataAgent -Config` and src/fmt/dst. Pipeline/Extract/Transform/Deliver and provider/receipt helpers are removed. installation does not migrate jobs. ## test and adoption diff --git a/examples/invoice-settings.json b/examples/invoice-settings.json new file mode 100644 index 0000000..0a96583 --- /dev/null +++ b/examples/invoice-settings.json @@ -0,0 +1,4 @@ +{ + "src": { "adapter": "csv", "args": { "LiteralPath": "rows.csv" } }, + "fmt": { "adapter": "pdi", "args": { "Path": "invoices.csv", "Values": { "sender_id": "SYN" } } } +} diff --git a/examples/job.ps1 b/examples/job.ps1 index 3faefa8..de00388 100644 --- a/examples/job.ps1 +++ b/examples/job.ps1 @@ -1,2 +1,2 @@ -Import-Module DataAgent -RequiredVersion 0.8.0 -ErrorAction Stop +Import-Module DataAgent -RequiredVersion 0.9.0 -ErrorAction Stop Invoke-DataAgent "$PSScriptRoot/settings.json" diff --git a/testing/DataAgent.Test/DataAgent.Test.psd1 b/testing/DataAgent.Test/DataAgent.Test.psd1 index 2c23ff8..91b1062 100644 --- a/testing/DataAgent.Test/DataAgent.Test.psd1 +++ b/testing/DataAgent.Test/DataAgent.Test.psd1 @@ -1,6 +1,6 @@ @{ RootModule = 'DataAgent.Test.psm1' - ModuleVersion = '0.8.0' + ModuleVersion = '0.9.0' GUID = '3d16fcb0-a991-4c65-a6e4-3bfcd360688d' Author = 'Roy Ashbrook' Copyright = '(c) 2026 Roy Ashbrook. MIT License.' @@ -10,7 +10,7 @@ CmdletsToExport = @() VariablesToExport = @() AliasesToExport = @() - RequiredModules = @(@{ ModuleName = 'DataAgent'; RequiredVersion = '0.8.0' }) + RequiredModules = @(@{ ModuleName = 'DataAgent'; RequiredVersion = '0.9.0' }) FileList = @('DataAgent.Test.psd1', 'DataAgent.Test.psm1', 'LICENSE', 'synthetic.csv') PrivateData = @{ PSData = @{ Tags = @('ETL', 'DataAgent'); ProjectUri = 'https://github.com/royashbrook/DataAgent'; LicenseUri = 'https://github.com/royashbrook/DataAgent/blob/main/LICENSE' } } } diff --git a/tests/acceptance.ps1 b/tests/acceptance.ps1 index 3fa936b..2b45858 100644 --- a/tests/acceptance.ps1 +++ b/tests/acceptance.ps1 @@ -371,6 +371,7 @@ Remove-Item Env:DATAAGENT_TEST_SOURCE, Env:DATAAGENT_TEST_USER . "$PSScriptRoot/transfers.ps1" . "$PSScriptRoot/cosmos.ps1" +& "$PSScriptRoot/invoice-formats.ps1" foreach ($package in @('DataAgent','testing/DataAgent.Test')) { $name = Split-Path $package -Leaf @@ -381,7 +382,7 @@ foreach ($package in @('DataAgent','testing/DataAgent.Test')) { } Remove-Module DataAgent -Force $env:PSModulePath = (@("$root/staged", $priorModules) -join [IO.Path]::PathSeparator) -Import-Module DataAgent.Test -RequiredVersion 0.8.0 +Import-Module DataAgent.Test -RequiredVersion 0.9.0 $result = @(Test-DataAgent) Assert (@($result | Where-Object { $_ -is [IO.FileInfo] -and $_.Name -eq 'output.csv' }).Count -eq 1) 'staged optional test package exercises bundled formatter' $env:PSModulePath = $priorModules diff --git a/tests/invoice-formats.ps1 b/tests/invoice-formats.ps1 new file mode 100644 index 0000000..694814a --- /dev/null +++ b/tests/invoice-formats.ps1 @@ -0,0 +1,109 @@ +param([string] $ModulePath = "$PSScriptRoot/../DataAgent") +$ErrorActionPreference = 'Stop' +$ModulePath = (Resolve-Path $ModulePath).Path +$root = [IO.Directory]::CreateTempSubdirectory('dataagent-invoice-').FullName +$before = Get-Location +$checks = 0 +function Check($Condition, $Name) { if (!$Condition) { throw "FAIL: $Name" }; $script:checks++ } +function Format($Name, $Rows, $Options) { + $Options.Path = "$root/$Name.txt" + & "$ModulePath/fmt/$Name.ps1" -Data $Rows -Options $Options + @(Get-Content -LiteralPath $Options.Path) +} + +. "$ModulePath/fmt/invoice/template.ps1" +Check ((Expand-InvoiceLine '{id,-5}|{amount:0.00}|{invoice.id}|{absent}' @{id='row';amount=1.5} @{id='SYN';invoice=@{id='A'}}) -ceq 'SYN |1.50|A|') 'padding, precision, dotted lookup, missing field and config precedence' +Check ((Expand-InvoiceLine '{id,2}|$(never-run)' @{id='LONG'}) -ceq 'LONG|$(never-run)') 'width does not truncate and templates do not execute code' + +$culture = [Globalization.CultureInfo]::CurrentCulture +try { + foreach ($name in 'en-US', 'fr-FR') { + [Globalization.CultureInfo]::CurrentCulture = [Globalization.CultureInfo]::GetCultureInfo($name) + foreach ($value in @([datetime]'2026-10-14T13:05:00', [decimal]'1234.50')) { + Check ((Expand-InvoiceLine '{value}' @{value=$value}) -ceq "$value") "$name plain $($value.GetType().Name) keeps interpolation bytes" + Check ((Expand-InvoiceLine '{value,-32}' @{value=$value}) -ceq ('{0,-32}' -f "$value")) "$name alignment pads the interpolated value" + $format = if ($value -is [datetime]) { 'yyyyMMdd HH:mm' } else { 'N2' } + Check ((Expand-InvoiceLine ('{value:' + $format + '}') @{value=$value}) -ceq (('{0:' + $format + '}') -f $value)) "$name explicit format still receives the typed value" + } + } +} finally { [Globalization.CultureInfo]::CurrentCulture = $culture } + +$table = [Data.DataTable]::new() +foreach ($name in 'ivh_invoicenumber','ivh_billdate','ivh_totalcharge','dori','ivd_refnum','ivh_consignee','cmd_code','not_text','ivd_quantity','ivd_wgt','ivd_rate','ivd_charge','cht_itemcode') { $null = $table.Columns.Add($name) } +$null = $table.Rows.Add(@('B','20261014','2','d','B1','C','DSL','note, one','1','2','2','2','FRT')) +$null = $table.Rows.Add(@('A','20261014','3','i','','C','','','1','0','1','1','FSC')) +$null = $table.Rows.Add(@('A','20261014','3','d','A1','C','DSL','','1','2','2','2','FRT')) +$lines = Format pdi @($table.Rows) @{Values=@{sender_id='SYN'}} +$expected = @( + '"H","SYN","A","20261014",,,,,,"3","N"' + '"D","A1","C","DSL","","SYN",,"1","2","2",,"2",' + '"I","FSC","1",' + '"H","SYN","B","20261014",,,,,,"2","N"' + '"D","B1","C","DSL","note, one","SYN",,"1","2","2",,"2",' +) +$expected | Set-Content "$root/expected.txt" -Encoding utf8NoBOM +Check ((Get-FileHash "$root/pdi.txt").Hash -ceq (Get-FileHash "$root/expected.txt").Hash) 'pdi DataRow input has exact H/D/I bytes, sorted invoices and D before I' +$lines = Format pdi @([pscustomobject]@{id='A';kind='d';amount=2}) @{Key='id';Head='id';Type='kind';H='H,{id}';D='D,{amount:0.00}';I=''} +Check (($lines -join '|') -ceq 'H,A|D,2.00') 'pdi record selectors and whole templates can be replaced' + +$dated = $table.Clone() +$dated.Columns['ivh_billdate'].DataType = [datetime] +$date = [datetime]'2026-10-14T13:05:00' +$null = $dated.Rows.Add(@('A',$date,'3','d','A1','C','DSL','','1','2','2','2','FRT')) +$lines = Format pdi @($dated.Rows) @{Values=@{sender_id='SYN'}} +Check ($lines[0] -ceq ('"H","SYN","A","{0}",,,,,,"3","N"' -f "$date")) 'pdi DateTime DataRow preserves the legacy interpolated date' +$lines = Format pdi @($dated.Rows) @{H='"H","{ivh_billdate:yyyyMMdd}"'} +Check ($lines[0] -ceq '"H","20261014"') 'pdi DateTime DataRow still supports explicit formatting' + +$rows = @( + [pscustomobject]@{ b302='A';b303='B1';b306='20261014';b307='3.00';b309='20261014';b312='PP';l502='DSL';l507='DIESEL';l004=2;l006=2;l102=1;l104=2 } + [pscustomobject]@{ b302='A';b303='B1';b306='20261014';b307='3.00';b309='20261014';b312='PP';l502='FSC';l507='FUEL';l004=1;l006=1;l102=1;l104=1 } + [pscustomobject]@{ b302='B';b303='B2';b306='20261014';b307='1.00';b309='20261014';b312='PP';l502='DSL';l507='DIESEL';l004=1;l006=1;l102=1;l104=1 } +) +$values = @{sender_id='SYN';receiver_id='RECEIVER';company_name='Synthetic Carrier';company_address='1 Example St';company_city='Example';company_state='FL';company_zip='00000';now=[datetime]'2026-10-14T09:30:00';control=260123} +$lines = Format x12-210 $rows @{Values=$values} +$isa = 'ISA*00* *00* *02*SYN *ZZ*RECEIVER *261014*0930*U*00401*000260123*0*P*>' +Check ($lines[0] -ceq $isa) 'every default ISA field, including padding, date, time, control and component separator' +Check ($lines[1] -ceq 'GS*IM*SYN*RECEIVER*20261014*0930*260123*X*004010') 'every GS field uses the same filing time and configured identifiers' +Check ($lines[-2] -ceq 'GE*2*260123' -and $lines[-1] -ceq 'IEA*1*000260123') 'GE/IEA counts and control numbers match their envelopes' +Check (@($lines | Where-Object { $_ -ceq 'SE*22*000000001' }).Count -eq 1 -and @($lines | Where-Object { $_ -ceq 'SE*18*000000002' }).Count -eq 1) 'actual ST-through-SE segment counts for multiple invoices and details' +Check (@($lines | Where-Object { $_ -ceq 'LX*001' }).Count -eq 2 -and @($lines | Where-Object { $_ -ceq 'LX*002' }).Count -eq 1) 'line numbering restarts in each invoice' +$variant = @{} + $values +$lines = Format x12-210 $rows @{Values=$variant;Head=@('ISA*{sender_id,-10}*000{control}','GS*000{control}');Tail=@('GE*{invoices}*{control}','IEA*1*{control}');SeExtra=2} +Check ($lines[0] -ceq 'ISA*SYN *000260123' -and $lines[1] -ceq 'GS*000260123' -and $lines[-1] -ceq 'IEA*1*260123') 'legacy width and inconsistent prefix stay explicit template overrides' +Check (@($lines | Where-Object { $_ -ceq 'SE*24*000000001' }).Count -eq 1) 'a legacy count offset is explicit, not the default' + +$invoices = @( + @{ivh_invoicenumber='A';ivh_billdate='20261014';ivh_totalcharge=3;ShipDate='20261013';ShipTime='0830';Detail=@(@{RecordType='ITMF';bol='B1';ivd_description='DIESEL';cmd_code='DSL';ivd_quantity=2;ivd_wgt=3;ivd_unit='GA';ivd_rate=1;ivd_charge=2},@{RecordType='OTHF';ivd_rate=1;ivd_charge=1})} + @{ivh_invoicenumber='B';Detail=@(@{RecordType='ITMF';ivd_rate=1.23456;ivd_charge=2.345})} +) +$values = @{sender_id='SYNDTN';terms='NET-30';dtn=@{user='synthetic-user';pass='synthetic-password';site='SYN-SITE'}} +$lines = Format dtn $invoices @{Values=$values} +Check ($lines[0] -ceq 'synthetic-user,synthetic-password' -and $lines[1] -ceq 'INF' -and $lines[3] -ceq 'SYN-SITE') 'DTN login and site are configured values' +Check ($lines[2] -ceq 'C02,1001, / / ,00:00,COMP,2,0001,S' -and $lines[9] -ceq 'C02,1002, / / ,00:00,COMP,2,0001,S') 'one numbered DTN block per nested invoice' +Check ($lines[5].Split(',').Count -eq 35 -and $lines[5] -like '"INVH","SYNDTN","A",*"NET-30"*') 'DTN header order, field count, sender and terms' +Check ($lines[6] -ceq '"ITMF","B1","DIESEL","DSL","G","2","3","2","GA","1.0000","2.00","20261013","0830","","",""') 'DTN detail has exact precision, parent date/time and trailing empty columns' +Check ($lines[13] -ceq '"ITMF","","","","G","","","","","1.2346","2.35","","","","",""') 'DTN next invoice does not inherit parent data' +Check ($lines[-1] -ceq '') 'DTN file trailer' +$profile = Import-PowerShellDataFile "$ModulePath/fmt/invoice/dtn.psd1" +$profile.Line[9] = '{ivd_rate:N4}'; $profile.Line[10] = '{ivd_charge:N4}' +$profile.Values = $values +$lines = Format dtn $invoices $profile +Check ($lines[6] -like '*"1.0000","2.0000"*') 'DTN receiver precision variant is a template override' + +try { + Import-Module "$ModulePath/DataAgent.psd1" -Force + $job = New-Item -ItemType Directory "$root/runner" + $invoices | ConvertTo-Json -Depth 8 | Set-Content "$job/rows.json" + 'param($Data,$Options); Get-Content $Options.InputFile -Raw | ConvertFrom-Json' | Set-Content "$job/source.ps1" + $env:DATAAGENT_INVOICE_TEST_USER='synthetic-user' + $config = @{directory=$job.FullName;src=@{adapter='./source.ps1';args=@{InputFile='rows.json'}};fmt=@{adapter='dtn';args=@{Path='out.txt';Values=@{sender_id='SYNDTN';terms='NET-30';dtn=@{user='env:DATAAGENT_INVOICE_TEST_USER';pass='synthetic-password';site='SYN-SITE'}}}}} + $null = Invoke-DataAgent $config + Check ((Get-Content "$job/out.txt")[0] -ceq 'synthetic-user,synthetic-password') 'whole runner resolves nested env values into the DTN file' + $log = Get-Content (Join-Path $job ('{0:yyyyMMdd}.log' -f (Get-Date))) -Raw + Check ($log -match 'End' -and $log -notmatch 'synthetic-user|synthetic-password') 'whole runner completes without logging the DTN credentials' +} finally { + Remove-Item Env:DATAAGENT_INVOICE_TEST_USER -ErrorAction SilentlyContinue + Set-Location $before +} +"PASS: $checks invoice checks; evidence: $root" diff --git a/tests/package.ps1 b/tests/package.ps1 index 5b50b57..8a401c7 100644 --- a/tests/package.ps1 +++ b/tests/package.ps1 @@ -9,6 +9,7 @@ try { $version = (Import-PowerShellDataFile "$PSScriptRoot/../DataAgent/DataAgent.psd1").ModuleVersion $installed = "$root/saved/DataAgent/$version" & "$PSScriptRoot/capabilities.ps1" -ModulePath $installed + & "$PSScriptRoot/invoice-formats.ps1" -ModulePath $installed if ((Get-FileHash "$installed/CAPABILITIES.md").Hash -ne (Get-FileHash "$PSScriptRoot/../DataAgent/CAPABILITIES.md").Hash) { throw 'packaged brief differs' } "PASS: published and saved package contains the exact brief ($version)" } finally {