From ba06cee545e6667f3b835d21779f248a6bd1a4e6 Mon Sep 17 00:00:00 2001 From: DEls Date: Fri, 9 Oct 2026 20:06:25 +0200 Subject: [PATCH] feat(dn): explain a slip price against the PO rate (length, VAT, per 1000, kits) Co-Authored-By: MiniMax Code --- src/lib/dn-pipeline/price-explainer.test.ts | 32 ++++++++++++++ src/lib/dn-pipeline/price-explainer.ts | 48 +++++++++++++++++++++ 2 files changed, 80 insertions(+) create mode 100644 src/lib/dn-pipeline/price-explainer.test.ts create mode 100644 src/lib/dn-pipeline/price-explainer.ts diff --git a/src/lib/dn-pipeline/price-explainer.test.ts b/src/lib/dn-pipeline/price-explainer.test.ts new file mode 100644 index 0000000..4aab739 --- /dev/null +++ b/src/lib/dn-pipeline/price-explainer.test.ts @@ -0,0 +1,32 @@ +import {expect,it} from 'vitest'; +import {explainPrice,explainKit} from './price-explainer'; +/** en-ZA groups with a NO-BREAK SPACE (U+00A0), not a plain space. */ +const NB=' '; +it('accepts the same price',()=>{ + expect(explainPrice({slipUnitPrice:65,poRate:65,description:'110x110x45 UG PVC SEWER JUNCTION'})).toEqual({ok:true,working:'slip price = PO rate'}); +}); +it('explains a price per length from the description',()=>{ + const r=explainPrice({slipUnitPrice:276,poRate:46,description:'160x6M KABELFLEX PIPE & COUPLING'}); + expect(r.ok).toBe(true);expect(r.working).toBe('R276,00 per 6 m length = R46,00/m = PO rate'); + expect(explainPrice({slipUnitPrice:25,poRate:10,description:'1050 x 2.5m pipe'}).working).toBe('R25,00 per 2,5 m length = R10,00/m = PO rate'); +}); +it('explains a VAT-inclusive slip and a per-1000 price',()=>{ + expect(explainPrice({slipUnitPrice:115,poRate:100,description:'Cement'}).working).toBe('R115,00 incl VAT = R100,00 excl = PO rate'); + expect(explainPrice({slipUnitPrice:2604.74,poRate:2.60474,description:'Bricks'}).ok).toBe(true); +}); +it('applies the PO discount',()=>{ + expect(explainPrice({slipUnitPrice:90,poRate:100,poDiscount:10,description:'Valve'}).ok).toBe(true); +}); +it('refuses a price it cannot explain, saying what it tried',()=>{ + expect(explainPrice({slipUnitPrice:430,poRate:662,description:'160mm CI END CAP'})).toEqual({ok:false,working:"R430,00 on the slip vs R662,00 on the PO (tried same price, VAT, per 1000)"}); + expect(explainPrice({slipUnitPrice:337,poRate:274,description:'80mm GALV PIPE TBE (PER METER) 1 - 80 x 2.0m'}).working).toContain('per 2 m length'); +}); +it('is fine without a slip price and flags a missing PO rate',()=>{ + expect(explainPrice({slipUnitPrice:null,poRate:168.5,description:'G6 Sub Base'})).toEqual({ok:true,working:'no price on the slip'}); + expect(explainPrice({slipUnitPrice:10,poRate:null,description:'x'})).toEqual({ok:false,working:'the PO line has no rate'}); +}); +it('checks a kit by adding up the component amounts',()=>{ + expect(explainKit({slipAmounts:[178.5,3492.72,470.4],poRate:593.6,quantity:7})).toEqual({ok:true,working:`kit: slip R4${NB}141,62 ≈ PO R4${NB}155,20`}); + expect(explainKit({slipAmounts:[100,100],poRate:593.6,quantity:7}).ok).toBe(false); + expect(explainKit({slipAmounts:[null,null],poRate:593.6,quantity:7})).toEqual({ok:true,working:'no prices on the slip'}); +}); \ No newline at end of file diff --git a/src/lib/dn-pipeline/price-explainer.ts b/src/lib/dn-pipeline/price-explainer.ts new file mode 100644 index 0000000..910abda --- /dev/null +++ b/src/lib/dn-pipeline/price-explainer.ts @@ -0,0 +1,48 @@ +import {formatNumber} from '@/lib/utils'; +/** + * Does a slip's price agree with the PO rate it is booked at? + * + * Suppliers price the same goods per length, per metre, per 1000 or + * VAT-inclusive, so "different number" is not "wrong". Only a difference no + * known pattern explains is a question for a person. + */ +export type PriceCheck={ok:boolean;working:string}; +const money=(n:number)=>`R${formatNumber(n,{minimumFractionDigits:2,maximumFractionDigits:2})}`; +const qty=(n:number)=>formatNumber(n,{maximumFractionDigits:3}); +const finite=(v:unknown):v is number=>typeof v==='number'&&Number.isFinite(v); +/** The length in metres a description names ("160x6M", "2.5m", "x 2.0m"), if any. */ +export function lengthFromDescription(description:string|null):number|null{ + const matches=[...String(description??'').matchAll(/(\d+(?:[.,]\d+)?)\s*m\b/gi)]; + const last=matches.at(-1);if(!last)return null; + const length=Number(last[1].replace(',','.')); + return length>0&&length<=12?length:null; +} +export function explainPrice(input:{slipUnitPrice:number|null;poRate:number|null;poDiscount?:number|null;description:string|null}):PriceCheck{ + const slip=input.slipUnitPrice; + if(!finite(slip)||slip<=0)return {ok:true,working:'no price on the slip'}; + if(!finite(input.poRate))return {ok:false,working:'the PO line has no rate'}; + const rate=input.poRate*(1-(finite(input.poDiscount)?input.poDiscount:0)/100); + const close=(k:number)=>Math.abs(slip-rate*k)<=Math.max(0.01,0.005*slip); + const length=lengthFromDescription(input.description); + const tries:Array<[number,string,string]>=[ + [1,'same price','slip price = PO rate'], + [1.15,'VAT',`${money(slip)} incl VAT = ${money(slip/1.15)} excl = PO rate`], + ...(length?[[length,`per ${qty(length)} m length`,`${money(slip)} per ${qty(length)} m length = ${money(slip/length)}/m = PO rate`] as [number,string,string], + [length*1.15,`per ${qty(length)} m length incl VAT`,`${money(slip)} per ${qty(length)} m length incl VAT = PO rate`] as [number,string,string]]:[]), + [1000,'per 1000',`${money(slip)} per 1000 = PO rate`], + [0.001,'each vs per 1000',`${money(slip)} each = PO rate per 1000`], + ]; + const hit=tries.find(([k])=>close(k)); + if(hit)return {ok:true,working:hit[2]}; + const tried=[...new Set(tries.map(([,name])=>name.replace(' incl VAT','').replace('each vs per 1000','per 1000')))].join(', '); + return {ok:false,working:`${money(slip)} on the slip vs ${money(input.poRate)} on the PO (tried ${tried})`}; +} +export function explainKit(input:{slipAmounts:Array;poRate:number|null;poDiscount?:number|null;quantity:number|null}):PriceCheck{ + const amounts=input.slipAmounts.filter(finite); + if(!amounts.length)return {ok:true,working:'no prices on the slip'}; + if(!finite(input.poRate)||!finite(input.quantity))return {ok:false,working:'the PO line has no rate'}; + const slip=Math.round(amounts.reduce((s,a)=>s+a,0)*100)/100; + const po=Math.round(input.poRate*(1-(finite(input.poDiscount)?input.poDiscount:0)/100)*input.quantity*100)/100; + const ok=Math.abs(slip-po)<=Math.max(1,0.01*po); + return {ok,working:ok?`kit: slip ${money(slip)} ≈ PO ${money(po)}`:`kit: slip ${money(slip)} vs PO ${money(po)}`}; +} \ No newline at end of file