fix(dn): only a remembered unit connection auto-readies a unit change

Co-Authored-By: MiniMax (Pinchy)
This commit is contained in:
DEls 2026-10-26 18:55:48 +02:00
parent 0404688c75
commit 4107bbec9a
3 changed files with 41 additions and 10 deletions

View file

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

View file

@ -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> = {}): 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 }],

View file

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