From 938b1784ddafdc0f081a1897bd0687a252958f99 Mon Sep 17 00:00:00 2001 From: DEls Date: Sat, 10 Oct 2026 00:06:13 +0200 Subject: [PATCH] refactor(suppliers): share the delivery-line value computation The Suppliers page built its per-delivery-line value series inline to feed the "Payable this period" KPI. Extracted it to deliveryLineValues so the Financial landing can show the same figure for a single month without re-deriving the rules in a second place, where they could quietly diverge. Behaviour is unchanged: booking snapshot net wins, else qty_received x the PO line's rate; receipt documents only; lines against POs outside the page's scope are dropped; lines with no date_received are skipped. The returned rows now also carry the PO id so callers can attribute a total to a supplier. Co-Authored-By: Claude Opus 5.5 --- .../dashboard/financial/suppliers/page.tsx | 38 ++++------- src/lib/metrics/supplier-balance.test.ts | 65 ++++++++++++++++++- src/lib/metrics/supplier-balance.ts | 61 +++++++++++++++++ 3 files changed, 136 insertions(+), 28 deletions(-) diff --git a/src/app/dashboard/financial/suppliers/page.tsx b/src/app/dashboard/financial/suppliers/page.tsx index 67e52ac..6187ad9 100644 --- a/src/app/dashboard/financial/suppliers/page.tsx +++ b/src/app/dashboard/financial/suppliers/page.tsx @@ -4,16 +4,14 @@ import { Topbar } from "@/components/layout/sidebar"; import { AddSupplierButton } from "@/components/supplier-form-dialog"; import { computeSupplierBalances, - toIsoDate, + deliveryLineValues, type SupplierBalanceInput as In, } from "@/lib/metrics/supplier-balance"; -import { countsAsReceipt, type DocumentKind } from "@/lib/document-kind"; +import type { DocumentKind } from "@/lib/document-kind"; import { SuppliersView, type SupplierRow } from "./suppliers-view"; import { filterOpt, siteFilter, type SearchParams } from "@/lib/site-scope"; import { getSiteScope } from "@/lib/site-scope-server"; -const round2 = (n: number) => Math.round(n * 100) / 100; - export const dynamic = "force-dynamic"; export default async function SuppliersPage({ searchParams }: { searchParams: Promise }) { @@ -76,29 +74,15 @@ export default async function SuppliersPage({ searchParams }: { searchParams: Pr }); // Per-delivery-line value used by the "Payable this period" KPI. - // Value = qty_received * parent PO item rate (delivery_note_items has no - // rate field; the ledger uses the same rule). Only receipt documents - // count -- an invoice with no underlying delivery is not "received". - const visiblePoIds = new Set(pos.map((po) => po.id)); - const itemRateById = new Map( - items.filter((item) => visiblePoIds.has(item.purchase_order)) - .map((it) => [String(it.id), Number((it as { rate?: number }).rate ?? 0)]), - ); - const deliveryValues: { dateReceived: string; value: number }[] = []; - for (const line of deliveryLines) { - const dnId = String((line as { delivery_note?: string }).delivery_note ?? ""); - if (!countsAsReceipt(kindByDnId.get(dnId) ?? "")) continue; - const poItemId = String((line as { purchase_order_item?: string }).purchase_order_item ?? ""); - const snapshot = line.booking; - if (snapshot ? !visiblePoIds.has(snapshot.purchaseOrderId) : !poItemId || !itemRateById.has(poItemId)) continue; - const rate = itemRateById.get(poItemId) ?? 0; - const qty = Number((line as { qty_received?: number }).qty_received ?? 0); - const dateReceived = dateByDnId.get(dnId) ?? ""; - if (!dateReceived) continue; - // ISO "T" form: PocketBase's space-separated date sorts before the - // period window's "T" and dropped deliveries on the 1st. - deliveryValues.push({ dateReceived: toIsoDate(dateReceived), value: snapshot?.net ?? round2(qty * rate) }); - } + // Shared with the Financial landing's "Received this month" tile so the + // two pages cannot disagree about what we owe. + const deliveryValues = deliveryLineValues({ + visiblePoIds: new Set(pos.map((po) => String(po.id))), + items: items as unknown as In["items"], + deliveryLines: deliveryLines as unknown as In["deliveryLines"], + kindByDnId, + dateByDnId, + }); return ( <> diff --git a/src/lib/metrics/supplier-balance.test.ts b/src/lib/metrics/supplier-balance.test.ts index 673cd92..dbe1eba 100644 --- a/src/lib/metrics/supplier-balance.test.ts +++ b/src/lib/metrics/supplier-balance.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "vitest"; -import { computeSupplierBalances, receivedInPeriodBySupplier, toIsoDate } from "./supplier-balance"; +import { computeSupplierBalances, deliveryLineValues, receivedInPeriodBySupplier, toIsoDate, type DeliveryLineValueInput } from "./supplier-balance"; describe("computeSupplierBalances", () => { it("rolls up each supplier's POs and counts open candidates, most outstanding first", () => { @@ -89,3 +89,66 @@ describe("receivedInPeriodBySupplier", () => { expect(toIsoDate("2026-09-01 00:00:00.000Z")).toBe("2026-09-01T00:00:00.000Z"); }); }); + +describe("deliveryLineValues", () => { + const base: DeliveryLineValueInput = { + visiblePoIds: new Set(["po1", "po2"]), + items: [ + { id: "i1", purchase_order: "po1", rate: 100 }, + { id: "i2", purchase_order: "po2", rate: 50 }, + { id: "i3", purchase_order: "poHidden", rate: 999 }, + ], + deliveryLines: [{ purchase_order_item: "i1", qty_received: 4, delivery_note: "dn1" }], + kindByDnId: new Map([["dn1", "delivery_note"]]), + dateByDnId: new Map([["dn1", "2026-09-15 00:00:00.000Z"]]), + }; + + it("prices an unbooked line at qty_received x the PO line's rate", () => { + expect(deliveryLineValues(base)).toEqual([ + { dateReceived: "2026-09-15T00:00:00.000Z", value: 400, purchaseOrderId: "po1" }, + ]); + }); + + it("prefers the booking snapshot over the PO rate", () => { + const rows = deliveryLineValues({ + ...base, + deliveryLines: [ + { purchase_order_item: "i1", qty_received: 4, delivery_note: "dn1", booking: { purchaseOrderId: "po1", net: 350 } }, + ], + }); + expect(rows).toEqual([{ dateReceived: "2026-09-15T00:00:00.000Z", value: 350, purchaseOrderId: "po1" }]); + }); + + it("counts an invoice when countsAsReceipt says so", () => { + const rows = deliveryLineValues({ + ...base, + kindByDnId: new Map([["dn1", "invoice"]]), + }); + expect(rows).toHaveLength(1); + expect(rows[0].value).toBe(400); + }); + + it("skips a line with no date_received", () => { + expect(deliveryLineValues({ ...base, dateByDnId: new Map() })).toEqual([]); + }); + + it("skips a line booked against a PO outside the visible set", () => { + expect( + deliveryLineValues({ + ...base, + deliveryLines: [ + { purchase_order_item: "i1", qty_received: 4, delivery_note: "dn1", booking: { purchaseOrderId: "poOther", net: 1 } }, + ], + }), + ).toEqual([]); + }); + + it("skips an unbooked line whose PO item is not in the visible set", () => { + expect( + deliveryLineValues({ + ...base, + deliveryLines: [{ purchase_order_item: "i3", qty_received: 1, delivery_note: "dn1" }], + }), + ).toEqual([]); + }); +}); diff --git a/src/lib/metrics/supplier-balance.ts b/src/lib/metrics/supplier-balance.ts index 4de4342..5f0e5d4 100644 --- a/src/lib/metrics/supplier-balance.ts +++ b/src/lib/metrics/supplier-balance.ts @@ -115,6 +115,67 @@ export function toIsoDate(pbDate: string): string { return pbDate.replace(" ", "T"); } +export interface DeliveryLineValue { + /** delivery_notes.date_received in ISO "T" form. */ + dateReceived: string; + value: number; + /** From the booking snapshot, else the parent PO item's order. */ + purchaseOrderId: string; +} + +export interface DeliveryLineValueInput { + /** In-scope PO ids; a delivery against any other order is not ours. */ + visiblePoIds: Set; + items: { id: string; purchase_order: string; rate?: number }[]; + deliveryLines: { booking?: { purchaseOrderId: string; net: number }; purchase_order_item?: string; qty_received?: number; delivery_note?: string }[]; + kindByDnId: Map; + dateByDnId: Map; +} + +const round2 = (n: number) => Math.round(n * 100) / 100; + +/** + * Value received on each delivery line, dated by the delivery note's + * `date_received`. This is the "what we owe for what arrived" series: + * liability accrues on the delivery note date, not the PO upload date + * ("we only pay what we received this month"). + * + * Value = the booking snapshot's net where one exists (immutable, what + * was actually booked), else qty_received × the PO line's rate, since + * delivery_note_items carries no rate of its own. A booking snapshot + * names its own PO; an unbooked line inherits the parent item's. + */ +export function deliveryLineValues(input: DeliveryLineValueInput): DeliveryLineValue[] { + const { visiblePoIds, items, deliveryLines, kindByDnId, dateByDnId } = input; + const rateByItemId = new Map( + items + .filter((item) => visiblePoIds.has(item.purchase_order)) + .map((item) => [String(item.id), { rate: Number(item.rate ?? 0), purchaseOrderId: String(item.purchase_order) }]), + ); + + const out: DeliveryLineValue[] = []; + for (const line of deliveryLines) { + const dnId = String(line.delivery_note ?? ""); + if (!countsAsReceipt(kindByDnId.get(dnId) ?? "")) continue; + const poItemId = String(line.purchase_order_item ?? ""); + const snapshot = line.booking; + const item = rateByItemId.get(poItemId); + const purchaseOrderId = snapshot ? snapshot.purchaseOrderId : item?.purchaseOrderId ?? ""; + if (!visiblePoIds.has(purchaseOrderId)) continue; + const dateReceived = dateByDnId.get(dnId) ?? ""; + if (!dateReceived) continue; + const qty = Number(line.qty_received ?? 0); + // ISO "T" form: PocketBase's space-separated date sorts before the + // period window's "T" and dropped deliveries on the 1st. + out.push({ + dateReceived: toIsoDate(dateReceived), + value: snapshot?.net ?? round2(qty * (item?.rate ?? 0)), + purchaseOrderId, + }); + } + return out; +} + /** * Value received per supplier (by supplier id) where the delivery note's * `date_received` passes `inPeriod` — qty_received × the PO line's rate,