From 0404688c758b823fd162b33e57c10d41c8169180 Mon Sep 17 00:00:00 2001 From: DEls Date: Mon, 26 Oct 2026 18:53:56 +0200 Subject: [PATCH] fix(dn): confirm refuses a slip on another supplier's PO Co-Authored-By: MiniMax (Pinchy) --- src/lib/dn-pipeline/checks.ts | 19 +++++++++++++++++++ src/lib/dn-pipeline/matching.test.ts | 14 ++++++++++++++ src/lib/dn-pipeline/matching.ts | 4 ++++ src/lib/inbox-three/readiness.test.ts | 21 +++++++++++++++++++++ src/lib/inbox-three/readiness.ts | 4 ++++ 5 files changed, 62 insertions(+) diff --git a/src/lib/dn-pipeline/checks.ts b/src/lib/dn-pipeline/checks.ts index 3c28cc7..958910a 100644 --- a/src/lib/dn-pipeline/checks.ts +++ b/src/lib/dn-pipeline/checks.ts @@ -317,6 +317,25 @@ export function supplierGuardOverride(manualOverrides: unknown): { reason: strin return reason ? { reason } : null; } +/** + * Did the supplier guard fail on this note? + * + * A slip printed by another company is not a posting problem, it is a + * linking problem: the note has to be linked to the right order, or an + * operator has to record why it is allowed through. Until one of those + * happens, nothing on the note may be confirmed. + */ +export function supplierGuardFailed(note: unknown): boolean { + const bag = note as { checks?: unknown } | null | undefined; + const checks = bag?.checks; + if (!Array.isArray(checks)) return false; + return checks.some((check) => { + if (!check || typeof check !== "object") return false; + const row = check as { id?: unknown; passed?: unknown }; + return row.id === "supplier_matches_po" && row.passed === false; + }); +} + function supplierMatchesPoCheck(note: NoteSegment, ctx: NoteCheckContext): CheckResult { const id = "supplier_matches_po"; if (!ctx.linkedSupplier) return { id, passed: null, reason: "no supplier linked to this note yet" }; diff --git a/src/lib/dn-pipeline/matching.test.ts b/src/lib/dn-pipeline/matching.test.ts index ed83bd6..8bc73be 100644 --- a/src/lib/dn-pipeline/matching.test.ts +++ b/src/lib/dn-pipeline/matching.test.ts @@ -170,3 +170,17 @@ it('refuses to confirm a slip while the site question is open',()=>{ const p=threeLinePreview(c); expect(()=>prepareReadyLines(c,p)).toThrow(/Answer the outstanding site question/); }); + +// ---------- the supplier guard blocks a confirmation ---------- +it('refuses to confirm a slip whose supplier does not match the purchase order',()=>{ + const c=context(); + c.note.checks=[{id:'supplier_matches_po',passed:false,reason:'The slip is from FH Chamberlain Trading but this purchase order belongs to Avidan Trading CC.'}]; + const p=buildPreview(c,{allocations:[]}); + expect(()=>prepareDelivery(c,p)).toThrow(/supplier does not match the purchase order/); + expect(()=>prepareReadyLines(c,p)).toThrow(/supplier does not match the purchase order/); +}); +it('confirms a slip whose supplier guard has passed',()=>{ + const c=threeLines();c.note.checks=[{id:'supplier_matches_po',passed:true,reason:'The slip is from the supplier of this order.'}]; + const p=threeLinePreview(c); + expect(prepareReadyLines(c,p)).toHaveLength(1); +}); diff --git a/src/lib/dn-pipeline/matching.ts b/src/lib/dn-pipeline/matching.ts index 9b386cf..c5e99ed 100644 --- a/src/lib/dn-pipeline/matching.ts +++ b/src/lib/dn-pipeline/matching.ts @@ -3,6 +3,7 @@ import {buildPreview,snapshotHash,type PreviewAllocation} from './ai-preview'; import {PostLineSchema} from './post-line'; import {prepareLineBooking,stableSourceKey,type LineBooking} from './line-booking'; import {deliveryReady} from './delivery-ready'; +import {supplierGuardFailed} from './checks'; import {readyIndexes} from '@/lib/inbox-three/readiness'; import {openSiteDecision} from './site-decision-model'; import type {LineConfirmation} from './line-booking'; @@ -29,6 +30,9 @@ export function resolveMatch(context:PreviewContext,preview:ProcessingPreview,in function assertConfirmable(context:PreviewContext){ if(openSiteDecision(context.note.checks))throw Error('Answer the outstanding site question before confirming delivery'); if(Array.isArray(context.note.checks)&&context.note.checks.some(c=>c&&typeof c==='object'&&c.id==='not_duplicate'&&c.passed===false))throw Error('Resolve the possible duplicate delivery before confirming'); + // A slip from another supplier is not this order's delivery. The link has + // to be fixed, or an operator has to say why it is allowed through. + if(supplierGuardFailed(context.note))throw Error("This slip's supplier does not match the purchase order's supplier. Fix the link or record an override first."); } /** Preflight every line and cumulative totals before the first receipt write. */ diff --git a/src/lib/inbox-three/readiness.test.ts b/src/lib/inbox-three/readiness.test.ts index b880e06..40a248b 100644 --- a/src/lib/inbox-three/readiness.test.ts +++ b/src/lib/inbox-three/readiness.test.ts @@ -194,3 +194,24 @@ describe("readyIndexes", () => { expect(readyIndexes(c, preview)).toEqual([0]); }); }); + +// ---------- the supplier guard blocks a ready note ---------- +const guardedContext = () => context({ + lines: [{ sourceLineId: "pipe", description: "1050 pipe", qty: 2, unit: "m", unit_price: 1124.35, line_amount: 2248.7, product_code: null }], + poItems: [{ id: PIPE_ITEM, description: "1050 pipe", unit: "m", qty: 100, rate: 1124.35, disc_percent: 0, vat_percent: 15 }], +}); +describe("the supplier guard", () => { + it("readies nothing on a note whose supplier guard failed", () => { + const c = guardedContext(); + c.note.checks = [{ id: "supplier_matches_po", passed: false, reason: "The slip is from FH Chamberlain Trading but this purchase order belongs to Avidan Trading CC." }]; + const preview = buildPreview(c, { allocations: [allocation({ sourceLineIds: ["pipe"] })] }); + expect(readyIndexes(c, preview)).toEqual([]); + }); + + it("readies the line once the supplier guard has passed", () => { + const c = guardedContext(); + c.note.checks = [{ id: "supplier_matches_po", passed: true, reason: "The slip is from the supplier of this order." }]; + const preview = buildPreview(c, { allocations: [allocation({ sourceLineIds: ["pipe"] })] }); + expect(readyIndexes(c, preview)).toEqual([0]); + }); +}); diff --git a/src/lib/inbox-three/readiness.ts b/src/lib/inbox-three/readiness.ts index d66b1a3..efad4eb 100644 --- a/src/lib/inbox-three/readiness.ts +++ b/src/lib/inbox-three/readiness.ts @@ -11,6 +11,7 @@ * ready in one and open in the other. */ import { allocationNeedsHelp } from "@/lib/dn-pipeline/delivery-ready"; +import { supplierGuardFailed } from "@/lib/dn-pipeline/checks"; import { prepareLineBooking } from "@/lib/dn-pipeline/line-booking"; import { dims, resolvePoItem, type PoItemRef } from "@/lib/dn-pipeline/match-po-item"; import { sameReceivingUnit } from "@/lib/dn-pipeline/receiving-defaults"; @@ -199,6 +200,9 @@ export function lineReady(context: PreviewContext, preview: ProcessingPreview, i /** The indexes of the lines that can be confirmed without a change. */ export function readyIndexes(context: PreviewContext, preview: ProcessingPreview): number[] { + // Nothing on a slip from another supplier is ready: the note has to be + // linked to the right order first, so the stage never offers Ready to post. + if (supplierGuardFailed(context.note)) return []; const ready: number[] = []; preview.allocations.forEach((_, index) => { if (lineReady(context, preview, index)) ready.push(index);