diff --git a/src/app/dashboard/financial/delivery-notes/work-queue/page.tsx b/src/app/dashboard/financial/delivery-notes/work-queue/page.tsx index 9b37c99..a4ca53f 100644 --- a/src/app/dashboard/financial/delivery-notes/work-queue/page.tsx +++ b/src/app/dashboard/financial/delivery-notes/work-queue/page.tsx @@ -2,8 +2,7 @@ import { Topbar } from "@/components/layout/sidebar"; import { pbAdmin } from "@/lib/pb"; import { composeFilter, filterOpt, siteFilter, type SearchParams } from "@/lib/site-scope"; import { getSiteScope } from "@/lib/site-scope-server"; -import { displayCandidateStage, type CandidateStage } from "@/lib/dn-pipeline/stages"; -import { WorkQueueView, type WorkQueueCandidate } from "./work-queue-view"; +import { WorkQueueView, mapCandidateRecord, matchesCandidate } from "./work-queue-view"; export const dynamic = "force-dynamic"; @@ -21,56 +20,9 @@ export default async function WorkQueuePage({ searchParams }: { searchParams: Pr }); const candidates = rows - .map(toCandidate) + .map(mapCandidateRecord) .filter((candidate) => candidate.stage !== "posted" && candidate.stage !== "dismissed") - .filter((candidate) => matches(candidate, params)); + .filter((candidate) => matchesCandidate(candidate, Object.fromEntries(Object.entries(params).map(([key, value]) => [key, Array.isArray(value) ? value[0] : value])))); const filters = Object.fromEntries(Object.entries(params).flatMap(([key, value]) => [[key, Array.isArray(value) ? value[0] : value ?? ""]])) as Record; return <>; } - -function toCandidate(row: Record): WorkQueueCandidate { - const batch = row.expand?.batch ?? {}; - const site = batch.expand?.site ?? row.expand?.delivery_note?.expand?.site ?? {}; - const supplier = row.expand?.supplier ?? row.expand?.purchase_order?.expand?.supplier_ref ?? {}; - const po = row.expand?.purchase_order ?? {}; - const deliveryNote = row.expand?.delivery_note ?? {}; - const checks = Array.isArray(row.checks) ? row.checks : []; - const unresolvedLineItems = Array.isArray(row.items) - ? row.items.filter((item: any) => !item?.purchase_order_item && !item?.purchaseOrderItemId).length - : Number(row.unresolved_line_items ?? 0); - const reason = checks.find((check: any) => check?.passed === false)?.reason ?? row.reason ?? "Needs review"; - return { - id: String(row.id ?? ""), - siteId: String(batch.site ?? deliveryNote.site ?? ""), - siteName: String(site.name ?? "Unknown site"), - supplierId: String(row.supplier ?? po.supplier_ref ?? ""), - supplierName: String(supplier.name ?? row.supplier_name_raw ?? po.supplier ?? ""), - purchaseOrderId: String(row.purchase_order ?? deliveryNote.purchase_order ?? ""), - purchaseOrderNumber: String(po.purchase_order_number ?? row.po_number_raw ?? ""), - documentId: String(row.delivery_note ?? deliveryNote.id ?? ""), - documentNumber: String(row.delivery_note_number ?? deliveryNote.delivery_note_number ?? ""), - documentType: String(deliveryNote.document_type ?? "unknown"), - documentDate: String(deliveryNote.date_received ?? row.posted_at ?? batch.uploaded_at ?? ""), - batchId: String(row.batch ?? batch.id ?? ""), - batchName: String(batch.file ?? batch.id ?? ""), - stage: displayCandidateStage(String(row.status ?? ""), String(row.stage ?? "")), - reason, - matchMethod: String(row.match_method ?? ""), - unresolvedLineItems, - }; -} - -function matches(candidate: WorkQueueCandidate, params: SearchParams): boolean { - const value = (key: string) => { - const raw = params[key]; - return Array.isArray(raw) ? raw[0] ?? "" : raw ?? ""; - }; - return [ - ["site", candidate.siteId, candidate.siteName], - ["supplier", candidate.supplierId, candidate.supplierName], - ["purchase_order", candidate.purchaseOrderId, candidate.purchaseOrderNumber], - ["stage", candidate.stage], - ["reason", candidate.reason], - ["document_type", candidate.documentType], - ].every(([key, ...values]) => !value(key) || values.some((item) => String(item).toLowerCase() === value(key).toLowerCase())); -} diff --git a/src/app/dashboard/financial/delivery-notes/work-queue/work-queue-view.tsx b/src/app/dashboard/financial/delivery-notes/work-queue/work-queue-view.tsx index ca8e012..f688fd4 100644 --- a/src/app/dashboard/financial/delivery-notes/work-queue/work-queue-view.tsx +++ b/src/app/dashboard/financial/delivery-notes/work-queue/work-queue-view.tsx @@ -4,6 +4,7 @@ import Link from "next/link"; import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { displayCandidateStage } from "@/lib/dn-pipeline/stages"; export type QueueStage = "matching" | "needs_review" | "ready_to_post" | "posted" | "dismissed"; @@ -42,6 +43,35 @@ export interface QueueGroup { purchaseOrders: QueuePoGroup[]; } +export function mapCandidateRecord(row: Record): WorkQueueCandidate { + const batch = row.expand?.batch ?? {}; + const site = batch.expand?.site ?? row.expand?.delivery_note?.expand?.site ?? {}; + const supplier = row.expand?.supplier ?? row.expand?.purchase_order?.expand?.supplier_ref ?? {}; + const po = row.expand?.purchase_order ?? {}; + const deliveryNote = row.expand?.delivery_note ?? {}; + const legacyPosted = row.status === "posted" || row.status === "promoted" || row.stage === "posted" || Boolean(row.posted_at); + const checks = Array.isArray(row.checks) ? row.checks : []; + const unresolvedLineItems = Array.isArray(row.items) + ? row.items.filter((item: any) => !item?.purchase_order_item && !item?.purchaseOrderItemId).length + : Number(row.unresolved_line_items ?? 0); + const reason = checks.find((check: any) => check?.passed === false)?.reason ?? row.reason ?? "Needs review"; + return { + id: String(row.id ?? ""), siteId: String(row.site ?? batch.site ?? deliveryNote.site ?? ""), siteName: String(site.name ?? "Unknown site"), + supplierId: String(row.supplier ?? po.supplier_ref ?? ""), supplierName: String(supplier.name ?? row.supplier_name_raw ?? po.supplier ?? ""), + purchaseOrderId: String(row.purchase_order ?? deliveryNote.purchase_order ?? ""), purchaseOrderNumber: String(row.doc_po_number ?? po.purchase_order_number ?? row.po_number_raw ?? ""), + documentId: String(row.delivery_note ?? deliveryNote.id ?? ""), documentNumber: String(row.doc_number ?? (legacyPosted ? row.delivery_note_number ?? deliveryNote.delivery_note_number : "")), + documentType: String(row.doc_type ?? (legacyPosted ? deliveryNote.document_type : "unknown")), documentDate: String(row.doc_date ?? (legacyPosted ? deliveryNote.date_received : "") ?? row.posted_at ?? batch.uploaded_at ?? ""), + batchId: String(row.batch ?? batch.id ?? ""), batchName: String(batch.file ?? batch.id ?? ""), + stage: displayCandidateStage(String(row.status ?? ""), String(row.stage ?? "")), reason, matchMethod: String(row.match_method ?? ""), unresolvedLineItems, + }; +} + +export function matchesCandidate(candidate: WorkQueueCandidate, params: Record): boolean { + const value = (key: string) => params[key] ?? ""; + return [["site", candidate.siteId, candidate.siteName], ["supplier", candidate.supplierId, candidate.supplierName], ["purchase_order", candidate.purchaseOrderId, candidate.purchaseOrderNumber], ["stage", candidate.stage], ["reason", candidate.reason], ["document_type", candidate.documentType]] + .every(([key, ...values]) => !value(key) || values.some((item) => String(item).toLowerCase() === value(key).toLowerCase())); +} + const EXCEPTION_LABELS = { unknownSupplier: "Unknown supplier", supplierKnownPoUnknown: "Supplier known / PO unknown", @@ -53,14 +83,14 @@ export function groupCandidates(candidates: WorkQueueCandidate[]): QueueGroup[] const exceptions = new Map(); for (const candidate of candidates) { let group: QueueGroup; - if (!candidate.supplierId && !candidate.supplierName) { + if (!candidate.supplierId) { group = getException(exceptions, "unknown-supplier", EXCEPTION_LABELS.unknownSupplier); } else if (!candidate.purchaseOrderId && !candidate.purchaseOrderNumber) { group = getException(exceptions, "supplier-known-po-unknown", EXCEPTION_LABELS.supplierKnownPoUnknown); } else if (candidate.unresolvedLineItems > 0) { group = getException(exceptions, "po-known-lines-unresolved", EXCEPTION_LABELS.poKnownLinesUnresolved); } else { - const key = `supplier:${candidate.supplierId || candidate.supplierName}`; + const key = `supplier:${candidate.supplierId}`; group = normal.get(key) ?? { key, label: candidate.supplierName || "Unknown supplier", @@ -175,7 +205,7 @@ function FilterBar({ candidates, filters }: { candidates: WorkQueueCandidate[]; function QueueCard({ candidate }: { candidate: WorkQueueCandidate }) { return

{candidate.documentNumber || "Unnumbered document"}

{candidate.documentType || "Unknown type"} ยท {candidate.documentDate || "No date"}

{candidate.stage.replaceAll("_", " ")}
-
Batch
{candidate.batchName || candidate.batchId}
Match method
{candidate.matchMethod || "Unmatched"}
Exception
{candidate.reason || "Needs review"}
+
Batch
{candidate.batchName || candidate.batchId}
Match method
{candidate.matchMethod || "Unmatched"}
Exception
{candidate.reason || "Needs review"}
{!candidate.supplierId && candidate.supplierName &&
Supplier evidence
{candidate.supplierName}
}
; } diff --git a/src/app/dashboard/financial/delivery-notes/work-queue/work-queue.test.ts b/src/app/dashboard/financial/delivery-notes/work-queue/work-queue.test.ts index b5fd12a..b11ca3f 100644 --- a/src/app/dashboard/financial/delivery-notes/work-queue/work-queue.test.ts +++ b/src/app/dashboard/financial/delivery-notes/work-queue/work-queue.test.ts @@ -2,8 +2,14 @@ import { describe, expect, it } from "vitest"; import { buildQueueHref, groupCandidates, + mapCandidateRecord, + matchesCandidate, + WorkQueueView, type WorkQueueCandidate, } from "./work-queue-view"; +import React from "react"; +import { renderToStaticMarkup } from "react-dom/server"; +import { SuppliersView } from "../../suppliers/suppliers-view"; const candidate = ( overrides: Partial = {}, @@ -54,4 +60,49 @@ describe("supplier/PO queue grouping", () => { buildQueueHref({ site: "site-vula", stage: "needs_review" }, { supplier: "supplier-vula", reason: "line item / unresolved" }), ).toBe("/dashboard/financial/delivery-notes/work-queue?site=site-vula&stage=needs_review&supplier=supplier-vula&reason=line+item+%2F+unresolved"); }); + + it("uses staged doc fields before legacy delivery-note expansions", () => { + const mapped = mapCandidateRecord({ + id: "candidate-1", batch: "batch-1", site: "site-vula", supplier: "", supplier_name_raw: "OCR Vula", + purchase_order: "po-a", doc_number: "STAGED-42", doc_type: "invoice", posted_at: "", + status: "candidate", stage: "blocked", match_method: "po_exact", checks: [], + expand: { + batch: { id: "batch-1", file: "scan.pdf", uploaded_at: "2026-09-18", expand: { site: { id: "site-vula", name: "Vula" } } }, + delivery_note: { id: "legacy-dn", delivery_note_number: "LEGACY", document_type: "delivery_note", date_received: "2026-01-01" }, + }, + }); + expect(mapped).toMatchObject({ documentNumber: "STAGED-42", documentType: "invoice", siteId: "site-vula", siteName: "Vula" }); + }); + + it("uses legacy document fields only for posted candidates", () => { + const mapped = mapCandidateRecord({ id: "candidate", status: "candidate", stage: "needs_review", delivery_note_number: "LEGACY", expand: { delivery_note: { delivery_note_number: "LEGACY", document_type: "collection_note" } } }); + expect(mapped.documentNumber).toBe(""); + expect(mapped.documentType).toBe("unknown"); + expect(mapCandidateRecord({ id: "posted", status: "promoted", delivery_note_number: "LEGACY", expand: { delivery_note: { delivery_note_number: "LEGACY", document_type: "collection_note" } } })).toMatchObject({ documentNumber: "LEGACY", documentType: "collection_note" }); + }); + + it("keeps raw supplier evidence inside the unknown supplier exception", () => { + const mapped = mapCandidateRecord({ id: "unknown", batch: "batch", supplier: "", supplier_name_raw: "OCR SUPPLIER", purchase_order: "", status: "candidate", stage: "needs_review", checks: [] }); + expect(groupCandidates([mapped])[0]).toMatchObject({ key: "exception:unknown-supplier", count: 1 }); + expect(mapped.supplierName).toBe("OCR SUPPLIER"); + }); + + it("filters by staged invoice type and renders exception headings, count, card, and action", () => { + const mapped = mapCandidateRecord({ id: "invoice", batch: "batch-1", doc_number: "INV-1", doc_type: "invoice", supplier: "", supplier_name_raw: "OCR", purchase_order: "", status: "candidate", stage: "needs_review", checks: [], expand: { batch: { id: "batch-1", file: "batch.pdf" } } }); + expect(matchesCandidate(mapped, { document_type: "invoice" })).toBe(true); + const html = renderToStaticMarkup(React.createElement(WorkQueueView, { candidates: [mapped], filters: {} })); + expect(html).toContain("Unknown supplier"); + expect(html).toContain("1 queue item"); + expect(html).toContain("INV-1"); + expect(html).toContain("/dashboard/financial/delivery-notes/inbox/batch-1"); + }); + + it("renders supplier unresolved count as a queue link without changing ledger values", () => { + const html = renderToStaticMarkup(React.createElement(SuppliersView, { rows: [{ id: "supplier-vula", name: "Vula Civil", poCount: 1, ordered: 100, delivered: 40, outstanding: 60, unresolvedCandidates: 2 }] })); + expect(html).toContain("2 unresolved"); + expect(html).toContain("delivery-notes/work-queue?supplier=supplier-vula"); + expect(html).toContain("R"); + expect(html).toContain("100"); + expect(html).toContain("40"); + }); }); diff --git a/src/app/dashboard/financial/suppliers/page.tsx b/src/app/dashboard/financial/suppliers/page.tsx index 0a4abe6..b4130e8 100644 --- a/src/app/dashboard/financial/suppliers/page.tsx +++ b/src/app/dashboard/financial/suppliers/page.tsx @@ -28,7 +28,7 @@ export default async function SuppliersPage({ searchParams }: { searchParams: Pr pb.collection("dn_batch_notes").getFullList({ expand: "batch,batch.site,supplier", ...filterOpt(siteFilter(scope).replace(/\bsite=/g, "batch.site=")), - fields: "id,supplier,status,stage,batch", + fields: "id,supplier,supplier_name_raw,status,stage,batch", }), ]); @@ -50,6 +50,27 @@ export default async function SuppliersPage({ searchParams }: { searchParams: Pr posBySupplier.set(p.supplier_ref, list); } + const unresolvedBySupplier = new Map(); + for (const candidate of candidates) { + const supplierId = String(candidate.supplier ?? candidate.expand?.supplier?.id ?? ""); + if (!supplierId || candidate.status === "posted" || candidate.status === "dismissed" || candidate.stage === "posted" || candidate.stage === "dismissed") continue; + unresolvedBySupplier.set(supplierId, (unresolvedBySupplier.get(supplierId) ?? 0) + 1); + } + + const deliveryLike = deliveryLines.map((r) => ({ + purchase_order_item: String((r as { purchase_order_item?: string }).purchase_order_item ?? ""), + qty_received: Number((r as { qty_received?: number }).qty_received ?? 0), + document_type: kindByDnId.get(String((r as { delivery_note?: string }).delivery_note ?? "")) ?? "", + })); + const deliveriesByItem = new Map(); + for (const receipt of deliveryLike) { + const list = deliveriesByItem.get(receipt.purchase_order_item) ?? []; + list.push(receipt); + deliveriesByItem.set(receipt.purchase_order_item, list); + } + // getFullList is intentional here: the candidate read is already site-scoped, + // and truncating a scoped work queue would hide unresolved work. + const rows: SupplierRow[] = suppliers.map((s) => { const supplierPos = posBySupplier.get(s.id) ?? []; const poIds = new Set(supplierPos.map((p) => p.id)); @@ -61,13 +82,10 @@ export default async function SuppliersPage({ searchParams }: { searchParams: Pr qty: it.qty ?? 0, rate: it.rate ?? 0, })); - const deliveryLike = deliveryLines.map((r) => ({ - purchase_order_item: (r as { purchase_order_item?: string }).purchase_order_item ?? "", - qty_received: (r as { qty_received?: number }).qty_received ?? 0, - document_type: - kindByDnId.get((r as { delivery_note?: string }).delivery_note ?? "") ?? "", - })); - const ledger = computeSupplierLedger(supplierItems, deliveryLike); + const ledger = computeSupplierLedger( + supplierItems, + supplierItems.flatMap((item) => deliveriesByItem.get(item.id) ?? []), + ); return { id: s.id, @@ -76,10 +94,7 @@ export default async function SuppliersPage({ searchParams }: { searchParams: Pr ordered: ledger.totals.ordered, delivered: ledger.totals.delivered, outstanding: ledger.totals.outstanding, - unresolvedCandidates: candidates.filter((candidate) => { - const supplierId = String(candidate.supplier ?? candidate.expand?.supplier?.id ?? ""); - return supplierId === s.id && candidate.status !== "posted" && candidate.status !== "dismissed" && candidate.stage !== "posted" && candidate.stage !== "dismissed"; - }).length, + unresolvedCandidates: unresolvedBySupplier.get(s.id) ?? 0, }; });