diff --git a/.dockerignore b/.dockerignore
index 8c1e59b..d26a739 100644
--- a/.dockerignore
+++ b/.dockerignore
@@ -9,3 +9,9 @@ docs/
*.md
!README.md
.env
+
+# --- lailara engagement scaffold ---
+client-data/
+client-output/
+engagement.yml
+engagement.yaml
diff --git a/.github/workflows/client-mode.yml b/.github/workflows/client-mode.yml
new file mode 100644
index 0000000..dd2ef3a
--- /dev/null
+++ b/.github/workflows/client-mode.yml
@@ -0,0 +1,34 @@
+name: client-mode
+
+# Runs the client-mode suite (checklist §6), which imports lailara_engagement.
+# The lib is installed from the private repo pinned to v0.2.1 via the
+# LAILARA_ENGAGEMENT_TOKEN secret (fine-grained, read-only). This is the ONLY job
+# that depends on the PAT; the demo golden gate stays credential-free, so a token
+# expiry degrades client-mode coverage without ever reddening the demo invariant.
+
+on:
+ push:
+ branches: [main]
+ pull_request:
+ branches: [main]
+ workflow_dispatch:
+
+jobs:
+ client-mode:
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v4
+ - name: Engagement guard
+ run: python3 scripts/engagement_guard.py
+ - uses: actions/setup-python@v5
+ with:
+ python-version: "3.12"
+ - name: Install (repo runtime + lailara_engagement @ v0.2.1)
+ env:
+ LAILARA_ENGAGEMENT_TOKEN: ${{ secrets.LAILARA_ENGAGEMENT_TOKEN }}
+ run: |
+ python -m pip install --upgrade pip
+ pip install -e ".[dev]"
+ pip install "lailara_engagement @ git+https://x-access-token:${LAILARA_ENGAGEMENT_TOKEN}@github.com/MsShawnP/lailara-engagement.git@v0.2.1#subdirectory=lib"
+ - name: Client-mode suite
+ run: python -m pytest tests/test_client_mode.py -q
diff --git a/.github/workflows/fly-deploy.yml b/.github/workflows/fly-deploy.yml
index a20a04a..c4da8a6 100644
--- a/.github/workflows/fly-deploy.yml
+++ b/.github/workflows/fly-deploy.yml
@@ -13,6 +13,9 @@ jobs:
steps:
- uses: actions/checkout@v4
+ - name: Engagement guard
+ run: python3 scripts/engagement_guard.py
+
- uses: superfly/flyctl-actions/setup-flyctl@master
- run: flyctl deploy --remote-only
diff --git a/.gitignore b/.gitignore
index d59fd7d..23bdbff 100644
--- a/.gitignore
+++ b/.gitignore
@@ -34,3 +34,11 @@ Thumbs.db
*.key
credentials.*
secrets.*
+
+# --- lailara engagement scaffold ---
+# Client engagement data is runtime-only: never commit it, never deploy it.
+client-data/
+client-output/
+/engagement.yml
+/engagement.yaml
+# (engagement.demo.yml and engagement.example.yml stay committable)
diff --git a/INPUT-SPEC.md b/INPUT-SPEC.md
new file mode 100644
index 0000000..7c99dbe
--- /dev/null
+++ b/INPUT-SPEC.md
@@ -0,0 +1,133 @@
+# INPUT-SPEC — edi-preflight (client mode)
+
+What to hand the EDI pre-flight in a client engagement, and the partner-ID
+configuration that steers retailer-specific validation. Written so a client's EDI
+or IT person can produce the files without a call. **Derived from the engine
+code** (`src/x12_tokenizer.py`, `src/envelope.py`, `src/extract_850.py`,
+`src/validate_856*.py`), not from marketing copy.
+
+## The files
+
+- **Raw X12 EDI documents** — one transaction per file is simplest, but a file
+ may carry a full interchange. Extension is not checked; `.edi`, `.txt`, or
+ `.x12` are all fine. Point client mode at a single file **or a directory** of
+ files.
+- **Encoding:** UTF-8 preferred; Latin-1 is accepted as a fallback. Files up to
+ 2 MB. (Real 850/856 documents are typically well under 100 KB.)
+- **Delimiters are auto-detected from the ISA line**, not assumed. The tokenizer
+ reads the element separator from the character after `ISA`, the sub-element
+ separator from ISA16 (byte 105), the segment terminator from byte 106, and the
+ repetition separator from ISA11. Any consistent delimiter set works, as long as
+ the element, sub-element, and segment characters are all different.
+- Line breaks after segment terminators (for human readability) are stripped
+ before parsing. Content before the first `ISA` is ignored.
+
+The tool consumes two X12 transaction sets:
+
+| Set | Direction | What the engine does |
+|---|---|---|
+| **850** Purchase Order | inbound (retailer → you) | Parses into structured PO data (header, lines, allowances, addresses, dates, totals) for CSV/PDF/ERP. |
+| **856** Advance Ship Notice | outbound (you → retailer) | Validates structure, fields, and retailer-specific rules; attributes chargeback dollars. |
+
+## Envelope — required in every file
+
+A document must carry a complete, closed interchange or it is reported as not
+ready (never silently half-parsed):
+
+| Segment | Fields the engine reads | Rule |
+|---|---|---|
+| `ISA` | sender qual/ID (ISA05/06), receiver qual/ID (ISA07/08), control # (ISA13), version (ISA12) | Must be present; fixed 106-char header. |
+| `IEA` | control # (IEA02) | Must be present and **match ISA13**. |
+| `GS` | functional ID (GS01: `PO`=850, `SH`=856), sender (GS02), receiver (GS03), control # (GS06) | GS01 must match the transaction. |
+| `GE` | control # (GE02) | Must **match GS06**. |
+| `ST`/`SE` | ST01 (`850`/`856`), control # (ST02), SE01 segment count | SE01 should equal the true segment count. |
+
+## 850 Purchase Order — fields extracted
+
+| Segment | Canonical field(s) | Notes |
+|---|---|---|
+| `BEG` | purpose code (BEG01), PO type (BEG02), **PO number (BEG03)**, PO date (BEG05) | Core PO identity. |
+| `REF*DP` | department | Optional. |
+| `DTM` (header) | date references | Labeled by qualifier (002 requested-delivery, 010 requested-ship, 037/038 ship windows, etc.). |
+| `ITD` | payment terms | From ITD12 or ITD09. |
+| `SAC` | allowances / charges | Indicator A/C, code, **amount (SAC05, N2 — two implied decimals)**, percent (SAC07), handling (SAC12). Header- and line-level. |
+| `N1`/`N3`/`N4` | addresses | Entity code (ST ship-to, BT bill-to, …), name, street, city/state/ZIP/country. |
+| `PO1` | line: number, qty (PO102), UOM (PO103), unit price (PO104) | Product IDs from PO106+ pairs: `IN` buyer item, `UP`/`EN` UPC, `VN` vendor item, `UK` GTIN-14, `SK` SKU. UOM in LB/KG/OZ/CW ⇒ catch-weight. |
+| `PID` | line description | |
+| `PO4` | pack qty / size / UOM | |
+| `MEA*WT` | line weight | Sets catch-weight. |
+| `CTT` | total line items (CTT01), total qty (CTT02) | |
+| `AMT*35` | total amount | |
+
+Identifiers (UPC, GTIN-14, SSCC-18, PO number) are handled as **text** — leading
+zeros are never dropped.
+
+## 856 Advance Ship Notice — validated fields
+
+Three layers run in order (structural → field → retailer-specific):
+
+- **Envelope / structural:** transaction is an 856 (ST01=856, GS01=SH); `BSN`
+ present; at least one shipment-level `HL`; control numbers foot; SE count foots;
+ HL parents resolve (no orphaned loops).
+- **Field-level:** `BSN01` purpose ∈ {00 Original, 01 Cancellation, 05 Replace};
+ `BSN02` shipment ID present; `BSN03` date `CCYYMMDD`; `BSN04` time `HHMM`; every
+ `DTM` date `CCYYMMDD`; `TD503` transport ∈ {M,R,S,A,LT}; `MAN01` ∈ {GM,CP};
+ `SN102` quantity numeric and > 0; `SN103` UOM present.
+- **HL hierarchy:** `S`hipment → `O`rder → `T`are → `P`ack → `I`tem (Tare
+ optional, so O→P is valid). Per level:
+ - Shipment (S): `TD5` carrier, **`DTM*011` shipped date**, **`N1*ST` ship-to**.
+ - Order (O): **`PRF` PO reference**.
+ - Container (T/P): **`MAN` SSCC-18** — 18 digits, valid mod-10 check digit.
+ - Item (I): catch-weight `SN1` (UOM LB/KG/OZ) requires **`MEA*WT`**.
+
+Findings are tagged Blocks-Transmission / Will-Cause-Chargeback /
+May-Cause-Chargeback / Cosmetic, with the retailer's fee where one applies. Fees
+are reported **per unit basis** ($/PO, $/case, $/item) and are never summed across
+bases into one figure.
+
+## Partner-ID configuration (engagement.yml)
+
+The engine auto-detects the retailer from the interchange to pick the right 856
+ruleset: it inspects ISA06, ISA08, GS02, and GS03 against known retailer EDI/DUNS
+IDs and name patterns (Walmart, Amazon, UNFI, KeHE, Costco). For an inbound 850
+the retailer is the **sender**; for an outbound 856 it is the **receiver**.
+
+When a client's trading-partner IDs are not ones the engine already knows, map
+them in `engagement.yml` — never by editing code:
+
+```yaml
+client:
+ name: "Meridian Farms"
+engagement:
+ id: "MER-2026-08"
+as_of_date: "2026-05-10" # required; never defaulted to today
+partners: # trading-partner ID -> retailer ruleset
+ "0078742099999": walmart # exact ISA/GS ID match
+ "MERIDIAN-WMT": walmart # case-insensitive substring match also works
+ "SUPERVALU": unfi
+```
+
+Resolution order per file: (1) the engine's built-in detection; (2) if that
+returns *unknown*, the `partners` map (exact ID, then case-insensitive
+substring). If the retailer still cannot be resolved for an 856, the document is
+validated with **structural + field-level rules only** and the deliverable
+discloses that the retailer-specific layer was skipped. Supported retailer keys:
+`walmart`, `amazon`, `unfi`, `kehe`, `costco`.
+
+## Run
+
+```bash
+# with lailara_engagement installed: pip install -e ../engagement-template/lib
+python client_mode.py --config engagement.yml --input client-data/asns/ \
+ --out client-output [--final]
+```
+
+`--input` accepts a single EDI file or a directory (all files in it are
+processed). Outputs to `client-output/` (gitignored):
+
+- `edi-preflight-report.html` — branded, provenance-footed (each input's
+ SHA-256, segment/transaction counts, `as_of_date`, config hash, validation
+ status), DRAFT-watermarked until `--final`.
+- `edi-preflight-report.txt` — plain-text companion.
+- If no file yields a usable 850/856, a **Data Readiness Report** naming what is
+ wrong per file is produced instead of results.
diff --git a/README.md b/README.md
index 03b14b3..3f49519 100644
--- a/README.md
+++ b/README.md
@@ -49,7 +49,7 @@ Opens at `http://localhost:8000`. No database, no external services.
pytest
```
-322 tests covering tokenization, envelope parsing, 850 extraction (all 5 retailers), 856 validation (structural, field-level, and retailer-specific rules), CSV/PDF export, input validation, and all HTTP endpoints.
+327 tests covering tokenization, envelope parsing, 850 extraction (all 5 retailers), 856 validation (structural, field-level, and retailer-specific rules), CSV/PDF export, input validation, all HTTP endpoints, and a demo golden-file lock on the samples the deployed demo serves. An additional client-mode suite runs when the optional `lailara_engagement` package is installed.
**Deploy:** `Dockerfile` and `fly.toml` are configured for Fly.io:
@@ -59,6 +59,18 @@ flyctl deploy
Live at [edi.lailarallc.com](https://edi.lailarallc.com).
+## Client engagement use
+
+For paid engagements the tool runs fully local against a client's own EDI files — nothing is uploaded, stored, or deployed. Point it at a single file or a directory:
+
+```
+pip install -e ../engagement-template/lib # shared engagement scaffold
+python client_mode.py --config engagement.yml --input client-data/asns/ \
+ --out client-output [--final]
+```
+
+It reads the 850/856 documents, applies the retailer ruleset (steered by a partner-ID map in `engagement.yml` when a trading-partner ID isn't auto-detected), and writes a branded, provenance-footed report to `client-output/` (gitignored) — or a Data Readiness Report naming what's wrong if nothing is usable. Reports are DRAFT-watermarked until `--final`. See [INPUT-SPEC.md](INPUT-SPEC.md) for the full input contract and `engagement.demo.yml` for a safe-to-run example.
+
## Tech stack
- **Backend** — Python, FastAPI, Jinja2 server-side templates
@@ -87,7 +99,7 @@ src/ FastAPI app, parser, validators, exporters
static/ CSS, JS, HTMX
rules/ Retailer EDI specs in YAML (10 files, reference docs)
samples/ 26 synthetic EDI files across 5 retailers
-tests/ 20 test modules, 322 tests
+tests/ 22 test modules, 327 tests (+ optional client-mode suite)
Dockerfile Python 3.13-slim, non-root user
fly.toml Fly.io deployment config
pyproject.toml Dependencies and project metadata
diff --git a/client_mode.py b/client_mode.py
new file mode 100644
index 0000000..19f5b5d
--- /dev/null
+++ b/client_mode.py
@@ -0,0 +1,525 @@
+"""Client-mode CLI for the EDI pre-flight.
+
+Wraps the existing X12 engine (tokenizer -> envelope -> extract_850 /
+validate_856 + retailer rules) with the shared ``lailara_engagement`` scaffold so
+a client's own EDI files can be checked locally: tolerant intake of one file or a
+directory of 850/856 documents, a preflight that names anything that can't be read
+(Data Readiness Report if nothing is usable), a partner-ID map from
+``engagement.yml`` that steers retailer selection without editing code, and a
+branded, provenance-footed, draft-watermarked report written to
+``client-output/`` only.
+
+Usage:
+ python client_mode.py --config engagement.yml --input client-data/asns/ \
+ --out client-output [--final]
+
+The document itself is never transmitted, stored, or deployed — client mode runs
+fully local and writes only to the gitignored ``client-output/`` directory.
+"""
+
+from __future__ import annotations
+
+import argparse
+import hashlib
+import html
+from dataclasses import dataclass
+from pathlib import Path
+
+from lailara_engagement import build_provenance, load_config, validation_status_label
+from lailara_engagement import palette as P
+from lailara_engagement.provenance import InputRef, Provenance
+
+from src.envelope import (
+ EnvelopeError,
+ Retailer,
+ TransactionType,
+ parse_envelope,
+)
+from src.extract_850 import ExtractionError, PurchaseOrder, extract_850
+from src.validate_856 import Severity, ValidationResult, validate_856
+from src.validate_856_amazon import validate_856_amazon
+from src.validate_856_costco import validate_856_costco
+from src.validate_856_kehe import validate_856_kehe
+from src.validate_856_unfi import validate_856_unfi
+from src.validate_856_walmart import validate_856_walmart
+from src.x12_tokenizer import TokenizeError, tokenize
+
+TOOL = "edi-preflight"
+TOOL_VERSION = "0.1"
+
+_MAX_INPUT_BYTES = 2 * 1024 * 1024 # mirror the web app's 2 MB ceiling
+
+_RETAILER_VALIDATORS = {
+ Retailer.WALMART: validate_856_walmart,
+ Retailer.AMAZON: validate_856_amazon,
+ Retailer.UNFI: validate_856_unfi,
+ Retailer.KEHE: validate_856_kehe,
+ Retailer.COSTCO: validate_856_costco,
+}
+
+_RETAILER_KEYS = {
+ "walmart": Retailer.WALMART,
+ "amazon": Retailer.AMAZON,
+ "unfi": Retailer.UNFI,
+ "kehe": Retailer.KEHE,
+ "costco": Retailer.COSTCO,
+}
+
+_RETAILER_LABELS = {
+ Retailer.WALMART: "Walmart",
+ Retailer.AMAZON: "Amazon",
+ Retailer.UNFI: "UNFI",
+ Retailer.KEHE: "KeHE",
+ Retailer.COSTCO: "Costco",
+ Retailer.UNKNOWN: "Unknown",
+}
+
+# EDI severity -> (fill, text, label) using the design-system palette.
+_SEV_STYLE = {
+ Severity.BLOCKS_TRANSMISSION: (P.LL_RED_SURFACE, P.LL_RED_DARK, "Blocks Transmission"),
+ Severity.WILL_CAUSE_CHARGEBACK: (P.LL_SG_SURFACE, P.LL_SG_DARK, "Will Cause Chargeback"),
+ Severity.MAY_CAUSE_CHARGEBACK: (P.LL_CHICAGO_SURFACE, P.LL_CHICAGO, "May Cause Chargeback"),
+ Severity.COSMETIC: (P.LL_SURFACE, P.LL_TEXT_SEC, "Cosmetic"),
+}
+
+
+# --------------------------------------------------------------------------- #
+# Intake + preflight
+# --------------------------------------------------------------------------- #
+
+@dataclass
+class FileOutcome:
+ filename: str
+ sha256: str
+ n_segments: int = 0
+ n_transactions: int = 0
+ doc_type: str = "unknown" # "850" | "856" | "unknown"
+ retailer: Retailer = Retailer.UNKNOWN
+ retailer_source: str = "unresolved" # "auto" | "partner-map" | "unresolved"
+ status: str = "error" # "ok" | "error"
+ error: str = ""
+ result: ValidationResult | None = None # 856
+ po: PurchaseOrder | None = None # 850
+
+ @property
+ def input_ref(self) -> InputRef:
+ # For EDI, "rows" = segment count and "cols" = transaction-set count.
+ return InputRef(
+ filename=self.filename,
+ sha256=self.sha256,
+ n_rows=self.n_segments,
+ n_cols=self.n_transactions,
+ )
+
+
+def _iter_input_files(input_path: str) -> list[Path]:
+ p = Path(input_path)
+ if p.is_dir():
+ return sorted(f for f in p.iterdir() if f.is_file())
+ return [p]
+
+
+def _decode(raw: bytes) -> str:
+ try:
+ return raw.decode("utf-8")
+ except UnicodeDecodeError:
+ return raw.decode("latin-1")
+
+
+def _resolve_retailer(envelope, partners: dict) -> tuple[Retailer, str]:
+ """Resolve the retailer: built-in detection first, then the partner-ID map.
+
+ The engine already inspects ISA/GS ids for the known majors. When it comes
+ back UNKNOWN we consult the client's ``partners`` map (exact id, then
+ case-insensitive substring) so client-specific trading-partner ids steer the
+ ruleset without any code edit. Returns (retailer, source)."""
+ if envelope.retailer is not Retailer.UNKNOWN:
+ return envelope.retailer, "auto"
+ if not partners:
+ return Retailer.UNKNOWN, "unresolved"
+
+ candidates = [envelope.interchange.sender_id, envelope.interchange.receiver_id]
+ for g in envelope.groups:
+ candidates.extend([g.sender_code, g.receiver_code])
+ candidates = [str(c).strip().casefold() for c in candidates if str(c).strip()]
+
+ norm = {str(k).strip().casefold(): str(v).lower() for k, v in partners.items()}
+ # exact match
+ for cand in candidates:
+ if cand in norm:
+ return _RETAILER_KEYS.get(norm[cand], Retailer.UNKNOWN), "partner-map"
+ # substring match
+ for cand in candidates:
+ for pat, ret in norm.items():
+ if pat and pat in cand:
+ return _RETAILER_KEYS.get(ret, Retailer.UNKNOWN), "partner-map"
+ return Retailer.UNKNOWN, "unresolved"
+
+
+def _process_file(path: Path, partners: dict) -> FileOutcome:
+ raw = path.read_bytes()
+ sha = hashlib.sha256(raw).hexdigest()
+ outcome = FileOutcome(filename=path.name, sha256=sha)
+
+ if len(raw) > _MAX_INPUT_BYTES:
+ outcome.error = f"file exceeds the 2 MB limit ({len(raw):,} bytes)"
+ return outcome
+ if not raw.strip():
+ outcome.error = "file is empty"
+ return outcome
+
+ content = _decode(raw)
+ try:
+ tokens = tokenize(content)
+ envelope = parse_envelope(tokens)
+ except (TokenizeError, EnvelopeError) as exc:
+ outcome.error = str(exc)
+ return outcome
+
+ outcome.n_segments = len(tokens.segments)
+ outcome.n_transactions = len(envelope.transactions)
+
+ if not envelope.transactions:
+ outcome.error = "no transaction set (ST/SE) found"
+ return outcome
+
+ tx_type = envelope.transactions[0].transaction_type
+ retailer, source = _resolve_retailer(envelope, partners)
+ outcome.retailer = retailer
+ outcome.retailer_source = source
+
+ if tx_type == TransactionType.PURCHASE_ORDER_850:
+ outcome.doc_type = "850"
+ try:
+ outcome.po = extract_850(envelope)
+ except ExtractionError as exc:
+ outcome.error = str(exc)
+ return outcome
+ outcome.status = "ok"
+ elif tx_type == TransactionType.ASN_856:
+ outcome.doc_type = "856"
+ result = validate_856(envelope)
+ validator = _RETAILER_VALIDATORS.get(retailer)
+ if validator:
+ result = validator(result)
+ outcome.result = result
+ outcome.status = "ok"
+ else:
+ outcome.error = "document is neither an 850 nor an 856 transaction"
+
+ return outcome
+
+
+# --------------------------------------------------------------------------- #
+# Branded report
+# --------------------------------------------------------------------------- #
+
+def _po_summary_rows(po: PurchaseOrder) -> str:
+ esc = html.escape
+ rows = [
+ ("PO number", esc(po.po_number or "—")),
+ ("Line items", f"{len(po.line_items):,}"),
+ ("Total quantity", f"{po.total_quantity:,.0f}"),
+ ("Total amount", f"${po.total_amount:,.2f}"),
+ ("Allowances / charges", f"{len(po.all_allowances):,}"),
+ ("Addresses", ", ".join(esc(a.entity_code) for a in po.addresses) or "—"),
+ ]
+ return "".join(f"
| {k} | {v} |
" for k, v in rows)
+
+
+def _findings_table(result: ValidationResult) -> str:
+ esc = html.escape
+ if not result.findings:
+ return "No findings. This ASN passes all checks.
"
+ body = ""
+ for f in result.sorted_findings():
+ fill, text, label = _SEV_STYLE[f.severity]
+ fee = f"${f.fee:,.2f}/{esc(f.fee_per)}" if f.has_fee else "—"
+ body += (
+ f"| "
+ f"{esc(label)} | "
+ f"{esc(f.layer)} | "
+ f"{esc(f.message)} | "
+ f"{fee} |
"
+ )
+ return (
+ "| Severity | Layer | "
+ "Finding | Fee |
"
+ f"{body}
"
+ )
+
+
+def _fee_breakdown_note(result: ValidationResult) -> str:
+ esc = html.escape
+ breakdown = result.fee_breakdown
+ if not breakdown:
+ return ""
+ parts = [
+ f"${b['subtotal']:,.2f} across {b['count']} finding(s) @ per-{esc(b['fee_per'])}"
+ for b in breakdown
+ ]
+ return (
+ "Chargeback exposure (per basis, never summed "
+ "across bases): " + "; ".join(parts) + ".
"
+ )
+
+
+def _file_section(o: FileOutcome) -> str:
+ esc = html.escape
+ retailer_label = _RETAILER_LABELS.get(o.retailer, "Unknown")
+ src = {"auto": "auto-detected", "partner-map": "via partner-ID map",
+ "unresolved": "unresolved"}[o.retailer_source]
+ meta = (f"Type {esc(o.doc_type)} "
+ f"· Retailer {esc(retailer_label)} ({src}) "
+ f"· Segments {o.n_segments:,}
")
+
+ if o.status != "ok":
+ body = f"Not processed — {esc(o.error)}
"
+ elif o.doc_type == "850":
+ body = f""
+ else: # 856
+ body = _findings_table(o.result) + _fee_breakdown_note(o.result)
+ if o.retailer_source == "unresolved":
+ body += ("Retailer-specific layer skipped — no "
+ "retailer resolved for this ASN; structural and field-level "
+ "rules only. Add the trading-partner id to partners "
+ "in engagement.yml.
")
+
+ return (f"{esc(o.filename)}
"
+ f"{meta}{body}")
+
+
+def _data_limitations(outcomes: list[FileOutcome]) -> list[str]:
+ items: list[str] = []
+ for o in outcomes:
+ if o.status != "ok":
+ items.append(f"{o.filename}: not processed — {o.error}")
+ elif o.doc_type == "856" and o.retailer_source == "unresolved":
+ items.append(f"{o.filename}: retailer unresolved — retailer-specific "
+ "rules skipped (structural + field-level only)")
+ return items
+
+
+def _report_html(config, outcomes: list[FileOutcome], provenance: Provenance,
+ *, draft: bool, blocked: bool) -> str:
+ esc = html.escape
+ draft_class = "ll-draft" if draft else ""
+ title = "EDI Data Readiness Report" if blocked else "EDI Pre-flight Report"
+
+ n_ok = sum(1 for o in outcomes if o.status == "ok")
+ if blocked:
+ fill, text, label = P.LL_RED_SURFACE, P.LL_RED_DARK, "Blocked — data not ready"
+ elif any(o.status != "ok" for o in outcomes) or any(
+ o.doc_type == "856" and o.retailer_source == "unresolved" for o in outcomes
+ ):
+ fill, text, label = P.LL_SG_SURFACE, P.LL_SG_DARK, "Proceeded with warnings"
+ else:
+ fill, text, label = P.LL_HK_SURFACE, P.LL_HK_DARK, "Clean"
+
+ sections = "".join(_file_section(o) for o in outcomes)
+
+ limitations = _data_limitations(outcomes)
+ if limitations:
+ lim = "".join(f"{esc(x)}" for x in limitations)
+ limitations_html = (f"")
+ else:
+ limitations_html = ""
+
+ return f"""
+
+{esc(title)} — {esc(config.client_name)}
+
+
+
+
+ {esc(label)}
+ {n_ok} of {len(outcomes)} file(s) processed
+
+{sections}
+{limitations_html}
+{provenance.to_html()}
+"""
+
+
+def _report_text(config, outcomes: list[FileOutcome], provenance: Provenance) -> str:
+ lines = ["LAILARA LLC — EDI PRE-FLIGHT REPORT",
+ f"Client: {config.client_name} ({config.engagement_id})",
+ f"As of: {config.as_of_date.isoformat()}", "-" * 60]
+ for o in outcomes:
+ rl = _RETAILER_LABELS.get(o.retailer, "Unknown")
+ if o.status != "ok":
+ lines.append(f"{o.filename}: NOT PROCESSED — {o.error}")
+ elif o.doc_type == "850":
+ lines.append(f"{o.filename}: 850 PO {o.po.po_number} — "
+ f"{len(o.po.line_items)} line(s), ${o.po.total_amount:,.2f}")
+ else:
+ lines.append(f"{o.filename}: 856 ASN ({rl}) — "
+ f"{len(o.result.findings)} finding(s)")
+ lines.append("-" * 60)
+ lines.append(provenance.to_text())
+ return "\n".join(lines)
+
+
+def _css(draft: bool) -> str:
+ draft_css = (
+ ".ll-draft::before{content:'DRAFT';position:fixed;top:50%;left:50%;"
+ "transform:translate(-50%,-50%) rotate(-32deg);font-family:var(--s);"
+ "font-size:22vw;font-weight:700;color:rgba(204,16,10,.06);z-index:0;"
+ "pointer-events:none;white-space:nowrap}" if draft else ""
+ )
+ return f"""
+:root{{--s:{P.LL_SERIF};--f:{P.LL_SANS}}}
+*{{box-sizing:border-box}}
+body{{margin:0;background:{P.LL_CANVAS};color:{P.LL_TEXT};font-family:var(--f);line-height:1.6}}
+.ll-page{{position:relative;z-index:1;max-width:{P.LL_MAX_WIDTH};margin:0 auto;padding:48px 24px}}
+.ll-header{{border-bottom:1px solid {P.LL_GRIDLINE};padding-bottom:24px;margin-bottom:24px}}
+.ll-eyebrow{{font-size:12px;letter-spacing:.04em;text-transform:uppercase;color:{P.LL_RED};font-weight:600}}
+.ll-title{{font-family:var(--s);font-weight:700;color:{P.LL_INK};font-size:34px;margin:8px 0 16px}}
+.ll-client{{display:grid;grid-template-columns:repeat(auto-fit,minmax(160px,1fr));gap:8px 24px;font-size:14px}}
+.ll-k{{color:{P.LL_TEXT_SEC};font-size:11px;text-transform:uppercase;letter-spacing:.04em}}
+.ll-client .ll-k{{display:block}}
+.ll-banner{{border-radius:{P.LL_RADIUS};padding:16px 20px;margin-bottom:32px;display:flex;
+ justify-content:space-between;align-items:baseline;flex-wrap:wrap;gap:8px}}
+.ll-banner-status{{font-family:var(--s);font-weight:700;font-size:22px}}
+.ll-section{{margin:0 0 32px}}
+.ll-h2{{font-family:var(--s);font-weight:700;color:{P.LL_INK};font-size:22px;margin:0 0 8px;
+ padding-bottom:6px;border-bottom:1px solid {P.LL_GRIDLINE};word-break:break-all}}
+.ll-file-meta{{font-size:13px;color:{P.LL_TEXT_SEC};margin-bottom:12px}}
+.ll-file-error{{color:{P.LL_RED_DARK};background:{P.LL_RED_SURFACE};padding:10px 14px;border-radius:{P.LL_RADIUS}}}
+.ll-basis{{font-size:13px;color:{P.LL_TEXT_SEC};margin-top:10px}}
+.ll-clean-note{{background:{P.LL_HK_SURFACE};border-left:3px solid {P.LL_HK_DARK};padding:12px 16px;border-radius:{P.LL_RADIUS}}}
+.ll-table{{width:100%;border-collapse:collapse;font-size:14px}}
+.ll-table th{{text-align:left;background:{P.LL_CHICAGO};color:#fff;padding:8px 12px}}
+.ll-table td{{padding:8px 12px;border-bottom:1px solid {P.LL_GRIDLINE};vertical-align:top}}
+.ll-badge{{display:inline-block;font-size:11px;font-weight:600;text-transform:uppercase;
+ letter-spacing:.03em;padding:2px 8px;border-radius:{P.LL_RADIUS};white-space:nowrap}}
+.num{{text-align:right;font-variant-numeric:tabular-nums}}
+.ll-limitations{{margin:0;padding-left:20px}}
+.ll-limitations li{{margin-bottom:6px}}
+.ll-provenance{{margin-top:40px;background:{P.LL_CARD_BG};color:{P.LL_CARD_TEXT};padding:20px 24px;
+ border-radius:{P.LL_RADIUS};font-size:13px}}
+.ll-prov-title{{font-family:var(--s);font-weight:700;font-size:16px;margin-bottom:8px}}
+.ll-provenance div{{margin-bottom:4px;color:{P.LL_CARD_SUBTITLE}}}
+.ll-provenance strong{{color:{P.LL_CARD_TEXT}}}
+.ll-prov-inputs{{width:100%;border-collapse:collapse;margin-top:8px}}
+.ll-prov-inputs th{{text-align:left;border-bottom:1px solid rgba(255,255,255,.12);padding:4px 8px;color:{P.LL_CARD_MUTED}}}
+.ll-prov-inputs td{{padding:4px 8px;border-bottom:1px solid rgba(255,255,255,.08);color:{P.LL_CARD_SUBTITLE}}}
+.ll-prov-brand{{margin-top:12px;font-family:var(--s);color:{P.LL_CARD_MUTED}}}
+.mono{{font-family:ui-monospace,Consolas,monospace;font-size:12px;word-break:break-all}}
+{draft_css}
+@media print{{body{{background:#fff}}}}
+"""
+
+
+# --------------------------------------------------------------------------- #
+# Orchestration
+# --------------------------------------------------------------------------- #
+
+def _partner_map(config) -> dict:
+ partners = config.raw.get("partners") or config.basis.get("partners") or {}
+ return partners if isinstance(partners, dict) else {}
+
+
+def run(config_path: str, input_path: str, out_dir: str, *, final: bool = False) -> dict:
+ config = load_config(config_path)
+ partners = _partner_map(config)
+
+ files = _iter_input_files(input_path)
+ if not files:
+ raise SystemExit(f"no input files found at {input_path}")
+
+ outcomes = [_process_file(p, partners) for p in files]
+ n_ok = sum(1 for o in outcomes if o.status == "ok")
+ blocked = n_ok == 0
+
+ # Status: nothing usable -> failed; any error/unresolved -> warnings; else clean.
+ n_warn = sum(
+ 1 for o in outcomes
+ if o.status != "ok" or (o.doc_type == "856" and o.retailer_source == "unresolved")
+ )
+ if blocked:
+ status = "failed"
+ elif n_warn:
+ status = "warnings"
+ else:
+ status = "clean"
+
+ out = Path(out_dir)
+ out.mkdir(parents=True, exist_ok=True)
+
+ # The partner map now folds into config_hash (lailara_engagement >=0.2.1
+ # hashes extra top-level config blocks), so a change to it already moves the
+ # provenance hash. This surfaces the same fact human-readably in the footer.
+ extra = {}
+ if partners:
+ blob = repr(sorted((str(k), str(v)) for k, v in partners.items())).encode()
+ extra["partner_map"] = hashlib.sha256(blob).hexdigest()[:12]
+
+ provenance = build_provenance(
+ tool=TOOL, tool_version=TOOL_VERSION,
+ inputs=[o.input_ref for o in outcomes], config=config,
+ validation_status=validation_status_label(status, n_warn),
+ extra=extra,
+ )
+
+ html_path = out / "edi-preflight-report.html"
+ html_path.write_text(
+ _report_html(config, outcomes, provenance, draft=not final, blocked=blocked),
+ encoding="utf-8",
+ )
+ txt_path = out / "edi-preflight-report.txt"
+ txt_path.write_text(_report_text(config, outcomes, provenance), encoding="utf-8")
+
+ total_findings = sum(len(o.result.findings) for o in outcomes if o.result)
+ result = {
+ "status": "blocked" if blocked else "ok",
+ "report": str(html_path),
+ "txt": str(txt_path),
+ "n_files": len(outcomes),
+ "n_ok": n_ok,
+ "total_findings": total_findings,
+ "files": [
+ {"filename": o.filename, "doc_type": o.doc_type, "status": o.status,
+ "retailer": _RETAILER_LABELS.get(o.retailer, "Unknown"),
+ "retailer_source": o.retailer_source}
+ for o in outcomes
+ ],
+ }
+ if blocked:
+ result["readiness_report"] = str(html_path)
+ return result
+
+
+def main(argv=None) -> int:
+ ap = argparse.ArgumentParser(
+ prog="edi client mode",
+ description="Pre-flight a client's EDI file(s) in engagement mode.",
+ )
+ ap.add_argument("--config", required=True)
+ ap.add_argument("--input", required=True, help="an EDI file or a directory of them")
+ ap.add_argument("--out", default="client-output")
+ ap.add_argument("--final", action="store_true")
+ args = ap.parse_args(argv)
+
+ result = run(args.config, args.input, args.out, final=args.final)
+ if result["status"] == "blocked":
+ print(f"BLOCKED — no usable EDI. See {result['readiness_report']}")
+ return 3
+ print(f"processed {result['n_ok']}/{result['n_files']} file(s); "
+ f"{result['total_findings']} ASN finding(s)")
+ print(f"report -> {result['report']}\ntext -> {result['txt']}")
+ return 0
+
+
+if __name__ == "__main__": # pragma: no cover
+ raise SystemExit(main())
diff --git a/engagement.demo.yml b/engagement.demo.yml
new file mode 100644
index 0000000..d26da46
--- /dev/null
+++ b/engagement.demo.yml
@@ -0,0 +1,14 @@
+# Demo engagement config (safe to deploy: demo: true).
+client:
+ name: "Cinderhaven Provisions (demo)"
+engagement:
+ id: "DEMO-EDI-001"
+as_of_date: "2026-05-10"
+prepared_by: "Lailara LLC"
+demo: true
+# Partner-ID map: a client's trading-partner identifier (an ISA/GS sender or
+# receiver id, exact or case-insensitive substring) -> retailer ruleset. The
+# engine already recognizes the majors by name/DUNS, so this is only needed for
+# ids it can't infer. Keys: walmart | amazon | unfi | kehe | costco.
+partners:
+ "0078742099999": walmart # illustrative: a Walmart DC id not in the built-in table
diff --git a/scripts/engagement_guard.py b/scripts/engagement_guard.py
new file mode 100644
index 0000000..ec77677
--- /dev/null
+++ b/scripts/engagement_guard.py
@@ -0,0 +1,25 @@
+#!/usr/bin/env python3
+"""Lailara engagement deploy guard (Python, stdlib-only).
+
+Exit 2 if an ACTIVE (non-demo) client engagement.yml is present in the current
+directory. No-op otherwise, so demo builds and clean CI checkouts are unaffected.
+Self-contained (no dependency on the installed lailara_engagement package) so it can
+run in any repo's deploy/build environment.
+"""
+import os
+import re
+import sys
+
+for _f in ("engagement.yml", "engagement.yaml"):
+ if os.path.isfile(_f):
+ with open(_f, encoding="utf-8-sig") as _fh:
+ _txt = _fh.read()
+ if re.search(r"^\s*demo:\s*true\s*$", _txt, re.M):
+ continue # demo config -> safe
+ sys.stderr.write(
+ f"ENGAGEMENT GUARD: active client engagement config present ({_f}). "
+ "Client mode is runtime-only and must never deploy. Deactivate it "
+ "(set 'demo: true', or use engagement.demo.yml) before deploying.\n"
+ )
+ raise SystemExit(2)
+raise SystemExit(0)
diff --git a/scripts/git-hooks/pre-push b/scripts/git-hooks/pre-push
new file mode 100755
index 0000000..9be9de9
--- /dev/null
+++ b/scripts/git-hooks/pre-push
@@ -0,0 +1,24 @@
+#!/bin/sh
+# Lailara engagement deploy guard (git pre-push hook).
+#
+# Refuses to push while an ACTIVE (non-demo) client engagement.yml is present in
+# the working tree. Every tool repo auto-deploys on push, so blocking the push
+# blocks the deploy — client mode is runtime-only and must never ship.
+#
+# No-op when no engagement.yml exists (demo builds and clean CI checkouts push
+# normally), so demo behavior is unchanged.
+#
+# Activated per repo with: git config core.hooksPath scripts/git-hooks
+set -e
+for f in engagement.yml engagement.yaml; do
+ if [ -f "$f" ]; then
+ if grep -Eq '^[[:space:]]*demo:[[:space:]]*true[[:space:]]*$' "$f"; then
+ continue # demo config -> safe
+ fi
+ echo "ENGAGEMENT GUARD: active client engagement config present ($f)." >&2
+ echo "Client mode is runtime-only and must never deploy. Deactivate it" >&2
+ echo "(set 'demo: true', or remove/rename to engagement.demo.yml) before pushing." >&2
+ exit 2
+ fi
+done
+exit 0
diff --git a/tests/test_client_mode.py b/tests/test_client_mode.py
new file mode 100644
index 0000000..510b3d0
--- /dev/null
+++ b/tests/test_client_mode.py
@@ -0,0 +1,157 @@
+"""Client-mode tests for edi-preflight: intake, preflight, provenance report.
+
+Adversarial fixtures per checklist §6: unreadable/non-EDI input (blocked path),
+a clean ASN, a Latin-1-encoded file, partner-ID-map resolution of a retailer the
+engine can't auto-detect, and directory intake. Skipped if lailara_engagement
+isn't installed (as in the repo's default CI environment).
+"""
+
+from pathlib import Path
+
+import pytest
+
+pytest.importorskip("lailara_engagement")
+
+import client_mode # noqa: E402
+
+_SAMPLES = Path(__file__).resolve().parent.parent / "samples"
+
+_CONFIG = """
+client: {name: Meridian Farms}
+engagement: {id: MER-2026-08}
+as_of_date: 2026-05-10
+demo: true
+partners:
+ PARTNERX: walmart
+"""
+
+
+@pytest.fixture
+def cfg(tmp_path):
+ p = tmp_path / "engagement.demo.yml"
+ p.write_text(_CONFIG, encoding="utf-8")
+ return str(p)
+
+
+def _clean_856() -> str:
+ return (_SAMPLES / "walmart" / "856_clean.edi").read_text()
+
+
+def test_clean_856_reports_and_is_branded(cfg, tmp_path):
+ src = tmp_path / "asn.edi"
+ src.write_text(_clean_856(), encoding="utf-8")
+ out = str(tmp_path / "client-output")
+ result = client_mode.run(cfg, str(src), out)
+
+ assert result["status"] == "ok"
+ assert result["total_findings"] == 0 # 856_clean passes all checks
+ assert result["files"][0]["doc_type"] == "856"
+ assert result["files"][0]["retailer"] == "Walmart"
+ assert result["files"][0]["retailer_source"] == "auto"
+
+ html = Path(result["report"]).read_text(encoding="utf-8")
+ assert "Meridian Farms" in html # client header
+ assert "#f5f3ee" in html # branded warm canvas
+ assert "SHA-256" in html # provenance footer
+ assert "DRAFT" in html # draft watermark
+
+
+def test_non_edi_input_is_blocked(cfg, tmp_path):
+ src = tmp_path / "notes.txt"
+ src.write_text("this is not an EDI document\n", encoding="utf-8")
+ out = str(tmp_path / "out")
+ result = client_mode.run(cfg, str(src), out)
+
+ assert result["status"] == "blocked"
+ html = Path(result["readiness_report"]).read_text(encoding="utf-8")
+ assert "Data Readiness" in html # blocked -> readiness report
+ assert "notes.txt" in html # the offending file is named
+
+
+def test_latin1_encoded_file_is_read(cfg, tmp_path):
+ # A Latin-1 byte (the é in "Café") must not break intake — the reader falls
+ # back from UTF-8 to Latin-1 exactly as the web app does.
+ text = _clean_856().replace("Artisanal Sea Salt Crackers 12ct", "Café Crackers")
+ src = tmp_path / "latin1.edi"
+ src.write_bytes(text.encode("latin-1"))
+ out = str(tmp_path / "out")
+ result = client_mode.run(cfg, str(src), out)
+
+ assert result["status"] == "ok"
+ assert result["files"][0]["doc_type"] == "856"
+
+
+def test_partner_map_resolves_unknown_retailer(cfg, tmp_path):
+ # Swap the receiver id (ISA08 + GS03) to one the engine can't auto-detect;
+ # the partner map must still resolve it to the Walmart ruleset.
+ text = (_clean_856()
+ .replace("WALMART ", "PARTNERX ") # ISA08 (15-wide field)
+ .replace("*WALMART*", "*PARTNERX*")) # GS03
+ src = tmp_path / "asn.edi"
+ src.write_text(text, encoding="utf-8")
+ out = str(tmp_path / "out")
+ result = client_mode.run(cfg, str(src), out)
+
+ f = result["files"][0]
+ assert result["status"] == "ok"
+ assert f["retailer"] == "Walmart"
+ assert f["retailer_source"] == "partner-map"
+
+
+def test_unknown_retailer_without_map_falls_back_to_generic(tmp_path):
+ # Same swapped file, but a config with NO partner map: the retailer is
+ # unresolved and the retailer-specific layer is skipped (still processed).
+ cfg_no_partners = tmp_path / "cfg.yml"
+ cfg_no_partners.write_text(
+ "client: {name: X}\nengagement: {id: E1}\nas_of_date: 2026-05-10\ndemo: true\n",
+ encoding="utf-8",
+ )
+ text = (_clean_856()
+ .replace("WALMART ", "PARTNERX ")
+ .replace("*WALMART*", "*PARTNERX*"))
+ src = tmp_path / "asn.edi"
+ src.write_text(text, encoding="utf-8")
+ out = str(tmp_path / "out")
+ result = client_mode.run(str(cfg_no_partners), str(src), out)
+
+ assert result["status"] == "ok" # still processed
+ assert result["files"][0]["retailer_source"] == "unresolved"
+ html = Path(result["report"]).read_text(encoding="utf-8")
+ assert "retailer-specific layer skipped" in html.lower() or "unresolved" in html.lower()
+
+
+def test_directory_intake_processes_all_files(cfg, tmp_path):
+ d = tmp_path / "asns"
+ d.mkdir()
+ (d / "a.edi").write_text(_clean_856(), encoding="utf-8")
+ (d / "b.edi").write_text((_SAMPLES / "walmart" / "856_bad_dtm.edi").read_text(),
+ encoding="utf-8")
+ out = str(tmp_path / "out")
+ result = client_mode.run(cfg, str(d), out)
+
+ assert result["n_files"] == 2
+ assert result["n_ok"] == 2
+ assert result["total_findings"] == 4 # 856_clean (0) + 856_bad_dtm (4)
+
+
+def test_850_purchase_order_path(cfg, tmp_path):
+ src = tmp_path / "po.edi"
+ src.write_text((_SAMPLES / "walmart" / "850_with_allowances.edi").read_text(),
+ encoding="utf-8")
+ out = str(tmp_path / "out")
+ result = client_mode.run(cfg, str(src), out)
+
+ assert result["status"] == "ok"
+ assert result["files"][0]["doc_type"] == "850"
+ html = Path(result["report"]).read_text(encoding="utf-8")
+ assert "4500012400" in html # PO number in the 850 summary
+
+
+def test_final_flag_drops_watermark(cfg, tmp_path):
+ src = tmp_path / "asn.edi"
+ src.write_text(_clean_856(), encoding="utf-8")
+ out = str(tmp_path / "out")
+ result = client_mode.run(cfg, str(src), out, final=True)
+ html = Path(result["report"]).read_text(encoding="utf-8")
+ assert "ll-draft" not in html
+ assert "DRAFT" not in html
diff --git a/tests/test_demo_golden.py b/tests/test_demo_golden.py
new file mode 100644
index 0000000..ef2261d
--- /dev/null
+++ b/tests/test_demo_golden.py
@@ -0,0 +1,82 @@
+"""Demo golden-file lock for edi-preflight.
+
+Pins the engine output on the two files the deployed demo actually serves
+(``src.main._SAMPLE_FILES``) so the live site's demo experience cannot drift
+during the client-mode conversion. If either the served sample file or the
+engine changes what the demo shows, these tests fail on purpose.
+
+The 856 sample (``856_bad_dtm``) is deliberately chosen to exercise a
+chargeback-tier rule so the demo's "Est. Chargebacks" tile is non-empty; the
+850 sample (``850_with_allowances``) exercises header + line allowances. See
+the note in ``src/main.py`` next to ``_SAMPLE_FILES``.
+"""
+
+from collections import Counter
+
+from src.envelope import Retailer, parse_envelope
+from src.extract_850 import extract_850
+from src.main import _SAMPLE_FILES
+from src.validate_856 import validate_856
+from src.validate_856_walmart import validate_856_walmart
+from src.x12_tokenizer import tokenize
+
+
+def _load(doc_type: str) -> str:
+ return _SAMPLE_FILES[doc_type].read_text()
+
+
+class TestDemo856Golden:
+ """Locks the deployed 856 demo (walmart/856_bad_dtm.edi)."""
+
+ def test_sample_856_is_the_walmart_bad_dtm_file(self):
+ assert _SAMPLE_FILES["856"].name == "856_bad_dtm.edi"
+
+ def test_sample_856_findings_are_locked(self):
+ env = parse_envelope(tokenize(_load("856")))
+ assert env.retailer is Retailer.WALMART
+ result = validate_856_walmart(validate_856(env))
+ assert len(result.findings) == 4
+ by_severity = dict(Counter(f.severity.value for f in result.findings))
+ assert by_severity == {
+ "may-cause-chargeback": 3,
+ "will-cause-chargeback": 1,
+ }
+ assert sorted(f.rule_id for f in result.findings) == [
+ "invalid_bsn_date",
+ "invalid_dtm_date",
+ "invalid_dtm_date",
+ "invalid_sscc18_format",
+ ]
+
+ def test_sample_856_fee_breakdown_is_locked(self):
+ env = parse_envelope(tokenize(_load("856")))
+ result = validate_856_walmart(validate_856(env))
+ # One $1/case labeling defect; per-basis breakdown, never a cross-basis sum.
+ assert result.fee_breakdown == [
+ {"fee_per": "case", "count": 1, "subtotal": 1.0}
+ ]
+
+
+class TestDemo850Golden:
+ """Locks the deployed 850 demo (walmart/850_with_allowances.edi)."""
+
+ def test_sample_850_is_the_walmart_with_allowances_file(self):
+ assert _SAMPLE_FILES["850"].name == "850_with_allowances.edi"
+
+ def test_sample_850_extraction_is_locked(self):
+ env = parse_envelope(tokenize(_load("850")))
+ assert env.retailer is Retailer.WALMART
+ po = extract_850(env)
+ assert po.po_number == "4500012400"
+ assert po.po_type == "SA"
+ assert po.purpose_code == "00"
+ assert len(po.line_items) == 3
+ assert po.total_line_items == 3
+ assert po.total_quantity == 420.0
+ assert po.total_amount == 9329.7
+ assert len(po.header_allowances) == 2
+ assert len(po.all_allowances) == 6
+ assert [(a.entity_code, a.entity_name) for a in po.addresses] == [
+ ("ST", "WALMART RDC 7033"),
+ ("BT", "WALMART ACCOUNTS PAYABLE"),
+ ]