From 703cdedc42db732d52f6adbdcab67cc6fde57737 Mon Sep 17 00:00:00 2001 From: DEls Date: Thu, 17 Sep 2026 20:57:44 +0200 Subject: [PATCH] fix(dn-pipeline): a PO line with no unit no longer blocks delivery 61 of 148 purchase_order_items have an empty unit. Blocking every delivery against them rejects 41% of lines over a gap on the order. Co-Authored-By: Claude Sonnet 5 --- src/lib/dn-item-row.test.ts | 14 ++++++++++++++ src/lib/dn-item-row.ts | 7 +++++++ src/lib/dn-pipeline/checks.test.ts | 13 +++++++++++++ src/lib/dn-pipeline/checks.ts | 23 ++++++++++++++++++++--- 4 files changed, 54 insertions(+), 3 deletions(-) diff --git a/src/lib/dn-item-row.test.ts b/src/lib/dn-item-row.test.ts index 97e949f..2e166f5 100644 --- a/src/lib/dn-item-row.test.ts +++ b/src/lib/dn-item-row.test.ts @@ -69,6 +69,20 @@ describe("buildDeliveryNoteItemRows", () => { expect(rows[0].condition_notes).toContain("unknown_unit"); }); + it("passes the slip quantity through when the PO line has no unit", () => { + const { rows } = buildDeliveryNoteItemRows({ + items: [{ description: "G6 Sub Base", qty: 12, unit: "ton", matched_po_item_id: "aaaaaaaaaaaaaaa" }], + poItems: [{ id: "aaaaaaaaaaaaaaa", unit: "" }], + confidence: 1, + }); + expect(rows[0].qty_received).toBe(12); + expect(rows[0].qty_raw).toBe(12); + expect(rows[0].unit_raw).toBe("ton"); + expect(rows[0].unit_converted).toBe(false); + expect(rows[0].purchase_order_item).toBe("aaaaaaaaaaaaaaa"); + expect(rows[0].condition_notes).toContain("po unit missing"); + }); + it("keeps an unmatched line at zero with no link", () => { const { rows } = buildDeliveryNoteItemRows({ items: [{ description: "Mystery", qty: 4, unit: "kg" }], diff --git a/src/lib/dn-item-row.ts b/src/lib/dn-item-row.ts index 3b06148..2b26bd5 100644 --- a/src/lib/dn-item-row.ts +++ b/src/lib/dn-item-row.ts @@ -92,6 +92,13 @@ export function buildDeliveryNoteItemRows( // local fetch). Keep the link for the audit trail but do not // invent a quantity we cannot express in the right unit. failure = "po_item_unavailable"; + } else if (!normaliseUnit(poItem.unit ?? null)) { + // No target unit to convert into. Keep the link and the slip's + // own number — the alternative is discarding a real delivery + // because the purchase order is missing a field. + qtyReceived = qty; + converted = false; + failure = "po unit missing"; } else { const conv = convertQty(qty, normaliseUnit(unitRaw), normaliseUnit(poItem.unit)); if (conv.ok) { diff --git a/src/lib/dn-pipeline/checks.test.ts b/src/lib/dn-pipeline/checks.test.ts index 7682a34..1d6aec0 100644 --- a/src/lib/dn-pipeline/checks.test.ts +++ b/src/lib/dn-pipeline/checks.test.ts @@ -171,6 +171,19 @@ describe("runNoteChecks", () => { expect(r.passed).toBe(false); }); + it("skips units_convertible when the PO line has no unit", () => { + const ctx = { ...CTX, poItems: [{ id: "bxqbo8rs04pjqwc", description: "G6 Sub Base", unit: "" }] }; + const r = byId(runNoteChecks(note(), ctx), "units_convertible"); + expect(r.passed).toBeNull(); + expect(r.reason).toContain("no unit"); + }); + + it("still fails when the SLIP line has no unit", () => { + const n = note({ items: [{ description: "G6 Sub Base", qty: 5, unit: null, unit_price: null }] }); + const r = byId(runNoteChecks(n, CTX), "units_convertible"); + expect(r.passed).toBe(false); + }); + it("fails when the note number is already in delivery_notes", () => { const ctx = { ...CTX, existingNoteNumbers: new Set(["DQ0010107"]) }; const r = byId(runNoteChecks(note(), ctx), "not_duplicate"); diff --git a/src/lib/dn-pipeline/checks.ts b/src/lib/dn-pipeline/checks.ts index 7cec131..750ac5b 100644 --- a/src/lib/dn-pipeline/checks.ts +++ b/src/lib/dn-pipeline/checks.ts @@ -147,15 +147,32 @@ function unitsConvertible(note: NoteSegment, ctx: NoteCheckContext): CheckResult return { id: "units_convertible", passed: null, reason: "no purchase order lines to convert into" }; } const bad: string[] = []; + const missingPoUnit: string[] = []; for (const it of note.items) { const poItem = matchPoItem(it.description, ctx.poItems); if (!poItem) continue; // product_matched already reports this + // A PO line with no stated unit cannot be converted into. That is a + // purchasing data gap, not a delivery problem — 61 of 148 PO lines + // are affected, and blocking them all would reject 41% of deliveries + // over a missing field on the order. + if (!normaliseUnit(poItem.unit ?? null)) { + missingPoUnit.push(it.description); + continue; + } const conv = convertQty(it.qty, normaliseUnit(it.unit), normaliseUnit(poItem.unit ?? null)); if (!conv.ok) bad.push(`${it.description} (${it.unit ?? "no unit"} → ${poItem.unit || "no unit"})`); } - return bad.length === 0 - ? { id: "units_convertible", passed: true, reason: "all units convert" } - : { id: "units_convertible", passed: false, reason: `cannot convert: ${bad.join(", ")}` }; + if (bad.length > 0) { + return { id: "units_convertible", passed: false, reason: `cannot convert: ${bad.join(", ")}` }; + } + if (missingPoUnit.length > 0) { + return { + id: "units_convertible", + passed: null, + reason: `purchase order line has no unit: ${missingPoUnit.join(", ")}`, + }; + } + return { id: "units_convertible", passed: true, reason: "all units convert" }; } function notDuplicate(note: NoteSegment, ctx: NoteCheckContext): CheckResult {