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:
parent
938b1784dd
commit
2c6a66182c
2 changed files with 178 additions and 22 deletions
110
src/app/dashboard/financial/page.test.tsx
Normal file
110
src/app/dashboard/financial/page.test.tsx
Normal 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");
|
||||
});
|
||||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in a new issue