EDI 850 purchase orders
Your biggest retail customer places its orders the old-fashioned way. Instead of calling a web service, it writes purchase orders in a long-standing business format called EDI and drops them, several at a time, into a shared mailbox on a secure file server. The job is simple to state. Every fifteen minutes, collect whatever new orders have arrived, read them, and book the good ones into your order system the same hour. Now and then a customer will send an order that is garbled or incomplete. That one must be turned away politely with a standard “we could not accept this” reply, and one bad order must never hold up all the others in the same batch.
So three parties are involved: the retail customer who writes the orders, the shared mailbox where they land, and your own order system that needs to book them. Every fifteen minutes you sweep the mailbox, open each order, book the ones that look right, and send the customer a short rejection notice for any that do not. The picture below is the whole story in plain terms.
Here is how Art2link ESB builds that. Everything inside the product moves as small messages across a shared message backbone called the bus. A flow is wired from a few simple parts: a receive port brings data in from the outside world, a send port hands data out to a destination, and an adapter is the connector a port uses to talk to a specific kind of system (a file server, a database, a mailbox). The trigger here is the scheduled-download shape: a Scheduler (a clock that wakes the flow on a timer) ticks every fifteen minutes, and a two-way SFTP Caller port reaches into the mailbox and fetches whatever is new.
The interesting work happens on the way back in. EDI is written as lines of cryptic codes that nothing else on the bus understands, so it has to be translated the moment it arrives. That translation is done by a pipeline component, a small piece of custom code that runs on a port to reshape a message as it passes through. The component here is a disassembler: it reads the incoming EDI bundle (which can hold several orders), splits it into one order at a time, and rewrites each one as a clean, ordinary order record the rest of the flow can work with. As it does so it stamps each one with a message type of Edi850Order, a label that says “this is a purchase order,” so later parts of the flow can pick it out. This is the splitter pattern with a translation step folded in, because only this component knows where one order ends and the next begins. The same idea reused as a plain receive port is the pipeline component at its most basic.
Each order, now a clean Edi850Order on the bus, is checked against a schema (a rulebook for what a valid order must contain) before anything is booked. The orders that pass are ordinary order-intake material. The port that books them watches the bus and picks up only messages carrying that label, a standing instruction called a subscription:
{{Message.MessageType}} == "Edi850Order"
When it fails. EDI fails in layers, and each layer has its place. A file that is not an interchange at all, truncated, wrong delimiters, is rejected by the disassembler as a poison message and the run suspends at the edge with the original bytes intact in tracking; Activity Notifications (On Error Only) mails the team. A single bad 850 inside a good interchange fails alone after the split, exactly like a bad record in the settlement debatch. A PO that parses cleanly but violates the schema is marked errored at validation; the fetch port’s exception handling re-publishes it as Edi850Rejected, and a map, its control number drawn by a custom function, turns that into a negative 997 back to the partner (Step 5), a deterministic, terminal rejection, not a retry. And a booking failure downstream re-publishes under the shared exception type with the usual O365 Mail alert:
{{Message.MessageType}} == "EdiOrderFailed"
Build it, step by step. The steps run in dependency order: every object is created before the object that selects it.
Under the application’s Message types, create the five types this flow routes on. A message type is a name plus a format:
| Name | Format | Purpose |
|---|---|---|
| EdiInterchange | TXT | the raw interchange as fetched, the fallback type the disassembler component reads and the archive port (Step 9) subscribes to; never a map source |
| Edi850Order | JSON | one parsed PO the component emits and classifies, the type the schema below validates |
| Edi850Rejected | JSON | a PO that failed validation, on its way to a 997; the JSON source of the Step 5 map |
| Edi997Ack | TXT | the negative acknowledgment the map renders as text on its output side (Step 5) |
| EdiOrderFailed | JSON | the shared exception type the operational alerts subscribe to (Step 11) |
Promotions live on the message type, so add them while you are here. The booking port’s parameters (Step 8) and the 997 filename (Step 10) bind these values, and adapter parameters are plain strings: they bind {{…}} tokens but never evaluate a body path.
| Promotion | Path |
|---|---|
| PoNumber | $.orderNumber |
| CustomerId | $.customer.id |
| OrderTotal | $.total |
PoNumber surfaces every parsed PO’s number as {{Promoted.Edi850Order.PoNumber}} for routing and tracing; the other two feed the booking procedure.
A rejected PO re-publishes payload intact, but promotions are type-qualified, so the rejection type needs its own; the rejected PO’s own interchange number keeps each acknowledgment distinct (Step 10). EdiOrderFailed needs none, its alert subject binds {{Message.MessageType}}:
| Promotion | Path |
|---|---|
| InterchangeControl | $.interchange.control |
Associate a schema with the Edi850Order message type. Because the disassembler (Step 4) emits JSON, this is a JSON Schema; for an XML payload it would be XSD. Validation fires automatically the moment a message is classified as Edi850Order, a fast structural rejection before the order is booked or routed. A payload that fails is marked errored: the outcome is deterministic and terminal, so there is no retry that would rescue it, the right response is to tell the partner, which is what the 997 does.
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"title": "Edi850Order",
"type": "object",
"required": ["orderNumber", "customer", "lines"],
"properties": {
"orderNumber": { "type": "string", "pattern": "^PO-" },
"customer": {
"type": "object",
"required": ["id"],
"properties": {
"id": { "type": "string" },
"name": { "type": "string" }
}
},
"lines": {
"type": "array",
"minItems": 1,
"items": {
"type": "object",
"required": ["sku", "qty"],
"properties": {
"sku": { "type": "string" },
"qty": { "type": "integer", "minimum": 1 },
"unitPrice": { "type": "number" }
}
}
}
}
}Three external parties, three credentials. Under the application’s Authentications, the dialog asks for a Name, the Adapter it pairs with, and a Definition, the credential shape that adapter offers, with the Application preset; the Definition decides the config section that follows.
| Setting | Value |
|---|---|
| Name | RetailcoSftp |
| Adapter | SFTP Caller |
| Definition | SFTP Password Authentication |
| Username / Password | the credentials for the customer’s SFTP server |
| Host Key Fingerprint (optional) | the customer’s host-key fingerprint |
| Setting | Value |
|---|---|
| Name | OrdersDbSql |
| Adapter | SQL Caller |
| Definition | SQL Server Connection |
| Connection String (Database Config) | the order database’s connection string, credentials included |
| Setting | Value |
|---|---|
| Name | O365Ops |
| Adapter | O365 Mail Sender |
| Definition | Microsoft Graph |
| Tenant Id / Client Id / Client Secret (Graph AuthConfig) | an app registration with Mail.Send granted |
Create the table and procedure the booking port (Step 8) calls, repeated here so this tutorial is self-contained; the MERGE is idempotent on the PO number:
CREATE TABLE dbo.Orders ( OrderNumber VARCHAR(40) NOT NULL PRIMARY KEY, CustomerId VARCHAR(40) NOT NULL, CustomerName NVARCHAR(200) NULL, Region VARCHAR(20) NULL, OrderTotal DECIMAL(18,2) NOT NULL, Currency CHAR(3) NOT NULL CONSTRAINT DF_Orders_Cur DEFAULT 'EUR', ReceivedAt DATETIME2 NOT NULL CONSTRAINT DF_Orders_Rcv DEFAULT SYSUTCDATETIME() ); CREATE OR ALTER PROCEDURE dbo.UpsertOrder @OrderNumber VARCHAR(40), @CustomerId VARCHAR(40), @OrderTotal DECIMAL(18,2) AS BEGIN SET NOCOUNT ON; MERGE dbo.Orders AS tgt USING (SELECT @OrderNumber AS OrderNumber) AS src ON tgt.OrderNumber = src.OrderNumber WHEN MATCHED THEN UPDATE SET CustomerId = @CustomerId, OrderTotal = @OrderTotal WHEN NOT MATCHED THEN INSERT (OrderNumber, CustomerId, OrderTotal) VALUES (@OrderNumber, @CustomerId, @OrderTotal); END;
Create the counter table and the transactional draw, the same discipline the 810 tutorial applies to its interchange numbers. There the number reaches the assembler component through a SQL Caller and a Variable; here a custom function (Step 5) draws it directly, two routes to the same atomic increment:
CREATE TABLE dbo.EdiControlNumber ( Partner VARCHAR(30) NOT NULL PRIMARY KEY, LastControlNo BIGINT NOT NULL CONSTRAINT DF_Ctl DEFAULT 0 ); INSERT INTO dbo.EdiControlNumber (Partner, LastControlNo) VALUES ('RETAILCO', 0); CREATE OR ALTER PROCEDURE dbo.NextControlNumber @Partner VARCHAR(30) AS BEGIN SET NOCOUNT ON; DECLARE @next BIGINT; UPDATE dbo.EdiControlNumber WITH (UPDLOCK, HOLDLOCK) SET @next = LastControlNo = LastControlNo + 1 WHERE Partner = @Partner; SELECT @next AS ControlNumber; END;
In Pipeline components, create Edi850Disassembler, the AI assistant’s guided mode fits. It splits the interchange on segment terminators, walks each ST…SE set, and emits one Edi850Order per PO. Critically, each message carries the interchange, group and transaction control numbers, the 997 must echo them if this PO is later rejected.
ISA*00* *00* *ZZ*RETAILCO *ZZ*YOURCO *260605*0710*U*00401*000004821*0*P*>~ GS*PO*RETAILCO*YOURCO*20260605*0710*4821*X*004010~ ST*850*0001~ BEG*00*SA*PO-7714**20260605~ N1*ST*Brams Outdoor BV*92*C-2201~ PO1*1*40*EA*61.20**VN*TP-4501~ CTT*1~ SE*7*0001~ GE*1*4821~ IEA*1*000004821~
using System.Text.Json; using CC.Art2link.Pipelines.Domain.Models.PipelineComponents; public sealed class EdiConfig { public string SegmentTerminator { get; set; } = "~"; public string ElementSeparator { get; set; } = "*"; } public sealed class Edi850Disassembler : PipelineComponentBase<EdiConfig> { public override string Name => "Edi850Disassembler"; protected override Task<PipelineComponentOutput> ExecuteAsync( PipelineComponentInput input, EdiConfig config, CancellationToken cancellationToken) { try { var segments = input.Body .Split(config.SegmentTerminator, StringSplitOptions.RemoveEmptyEntries) .Select(s => s.Trim().Split(config.ElementSeparator)) .ToList(); var isa = segments.FirstOrDefault(s => s[0] == "ISA"); var gs = segments.FirstOrDefault(s => s[0] == "GS"); var sender = isa is not null ? isa[6].Trim() : ""; var receiver = isa is not null ? isa[8].Trim() : ""; var control = isa is not null ? isa[13].Trim() : ""; var groupControl = gs is not null ? gs[6].Trim() : ""; var functionalId = gs is not null ? gs[1].Trim() : ""; // GS01, e.g. "PO" var messages = new List<PipelineMessage>(); List<string[]>? st = null; foreach (var seg in segments) { if (seg[0] == "ST") st = new List<string[]>(); st?.Add(seg); if (seg[0] == "SE" && st is not null) { messages.Add(BuildOrder(st, sender, receiver, control, groupControl, functionalId)); st = null; } } return Task.FromResult(new PipelineComponentOutput { Success = true, Messages = messages }); } catch (Exception ex) { return Task.FromResult(new PipelineComponentOutput { Success = false, ErrorMessage = $"Edi850Disassembler: {ex.Message}", Exception = ex }); } } private static PipelineMessage BuildOrder( List<string[]> st, string sender, string receiver, string control, string groupControl, string functionalId) { var stSeg = st.First(s => s[0] == "ST"); var beg = st.First(s => s[0] == "BEG"); var n1 = st.FirstOrDefault(s => s[0] == "N1"); var lines = st.Where(s => s[0] == "PO1") .Select(p => new { sku = p[7], qty = int.Parse(p[2]), unitPrice = decimal.Parse(p[4]) }) .ToArray(); var total = lines.Sum(l => l.qty * l.unitPrice); var order = new { orderNumber = beg[3], customer = new { id = n1?[4], name = n1?[2] }, total, lines, // everything a 997 must echo about this message, lifted from the envelope interchange = new { control, groupControl, stControl = stSeg[2], // ST02 functionalId, // GS01, e.g. "PO" transactionSetId = stSeg[1], // ST01, e.g. "850" sender, receiver } }; return new PipelineMessage { Body = JsonSerializer.Serialize(order), MessageType = "Edi850Order" }; } }
Each PO comes out as the canonical order shape, classified as Edi850Order:
{
"orderNumber": "PO-7714",
"customer": { "id": "C-2201", "name": "Brams Outdoor BV" },
"total": 2448.00,
"lines": [
{ "sku": "TP-4501", "qty": 40, "unitPrice": 61.20 }
],
"interchange": {
"control": "000004821",
"groupControl": "4821",
"stControl": "0001",
"functionalId": "PO",
"transactionSetId": "850",
"sender": "RETAILCO",
"receiver": "YOURCO"
}
}No pipeline component this time. The rejected PO already carries everything a 997 must echo, the group code (AK1), the acknowledged set (AK2), the sender and receiver (swapped, since the 997 replies to whoever sent the 850), and the acknowledgment itself is flat text, which is exactly what a map emits with method="text". The one piece of live data is the fresh interchange control number for the outgoing 997, and that is a job for a custom function: one deliberate, transactional draw from the Step 3 counter, the single-database-call shape that keep custom functions lean sanctions.
The function needs to reach the database, and a connection string is deployment configuration, not code, define it as a Constant on the application:
| Name | Description | Value |
|---|---|---|
| EdiDbConn | Control-number database connection | Server=prod-sql-01;Database=Orders;Integrated Security=true |
In Custom functions, create fnNextControlNumber. Functions are system-scoped, so from now on any map in any application can draw an EDI control number. It does exactly one thing, one stored-procedure call, one number back:
using CC.Art2link.Attributes; using Microsoft.Data.SqlClient; namespace Custom.Functions; public static class EdiFunctions { // One job: draw the next interchange control number for a partner. // Every call consumes a number, callers capture the result once. [CustomFunction("fnNextControlNumber")] public static string NextControlNumber(string partner, string connectionString) { using var conn = new SqlConnection(connectionString); conn.Open(); using var cmd = new SqlCommand("dbo.NextControlNumber", conn); cmd.CommandType = System.Data.CommandType.StoredProcedure; cmd.Parameters.AddWithValue("@Partner", partner); return Convert.ToInt64(cmd.ExecuteScalar()).ToString(); } }
Under the application’s Maps, create the map; both message types come from Step 1:
| Map setting | Value |
|---|---|
| Name | Edi850RejectedToEdi997Ack |
| Source message type | Edi850Rejected, from Step 1 |
| Target message type | Edi997Ack, from Step 1 |
The rejected order’s JSON body arrives as a string the map parses with parse-json(); it draws the control number once into a variable via art:invoke and renders the whole 997 as text. The {{Constant.EdiDbConn}} argument is resolved by the binding preprocessor before the XSLT runs. The AK9/SE counts are for the single set being acknowledged, a 997 that acknowledges several sets at once would aggregate them across the functional group:
<xsl:stylesheet version="3.0" xmlns:xsl="http://www.w3.org/1999/XSL/Transform" xmlns:art="urn:art2link:functions" exclude-result-prefixes="art"> <xsl:output method="text"/> <!-- JSON arrives as a string; parse-json() turns it into an XPath 3.1 map --> <xsl:template match="/"> <xsl:variable name="in" select="parse-json(.)"/> <!-- The 997 answers whoever sent the 850, so the IDs swap. --> <xsl:variable name="sender" select="string($in?interchange?receiver)"/> <xsl:variable name="receiver" select="string($in?interchange?sender)"/> <!-- One deliberate call. Every invocation draws a number from the counter, so capture it once and reuse the variable. --> <xsl:variable name="ctl" select="art:invoke('fnNextControlNumber', $receiver, '{{Constant.EdiDbConn}}')"/> <xsl:variable name="ctl9" select="format-number(number($ctl), '000000000')"/> <xsl:variable name="now" select="current-dateTime()"/> <xsl:variable name="d6" select="format-dateTime($now, '[Y,2-2][M01][D01]')"/> <xsl:variable name="d8" select="format-dateTime($now, '[Y0001][M01][D01]')"/> <xsl:variable name="t4" select="format-dateTime($now, '[H01][m01]')"/> <xsl:variable name="segments" select="( 'ISA*00* *00* *ZZ*' || substring($sender || ' ', 1, 15) || '*ZZ*' || substring($receiver || ' ', 1, 15) || '*' || $d6 || '*' || $t4 || '*U*00401*' || $ctl9 || '*0*P*>', 'GS*FA*' || $sender || '*' || $receiver || '*' || $d8 || '*' || $t4 || '*' || $ctl || '*X*004010', 'ST*997*0001', 'AK1*' || $in?interchange?functionalId || '*' || $in?interchange?groupControl, 'AK2*' || $in?interchange?transactionSetId || '*' || $in?interchange?stControl, 'AK5*R', 'AK9*R*1*1*0', 'SE*6*0001', 'GE*1*' || $ctl, 'IEA*1*' || $ctl9 )"/> <xsl:value-of select="string-join($segments, '~ ')"/> <xsl:text>~ </xsl:text> </xsl:template> </xsl:stylesheet>
The negative acknowledgment the partner receives:
ISA*00* *00* *ZZ*YOURCO *ZZ*RETAILCO *260605*0712*U*00401*000000031*0*P*>~ GS*FA*YOURCO*RETAILCO*20260605*0712*31*X*004010~ ST*997*0001~ AK1*PO*4821~ AK2*850*0001~ AK5*R~ AK9*R*1*1*0~ SE*6*0001~ GE*1*31~ IEA*1*000000031~
Create the Scheduler receive port EdiMailboxClock: Repeat Every 15, Repeat Unit Minutes. Everything the ports from here on reference already exists, so each field is a pure selection. (The dropdowns can also create types and maps inline; building the dependencies first keeps each dialog a selection.)
Create a two-way SFTP Caller send port EdiMailboxFetch subscribing on {{Config.PortName}} == "EdiMailboxClock":
| Setting | Value |
|---|---|
| Adapter | SFTP Caller |
| Way | Two |
| Host | sftp.retailco.example |
| Authentication | RetailcoSftp, from Step 2 |
| Command | Download |
| Remote path | /outbound/po, the mailbox directory the customer drops interchanges in; the download picks up whatever is new since the last tick |
In the port’s inbound flow, classification comes first: classify the fetched file as EdiInterchange at the start of the inbound flow, before the disassembler runs, so the raw interchange publishes under the type the archive port (Step 9) subscribes to. Then reference Edi850Disassembler, from Step 4, in the inbound pipeline.
Set this port’s Exception Message Type to Edi850Rejected, from Step 1, when schema validation errors a message in the inbound flow, the port re-publishes it under that type, payload and control numbers intact, for the 997 path to pick up.
Create a send port OrderBooking on the SQL Caller, subscribing on {{Message.MessageType}} == "Edi850Order". Only validated orders ever carry that type, so nothing un-checked reaches the database. The Step 1 PoNumber Promotion earns its keep here too: a narrower subscription such as {{Promoted.Edi850Order.PoNumber}} == "PO-7714" pulls a single PO out of the stream when one needs tracing or quarantining.
| Setting | Value |
|---|---|
| Adapter | SQL Caller |
| Way | One |
| Auth Config | OrdersDbSql, from Step 2 |
| Command Type | StoredProcedure |
| Command Text | dbo.UpsertOrder |
Adapter parameters bind promotions, not body paths; the three Promotions defined on Edi850Order in Step 1 supply the values to the procedure from Step 3, one key/value row each:
| Parameter | Value |
|---|---|
| OrderNumber | {{Promoted.Edi850Order.PoNumber}} |
| CustomerId | {{Promoted.Edi850Order.CustomerId}} |
| OrderTotal | {{Promoted.Edi850Order.OrderTotal}} |
Add an archive SFTP send port EdiArchive on {{Message.MessageType}} == "EdiInterchange", the type the fetch port classifies in Step 7, writing to Remote Directory /archive/edi-in on your archive server to keep the raw file.
Create an SFTP Caller send port Ack997Out subscribing on {{Message.MessageType}} == "Edi850Rejected", delivering to the partner’s acknowledgment directory:
| Setting | Value |
|---|---|
| Adapter | SFTP Caller |
| Host | sftp.retailco.example |
| Authentication | RetailcoSftp, from Step 2 |
| Remote Directory | /inbound/ack |
| Filename | 997_{{Promoted.Edi850Rejected.InterchangeControl}}.edi, binding the Promotion from Step 1 |
In the port’s outbound flow, select the map from Step 5 (Typed, keyed on Edi850Rejected).
On the booking, archive and Ack997Out ports, set Exception Message Type to EdiOrderFailed, from Step 1.
Create an O365 Mail send port OpsAlert subscribing on {{Message.MessageType}} == "EdiOrderFailed":
| Setting | Value |
|---|---|
| Authentication | O365Ops, from Step 2 |
| From | noreply@acme.example |
| To | ops@acme.example |
| Subject | Operations alert, {{Message.MessageType}} |
Cover files the disassembler cannot parse at all (not even an interchange) with Activity Notifications (On Error Only), those suspend as poison messages before any PO exists to reject.
Everything you configure is live the moment you save it, there is nothing to deploy. Start the send ports first, then the receive port.
Set Tracking to Enabled + Body on every port the flow touches, so you can walk each run step by step, with its message body, in tracking.
To test without waiting for the next quarter-hour tick, temporarily set EdiMailboxClock to repeat every 1 Minutes, then restore the schedule.
Drop an interchange holding two POs, one valid, one missing a required field (say a PO with no PO1 line, which the schema’s minItems rejects).
In tracking the valid PO validates and books; the invalid one is errored at the schema, re-published as Edi850Rejected, and a negative 997 lands in the partner’s acknowledgment directory, while the raw interchange sits in the archive showing both as received.