fix(dn): only a remembered unit connection auto-readies a unit change
Co-Authored-By: MiniMax (Pinchy)
This commit is contained in:
parent
0404688c75
commit
4107bbec9a
3 changed files with 41 additions and 10 deletions
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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 }],
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue