fix(dn): confirm refuses a slip on another supplier's PO

Co-Authored-By: MiniMax (Pinchy)
This commit is contained in:
DEls 2026-10-26 18:53:56 +02:00
parent 2dfe92b7d6
commit 0404688c75
5 changed files with 62 additions and 0 deletions

View file

@ -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" };

View file

@ -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);
});

View file

@ -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. */

View file

@ -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]);
});
});

View file

@ -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);