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 <noreply@anthropic.com>
This commit is contained in:
DEls 2026-10-10 00:06:13 +02:00
parent 8108640f87
commit 938b1784dd
3 changed files with 136 additions and 28 deletions

View file

@ -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<SearchParams> }) {
@ -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 (
<>

View file

@ -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([]);
});
});

View file

@ -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<string>;
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<string, DocumentKind>;
dateByDnId: Map<string, string>;
}
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,