diff --git a/src/lib/supplier-ledger.test.ts b/src/lib/supplier-ledger.test.ts new file mode 100644 index 0000000..43eb34a --- /dev/null +++ b/src/lib/supplier-ledger.test.ts @@ -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, + }); + }); +}); \ No newline at end of file diff --git a/src/lib/supplier-ledger.ts b/src/lib/supplier-ledger.ts new file mode 100644 index 0000000..8c078dd --- /dev/null +++ b/src/lib/supplier-ledger.ts @@ -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(); + 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(); + 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), + }, + }; +} \ No newline at end of file