fix: harden delivery note queue mappings and counts

This commit is contained in:
DEls 2026-09-19 18:10:40 +02:00
parent 2e5e504054
commit c094aa0140
4 changed files with 114 additions and 66 deletions

View file

@ -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<string, string>;
return <><Topbar title="Delivery note work queue" subtitle="Resolve supplier, purchase order, and line-item exceptions before posting." /><WorkQueueView candidates={candidates} filters={filters} /></>;
}
function toCandidate(row: Record<string, any>): 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()));
}

View file

@ -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<string, any>): 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<string, string | undefined>): 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<string, QueueGroup>();
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 <article className="rounded-lg border p-3">
<div className="flex items-start justify-between gap-3"><div><p className="font-medium">{candidate.documentNumber || "Unnumbered document"}</p><p className="text-xs text-muted-foreground">{candidate.documentType || "Unknown type"} · {candidate.documentDate || "No date"}</p></div><Badge variant={candidate.stage === "needs_review" ? "destructive" : "outline"}>{candidate.stage.replaceAll("_", " ")}</Badge></div>
<dl className="mt-3 grid grid-cols-2 gap-x-3 gap-y-1 text-xs text-muted-foreground"><div><dt>Batch</dt><dd className="text-foreground">{candidate.batchName || candidate.batchId}</dd></div><div><dt>Match method</dt><dd className="text-foreground">{candidate.matchMethod || "Unmatched"}</dd></div><div className="col-span-2"><dt>Exception</dt><dd className="text-foreground">{candidate.reason || "Needs review"}</dd></div></dl>
<dl className="mt-3 grid grid-cols-2 gap-x-3 gap-y-1 text-xs text-muted-foreground"><div><dt>Batch</dt><dd className="text-foreground">{candidate.batchName || candidate.batchId}</dd></div><div><dt>Match method</dt><dd className="text-foreground">{candidate.matchMethod || "Unmatched"}</dd></div><div className="col-span-2"><dt>Exception</dt><dd className="text-foreground">{candidate.reason || "Needs review"}</dd></div>{!candidate.supplierId && candidate.supplierName && <div className="col-span-2"><dt>Supplier evidence</dt><dd className="text-foreground">{candidate.supplierName}</dd></div>}</dl>
<Button asChild size="sm" className="mt-3 w-full"><Link href={`/dashboard/financial/delivery-notes/inbox/${encodeURIComponent(candidate.batchId)}`}>Review document</Link></Button>
</article>;
}

View file

@ -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<WorkQueueCandidate> = {},
@ -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");
});
});

View file

@ -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<string, number>();
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<string, typeof deliveryLike>();
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,
};
});