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:
parent
529028b8d1
commit
ba06cee545
2 changed files with 80 additions and 0 deletions
32
src/lib/dn-pipeline/price-explainer.test.ts
Normal file
32
src/lib/dn-pipeline/price-explainer.test.ts
Normal 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'});
|
||||
});
|
||||
48
src/lib/dn-pipeline/price-explainer.ts
Normal file
48
src/lib/dn-pipeline/price-explainer.ts
Normal 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)}`};
|
||||
}
|
||||
Loading…
Reference in a new issue