From 4107bbec9a1050970458c05e377dcd7acde282d4 Mon Sep 17 00:00:00 2001 From: DEls Date: Mon, 26 Oct 2026 18:55:48 +0200 Subject: [PATCH] fix(dn): only a remembered unit connection auto-readies a unit change Co-Authored-By: MiniMax (Pinchy) --- src/lib/dn-pipeline/remembered-match.ts | 12 +++++++--- src/lib/inbox-three/readiness.test.ts | 32 +++++++++++++++++++++---- src/lib/inbox-three/readiness.ts | 7 ++++-- 3 files changed, 41 insertions(+), 10 deletions(-) diff --git a/src/lib/dn-pipeline/remembered-match.ts b/src/lib/dn-pipeline/remembered-match.ts index eda6970..c774425 100644 --- a/src/lib/dn-pipeline/remembered-match.ts +++ b/src/lib/dn-pipeline/remembered-match.ts @@ -5,9 +5,15 @@ import {PreviewResponseSchema,type PreviewContext,type ProcessingPreview,type Pr const norm=(v:unknown)=>String(v??'').trim().toLowerCase().replace(/\s+/g,' '); const sourceUnitKey=(v:unknown)=>typeof v==='string'&&v.trim()&&v.length<=40?normaliseUnit(v)??`raw:${norm(v)}`:null; const object=(v:unknown):v is PreviewRow=>!!v&&typeof v==='object'&&!Array.isArray(v); +/** + * The prefix every remembered receiving connection carries. + * + * Only a rule with this prefix remembers a unit conversion, so only a rule + * with this prefix may justify a slip whose unit differs from the PO's. + */ +export const RECEIVING_MATCH_RULE_PREFIX='receiving-match-'; /** Narrow receiving guidance: exact supplier/product/units only, never an amount. */ -export function rememberConnection(context:PreviewContext,preview:ProcessingPreview,index:number,actor:string){ - const a=preview.allocations[index];const item=context.poItems.find(i=>i.id===a?.purchaseOrderItemId); +export function rememberConnection(context:PreviewContext,preview:ProcessingPreview,index:number,actor:string){ const a=preview.allocations[index];const item=context.poItems.find(i=>i.id===a?.purchaseOrderItemId); if(a?.operatorConfirmation&&(a.operatorConfirmation.unitOverride||item&&!sameReceivingUnit(a.unit,String(item.unit??''))))throw Error('A unit override is specific to this receipt and cannot be remembered as a supplier rule'); const line=a?.sourceLineIds.length===1?context.lines.find(l=>l.sourceLineId===a.sourceLineIds[0]):null; const sourceUnit=sourceUnitKey(line?.unit),targetUnit=normaliseUnit(typeof item?.unit==='string'?item.unit:null); @@ -15,7 +21,7 @@ export function rememberConnection(context:PreviewContext,preview:ProcessingPrev const factor=a.quantity/line.qty; if(!Number.isFinite(factor)||factor<=0||factor>10000||sourceUnit===targetUnit&&factor!==1)throw Error('Only a supported unit conversion can be remembered'); const conditions={supplier:String(context.supplier.id),sourceDescription:norm(line.description),sourceCode:norm(line.product_code),sourceUnit,targetDescription:norm(item.description),targetCode:norm(item.material_code),targetUnit,factor}; - const key='receiving-match-'+createHash('sha256').update(JSON.stringify({...conditions,factor:undefined})).digest('hex').slice(0,16); + const key=RECEIVING_MATCH_RULE_PREFIX+createHash('sha256').update(JSON.stringify({...conditions,factor:undefined})).digest('hex').slice(0,16); const knowledge=object(context.supplier.reconciliation_rules)?context.supplier.reconciliation_rules:{}; const rules=Array.isArray(knowledge.rules)?knowledge.rules.filter(object):[]; const old=rules.find(r=>r.rule_key===key); diff --git a/src/lib/inbox-three/readiness.test.ts b/src/lib/inbox-three/readiness.test.ts index 40a248b..5bea2b8 100644 --- a/src/lib/inbox-three/readiness.test.ts +++ b/src/lib/inbox-three/readiness.test.ts @@ -5,12 +5,15 @@ import { guardAgrees, lineReady, priceDiffers, readyIndexes, slipRateNet } from const PIPE_ITEM = "pipeitem1234567"; const PLIER_ITEM = "plieritem1234567"; -const ROLL_RULE = { rule_key: "roll-to-metre", version: 1, status: "active", supplier_ids: [], instructions: ["One roll is 30 m"] }; +// The rule rememberConnection writes: a remembered receiving connection. +const ROLL_RULE = { rule_key: "receiving-match-abc123", version: 1, status: "active", supplier_ids: [], instructions: ["One roll is 30 m"] }; +// A rule that says nothing about units. +const UNRELATED_RULE = { rule_key: "delivery-line-without-po-item", version: 1, status: "draft", supplier_ids: [], instructions: ["Delivery lines without a PO item"] }; const context = (over: Partial = {}): PreviewContext => ({ note: { id: "noteabcdefghijk", batch: "batchabcdefghij", pages: [1], doc_number: "VDN1", items: [] }, pages: [], - supplier: { id: "supplierabcdefg", name: "Vula", reconciliation_rules: { rules: [ROLL_RULE] } }, + supplier: { id: "supplierabcdefg", name: "Vula", reconciliation_rules: { rules: [ROLL_RULE, UNRELATED_RULE] } }, po: { id: "poabcdefghijklm", purchase_order_number: "PO1", total_value: 1150 }, poItems: [ { id: PIPE_ITEM, description: "S&S Pipes-1050 X 2.5(100D)", unit: "m", qty: 100, rate: 1124.35, disc_percent: 0, vat_percent: 15 }, @@ -132,7 +135,7 @@ describe("lineReady", () => { lines: [{ sourceLineId: "pipe", description: "1050 pipe", qty: 2, unit: "roll", unit_price: 33730.5, line_amount: 67461, product_code: null }], poItems: [{ id: PIPE_ITEM, description: "1050 pipe", unit: "m", qty: 100, rate: 1124.35, disc_percent: 0, vat_percent: 15 }], }); - const preview = buildPreview(c, { allocations: [allocation({ sourceLineIds: ["pipe"], quantityBasis: "converted", factor: 30, ruleKeys: ["roll-to-metre"] })] }); + const preview = buildPreview(c, { allocations: [allocation({ sourceLineIds: ["pipe"], quantityBasis: "converted", factor: 30, ruleKeys: ["receiving-match-abc123"] })] }); expect(lineReady(c, preview, 0)).toBe(true); }); @@ -141,7 +144,7 @@ describe("lineReady", () => { lines: [{ sourceLineId: "pipe", description: "1050 pipe", qty: 2, unit: "roll", unit_price: 1298, line_amount: 2596, product_code: null }], poItems: [{ id: PIPE_ITEM, description: "1050 pipe", unit: "m", qty: 100, rate: 1124.35, disc_percent: 0, vat_percent: 15 }], }); - const preview = buildPreview(c, { allocations: [allocation({ sourceLineIds: ["pipe"], quantityBasis: "converted", factor: 30, ruleKeys: ["roll-to-metre"] })] }); + const preview = buildPreview(c, { allocations: [allocation({ sourceLineIds: ["pipe"], quantityBasis: "converted", factor: 30, ruleKeys: ["receiving-match-abc123"] })] }); expect(lineReady(c, preview, 0)).toBe(false); }); @@ -150,12 +153,31 @@ describe("lineReady", () => { lines: [{ sourceLineId: "pipe", description: "MESH DIAMOND ROLL", qty: 2, unit: "roll", unit_price: 1298, line_amount: 2596, product_code: null }], poItems: [{ id: PIPE_ITEM, description: "1050 pipe", unit: "m", qty: 100, rate: 1124.35, disc_percent: 0, vat_percent: 15 }], }); - const preview = buildPreview(c, { allocations: [allocation({ sourceLineIds: ["pipe"], quantityBasis: "converted", factor: 1, ruleKeys: ["roll-to-metre"] })] }); + const preview = buildPreview(c, { allocations: [allocation({ sourceLineIds: ["pipe"], quantityBasis: "converted", factor: 1, ruleKeys: ["receiving-match-abc123"] })] }); // 1298 incl is 1128.70 ex, which is R4.35 (0.39%) over the PO rate. expect(slipRateNet(c.lines[0], 1, 15, "gross")).toBe(1128.7); expect(lineReady(c, preview, 0)).toBe(false); }); + it("does not let an unrelated supplier rule justify a unit change", () => { + const c = context({ + lines: [{ sourceLineId: "pipe", description: "1050 pipe", qty: 2, unit: "roll", unit_price: 33730.5, line_amount: 67461, product_code: null }], + poItems: [{ id: PIPE_ITEM, description: "1050 pipe", unit: "m", qty: 100, rate: 1124.35, disc_percent: 0, vat_percent: 15 }], + }); + const preview = buildPreview(c, { allocations: [allocation({ sourceLineIds: ["pipe"], quantityBasis: "converted", factor: 30, ruleKeys: ["delivery-line-without-po-item"] })] }); + expect(preview.allocations[0].status).toBe("proposed"); + expect(lineReady(c, preview, 0)).toBe(false); + }); + + it("accepts a unit change a remembered receiving connection justifies", () => { + const c = context({ + lines: [{ sourceLineId: "pipe", description: "1050 pipe", qty: 2, unit: "roll", unit_price: 33730.5, line_amount: 67461, product_code: null }], + poItems: [{ id: PIPE_ITEM, description: "1050 pipe", unit: "m", qty: 100, rate: 1124.35, disc_percent: 0, vat_percent: 15 }], + }); + const preview = buildPreview(c, { allocations: [allocation({ sourceLineIds: ["pipe"], quantityBasis: "converted", factor: 30, ruleKeys: ["receiving-match-abc123"] })] }); + expect(lineReady(c, preview, 0)).toBe(true); + }); + it("keeps a line open while the rule guard contradicts the allocation", () => { const c = context({ lines: [{ sourceLineId: "pipe", description: "U PIPES-600 X 2.5(100D)", qty: 2, unit: "m", unit_price: 1124.35, line_amount: 2248.7, product_code: null }], diff --git a/src/lib/inbox-three/readiness.ts b/src/lib/inbox-three/readiness.ts index efad4eb..f5403bc 100644 --- a/src/lib/inbox-three/readiness.ts +++ b/src/lib/inbox-three/readiness.ts @@ -15,6 +15,7 @@ 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"; +import { RECEIVING_MATCH_RULE_PREFIX } from "@/lib/dn-pipeline/remembered-match"; import { finiteNumber, type PreviewAllocation, type PreviewContext, type PreviewLine, type ProcessingPreview } from "@/lib/dn-pipeline/ai-preview"; /** A line is flagged once its price is this far from the PO rate. */ @@ -179,8 +180,10 @@ export function lineReady(context: PreviewContext, preview: ProcessingPreview, i if (a.status !== "proposed") return false; if (!guardAgrees(context, a)) return false; // A unit that differs from the PO's is only allowed through on a - // conversion a supplier rule remembers, so the factor is on the line. - if (!unitAgrees(context, a) && a.ruleKeys.length === 0) return false; + // remembered receiving connection — the rule that carries the factor. + // Any other supplier rule says nothing about units, so it cannot + // explain the conversion. + if (!unitAgrees(context, a) && !a.ruleKeys.some((key) => key.startsWith(RECEIVING_MATCH_RULE_PREFIX))) return false; if (priceFlagged(context, a)) return false; }