@prefixcheck/edi 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,951 @@
1
+ // ============================================================
2
+ // @prefixcheck/edi · CODECO + COPRAR schemas, code lists, diagnostics
3
+ //
4
+ // Sourced from:
5
+ // - UN/EDIFACT D.00B directories (UNECE)
6
+ // - SMDG 2.1.3 ST VGM CODECO + COPRAR Implementation Guides
7
+ // - SMDG Recommendation 07 (code lists), JM4/120 (FTX), JM4/272 (damage)
8
+ // - DAKOSY, Valenciaport PCS, Transnet, EPB Bilbao operator guides
9
+ // ============================================================
10
+ // ── ISO 6346 check-digit (Mod-11 weighted-letter algorithm) ────
11
+ const LETTER_VALUES = {
12
+ A: 10,
13
+ B: 12,
14
+ C: 13,
15
+ D: 14,
16
+ E: 15,
17
+ F: 16,
18
+ G: 17,
19
+ H: 18,
20
+ I: 19,
21
+ J: 20,
22
+ K: 21,
23
+ L: 23,
24
+ M: 24,
25
+ N: 25,
26
+ O: 26,
27
+ P: 27,
28
+ Q: 28,
29
+ R: 29,
30
+ S: 30,
31
+ T: 31,
32
+ U: 32,
33
+ V: 34,
34
+ W: 35,
35
+ X: 36,
36
+ Y: 37,
37
+ Z: 38,
38
+ };
39
+ /**
40
+ * Validate an ISO 6346 container number's check digit (11th character).
41
+ * Letter values skip every multiple of 11.
42
+ */
43
+ export function validateCheckDigit(code) {
44
+ if (!/^[A-Z]{4}\d{7}$/.test(code))
45
+ return false;
46
+ let sum = 0;
47
+ for (let i = 0; i < 10; i++) {
48
+ const ch = code[i];
49
+ const v = i < 4 ? LETTER_VALUES[ch] : Number(ch);
50
+ sum += v * (1 << i);
51
+ }
52
+ return (sum % 11) % 10 === Number(code[10]);
53
+ }
54
+ export const SEGMENTS = {
55
+ UNA: {
56
+ name: "Service String Advice",
57
+ brief: "Optional delimiter override at the start of an interchange.",
58
+ },
59
+ UNB: {
60
+ name: "Interchange Header",
61
+ brief: "Envelope · sender, recipient, control reference, syntax level.",
62
+ },
63
+ UNZ: { name: "Interchange Trailer", brief: "Envelope close · message count + ref echo of UNB." },
64
+ UNH: {
65
+ name: "Message Header",
66
+ brief: "Message envelope open · message type + version + SMDG implementation tag.",
67
+ },
68
+ UNT: {
69
+ name: "Message Trailer",
70
+ brief: "Message envelope close · segment count + ref echo of UNH.",
71
+ },
72
+ BGM: {
73
+ name: "Beginning of Message",
74
+ brief: "Document type + reference number + function (original / replacement / change).",
75
+ },
76
+ DTM: {
77
+ name: "Date / Time / Period",
78
+ brief: "Timestamps qualified by purpose (137 issue date, 132 ETA, 134 ATA, 178 actual gate, 798 stuffing).",
79
+ },
80
+ LOC: {
81
+ name: "Place / Location Identification",
82
+ brief: "UN/LOCODE for ports, terminals, depots, plus stowage cell + next port of call.",
83
+ },
84
+ NAD: {
85
+ name: "Name and Address",
86
+ brief: "Parties to the message (CA carrier, CF container operator, TR terminal, CN consignee, CZ consignor).",
87
+ },
88
+ CTA: { name: "Contact Information", brief: "Contact person within a NAD party." },
89
+ COM: { name: "Communication Contact", brief: "Phone / email / fax for a CTA." },
90
+ RFF: {
91
+ name: "Reference",
92
+ brief: "External references — booking (BN), B/L (BM), equipment (EQ), release (AAY), voyage (VON).",
93
+ },
94
+ EQD: {
95
+ name: "Equipment Details",
96
+ brief: "Container: type CN, BIC code, ISO size-type, supplier indicator, full/empty status.",
97
+ },
98
+ EQN: { name: "Number of Units", brief: "Number of equipment units in a group." },
99
+ EQA: {
100
+ name: "Attached Equipment",
101
+ brief: "Chassis (CH) or reefer gen-set (RG) attached to the container.",
102
+ },
103
+ TMD: {
104
+ name: "Transport Movement Details",
105
+ brief: "FCL / LCL movement type, transport service code.",
106
+ },
107
+ HAN: {
108
+ name: "Handling Instructions",
109
+ brief: "How the container should be handled (handle with care, keep upright, reefer pre-cool, etc.).",
110
+ },
111
+ MEA: {
112
+ name: "Measurements",
113
+ brief: "Weights, dimensions, VGM — qualified (AAE gross, AAL tare, AAJ payload, VGM verified gross mass).",
114
+ },
115
+ DIM: {
116
+ name: "Dimensions",
117
+ brief: "Out-of-gauge dimensions (over-length, over-height, over-width).",
118
+ },
119
+ TMP: { name: "Temperature", brief: "Reefer setpoint temperature." },
120
+ RNG: { name: "Range Details", brief: "Reefer temperature range / acceptable variance." },
121
+ SEL: {
122
+ name: "Seal Number",
123
+ brief: "Container seal identifiers + applying party (CA carrier, SH shipper, TR terminal, CU customs).",
124
+ },
125
+ FTX: {
126
+ name: "Free Text",
127
+ brief: "Operator-readable narrative qualified by purpose (AAA general, DAR damage, OSI other info, ABS condition).",
128
+ },
129
+ DGS: {
130
+ name: "Dangerous Goods",
131
+ brief: "IMDG class, UN number, packing group, flashpoint — SOLAS-relevant.",
132
+ },
133
+ TDT: {
134
+ name: "Details of Transport",
135
+ brief: "Vessel name + voyage + IMO number + carrier (mode 1 sea, 2 rail, 3 road).",
136
+ },
137
+ TPL: {
138
+ name: "Transport Placement",
139
+ brief: "Stowage cell on the vessel: 6-digit BBBRRTT (bay-row-tier).",
140
+ },
141
+ DAM: {
142
+ name: "Damage",
143
+ brief: "Damage location + severity per SMDG JM4/272 (lifts ISO 9897 damage catalogue).",
144
+ },
145
+ COD: { name: "Component Details", brief: "Component code + damage detail for DAM segment." },
146
+ DOC: {
147
+ name: "Document / Message Details",
148
+ brief: "Document reference (EIR ID, gate receipt no., survey no.).",
149
+ },
150
+ GID: { name: "Goods Item Details", brief: "Description of goods inside the container." },
151
+ GDS: { name: "Nature of Cargo", brief: "Cargo nature classification." },
152
+ PIA: { name: "Additional Product Identification", brief: "Additional product identification." },
153
+ CNT: {
154
+ name: "Control Total",
155
+ brief: "CNT+16:n = number of equipment units. CNT+7:n = total TEU.",
156
+ },
157
+ STS: {
158
+ name: "Status",
159
+ brief: "Container event status (1 empty, 2 full/loaded, gate-in, gate-out, on-hire, off-hire, hold).",
160
+ },
161
+ };
162
+ export const CODE_LISTS = {
163
+ "BGM.docname": {
164
+ "34": "Transport equipment gate-in report (CODECO)",
165
+ "35": "Transport equipment gate-out report (CODECO)",
166
+ "36": "Transport equipment movement (CODECO)",
167
+ "45": "Container loading order (COPRAR Load)",
168
+ "46": "Container discharge order (COPRAR Discharge)",
169
+ "244": "Container loading list",
170
+ "245": "Pre-loading list",
171
+ "350": "Despatch advice",
172
+ },
173
+ "BGM.function": {
174
+ "1": "Cancellation",
175
+ "4": "Change (delta)",
176
+ "5": "Replacement (full re-issue)",
177
+ "6": "Confirmation",
178
+ "7": "Duplicate",
179
+ "9": "Original",
180
+ },
181
+ "DTM.qualifier": {
182
+ "7": "Effective date/time",
183
+ "11": "Despatch date/time",
184
+ "132": "ETA (estimated arrival)",
185
+ "133": "ETD (estimated departure)",
186
+ "134": "ATA (actual arrival)",
187
+ "136": "Document/message date",
188
+ "137": "Document/message issue date and time",
189
+ "178": "Actual arrival/gate of equipment",
190
+ "200": "Pick-up date/time, planned",
191
+ "201": "Delivery date/time, planned",
192
+ "203": "Execution date/time, actual",
193
+ "234": "Equipment pick-up planned (gate-out)",
194
+ "798": "Container stuffing date/time",
195
+ },
196
+ "DTM.format": {
197
+ "101": "YYMMDD",
198
+ "102": "CCYYMMDD",
199
+ "203": "CCYYMMDDHHMM (SMDG-mandated)",
200
+ "204": "CCYYMMDDHHMMSS",
201
+ "718": "CCYYMMDD–CCYYMMDD range",
202
+ },
203
+ "LOC.qualifier": {
204
+ "5": "Place of departure",
205
+ "7": "Place of delivery (final destination)",
206
+ "8": "Place of loading",
207
+ "9": "Port/place of loading (POL)",
208
+ "11": "Port/place of discharge (POD)",
209
+ "12": "Port of transhipment",
210
+ "13": "Place of transhipment",
211
+ "14": "Place of arrival",
212
+ "17": "Stowage cell",
213
+ "18": "Place of receipt",
214
+ "19": "Place of departure of carrier",
215
+ "27": "Country of origin",
216
+ "35": "Final place of delivery",
217
+ "76": "Place of consignment origin",
218
+ "83": "Place of final delivery",
219
+ "88": "Place of acceptance",
220
+ "92": "Routing",
221
+ "147": "Stowage cell / container position on board",
222
+ "152": "Container terminal",
223
+ "153": "Storage location",
224
+ "154": "Customs office",
225
+ "165": "Next port of call",
226
+ "168": "Container yard",
227
+ "172": "Estimated place of arrival",
228
+ "174": "Estimated place of departure",
229
+ "175": "Place of mooring",
230
+ "178": "Inland depot",
231
+ "197": "Country of origin of goods",
232
+ "248": "Empty container depot",
233
+ },
234
+ "EQD.type": {
235
+ AE: "Auxiliary equipment",
236
+ AF: "Aircraft ULD",
237
+ AL: "Aircraft / liner equipment",
238
+ BB: "Break bulk",
239
+ CH: "Chassis",
240
+ CN: "Container",
241
+ FF: "Flat",
242
+ GS: "General set",
243
+ PL: "Platform",
244
+ RG: "Reefer gen-set",
245
+ RR: "Rail car",
246
+ TE: "Trailer",
247
+ },
248
+ "EQD.supplier": {
249
+ "1": "Shipper-supplied",
250
+ "2": "Carrier-supplied",
251
+ "3": "Lessor / pool-supplied",
252
+ "4": "Buyer-supplied",
253
+ "5": "Container operator-supplied",
254
+ },
255
+ "EQD.fullEmpty": {
256
+ "4": "Full",
257
+ "5": "Empty",
258
+ },
259
+ "STS.code": {
260
+ "1": "Empty",
261
+ "2": "Full / loaded",
262
+ "3": "Damaged",
263
+ "4": "On-hire",
264
+ "5": "Off-hire",
265
+ "6": "In-service",
266
+ "7": "Out of service",
267
+ "8": "Available",
268
+ "9": "Hold",
269
+ "10": "Released",
270
+ "11": "Customs hold",
271
+ "14": "Inspection",
272
+ "16": "Repair required",
273
+ "17": "Cleaning required",
274
+ AAJ: "Inland transit equipment",
275
+ AAW: "Gate-in inspection done",
276
+ AE: "Equipment movement reported",
277
+ AKO: "Active reefer (plugged in)",
278
+ AKD: "Reefer unplugged",
279
+ LDD: "Loaded onto vessel",
280
+ UNL: "Unloaded from vessel",
281
+ RST: "Restow",
282
+ SHF: "Shifting",
283
+ OK: "Acceptable for next use",
284
+ TARE: "Tare weight verified",
285
+ },
286
+ "RFF.qualifier": {
287
+ AAY: "Release order number",
288
+ ABO: "Transhipment number",
289
+ ABT: "Internal customer number",
290
+ ACW: "Reference assigned by trade agent (e.g., survey no.)",
291
+ BN: "Booking reference number",
292
+ BM: "Bill of lading number",
293
+ CN: "Carrier reference number",
294
+ EQ: "Equipment number",
295
+ FF: "Freight forwarder reference",
296
+ GN: "Government reference",
297
+ HB: "House bill of lading",
298
+ MB: "Master bill of lading",
299
+ ON: "Order number",
300
+ RE: "Release number",
301
+ SI: "Shipper reference",
302
+ VON: "Voyage number",
303
+ XX: "Mutually defined reference",
304
+ ZZZ: "Mutually defined (alt)",
305
+ },
306
+ "NAD.party": {
307
+ AG: "Agent (carrier local agent)",
308
+ BO: "Bill of lading recipient",
309
+ CA: "Carrier (NVOCC, line, road, rail)",
310
+ CC: "Claimant",
311
+ CF: "Container operator (line owning the box)",
312
+ CN: "Consignee",
313
+ CZ: "Consignor (shipper)",
314
+ FW: "Freight forwarder",
315
+ MR: "Message recipient",
316
+ MS: "Document / message issuer / sender",
317
+ OS: "Original shipper",
318
+ SLS: "Shipping line service",
319
+ TCO: "Transit customs office",
320
+ TR: "Terminal operator",
321
+ },
322
+ "MEA.qualifier": {
323
+ AAE: "Gross weight",
324
+ AAL: "Tare weight",
325
+ AAJ: "Maximum payload",
326
+ AET: "Equipment gross weight",
327
+ G: "Gross weight",
328
+ T: "Tare weight",
329
+ LP: "Payload",
330
+ VGM: "Verified Gross Mass (SOLAS VI/2 §4-6)",
331
+ LDM: "Loading metres",
332
+ AAW: "Cargo volume",
333
+ },
334
+ "MEA.unit": {
335
+ KGM: "kilograms",
336
+ LBR: "pounds",
337
+ TNE: "metric tonnes",
338
+ MTQ: "cubic metres",
339
+ MTR: "metres",
340
+ FTI: "feet",
341
+ CMT: "centimetres",
342
+ CEL: "°C",
343
+ FAH: "°F",
344
+ },
345
+ "VGM.method": {
346
+ SM1: "Method 1 — physical weighing",
347
+ SM2: "Method 2 — calculated (sum of package + tare)",
348
+ },
349
+ "HAN.code": {
350
+ HBB: "Handle by both ends",
351
+ HBC: "Handle with care",
352
+ HKP: "Keep upright",
353
+ HKD: "Keep dry",
354
+ HKC: "Keep cool",
355
+ HFR: "Fragile",
356
+ HRD: "Reefer — pre-cool",
357
+ HSL: "Shock load — handle carefully",
358
+ HRR: "Rapid response required",
359
+ HXR: "Refrigerate / freeze",
360
+ EXP: "Export-loaded",
361
+ IMP: "Import-loaded",
362
+ RES: "Restow",
363
+ SHF: "Shifting",
364
+ TRH: "Transhipment",
365
+ },
366
+ "SEL.party": {
367
+ CA: "Carrier",
368
+ SH: "Shipper",
369
+ TR: "Terminal",
370
+ CU: "Customs",
371
+ },
372
+ "FTX.qualifier": {
373
+ AAA: "General information",
374
+ AAI: "Cargo description",
375
+ ABS: "Equipment condition",
376
+ DAR: "Damage remarks",
377
+ HAN: "Handling instruction text",
378
+ OSI: "Other service information",
379
+ REG: "Regulatory information",
380
+ },
381
+ "TDT.mode": {
382
+ "1": "Maritime",
383
+ "2": "Rail",
384
+ "3": "Road",
385
+ "4": "Air",
386
+ "8": "Inland waterway",
387
+ "9": "Not applicable",
388
+ },
389
+ "TDT.idCodeList": {
390
+ "87": "UN/EDIFACT party code",
391
+ "103": "Vessel call sign",
392
+ "146": "IMO ship number",
393
+ "172": "Carrier SCAC code",
394
+ },
395
+ "CNT.qualifier": {
396
+ "7": "Total TEU",
397
+ "16": "Number of equipment units",
398
+ "11": "Number of line items",
399
+ },
400
+ "UNB.syntax": {
401
+ UNOA: "Level A (uppercase, digits, punctuation, delimiters)",
402
+ UNOB: "Level B (adds lowercase)",
403
+ UNOC: "Level C (ISO 8859-1 Latin-1)",
404
+ UNOY: "UTF-8",
405
+ },
406
+ };
407
+ /**
408
+ * Decode a 4-character ISO 6346 size-type code into operator-readable
409
+ * parts: size, type group, height/variant, variant digit.
410
+ *
411
+ * Returns `null` if the input doesn't look like a valid size-type code.
412
+ *
413
+ * @example
414
+ * ```ts
415
+ * decodeISOSizeType("45R1"); // "40ft · Integral reefer · 9ft 6in (high cube) · variant 1"
416
+ * decodeISOSizeType("22G1"); // "20ft · General purpose · 8ft 6in (standard) · variant 1"
417
+ * ```
418
+ */
419
+ export function decodeISOSizeType(code) {
420
+ if (!/^[A-Z0-9]{4}$/.test(code))
421
+ return null;
422
+ const sizeMap = {
423
+ "1": "10ft",
424
+ "2": "20ft",
425
+ "3": "30ft",
426
+ "4": "40ft",
427
+ L: "45ft",
428
+ M: "48ft",
429
+ N: "49ft",
430
+ };
431
+ const heightMap = {
432
+ "0": "8ft",
433
+ "2": "8ft 6in (standard)",
434
+ "3": "8ft 6in",
435
+ "4": "9ft",
436
+ "5": "9ft 6in (high cube)",
437
+ "6": ">9ft 6in",
438
+ "8": "4ft 3in",
439
+ "9": "9ft 6in high cube",
440
+ };
441
+ const typeGroupMap = {
442
+ G: "General purpose",
443
+ V: "Ventilated",
444
+ B: "Bulk",
445
+ R: "Integral reefer",
446
+ H: "Refrigerated/heated",
447
+ U: "Open top",
448
+ P: "Platform / flat rack",
449
+ T: "Tank",
450
+ A: "Air/surface",
451
+ F: "Folding",
452
+ S: "Named cargo (livestock/auto)",
453
+ };
454
+ const size = sizeMap[code[0]];
455
+ const height = heightMap[code[1]];
456
+ const type = typeGroupMap[code[2]];
457
+ if (!size || !type)
458
+ return null;
459
+ return `${size} · ${type} · ${height || code[1]} · variant ${code[3]}`;
460
+ }
461
+ // ── Message schemas ───────────────────────────────────────────
462
+ export const CODECO = {
463
+ name: "CODECO",
464
+ longName: "Container Gate-In / Gate-Out Report",
465
+ purpose: "Terminal/depot → carrier. Confirms physical equipment moves through a gate or status changes inside a facility. The terminal's response to a COPRAR plan, and the depot's daily heartbeat to the carrier and lessor.",
466
+ bgmCodes: ["34", "35", "36"],
467
+ headerRequired: ["UNH", "BGM", "DTM"],
468
+ bodyRequired: ["TDT", "NAD", "EQD"],
469
+ trailerRequired: ["CNT", "UNT"],
470
+ };
471
+ export const COPRAR = {
472
+ name: "COPRAR",
473
+ longName: "Container Discharge / Loading Order",
474
+ purpose: "Carrier → terminal. The load (BGM 45) or discharge (BGM 46) order for a specific vessel call. The basis on which the terminal pre-receives boxes and the planner builds the stow.",
475
+ bgmCodes: ["45", "46", "244", "245"],
476
+ headerRequired: ["UNH", "BGM", "DTM"],
477
+ bodyRequired: ["TDT", "NAD", "EQD"],
478
+ trailerRequired: ["CNT", "UNT"],
479
+ };
480
+ /**
481
+ * Detect whether the parsed message is CODECO, COPRAR, or unknown.
482
+ * Inspects UNH.type first, then falls back to BGM document code.
483
+ */
484
+ export function detectMessageType(parsed) {
485
+ if (parsed.message && parsed.message.type) {
486
+ const t = parsed.message.type.toUpperCase();
487
+ if (t === "CODECO" || t === "COPRAR")
488
+ return t;
489
+ }
490
+ for (const s of parsed.segments) {
491
+ if (s.tag === "BGM") {
492
+ const doc = (s.elements[0] || [])[0];
493
+ if (doc === "34" || doc === "35" || doc === "36")
494
+ return "CODECO";
495
+ if (doc === "45" || doc === "46" || doc === "244" || doc === "245")
496
+ return "COPRAR";
497
+ }
498
+ }
499
+ return null;
500
+ }
501
+ /** Look up a code in a named list. Returns null if the list or code is unknown. */
502
+ export function lookup(listName, code) {
503
+ const list = CODE_LISTS[listName];
504
+ if (!list)
505
+ return null;
506
+ return list[code] || null;
507
+ }
508
+ /** Get the segment dictionary entry for a 3-letter tag. */
509
+ export function segmentInfo(tag) {
510
+ return (SEGMENTS[tag] || { name: tag, brief: "Unknown segment — not in CODECO/COPRAR dictionary." });
511
+ }
512
+ // ── Diagnostic engine ─────────────────────────────────────────
513
+ /**
514
+ * Run all single-message diagnostic rules against a parsed CODECO
515
+ * or COPRAR. Returns an array of diagnostics (empty = clean message).
516
+ *
517
+ * Rules implemented:
518
+ * - BAD_CHECK_DIGIT · ISO 6346 mod-11 fails on any EQD container number
519
+ * - BAD_BIC_FORMAT · EQD container ID not in 4-letter + 7-digit shape
520
+ * - BAD_LOCODE_FORMAT · LOC place doesn't match 5-char UN/LOCODE pattern
521
+ * - DTM_FORMAT · DTM format not 203 (SMDG mandates 203)
522
+ * - MISSING_NAD_CF · Required container operator party absent
523
+ * - EMPTY_BUT_HEAVY · EQD declared empty but gross weight exceeds tare
524
+ * - UNKNOWN_SIZETYPE · ISO 4-character size-type not in catalogue
525
+ * - UNT_COUNT_WRONG · UNT segment count doesn't match actual UNH→UNT count
526
+ * - CNT_EQD_MISMATCH · CNT+16 declared count != actual EQD count
527
+ * - REEFER_WITHOUT_TMP · R-type ISO size but no TMP segment
528
+ * - LOAD_BUT_EMPTY · COPRAR Load (BGM 45) but EQD declares empty
529
+ * - MISSING_VGM · Full container on Load order missing SOLAS VGM
530
+ * - CHARSET_LOWERCASE · UNB declares UNOA but body contains lowercase
531
+ */
532
+ export function diagnoseSingle(parsed) {
533
+ const diags = [];
534
+ const type = detectMessageType(parsed);
535
+ const segments = parsed.segments;
536
+ if (segments.length === 0) {
537
+ return [
538
+ {
539
+ level: "error",
540
+ code: "EMPTY",
541
+ message: "No segments found. Paste a CODECO or COPRAR message.",
542
+ },
543
+ ];
544
+ }
545
+ // 1. Bad container check digit
546
+ segments.forEach((s, i) => {
547
+ if (s.tag !== "EQD")
548
+ return;
549
+ const num = (s.elements[1] || [])[0];
550
+ if (num && /^[A-Z]{4}\d{7}$/.test(num)) {
551
+ if (!validateCheckDigit(num)) {
552
+ diags.push({
553
+ level: "error",
554
+ code: "BAD_CHECK_DIGIT",
555
+ message: `Container number ${num} fails ISO 6346 check-digit validation.`,
556
+ segmentIndex: i,
557
+ tag: "EQD",
558
+ });
559
+ }
560
+ }
561
+ else if (num) {
562
+ diags.push({
563
+ level: "warn",
564
+ code: "BAD_BIC_FORMAT",
565
+ message: `Equipment ID ${num} is not in valid ISO 6346 format (4 letters + 7 digits).`,
566
+ segmentIndex: i,
567
+ tag: "EQD",
568
+ });
569
+ }
570
+ });
571
+ // 2. UN/LOCODE format
572
+ segments.forEach((s, i) => {
573
+ if (s.tag !== "LOC")
574
+ return;
575
+ const place = (s.elements[1] || [])[0];
576
+ if (place && !/^[A-Z]{2}[A-Z0-9]{3}$/.test(place)) {
577
+ diags.push({
578
+ level: "warn",
579
+ code: "BAD_LOCODE_FORMAT",
580
+ message: `Location ${place} does not match the 5-character UN/LOCODE shape (2-letter country + 3-char place).`,
581
+ segmentIndex: i,
582
+ tag: "LOC",
583
+ });
584
+ }
585
+ });
586
+ // 3. DTM format (SMDG mandates 203)
587
+ segments.forEach((s, i) => {
588
+ if (s.tag !== "DTM")
589
+ return;
590
+ const fmt = (s.elements[0] || [])[2];
591
+ if (fmt && fmt !== "203") {
592
+ diags.push({
593
+ level: fmt === "204" ? "info" : "warn",
594
+ code: "DTM_FORMAT",
595
+ message: `DTM format ${fmt} used — SMDG mandates 203 (CCYYMMDDHHMM).`,
596
+ segmentIndex: i,
597
+ tag: "DTM",
598
+ });
599
+ }
600
+ });
601
+ // 4. NAD+CF required
602
+ const hasCF = segments.some((s) => s.tag === "NAD" && (s.elements[0] || [])[0] === "CF");
603
+ if (!hasCF) {
604
+ diags.push({
605
+ level: "error",
606
+ code: "MISSING_NAD_CF",
607
+ message: "No NAD+CF (container operator) segment. SMDG requires it at message level.",
608
+ });
609
+ }
610
+ // 5. Empty but heavy
611
+ // EQD element positions: 0=type, 1=container, 2=size-type, 3=condition,
612
+ // 4=supplier, 5=full/empty (DE 8169), 6=status.
613
+ segments.forEach((s, i) => {
614
+ if (s.tag !== "EQD")
615
+ return;
616
+ const fullEmpty = (s.elements[5] || [])[0];
617
+ if (fullEmpty !== "5")
618
+ return;
619
+ // MEA structure: elements[0] = purpose (DE 6311 — AAE, AAL, VGM, ...),
620
+ // elements[1] = value qualifier composite (DE 6313 — often G, T, AET),
621
+ // elements[2] = value composite (DE 6411 unit + DE 6314 value).
622
+ let aae = null;
623
+ let aal = null;
624
+ for (let j = i + 1; j < segments.length; j++) {
625
+ if (segments[j].tag === "EQD")
626
+ break;
627
+ if (segments[j].tag === "MEA") {
628
+ const q = (segments[j].elements[0] || [])[0];
629
+ const v = parseFloat((segments[j].elements[2] || [])[1]);
630
+ if (q === "AAE" && !isNaN(v))
631
+ aae = v;
632
+ if (q === "AAL" && !isNaN(v))
633
+ aal = v;
634
+ }
635
+ }
636
+ if (aae !== null && (aal === null || aae > aal + 100)) {
637
+ diags.push({
638
+ level: "error",
639
+ code: "EMPTY_BUT_HEAVY",
640
+ message: `EQD declared EMPTY but gross weight ${aae} kg exceeds tare ${aal ?? "?"} kg.`,
641
+ segmentIndex: i,
642
+ tag: "EQD",
643
+ });
644
+ }
645
+ });
646
+ // 6. Unknown ISO size-type
647
+ segments.forEach((s, i) => {
648
+ if (s.tag !== "EQD")
649
+ return;
650
+ const sz = (s.elements[2] || [])[0];
651
+ if (sz && decodeISOSizeType(sz) === null) {
652
+ diags.push({
653
+ level: "warn",
654
+ code: "UNKNOWN_SIZETYPE",
655
+ message: `ISO size-type ${sz} not in standard 4-character catalogue.`,
656
+ segmentIndex: i,
657
+ tag: "EQD",
658
+ });
659
+ }
660
+ });
661
+ // 7. UNT segment count
662
+ let unhIdx = -1;
663
+ let untIdx = -1;
664
+ for (let k = 0; k < segments.length; k++) {
665
+ if (segments[k].tag === "UNH" && unhIdx < 0)
666
+ unhIdx = k;
667
+ if (segments[k].tag === "UNT")
668
+ untIdx = k;
669
+ }
670
+ if (unhIdx >= 0 && untIdx >= 0) {
671
+ const declared = parseInt((segments[untIdx].elements[0] || [])[0], 10);
672
+ const actual = untIdx - unhIdx + 1;
673
+ if (!isNaN(declared) && declared !== actual) {
674
+ diags.push({
675
+ level: "error",
676
+ code: "UNT_COUNT_WRONG",
677
+ message: `UNT declares ${declared} segments but actual count is ${actual} (UNH to UNT inclusive).`,
678
+ segmentIndex: untIdx,
679
+ tag: "UNT",
680
+ });
681
+ }
682
+ }
683
+ // 8. CNT+16 vs EQD count
684
+ const eqdCount = segments.filter((s) => s.tag === "EQD").length;
685
+ segments.forEach((s, i) => {
686
+ if (s.tag !== "CNT")
687
+ return;
688
+ const cq = (s.elements[0] || [])[0];
689
+ const cv = parseInt((s.elements[0] || [])[1], 10);
690
+ if (cq === "16" && !isNaN(cv) && cv !== eqdCount) {
691
+ diags.push({
692
+ level: "error",
693
+ code: "CNT_EQD_MISMATCH",
694
+ message: `CNT+16 declares ${cv} equipment units but ${eqdCount} EQD segments are present.`,
695
+ segmentIndex: i,
696
+ tag: "CNT",
697
+ });
698
+ }
699
+ });
700
+ // 9. Reefer without TMP
701
+ segments.forEach((s, i) => {
702
+ if (s.tag !== "EQD")
703
+ return;
704
+ const sz = (s.elements[2] || [])[0];
705
+ if (!sz || sz[2] !== "R")
706
+ return;
707
+ let hasTmp = false;
708
+ for (let j = i + 1; j < segments.length; j++) {
709
+ if (segments[j].tag === "EQD")
710
+ break;
711
+ if (segments[j].tag === "TMP") {
712
+ hasTmp = true;
713
+ break;
714
+ }
715
+ }
716
+ if (!hasTmp) {
717
+ diags.push({
718
+ level: "warn",
719
+ code: "REEFER_WITHOUT_TMP",
720
+ message: `Reefer container (size-type ${sz}) has no TMP setpoint segment.`,
721
+ segmentIndex: i,
722
+ tag: "EQD",
723
+ });
724
+ }
725
+ });
726
+ // 10. COPRAR Load (BGM 45) but EQD empty + missing VGM
727
+ if (type === "COPRAR") {
728
+ const bgm = segments.find((s) => s.tag === "BGM");
729
+ const docCode = bgm ? (bgm.elements[0] || [])[0] : null;
730
+ if (docCode === "45") {
731
+ segments.forEach((s, i) => {
732
+ if (s.tag === "EQD" && (s.elements[5] || [])[0] === "5") {
733
+ diags.push({
734
+ level: "warn",
735
+ code: "LOAD_BUT_EMPTY",
736
+ message: "COPRAR Load order (BGM 45) but EQD declares EMPTY — use COPRAR Discharge or COPARN for empty repositioning.",
737
+ segmentIndex: i,
738
+ tag: "EQD",
739
+ });
740
+ }
741
+ });
742
+ segments.forEach((s, i) => {
743
+ if (s.tag !== "EQD")
744
+ return;
745
+ if ((s.elements[5] || [])[0] !== "4")
746
+ return;
747
+ let hasVgm = false;
748
+ for (let j = i + 1; j < segments.length; j++) {
749
+ if (segments[j].tag === "EQD")
750
+ break;
751
+ if (segments[j].tag === "MEA" && (segments[j].elements[0] || [])[0] === "VGM") {
752
+ hasVgm = true;
753
+ break;
754
+ }
755
+ }
756
+ if (!hasVgm) {
757
+ diags.push({
758
+ level: "warn",
759
+ code: "MISSING_VGM",
760
+ message: "Full container on a COPRAR Load order is missing VGM (SOLAS VI/2 §4-6, mandatory since 2016).",
761
+ segmentIndex: i,
762
+ tag: "EQD",
763
+ });
764
+ }
765
+ });
766
+ }
767
+ }
768
+ // 11. UNB UNOA charset vs lowercase in body
769
+ if (parsed.interchange && parsed.interchange.syntaxId === "UNOA") {
770
+ const bodyText = segments.map((s) => s.raw).join("|");
771
+ if (/[a-z]/.test(bodyText)) {
772
+ diags.push({
773
+ level: "warn",
774
+ code: "CHARSET_LOWERCASE",
775
+ message: "UNB declares UNOA (uppercase only) but lowercase letters appear in the body. Likely partner-side rejection.",
776
+ });
777
+ }
778
+ }
779
+ return diags;
780
+ }
781
+ function buildEqdMap(parsed) {
782
+ const map = {};
783
+ let current = null;
784
+ for (const s of parsed.segments) {
785
+ if (s.tag === "EQD") {
786
+ const num = (s.elements[1] || [])[0];
787
+ if (!num) {
788
+ current = null;
789
+ continue;
790
+ }
791
+ current = {
792
+ number: num,
793
+ sizeType: (s.elements[2] || [])[0] || "",
794
+ fullEmpty: (s.elements[5] || [])[0] || "",
795
+ pol: "",
796
+ pod: "",
797
+ grossKgm: null,
798
+ tareKgm: null,
799
+ vgmKgm: null,
800
+ booking: "",
801
+ bl: "",
802
+ tempC: null,
803
+ };
804
+ map[num] = current;
805
+ }
806
+ else if (current) {
807
+ if (s.tag === "LOC") {
808
+ const lq = (s.elements[0] || [])[0];
809
+ const lv = (s.elements[1] || [])[0];
810
+ if (lq === "9" && lv)
811
+ current.pol = lv;
812
+ if (lq === "11" && lv)
813
+ current.pod = lv;
814
+ }
815
+ else if (s.tag === "MEA") {
816
+ const mq = (s.elements[0] || [])[0];
817
+ const mv = parseFloat((s.elements[2] || [])[1]);
818
+ if (!isNaN(mv)) {
819
+ if (mq === "AAE")
820
+ current.grossKgm = mv;
821
+ if (mq === "AAL")
822
+ current.tareKgm = mv;
823
+ if (mq === "VGM")
824
+ current.vgmKgm = mv;
825
+ }
826
+ }
827
+ else if (s.tag === "RFF") {
828
+ const rq = (s.elements[0] || [])[0];
829
+ const rv = (s.elements[0] || [])[1];
830
+ if (rq === "BN" && rv)
831
+ current.booking = rv;
832
+ if (rq === "BM" && rv)
833
+ current.bl = rv;
834
+ }
835
+ else if (s.tag === "TMP") {
836
+ const t = parseFloat((s.elements[1] || [])[0]);
837
+ if (!isNaN(t))
838
+ current.tempC = t;
839
+ }
840
+ }
841
+ }
842
+ return map;
843
+ }
844
+ /**
845
+ * Cross-message reconciliation between a COPRAR and its matching CODECO.
846
+ * Returns container-by-container matches with field-level diffs and
847
+ * unmatched-container lists.
848
+ *
849
+ * Tolerances:
850
+ * - Gross weight: ±2%
851
+ * - VGM: ±5%
852
+ * - Reefer temperature: ±1°C
853
+ *
854
+ * @example
855
+ * ```ts
856
+ * import { parse, reconcile } from "@prefixcheck/edi";
857
+ * const coprar = parse(coprarText);
858
+ * const codeco = parse(codecoText);
859
+ * const report = reconcile(coprar, codeco);
860
+ * console.log(`${report.matched.length} matched, ${report.inCoprarOnly.length} expected but not gated`);
861
+ * ```
862
+ */
863
+ export function reconcile(coprar, codeco) {
864
+ const coprarMap = buildEqdMap(coprar);
865
+ const codecoMap = buildEqdMap(codeco);
866
+ const allKeys = new Set([...Object.keys(coprarMap), ...Object.keys(codecoMap)]);
867
+ const matched = [];
868
+ const inCoprarOnly = [];
869
+ const inCodecoOnly = [];
870
+ allKeys.forEach((num) => {
871
+ const c = coprarMap[num];
872
+ const d = codecoMap[num];
873
+ if (c && d) {
874
+ const diffs = [];
875
+ if (c.sizeType && d.sizeType && c.sizeType !== d.sizeType) {
876
+ diffs.push({
877
+ field: "ISO size-type",
878
+ coprar: c.sizeType,
879
+ codeco: d.sizeType,
880
+ severity: "error",
881
+ });
882
+ }
883
+ if (c.fullEmpty && d.fullEmpty && c.fullEmpty !== d.fullEmpty) {
884
+ diffs.push({
885
+ field: "Full/empty",
886
+ coprar: c.fullEmpty,
887
+ codeco: d.fullEmpty,
888
+ severity: "error",
889
+ });
890
+ }
891
+ if (c.pol && d.pol && c.pol !== d.pol) {
892
+ diffs.push({ field: "POL", coprar: c.pol, codeco: d.pol, severity: "error" });
893
+ }
894
+ if (c.pod && d.pod && c.pod !== d.pod) {
895
+ diffs.push({ field: "POD", coprar: c.pod, codeco: d.pod, severity: "error" });
896
+ }
897
+ if (c.booking && d.booking && c.booking !== d.booking) {
898
+ diffs.push({ field: "Booking", coprar: c.booking, codeco: d.booking, severity: "warn" });
899
+ }
900
+ if (c.grossKgm !== null && d.grossKgm !== null) {
901
+ const deltaPct = (Math.abs(c.grossKgm - d.grossKgm) / Math.max(c.grossKgm, 1)) * 100;
902
+ if (deltaPct > 2) {
903
+ diffs.push({
904
+ field: "Gross weight",
905
+ coprar: `${c.grossKgm} kg`,
906
+ codeco: `${d.grossKgm} kg`,
907
+ severity: "warn",
908
+ });
909
+ }
910
+ }
911
+ if (c.vgmKgm !== null && d.vgmKgm !== null) {
912
+ const vDelta = (Math.abs(c.vgmKgm - d.vgmKgm) / Math.max(c.vgmKgm, 1)) * 100;
913
+ if (vDelta > 5) {
914
+ diffs.push({
915
+ field: "VGM",
916
+ coprar: `${c.vgmKgm} kg`,
917
+ codeco: `${d.vgmKgm} kg`,
918
+ severity: "warn",
919
+ });
920
+ }
921
+ }
922
+ if (c.tempC !== null && d.tempC !== null) {
923
+ if (Math.abs(c.tempC - d.tempC) > 1) {
924
+ diffs.push({
925
+ field: "Reefer temp",
926
+ coprar: `${c.tempC}°C`,
927
+ codeco: `${d.tempC}°C`,
928
+ severity: "warn",
929
+ });
930
+ }
931
+ }
932
+ matched.push({ number: num, diffs });
933
+ }
934
+ else if (c) {
935
+ inCoprarOnly.push(num);
936
+ }
937
+ else {
938
+ inCodecoOnly.push(num);
939
+ }
940
+ });
941
+ return {
942
+ coprarCount: Object.keys(coprarMap).length,
943
+ codecoCount: Object.keys(codecoMap).length,
944
+ matched,
945
+ inCoprarOnly,
946
+ inCodecoOnly,
947
+ coprarType: detectMessageType(coprar),
948
+ codecoType: detectMessageType(codeco),
949
+ };
950
+ }
951
+ //# sourceMappingURL=schemas.js.map