fix: keep queue model server safe

This commit is contained in:
DEls 2026-09-19 20:56:25 +02:00
parent d00b5f1204
commit 03ac13d951
4 changed files with 57 additions and 68 deletions

View file

@ -2,7 +2,8 @@ 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 { WorkQueueView, mapCandidateRecord, matchesCandidate } from "./work-queue-view";
import { WorkQueueView } from "./work-queue-view";
import { mapCandidateRecord, matchesCandidate } from "./work-queue-model";
export const dynamic = "force-dynamic";

View file

@ -0,0 +1,42 @@
import { displayCandidateStage } from "@/lib/dn-pipeline/stages";
export type QueueStage = "matching" | "needs_review" | "ready_to_post" | "posted" | "dismissed";
export interface WorkQueueCandidate {
id: string; siteId: string; siteName: string; supplierId: string; supplierName: string;
purchaseOrderId: string; purchaseOrderNumber: string; documentId: string; documentNumber: string;
documentType: string; documentDate: string; batchId: string; batchName: string; stage: QueueStage;
reason: string; matchMethod: string; unresolvedLineItems: number;
}
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()));
}
export function buildQueueHref(current: Record<string, string | undefined>, changes: Record<string, string | undefined>): string {
const params = new URLSearchParams();
for (const [key, value] of Object.entries({ ...current, ...changes })) if (value) params.set(key, value);
const query = params.toString();
return `/dashboard/financial/delivery-notes/work-queue${query ? `?${query}` : ""}`;
}

View file

@ -4,29 +4,8 @@ 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";
export interface WorkQueueCandidate {
id: string;
siteId: string;
siteName: string;
supplierId: string;
supplierName: string;
purchaseOrderId: string;
purchaseOrderNumber: string;
documentId: string;
documentNumber: string;
documentType: string;
documentDate: string;
batchId: string;
batchName: string;
stage: QueueStage;
reason: string;
matchMethod: string;
unresolvedLineItems: number;
}
import { buildQueueHref, type WorkQueueCandidate } from "./work-queue-model";
export type { WorkQueueCandidate } from "./work-queue-model";
export interface QueuePoGroup {
key: string;
@ -43,35 +22,6 @@ 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",
@ -132,18 +82,6 @@ function getException(map: Map<string, QueueGroup>, key: string, label: string):
return created;
}
export function buildQueueHref(
current: Record<string, string | undefined>,
changes: Record<string, string | undefined>,
): string {
const params = new URLSearchParams();
for (const [key, value] of Object.entries({ ...current, ...changes })) {
if (value) params.set(key, value);
}
const query = params.toString();
return `/dashboard/financial/delivery-notes/work-queue${query ? `?${query}` : ""}`;
}
export function WorkQueueView({
candidates,
filters,

View file

@ -1,14 +1,14 @@
import { describe, expect, it } from "vitest";
import {
buildQueueHref,
groupCandidates,
mapCandidateRecord,
matchesCandidate,
WorkQueueView,
type WorkQueueCandidate,
} from "./work-queue-view";
import { buildQueueHref, mapCandidateRecord, matchesCandidate } from "./work-queue-model";
import React from "react";
import { renderToStaticMarkup } from "react-dom/server";
import { readFileSync } from "node:fs";
import { resolve } from "node:path";
import { SuppliersView } from "../../suppliers/suppliers-view";
const candidate = (
@ -35,6 +35,14 @@ const candidate = (
});
describe("supplier/PO queue grouping", () => {
it("keeps server-callable mapping out of the use-client view module", () => {
const page = readFileSync(resolve(__dirname, "page.tsx"), "utf8");
const view = readFileSync(resolve(__dirname, "work-queue-view.tsx"), "utf8");
expect(page).toContain('from "./work-queue-model"');
expect(view).toContain('from "./work-queue-model"');
expect(view).not.toContain("export function mapCandidateRecord");
});
it("groups known suppliers by purchase order and keeps exception groups explicit", () => {
const groups = groupCandidates([
candidate(),