feat(suppliers): add computeSupplierLedger, computed never stored
This commit is contained in:
parent
73f7e500ee
commit
5ecbd6eded
2 changed files with 188 additions and 0 deletions
86
src/lib/supplier-ledger.test.ts
Normal file
86
src/lib/supplier-ledger.test.ts
Normal file
|
|
@ -0,0 +1,86 @@
|
|||
// src/lib/supplier-ledger.test.ts
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { computeSupplierLedger } from "./supplier-ledger";
|
||||
|
||||
describe("computeSupplierLedger", () => {
|
||||
it("full delivery on a single PO nets to zero outstanding", () => {
|
||||
const result = computeSupplierLedger(
|
||||
[{ id: "item1", purchase_order: "po1", qty: 10, rate: 100 }],
|
||||
[{ purchase_order_item: "item1", qty_received: 10 }],
|
||||
);
|
||||
expect(result.lines).toEqual([
|
||||
{ purchaseOrderId: "po1", ordered: 1000, delivered: 1000, outstanding: 0 },
|
||||
]);
|
||||
expect(result.totals).toEqual({ ordered: 1000, delivered: 1000, outstanding: 0 });
|
||||
});
|
||||
|
||||
it("partial delivery reports the correct partial value", () => {
|
||||
const result = computeSupplierLedger(
|
||||
[{ id: "item1", purchase_order: "po1", qty: 10, rate: 100 }],
|
||||
[{ purchase_order_item: "item1", qty_received: 4 }],
|
||||
);
|
||||
expect(result.lines[0]).toEqual({
|
||||
purchaseOrderId: "po1", ordered: 1000, delivered: 400, outstanding: 600,
|
||||
});
|
||||
});
|
||||
|
||||
it("over-delivery is negative outstanding, not clamped", () => {
|
||||
const result = computeSupplierLedger(
|
||||
[{ id: "item1", purchase_order: "po1", qty: 10, rate: 100 }],
|
||||
[{ purchase_order_item: "item1", qty_received: 12 }],
|
||||
);
|
||||
expect(result.lines[0].outstanding).toBe(-200);
|
||||
});
|
||||
|
||||
it("an unmatched delivery line counts toward nothing", () => {
|
||||
const result = computeSupplierLedger(
|
||||
[{ id: "item1", purchase_order: "po1", qty: 10, rate: 100 }],
|
||||
[
|
||||
{ purchase_order_item: "", qty_received: 5 },
|
||||
{ purchase_order_item: "item1", qty_received: 4 },
|
||||
],
|
||||
);
|
||||
expect(result.lines[0]).toEqual({
|
||||
purchaseOrderId: "po1", ordered: 1000, delivered: 400, outstanding: 600,
|
||||
});
|
||||
});
|
||||
|
||||
it("a line with no deliveries at all is fully outstanding", () => {
|
||||
const result = computeSupplierLedger(
|
||||
[{ id: "item1", purchase_order: "po1", qty: 10, rate: 100 }],
|
||||
[],
|
||||
);
|
||||
expect(result.lines[0]).toEqual({
|
||||
purchaseOrderId: "po1", ordered: 1000, delivered: 0, outstanding: 1000,
|
||||
});
|
||||
});
|
||||
|
||||
it("multiple POs for one supplier sum correctly into totals", () => {
|
||||
const result = computeSupplierLedger(
|
||||
[
|
||||
{ id: "item1", purchase_order: "po1", qty: 10, rate: 100 },
|
||||
{ id: "item2", purchase_order: "po2", qty: 5, rate: 50 },
|
||||
],
|
||||
[
|
||||
{ purchase_order_item: "item1", qty_received: 10 },
|
||||
{ purchase_order_item: "item2", qty_received: 2 },
|
||||
],
|
||||
);
|
||||
expect(result.lines).toHaveLength(2);
|
||||
expect(result.totals).toEqual({ ordered: 1250, delivered: 1100, outstanding: 150 });
|
||||
});
|
||||
|
||||
it("multiple line items on the same PO combine into one ledger line", () => {
|
||||
const result = computeSupplierLedger(
|
||||
[
|
||||
{ id: "item1", purchase_order: "po1", qty: 10, rate: 100 },
|
||||
{ id: "item2", purchase_order: "po1", qty: 2, rate: 500 },
|
||||
],
|
||||
[{ purchase_order_item: "item1", qty_received: 10 }],
|
||||
);
|
||||
expect(result.lines).toHaveLength(1);
|
||||
expect(result.lines[0]).toEqual({
|
||||
purchaseOrderId: "po1", ordered: 2000, delivered: 1000, outstanding: 1000,
|
||||
});
|
||||
});
|
||||
});
|
||||
102
src/lib/supplier-ledger.ts
Normal file
102
src/lib/supplier-ledger.ts
Normal file
|
|
@ -0,0 +1,102 @@
|
|||
/**
|
||||
* Accounts-payable ledger for one supplier: ordered/delivered/outstanding
|
||||
* value in Rand, across every PO the supplier is linked to.
|
||||
*
|
||||
* Computed, never stored -- same reasoning as src/lib/po-fulfilment.ts.
|
||||
* A stored running balance would have to be kept correct on every PO,
|
||||
* every delivery, every OCR re-run and correction, and PocketBase has no
|
||||
* transactions to guarantee that. A sum computed fresh from the actual
|
||||
* PO items and delivery lines every time cannot drift.
|
||||
*
|
||||
* Delivered value uses the PO LINE's rate, not a rate from the delivery
|
||||
* note -- delivery_note_items has no rate field (qty_received,
|
||||
* condition_notes, and two relations only; confirmed against the live
|
||||
* schema). A partial delivery is priced at the same per-unit rate as the
|
||||
* full order, because that is the only rate that exists to use.
|
||||
*
|
||||
* See spec section 5.
|
||||
*/
|
||||
|
||||
export type SupplierPoItemLike = {
|
||||
id: string;
|
||||
purchase_order: string;
|
||||
qty: number;
|
||||
rate: number;
|
||||
};
|
||||
|
||||
export type SupplierDeliveryLineLike = {
|
||||
/** Empty when OCR could not match the line to a PO item. */
|
||||
purchase_order_item: string;
|
||||
qty_received: number;
|
||||
};
|
||||
|
||||
export type SupplierLedgerLine = {
|
||||
purchaseOrderId: string;
|
||||
ordered: number;
|
||||
delivered: number;
|
||||
/** Negative means over-delivered. Deliberately not clamped. */
|
||||
outstanding: number;
|
||||
};
|
||||
|
||||
export type SupplierLedger = {
|
||||
lines: SupplierLedgerLine[];
|
||||
totals: { ordered: number; delivered: number; outstanding: number };
|
||||
};
|
||||
|
||||
export function computeSupplierLedger(
|
||||
items: SupplierPoItemLike[],
|
||||
deliveryLines: SupplierDeliveryLineLike[],
|
||||
): SupplierLedger {
|
||||
const deliveredQtyByItem = new Map<string, number>();
|
||||
for (const line of deliveryLines) {
|
||||
const key = line.purchase_order_item;
|
||||
// An unmatched line counts toward nothing -- attaching it to the
|
||||
// nearest item would corrupt a line nobody touched.
|
||||
if (!key) continue;
|
||||
deliveredQtyByItem.set(
|
||||
key,
|
||||
(deliveredQtyByItem.get(key) ?? 0) + (line.qty_received ?? 0),
|
||||
);
|
||||
}
|
||||
|
||||
const byPo = new Map<string, { ordered: number; delivered: number }>();
|
||||
for (const item of items) {
|
||||
const lineOrdered = item.qty * item.rate;
|
||||
const deliveredQty = deliveredQtyByItem.get(item.id) ?? 0;
|
||||
const lineDelivered = deliveredQty * item.rate;
|
||||
|
||||
const bucket = byPo.get(item.purchase_order) ?? { ordered: 0, delivered: 0 };
|
||||
bucket.ordered += lineOrdered;
|
||||
bucket.delivered += lineDelivered;
|
||||
byPo.set(item.purchase_order, bucket);
|
||||
}
|
||||
|
||||
const round2 = (n: number) => Math.round(n * 100) / 100;
|
||||
|
||||
const lines: SupplierLedgerLine[] = Array.from(byPo.entries()).map(
|
||||
([purchaseOrderId, { ordered, delivered }]) => ({
|
||||
purchaseOrderId,
|
||||
ordered: round2(ordered),
|
||||
delivered: round2(delivered),
|
||||
outstanding: round2(ordered - delivered),
|
||||
}),
|
||||
);
|
||||
|
||||
const totals = lines.reduce(
|
||||
(acc, l) => ({
|
||||
ordered: acc.ordered + l.ordered,
|
||||
delivered: acc.delivered + l.delivered,
|
||||
outstanding: acc.outstanding + l.outstanding,
|
||||
}),
|
||||
{ ordered: 0, delivered: 0, outstanding: 0 },
|
||||
);
|
||||
|
||||
return {
|
||||
lines,
|
||||
totals: {
|
||||
ordered: round2(totals.ordered),
|
||||
delivered: round2(totals.delivered),
|
||||
outstanding: round2(totals.outstanding),
|
||||
},
|
||||
};
|
||||
}
|
||||
Loading…
Reference in a new issue