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:
parent
8108640f87
commit
938b1784dd
3 changed files with 136 additions and 28 deletions
|
|
@ -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 (
|
||||
<>
|
||||
|
|
|
|||
|
|
@ -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([]);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
Loading…
Reference in a new issue