Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions DataAgent/CAPABILITIES.md
Original file line number Diff line number Diff line change
Expand Up @@ -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` |
Expand All @@ -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.
Expand Down
6 changes: 3 additions & 3 deletions DataAgent/DataAgent.psd1
Original file line number Diff line number Diff line change
@@ -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.'
Expand All @@ -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.'
}
}
}
20 changes: 20 additions & 0 deletions DataAgent/fmt/dtn.ps1
Original file line number Diff line number Diff line change
@@ -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
11 changes: 11 additions & 0 deletions DataAgent/fmt/invoice/dtn.psd1
Original file line number Diff line number Diff line change
@@ -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 = @('<!--END OF FILE-->')
}
9 changes: 9 additions & 0 deletions DataAgent/fmt/invoice/pdi.psd1
Original file line number Diff line number Diff line change
@@ -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}",'
}
30 changes: 30 additions & 0 deletions DataAgent/fmt/invoice/template.ps1
Original file line number Diff line number Diff line change
@@ -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()
}
28 changes: 28 additions & 0 deletions DataAgent/fmt/invoice/x12-210.psd1
Original file line number Diff line number Diff line change
@@ -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}')
}
14 changes: 14 additions & 0 deletions DataAgent/fmt/pdi.ps1
Original file line number Diff line number Diff line change
@@ -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
30 changes: 30 additions & 0 deletions DataAgent/fmt/x12-210.ps1
Original file line number Diff line number Diff line change
@@ -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
8 changes: 5 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,24 +3,26 @@
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"
```

that is the whole job's `.ps1`. the run works in the settings file's folder. a secret stays out of the file: write `"env:NAME"` as the value and it is read from the environment when the run starts, and `{ "username": ..., "password": ... }` becomes a PSCredential. a script can still pass a hashtable instead of a path. [settings.json](examples/settings.json) and [job.ps1](examples/job.ps1) show SQL + CSV + email.

## 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
Expand Down
4 changes: 4 additions & 0 deletions examples/invoice-settings.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
{
"src": { "adapter": "csv", "args": { "LiteralPath": "rows.csv" } },
"fmt": { "adapter": "pdi", "args": { "Path": "invoices.csv", "Values": { "sender_id": "SYN" } } }
}
2 changes: 1 addition & 1 deletion examples/job.ps1
Original file line number Diff line number Diff line change
@@ -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"
4 changes: 2 additions & 2 deletions testing/DataAgent.Test/DataAgent.Test.psd1
Original file line number Diff line number Diff line change
@@ -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.'
Expand All @@ -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' } }
}
3 changes: 2 additions & 1 deletion tests/acceptance.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down
Loading
Loading