diff --git a/src/app/dashboard/financial/page.test.tsx b/src/app/dashboard/financial/page.test.tsx new file mode 100644 index 0000000..0d40b4a --- /dev/null +++ b/src/app/dashboard/financial/page.test.tsx @@ -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: () =>
Financial
})); +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]*?
([^<]*)

`)); + return (match?.[1] ?? "").replace(/\u00a0/g, " "); +} + +type Fixture = { + pos?: Record[]; + items?: Record[]; + dnItems?: Record[]; + notes?: Record[]; +}; + +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"); +}); diff --git a/src/app/dashboard/financial/page.tsx b/src/app/dashboard/financial/page.tsx index 76e813f..1c183f0 100644 --- a/src/app/dashboard/financial/page.tsx +++ b/src/app/dashboard/financial/page.tsx @@ -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({