feat(suppliers): add computeSupplierLedger, computed never stored

This commit is contained in:
DEls 2026-09-11 18:13:43 +02:00
parent 73f7e500ee
commit 5ecbd6eded
2 changed files with 188 additions and 0 deletions

View 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
View 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),
},
};
}