fix(financial): subtract deliveries from open PO value

"Open PO value" summed total_value of every processed order ever and
never subtracted what had been delivered, so it grew without bound and
read as total committed rather than still owed. A R1 000 order with R400
delivered reported R1 000 open.

It now comes from computeSupplierLedger -- the same computation the
Suppliers page uses -- over the items of real in-scope POs and the
delivery lines belonging to them (by booking snapshot, or by parent PO
item for older unbooked receipts). Clamped per PO, so an over-delivered
order cannot net off another order's open balance and understate what is
still owed. The sub-line counts orders still actually open rather than
repeating the same number as a "total POs" figure elsewhere on the page.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
DEls 2026-10-10 00:09:13 +02:00
parent 938b1784dd
commit 2c6a66182c
2 changed files with 178 additions and 22 deletions

View file

@ -0,0 +1,110 @@
import { renderToStaticMarkup } from "react-dom/server";
import { afterEach, beforeEach, expect, it, vi } from "vitest";
const { getFullList, getList, getScope } = vi.hoisted(() => ({
getFullList: vi.fn(),
getList: vi.fn(),
getScope: vi.fn(),
}));
vi.mock("@/lib/pb", () => ({
pbAdmin: async () => ({
collection: (name: string) => ({
getFullList: (options: unknown) => getFullList(name, options),
getList: (page: number, perPage: number, options: unknown) => getList(name, options),
}),
}),
}));
vi.mock("@/lib/site-scope-server", () => ({ getSiteScope: getScope }));
vi.mock("@/components/layout/sidebar", () => ({ Topbar: () => <header>Financial</header> }));
vi.mock("@/lib/dn-page-token", () => ({ signDnPageProxyToken: () => "tok" }));
vi.mock("@/lib/dn-pipeline/missing-purchase-orders", () => ({ missingPurchaseOrders: () => [] }));
vi.mock("./missing-po-attention", () => ({ MissingPoAttention: () => null }));
import FinancialLanding from "./page";
const site = { id: "site1", name: "The Hills", project_code: "HILLS" };
/** Tile value text for a KPI label, with Intl's non-breaking spaces normalised. */
function tileValue(html: string, label: string): string {
const match = html.match(new RegExp(`${label}[\\s\\S]*?<div class="text-2xl[^>]*>([^<]*)</div>`));
return (match?.[1] ?? "").replace(/\u00a0/g, " ");
}
/** Sub-line text for a KPI label. */
function tileSub(html: string, label: string): string {
const match = html.match(new RegExp(`${label}[\\s\\S]*?text-muted-foreground">([^<]*)</p>`));
return (match?.[1] ?? "").replace(/\u00a0/g, " ");
}
type Fixture = {
pos?: Record<string, unknown>[];
items?: Record<string, unknown>[];
dnItems?: Record<string, unknown>[];
notes?: Record<string, unknown>[];
};
function setup(fixture: Fixture) {
getFullList.mockImplementation(async (name: string) => {
if (name === "purchase_orders") return fixture.pos ?? [];
if (name === "purchase_order_items") return fixture.items ?? [];
if (name === "delivery_note_items") return fixture.dnItems ?? [];
if (name === "delivery_notes") return fixture.notes ?? [];
if (name === "dn_batch_notes") return [];
return [];
});
getList.mockResolvedValue({ totalItems: 0 });
}
async function renderPage() {
return renderToStaticMarkup(
await FinancialLanding({ searchParams: Promise.resolve({ site: site.id }) }),
);
}
beforeEach(() => {
vi.useFakeTimers();
// SAST month key is 2026-10 for this instant.
vi.setSystemTime(new Date("2026-10-04T10:00:00Z"));
getScope.mockResolvedValue({ allowed: [site], active: [site], mode: "single", unrestricted: false });
setup({});
});
afterEach(() => vi.useRealTimers());
it("shows what is still open on an order, not everything ever ordered", async () => {
setup({
// R1 000 ordered, R400 delivered -> R600 still owed.
pos: [{ id: "po1", site: "site1", supplier_ref: "s1", processing_status: "processed", total_value: 1000, date_uploaded: "2026-10-01 00:00:00.000Z" }],
items: [{ id: "i1", purchase_order: "po1", qty: 10, rate: 100 }],
dnItems: [{ id: "d1", purchase_order_item: "i1", qty_received: 4, delivery_note: "dn1" }],
notes: [{ id: "dn1", document_type: "delivery_note", date_received: "2026-10-02 00:00:00.000Z" }],
});
const html = await renderPage();
expect(tileValue(html, "Open PO value")).toBe("R 600");
expect(tileSub(html, "Open PO value")).toBe("1 orders still open");
});
it("does not let an over-delivered order cancel another order's open balance", async () => {
setup({
pos: [
{ id: "po1", site: "site1", supplier_ref: "s1", processing_status: "processed", total_value: 1000, date_uploaded: "2026-10-01 00:00:00.000Z" },
{ id: "po2", site: "site1", supplier_ref: "s2", processing_status: "processed", total_value: 1000, date_uploaded: "2026-10-01 00:00:00.000Z" },
],
items: [
{ id: "i1", purchase_order: "po1", qty: 10, rate: 100 },
{ id: "i2", purchase_order: "po2", qty: 10, rate: 100 },
],
dnItems: [
{ id: "d1", purchase_order_item: "i1", qty_received: 4, delivery_note: "dn1" },
// R1 500 against a R1 000 order: -R500 outstanding here.
{ id: "d2", purchase_order_item: "i2", qty_received: 15, delivery_note: "dn2" },
],
notes: [
{ id: "dn1", document_type: "delivery_note", date_received: "2026-10-02 00:00:00.000Z" },
{ id: "dn2", document_type: "delivery_note", date_received: "2026-10-02 00:00:00.000Z" },
],
});
const html = await renderPage();
// po1's R600 stands; po2 clamps to 0 rather than netting po1 down to R100.
expect(tileValue(html, "Open PO value")).toBe("R 600");
expect(tileSub(html, "Open PO value")).toBe("1 orders still open");
});

View file

@ -13,21 +13,27 @@ import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { composeFilter, filterOpt, siteFilter, type SearchParams } from "@/lib/site-scope";
import { getSiteScope } from "@/lib/site-scope-server";
import { formatCurrency, formatNumber } from "@/lib/utils";
import { computeSupplierLedger, type SupplierPoItemLike } from "@/lib/supplier-ledger";
import type { DocumentKind } from "@/lib/document-kind";
const round2 = (n: number) => Math.round(n * 100) / 100;
export const dynamic = "force-dynamic";
/**
* Landing for the Financial section. Per the sidebar restructure spec
* §4.3:
* - open PO value = Σ purchase_orders.total_value where processing_status
* is 'processed' or unset (excludes pending/processing placeholders and
* failed/dead rows that never became real orders)
* - open PO value = what is still owed on real orders: ordered minus
* delivered, from computeSupplierLedger over the items of real POs and
* the delivery lines belonging to them. Clamped per PO, so an
* over-delivered order cannot cancel another order's open balance.
* "Real" = processing_status 'processed' or unset, which excludes
* pending/processing placeholders and failed/dead rows that never
* became real orders
* - DNs needing review = count where processing_status='needs-review'
* - this month's PO spend = Σ total_value where date_uploaded falls in
* the current calendar month
*
* Kept inline here because this is the only surface that renders this
* exact trio; the per-section pages render their own KPI tiles.
* exact set of tiles; the per-section pages render their own KPI tiles.
*/
async function loadFinancialSummary(scope: import("@/lib/site-scope").SiteScope) {
const pb = await pbAdmin();
@ -37,7 +43,10 @@ async function loadFinancialSummary(scope: import("@/lib/site-scope").SiteScope)
Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), 1),
).toISOString();
const [pos, waitingNotes, needsReviewDns] = await Promise.all([
const [pos, waitingNotes, needsReviewDns, poItems, dnItems, deliveryNotes] = await Promise.all([
// No `fields` restriction: missingPurchaseOrders below reads site and
// purchase_order_number, and the month spend needs created as well as
// date_uploaded (MCP-raised orders carry no upload date).
pb.collection("purchase_orders").getFullList({
expand: "site",
...filterOpt(siteF),
@ -51,28 +60,65 @@ async function loadFinancialSummary(scope: import("@/lib/site-scope").SiteScope)
filter: composeFilter(siteF, "processing_status='needs-review'"),
$autoCancel: false,
}),
// Ledger inputs. Not site-scoped themselves: they are joined to the
// site-scoped POs above, exactly as the Suppliers page does it.
pb.collection("purchase_order_items").getFullList({
fields: "id,purchase_order,qty,rate",
$autoCancel: false,
}),
pb.collection("delivery_note_items").getFullList({
fields: "id,purchase_order_item,booking,qty_received,delivery_note",
$autoCancel: false,
}),
// document_type and date_received live on delivery_notes, not on
// delivery_note_items (see src/lib/document-kind.ts).
pb.collection("delivery_notes").getFullList({
fields: "id,document_type,date_received",
$autoCancel: false,
}),
]);
const REAL_STATUSES = new Set(["processed", ""]);
let openPoValue = 0;
const kindByDnId = new Map(
deliveryNotes.map((d) => [String(d.id), (d.document_type ?? "") as DocumentKind]),
);
const realPos = pos.filter((p) => REAL_STATUSES.has(p.processing_status ?? ""));
const realPoIds = new Set(realPos.map((p) => String(p.id)));
const realItems = poItems.filter((item) => realPoIds.has(String(item.purchase_order)));
const realItemIds = new Set(realItems.map((item) => String(item.id)));
// A delivery line belongs to a real PO by booking snapshot, or -- for an
// older unbooked receipt -- by its parent PO item.
const realDeliveries = dnItems.filter((line) => {
const booking = (line as { booking?: { purchaseOrderId: string } }).booking;
if (booking) return realPoIds.has(booking.purchaseOrderId);
return realItemIds.has(String((line as { purchase_order_item?: string }).purchase_order_item ?? ""));
});
const ledger = computeSupplierLedger(
realItems as unknown as SupplierPoItemLike[],
realDeliveries.map((line) => ({
booking: (line as { booking?: { purchaseOrderId: string; net: number } }).booking,
purchase_order_item: String((line as { purchase_order_item?: string }).purchase_order_item ?? ""),
qty_received: Number((line as { qty_received?: number }).qty_received ?? 0),
document_type:
kindByDnId.get(String((line as { delivery_note?: string }).delivery_note ?? "")) ?? "",
})),
);
// Clamp per PO: one over-delivered order must not net off another's open
// balance, which would understate what is still owed overall.
const openPoValue = round2(
ledger.lines.reduce((s, line) => s + Math.max(0, line.outstanding), 0),
);
// Month spend: unchanged for now (see the Task 5 commit).
let monthSpend = 0;
for (const p of pos) {
const total = p.total_value ?? 0;
const status = p.processing_status ?? "";
if (REAL_STATUSES.has(status)) {
openPoValue += total;
}
const dateUploaded = String(p.date_uploaded ?? "");
if (dateUploaded >= monthStart) {
monthSpend += total;
}
if (String(p.date_uploaded ?? "") >= monthStart) monthSpend += p.total_value ?? 0;
}
return {
openPoCount: pos.filter((p) => {
const s = p.processing_status ?? "";
return REAL_STATUSES.has(s);
}).length,
openPoCount: ledger.lines.filter((line) => line.outstanding > 0.005).length,
openPoValue,
missingPos: missingPurchaseOrders(waitingNotes, pos).map(row=>({...row,documents:row.noteIds.map(id=>{
const note=waitingNotes.find(n=>n.id===id)!;
@ -106,7 +152,7 @@ export default async function FinancialLanding({
<KpiTile
label="Open PO value"
value={formatCurrency(summary.openPoValue)}
sub={`${formatNumber(summary.openPoCount)} active orders`}
sub={`${formatNumber(summary.openPoCount)} orders still open`}
icon={Coins}
/>
<KpiTile