feat(dn): explain a slip price against the PO rate (length, VAT, per 1000, kits)

Co-Authored-By: MiniMax Code <noreply@minimax.io>
This commit is contained in:
DEls 2026-10-09 20:06:25 +02:00
parent 529028b8d1
commit ba06cee545
2 changed files with 80 additions and 0 deletions

View file

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

View file

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