If you sell to big retailers, sooner or later a purchase order arrives as a single line of text with * and ~ everywhere. This post walks through an ANSI X12 850 purchase order segment by segment, then builds a small JavaScript parser that detects the delimiters, reads the order, and expands the cross-dock SDQ segment into one flat CSV row per store.
The sample used here is a synthetic Walmart-style cross-dock PO from an open sample set: ansi-x12-edi-sample-files.
Anatomy of an 850
ISA*00* *00* *08*925485US00 *12*5551234567 *261109*0830*^*00501*000010850*0*P*:~
GS*PO*925485US00*5551234567*20261109*0830*10850*X*005010~
ST*850*0001~
BEG*00*SA*4500991288**20261109~
REF*DP*0018~
N1*ST*WALMART DC 6094G-RDC*UL*0078742035260~
PO1*1*100*EA*12.50*WE*UP*012345678905*IN*554433221*VN*SKU-TOWEL-BLU-L*UK*10012345678902~
PO4*10*1*EA~
SDQ*EA*92*0101*25*0102*35*0103*40~
PO1*2*60*EA*18.00*WE*UP*012345678912*IN*554433299*VN*SKU-ROBE-WHT-XL~
SDQ*EA*92*0101*20*0102*20*0103*20~
CTT*2*160~
SE*27*0001~
GE*1*10850~
IEA*1*000010850~
(Trimmed for readability; the full file is in the repo.)
| Segment | What it carries |
|---|---|
ISA / IEA
|
Interchange envelope: sender/receiver IDs, date, control number, and the delimiters |
GS / GE
|
Functional group. PO means purchase order group |
ST / SE
|
One transaction set. SE01 is the segment count, so it must match |
BEG |
Beginning of PO. BEG03 is the PO number (4500991288) |
REF*DP |
Department number |
N1*ST |
Ship-to party, here a distribution center |
PO1 |
One line item: quantity (PO102), unit, unit price, then pairs of ID qualifiers and values |
SDQ |
Destination quantity: how the line quantity is split across locations |
CTT |
Transaction totals: line count and hash total |
In PO1, everything after the price is qualifier, value pairs. UP is the UPC, VN is the vendor's SKU, IN is the buyer's item number.
Step 1: Sniff the delimiters from the ISA header
X12 does not fix the separators. Most files use * between elements and ~ between segments, but you should not assume that. The trick is that the ISA segment is fixed width: exactly 106 characters. That gives you the delimiters at known positions:
- index 3 is the element separator (the character right after
ISA) - index 104 is the component separator (
ISA16) - index 105 is the segment terminator
// Drop a UTF-8 byte order mark if the file starts with one.
const stripBom = (s) => (s.charCodeAt(0) === 0xfeff ? s.slice(1) : s);
export function sniffDelimiters(raw) {
const text = stripBom(raw).trimStart();
if (!text.startsWith("ISA") || text.length < 106) {
throw new Error("Not an X12 interchange: ISA header must be 106 characters");
}
return {
element: text[3],
component: text[104],
segment: text[105],
};
}
Two real-world gotchas: a UTF-8 BOM at the start of the file, and files where each segment is followed by a newline. The newline is not part of the terminator, so strip it before splitting.
Step 2: Split into segments
export function parseSegments(raw) {
const d = sniffDelimiters(raw);
return stripBom(raw)
.trim()
.split(d.segment)
.map((s) => s.replace(/[\r\n]/g, "").trim())
.filter(Boolean)
.map((s) => s.split(d.element));
}
Each segment is now an array where seg[0] is the tag. Note that seg[1] is element 01, so PO102 is seg[2].
Step 3: Expand SDQ into one row per store
SDQ is what makes cross-dock orders awkward in a spreadsheet. A single PO1 line says "100 towels", and the SDQ right after it says how many go to each store:
PO1*1*100*EA*12.50*...
SDQ*EA*92*0101*25*0102*35*0103*40~
Read it as:
-
SDQ01 = EA: unit of measure -
SDQ02 = 92: location qualifier, assigned by the buyer - then pairs of
location, quantity: store0101gets 25,0102gets 35,0103gets 40
The quantities must add up to PO102: 25 + 35 + 40 = 100. If they do not, that is worth failing loudly on, because shipping against a bad split is how you end up with a chargeback.
export function flattenCrossDock(segments) {
const rows = [];
const poNumber = segments.find((s) => s[0] === "BEG")?.[3];
let line = null;
for (const seg of segments) {
if (seg[0] === "PO1") {
line = { po: poNumber, lineNo: seg[1], qty: Number(seg[2]), ids: {} };
for (let i = 6; i + 1 < seg.length; i += 2) line.ids[seg[i]] = seg[i + 1];
} else if (seg[0] === "SDQ" && line) {
let sum = 0;
for (let i = 3; i + 1 < seg.length; i += 2) {
const qty = Number(seg[i + 1]);
sum += qty;
rows.push({
po: line.po, line: line.lineNo,
sku: line.ids.VN ?? "", upc: line.ids.UP ?? "",
store: seg[i], qty, uom: seg[1], locQualifier: seg[2],
});
}
if (sum !== line.qty) {
throw new Error(`PO1 line ${line.lineNo}: SDQ sums to ${sum} but PO102 is ${line.qty}`);
}
}
}
return rows;
}
Running it on the sample file prints:
po,line,sku,upc,store,qty,uom,locQualifier
4500991288,1,SKU-TOWEL-BLU-L,012345678905,0101,25,EA,92
4500991288,1,SKU-TOWEL-BLU-L,012345678905,0102,35,EA,92
4500991288,1,SKU-TOWEL-BLU-L,012345678905,0103,40,EA,92
4500991288,2,SKU-ROBE-WHT-XL,012345678912,0101,20,EA,92
4500991288,2,SKU-ROBE-WHT-XL,012345678912,0102,20,EA,92
4500991288,2,SKU-ROBE-WHT-XL,012345678912,0103,20,EA,92
Two lines became six rows, one per store, ready to paste into Excel or import elsewhere. One thing to remember when you open this in a spreadsheet: keep the UPC and store columns as text, or leading zeros will disappear (0101 becomes 101).
Things this sketch does not handle
This is a teaching parser, not a production one. It ignores:
- multiple
ISAenvelopes or multipleSTsets in one file -
SDQsegments with more than 10 location pairs, which retailers split across severalSDQsegments in the samePO1loop - repeated
PO1identifiers with unusual qualifiers - validation of
SE01andCTT01against the actual counts
Try it without writing code
If you just want to look at a file, you can paste or drop it into this browser-based EDI viewer. It parses locally in the browser, so the file is not uploaded. There is also a reference page for the segment itself: SDQ destination quantity (cross-dock). I build that tool, so treat this as a disclosure as much as a link.
More sample files (850, 855, 856, 810, 820, 824, 846, 997) are in the GitHub repo if you want test fixtures for your own parser.
Top comments (0)