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 ( + "" + "" + f"{body}
SeverityLayerFindingFee
" + ) + + +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"{_po_summary_rows(o.po)}
" + 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"

    Data " + f"limitations

    ") + else: + limitations_html = "" + + return f""" + +{esc(title)} — {esc(config.client_name)} + +
    +
    +
    Lailara LLC · EDI Pre-flight
    +

    {esc(title)}

    +
    +
    Client {esc(config.client_name)}
    +
    Engagement {esc(config.engagement_id)}
    +
    As of {esc(config.as_of_date.isoformat())}
    +
    Prepared by {esc(config.prepared_by)}
    +
    +
    +
    +
    {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"), + ]