fix: harden delivery note correction gates
This commit is contained in:
parent
70117628a6
commit
8f2bb1fc13
9 changed files with 183 additions and 24 deletions
|
|
@ -90,4 +90,19 @@ describe("resolve-entities route", () => {
|
|||
expect(response.status).toBe(409);
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects a JSON null body", async () => {
|
||||
const { POST } = await import("./route");
|
||||
const response = await POST(new Request("http://localhost/api", { method: "POST", body: "null" }), { params: Promise.resolve({ id: BATCH, noteId: NOTE }) });
|
||||
expect(response.status).toBe(400);
|
||||
});
|
||||
|
||||
it("updates an existing alias with manual audit metadata without duplicating it", async () => {
|
||||
fixtureState.rows.supplier_document_aliases.push({ id: "aliasabcdefghij", supplier: "old-supplierabc", raw_value: "Old", normalised_key: "ACME TRADING", source: "import", approved_by: "importer" });
|
||||
const { POST } = await import("./route");
|
||||
const response = await POST(request({ supplierId: SUPPLIER, purchaseOrderId: PO, reason: "verified", rememberSupplierAlias: true }), { params: Promise.resolve({ id: BATCH, noteId: NOTE }) });
|
||||
expect(response.status).toBe(200);
|
||||
expect(fixtureState.rows.supplier_document_aliases).toHaveLength(1);
|
||||
expect(fixtureState.rows.supplier_document_aliases[0]).toMatchObject({ source: "manual_correction", approved_by: "web" });
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ import { pbAdmin } from "@/lib/pb";
|
|||
import { withBatchLease } from "@/lib/dn-pipeline/lock";
|
||||
import type { BatchMutation } from "@/lib/dn-pipeline/batch-mutations";
|
||||
import { normaliseSupplierKey } from "@/lib/dn-pipeline/resolve-entities";
|
||||
import { evaluateCandidateReadiness } from "@/lib/dn-pipeline/readiness";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
export const runtime = "nodejs";
|
||||
|
|
@ -29,8 +30,8 @@ export async function POST(request: Request, { params }: { params: Promise<{ id:
|
|||
|
||||
const leased = await withBatchLease(pb, id, "resolve-entities", async (lease) => {
|
||||
const [batch, note] = await Promise.all([
|
||||
pb.collection("dn_batches").getOne(id, { fields: "id,site,status" }).catch(() => null),
|
||||
pb.collection("dn_batch_notes").getOne(noteId, { fields: "id,batch,status,stage,posted_at,delivery_note,supplier,supplier_name_raw,purchase_order,checks,items" }).catch(() => null),
|
||||
pb.collection("dn_batches").getOne(id, { fields: "id,site,status,page_count,assembly_revision,revision" }).catch(() => null),
|
||||
pb.collection("dn_batch_notes").getOne(noteId, { fields: "id,batch,status,stage,posted_at,delivery_note,supplier,supplier_name_raw,purchase_order,checks,items,pages,doc_number,delivery_note_number,assembly_revision,candidate_revision" }).catch(() => null),
|
||||
]);
|
||||
if (!batch) return NextResponse.json({ error: "batch_not_found" }, { status: 404 });
|
||||
if (!note) return NextResponse.json({ error: "note_not_found" }, { status: 404 });
|
||||
|
|
@ -54,11 +55,13 @@ export async function POST(request: Request, { params }: { params: Promise<{ id:
|
|||
if (poSupplier && poSupplier !== supplierId) return NextResponse.json({ error: "purchase_order_supplier_mismatch" }, { status: 400 });
|
||||
if (!poSupplier && (typeof purchaseOrder.supplier !== "string" || normaliseSupplierKey(purchaseOrder.supplier) !== normaliseSupplierKey(supplier.name))) return NextResponse.json({ error: "purchase_order_supplier_mismatch" }, { status: 400 });
|
||||
|
||||
const poItems = await pb.collection("purchase_order_items").getFullList({ filter: `purchase_order = ${JSON.stringify(purchaseOrderId)}`, fields: "id,purchase_order,description,unit,material_code,qty,rate" }).catch(() => []);
|
||||
const before = { supplier: note.supplier ?? "", purchase_order: note.purchase_order ?? "", stage: note.stage ?? "needs_review", status: note.status ?? "blocked" };
|
||||
const checks = Array.isArray(note.checks) ? note.checks.map((check: unknown) => isObject(check) && check.id === "po_resolved" ? { ...check, passed: true, reason: "purchase order selected by operator" } : check) : [];
|
||||
const items = Array.isArray(note.items) ? note.items : [];
|
||||
const ready = checks.every((check) => !isObject(check) || check.passed !== false) && items.every((item) => !isObject(item) || Boolean(item.purchase_order_item || item.purchase_order_item_id));
|
||||
const after = { supplier: supplierId, purchase_order: purchaseOrderId, stage: ready ? "ready_to_post" : "needs_review", status: ready ? "candidate" : "blocked" };
|
||||
const readiness = evaluateCandidateReadiness({ batch, note: { ...note, supplier: supplierId, purchase_order: purchaseOrderId }, supplierId, purchaseOrderId, purchaseOrderSiteId: poSite, purchaseOrderSupplierId: poSupplier || supplierId, poItems: (Array.isArray(poItems) ? poItems : []) as Record<string, unknown>[], checks: checks as Record<string, unknown>[] });
|
||||
const ready = readiness.ready;
|
||||
const after = { supplier: supplierId, purchase_order: purchaseOrderId, stage: ready ? "ready_to_post" : "needs_review", status: ready ? "candidate" : "blocked", reasons: readiness.reasons };
|
||||
const mutations: BatchMutation[] = [
|
||||
{ collection: "dn_batch_notes", method: "update", id: noteId, data: { supplier: supplierId, purchase_order: purchaseOrderId, checks, stage: after.stage, status: after.status } },
|
||||
{ collection: "dn_review_events", method: "create", data: { batch: id, note: noteId, action: "resolve_entities", before, after, reason, actor: "web" } },
|
||||
|
|
@ -68,11 +71,11 @@ export async function POST(request: Request, { params }: { params: Promise<{ id:
|
|||
const normalisedKey = normaliseSupplierKey(rawValue);
|
||||
const existing = await pb.collection("supplier_document_aliases").getFullList({ filter: `normalised_key = ${JSON.stringify(normalisedKey)}`, fields: "id,supplier,raw_value,normalised_key,source,approved_by" }).catch(() => []);
|
||||
const data = { supplier: supplierId, raw_value: rawValue, normalised_key: normalisedKey, source: "manual_correction", approved_by: "web" };
|
||||
if (existing[0]?.id) mutations.push({ collection: "supplier_document_aliases", method: "update", id: existing[0].id, data: { supplier: supplierId, raw_value: rawValue, normalised_key: normalisedKey } });
|
||||
if (existing[0]?.id) mutations.push({ collection: "supplier_document_aliases", method: "update", id: existing[0].id, data });
|
||||
else mutations.push({ collection: "supplier_document_aliases", method: "create", data });
|
||||
}
|
||||
await lease.batch(mutations);
|
||||
return NextResponse.json({ ok: true, stage: after.stage, status: after.status, readyToPost: ready });
|
||||
return NextResponse.json({ ok: true, stage: after.stage, status: after.status, readyToPost: ready, reasons: readiness.reasons });
|
||||
});
|
||||
if (!leased.acquired) return NextResponse.json({ error: "batch_locked" }, { status: 409 });
|
||||
return leased.value;
|
||||
|
|
|
|||
|
|
@ -55,4 +55,17 @@ describe("resolve-item route", () => {
|
|||
await POST(request(body), { params: Promise.resolve({ id: BATCH, noteId: NOTE }) });
|
||||
expect(rows.dn_item_aliases).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("rejects malformed null JSON and missing site or supplier ownership", async () => {
|
||||
const { POST } = await import("./route");
|
||||
const malformed = await POST(new Request("http://localhost/api", { method: "POST", body: "null" }), { params: Promise.resolve({ id: BATCH, noteId: NOTE }) });
|
||||
expect(malformed.status).toBe(400);
|
||||
collections.dn_batches.getOne.mockResolvedValue({ id: BATCH, site: "" });
|
||||
const missingSite = await POST(request({ lineIndex: 0, purchaseOrderItemId: ITEM, reason: "verified", rememberAlias: false }), { params: Promise.resolve({ id: BATCH, noteId: NOTE }) });
|
||||
expect(missingSite.status).toBe(400);
|
||||
collections.dn_batches.getOne.mockResolvedValue({ id: BATCH, site: "siteabcdefghijk" });
|
||||
collections.suppliers.getOne.mockResolvedValue(null);
|
||||
const missingSupplier = await POST(request({ lineIndex: 0, purchaseOrderItemId: ITEM, reason: "verified", rememberAlias: true }), { params: Promise.resolve({ id: BATCH, noteId: NOTE }) });
|
||||
expect(missingSupplier.status).toBe(400);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import { withBatchLease } from "@/lib/dn-pipeline/lock";
|
|||
import type { BatchMutation } from "@/lib/dn-pipeline/batch-mutations";
|
||||
import { aliasKey } from "@/lib/dn-pipeline/aliases";
|
||||
import { norm } from "@/lib/dn-pipeline/match-po-item";
|
||||
import { evaluateCandidateReadiness } from "@/lib/dn-pipeline/readiness";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
export const runtime = "nodejs";
|
||||
|
|
@ -17,14 +18,15 @@ export async function POST(request: Request, { params }: { params: Promise<{ id:
|
|||
if (!idPattern.test(id) || !idPattern.test(noteId)) return NextResponse.json({ error: "invalid_id" }, { status: 400 });
|
||||
let body: Record<string, unknown>;
|
||||
try { body = await request.json(); } catch { return NextResponse.json({ error: "invalid_json" }, { status: 400 }); }
|
||||
if (!object(body)) return NextResponse.json({ error: "invalid_item_resolution" }, { status: 400 });
|
||||
const lineIndex = body.lineIndex;
|
||||
const purchaseOrderItemId = body.purchaseOrderItemId;
|
||||
if (!Number.isInteger(lineIndex) || (lineIndex as number) < 0 || !idPattern.test(String(purchaseOrderItemId ?? "")) || typeof body.reason !== "string" || !String(body.reason).trim() || typeof body.rememberAlias !== "boolean") return NextResponse.json({ error: "invalid_item_resolution" }, { status: 400 });
|
||||
const pb = await pbAdmin();
|
||||
const leased = await withBatchLease(pb, id, "resolve-item", async (lease) => {
|
||||
const [batch, note] = await Promise.all([
|
||||
pb.collection("dn_batches").getOne(id, { fields: "id,site" }).catch(() => null),
|
||||
pb.collection("dn_batch_notes").getOne(noteId, { fields: "id,batch,status,stage,posted_at,delivery_note,purchase_order,supplier,items,checks" }).catch(() => null),
|
||||
pb.collection("dn_batches").getOne(id, { fields: "id,site,page_count,assembly_revision,revision" }).catch(() => null),
|
||||
pb.collection("dn_batch_notes").getOne(noteId, { fields: "id,batch,status,stage,posted_at,delivery_note,purchase_order,supplier,items,checks,pages,doc_number,delivery_note_number,assembly_revision,candidate_revision" }).catch(() => null),
|
||||
]);
|
||||
if (!batch) return NextResponse.json({ error: "batch_not_found" }, { status: 404 });
|
||||
if (!note) return NextResponse.json({ error: "note_not_found" }, { status: 404 });
|
||||
|
|
@ -34,26 +36,28 @@ export async function POST(request: Request, { params }: { params: Promise<{ id:
|
|||
if ((lineIndex as number) >= items.length || !object(items[lineIndex as number])) return NextResponse.json({ error: "line_index_out_of_range" }, { status: 400 });
|
||||
const candidatePoId = typeof note.purchase_order === "string" ? note.purchase_order : relationId(note.purchase_order);
|
||||
if (!candidatePoId) return NextResponse.json({ error: "candidate_po_missing" }, { status: 400 });
|
||||
const [supplier, purchaseOrder, poItem] = await Promise.all([
|
||||
const [supplier, purchaseOrder, poItem, allPoItems] = await Promise.all([
|
||||
note.supplier ? pb.collection("suppliers").getOne(String(note.supplier), { fields: "id,name" }).catch(() => null) : Promise.resolve(null),
|
||||
pb.collection("purchase_orders").getOne(candidatePoId, { fields: "id,site,supplier,supplier_ref" }).catch(() => null),
|
||||
pb.collection("purchase_order_items").getOne(String(purchaseOrderItemId), { fields: "id,purchase_order,description,material_code,unit,qty,rate" }).catch(() => null),
|
||||
Promise.resolve(pb.collection("purchase_order_items").getFullList({ filter: `purchase_order = ${JSON.stringify(candidatePoId)}`, fields: "id,purchase_order,description,unit,material_code,qty,rate" })).catch(() => []),
|
||||
]);
|
||||
if (note.supplier && !supplier) return NextResponse.json({ error: "supplier_not_found" }, { status: 400 });
|
||||
if (!purchaseOrder) return NextResponse.json({ error: "purchase_order_not_found" }, { status: 404 });
|
||||
const siteId = relationId(batch.site);
|
||||
const poSite = relationId(purchaseOrder.site);
|
||||
if (poSite && siteId && poSite !== siteId) return NextResponse.json({ error: "purchase_order_site_mismatch" }, { status: 400 });
|
||||
if (!siteId || !poSite || poSite !== siteId) return NextResponse.json({ error: "purchase_order_site_mismatch" }, { status: 400 });
|
||||
const poSupplier = relationId(purchaseOrder.supplier_ref) || (idPattern.test(relationId(purchaseOrder.supplier)) ? relationId(purchaseOrder.supplier) : "");
|
||||
if (note.supplier && poSupplier && poSupplier !== note.supplier) return NextResponse.json({ error: "purchase_order_supplier_mismatch" }, { status: 400 });
|
||||
if (!note.supplier || !supplier || !poSupplier || poSupplier !== note.supplier) return NextResponse.json({ error: "purchase_order_supplier_mismatch" }, { status: 400 });
|
||||
if (!poItem) return NextResponse.json({ error: "po_item_not_found" }, { status: 404 });
|
||||
if (relationId(poItem.purchase_order) !== candidatePoId) return NextResponse.json({ error: "po_item_not_in_candidate_po" }, { status: 400 });
|
||||
const before = { lineIndex, purchase_order_item: (items[lineIndex as number] as Record<string, unknown>).purchase_order_item ?? null };
|
||||
const replacement = { ...(items[lineIndex as number] as Record<string, unknown>), purchase_order_item: String(purchaseOrderItemId) };
|
||||
items[lineIndex as number] = replacement;
|
||||
const checks = Array.isArray(note.checks) ? note.checks : [];
|
||||
const ready = checks.every((check: unknown) => !object(check) || check.passed !== false) && items.every((item: unknown) => !object(item) || Boolean(item.purchase_order_item || item.purchase_order_item_id));
|
||||
const after = { lineIndex, purchase_order_item: String(purchaseOrderItemId), stage: ready ? "ready_to_post" : "needs_review", status: ready ? "candidate" : "blocked" };
|
||||
const readiness = evaluateCandidateReadiness({ batch, note: { ...note, items }, supplierId: String(note.supplier), purchaseOrderId: candidatePoId, purchaseOrderSiteId: poSite, purchaseOrderSupplierId: poSupplier, poItems: (Array.isArray(allPoItems) && allPoItems.length > 0 ? allPoItems : [poItem]) as Record<string, unknown>[], checks: checks as Record<string, unknown>[] });
|
||||
const ready = readiness.ready;
|
||||
const after = { lineIndex, purchase_order_item: String(purchaseOrderItemId), stage: ready ? "ready_to_post" : "needs_review", status: ready ? "candidate" : "blocked", reasons: readiness.reasons };
|
||||
const reason = String(body.reason).trim();
|
||||
const mutations: BatchMutation[] = [
|
||||
{ collection: "dn_batch_notes", method: "update", id: noteId, data: { items, stage: after.stage, status: after.status } },
|
||||
|
|
@ -62,6 +66,8 @@ export async function POST(request: Request, { params }: { params: Promise<{ id:
|
|||
if (body.rememberAlias) {
|
||||
const line = items[lineIndex as number] as Record<string, unknown>;
|
||||
const description = typeof line.description === "string" ? line.description.trim() : "";
|
||||
if (!supplier || !String(note.supplier).trim()) return NextResponse.json({ error: "supplier_unresolved" }, { status: 400 });
|
||||
if (!description) return NextResponse.json({ error: "item_description_missing" }, { status: 400 });
|
||||
const supplierKey = norm(String(supplier?.name ?? note.supplier ?? ""));
|
||||
const key = aliasKey(description);
|
||||
const existing = await pb.collection("dn_item_aliases").getFullList({ filter: `slip_description_key=${JSON.stringify(key)} && supplier_key=${JSON.stringify(supplierKey)}`, fields: "id,times_used" }).catch(() => []);
|
||||
|
|
@ -70,7 +76,7 @@ export async function POST(request: Request, { params }: { params: Promise<{ id:
|
|||
else mutations.push({ collection: "dn_item_aliases", method: "create", data: { ...data, times_used: 1 } });
|
||||
}
|
||||
await lease.batch(mutations);
|
||||
return NextResponse.json({ ok: true, stage: after.stage, status: after.status, readyToPost: ready, lineIndex, purchaseOrderItemId: String(purchaseOrderItemId) });
|
||||
return NextResponse.json({ ok: true, stage: after.stage, status: after.status, readyToPost: ready, reasons: readiness.reasons, lineIndex, purchaseOrderItemId: String(purchaseOrderItemId) });
|
||||
});
|
||||
if (!leased.acquired) return NextResponse.json({ error: "batch_locked" }, { status: 409 });
|
||||
return leased.value;
|
||||
|
|
|
|||
|
|
@ -0,0 +1,13 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
import { buildResolveItemRequest } from "./batch-detail-view";
|
||||
|
||||
describe("ProductDecisions resolve-item payload", () => {
|
||||
it("uses the stable line index and new route contract", () => {
|
||||
expect(buildResolveItemRequest(4, "itemabcdefghijk", true)).toEqual({
|
||||
lineIndex: 4,
|
||||
purchaseOrderItemId: "itemabcdefghijk",
|
||||
reason: "manual product match",
|
||||
rememberAlias: true,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -362,11 +362,16 @@ function PoLink({
|
|||
}
|
||||
|
||||
type ItemDecision = {
|
||||
lineIndex: number;
|
||||
description: string;
|
||||
kind: "ambiguous" | "none";
|
||||
candidates: { id: string; description: string; material_code: string }[];
|
||||
};
|
||||
|
||||
export function buildResolveItemRequest(lineIndex: number, purchaseOrderItemId: string, rememberAlias: boolean) {
|
||||
return { lineIndex, purchaseOrderItemId, reason: "manual product match", rememberAlias };
|
||||
}
|
||||
|
||||
function ProductDecisions({
|
||||
batchId,
|
||||
note,
|
||||
|
|
@ -382,17 +387,17 @@ function ProductDecisions({
|
|||
const decisions = (Array.isArray(check?.detail) ? check!.detail : []) as ItemDecision[];
|
||||
if (decisions.length === 0) return null;
|
||||
|
||||
async function choose(description: string, poItemId: string) {
|
||||
async function choose(lineIndex: number, poItemId: string) {
|
||||
if (!poItemId) return;
|
||||
setError("");
|
||||
setSaving(description);
|
||||
setSaving(String(lineIndex));
|
||||
try {
|
||||
const res = await fetch(
|
||||
`/api/dn-batches/${batchId}/notes/${note.id}/resolve-item`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ description, po_item_id: poItemId, global: applyAll }),
|
||||
body: JSON.stringify(buildResolveItemRequest(lineIndex, poItemId, applyAll)),
|
||||
},
|
||||
);
|
||||
if (!res.ok) {
|
||||
|
|
@ -412,15 +417,13 @@ function ProductDecisions({
|
|||
These lines need a product decision. Your choice is remembered for next time.
|
||||
</p>
|
||||
{decisions.map((d, di) => (
|
||||
// Keyed by position too: two slip lines on one note can carry
|
||||
// word-for-word the same description.
|
||||
<div key={`${di}-${d.description}`} className="space-y-1">
|
||||
<div key={`${di}-${d.lineIndex}-${d.description}`} className="space-y-1">
|
||||
<p className="text-sm">{d.description}</p>
|
||||
<select
|
||||
className="h-8 w-full rounded-md border bg-background px-2 text-sm"
|
||||
defaultValue=""
|
||||
disabled={saving === d.description}
|
||||
onChange={(e) => choose(d.description, e.target.value)}
|
||||
disabled={saving === String(d.lineIndex)}
|
||||
onChange={(e) => choose(d.lineIndex, e.target.value)}
|
||||
>
|
||||
<option value="">
|
||||
{d.kind === "ambiguous"
|
||||
|
|
@ -442,7 +445,7 @@ function ProductDecisions({
|
|||
checked={applyAll}
|
||||
onChange={(e) => setApplyAll(e.target.checked)}
|
||||
/>
|
||||
Apply to every supplier, not just this one
|
||||
Remember this supplier-specific alias
|
||||
</label>
|
||||
{error && <p className="text-xs text-destructive">{error}</p>}
|
||||
<p className="text-xs text-muted-foreground">
|
||||
|
|
|
|||
|
|
@ -327,7 +327,7 @@ function productMatched(note: NoteSegment, ctx: NoteCheckContext): CheckResult {
|
|||
}
|
||||
|
||||
const resolved = resolveNoteLines(note, ctx);
|
||||
const unresolved = resolved.filter((r) => r.match.kind !== "matched");
|
||||
const unresolved = resolved.map((r, index) => ({ ...r, index })).filter((r) => r.match.kind !== "matched");
|
||||
if (unresolved.length === 0) {
|
||||
return { id, passed: true, reason: `${resolved.length} line(s) matched` };
|
||||
}
|
||||
|
|
@ -344,6 +344,7 @@ function productMatched(note: NoteSegment, ctx: NoteCheckContext): CheckResult {
|
|||
reason: parts.join(", "),
|
||||
// The UI offers these as one-click choices; picking one writes an alias.
|
||||
detail: unresolved.map((r) => ({
|
||||
lineIndex: r.index,
|
||||
description: r.description,
|
||||
kind: r.match.kind,
|
||||
candidates:
|
||||
|
|
|
|||
41
src/lib/dn-pipeline/readiness.test.ts
Normal file
41
src/lib/dn-pipeline/readiness.test.ts
Normal file
|
|
@ -0,0 +1,41 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
import { evaluateCandidateReadiness, type ReadinessInput } from "./readiness";
|
||||
|
||||
const base: ReadinessInput = {
|
||||
batch: { id: "batch", site: "site", page_count: 2, assembly_revision: 3 },
|
||||
note: { id: "note", status: "blocked", stage: "needs_review", assembly_revision: 3, pages: [1], page_count: 2, doc_number: "DN-1", supplier: "supplier", purchase_order: "po", items: [{ purchase_order_item: "item" }] },
|
||||
supplierId: "supplier",
|
||||
purchaseOrderId: "po",
|
||||
purchaseOrderSiteId: "site",
|
||||
purchaseOrderSupplierId: "supplier",
|
||||
poItems: [{ id: "item", purchase_order: "po" }],
|
||||
checks: [
|
||||
{ id: "po_resolved", passed: true }, { id: "product_matched", passed: true },
|
||||
{ id: "units_convertible", passed: true }, { id: "not_duplicate", passed: true }, { id: "qty_plausible", passed: true },
|
||||
],
|
||||
};
|
||||
|
||||
describe("evaluateCandidateReadiness", () => {
|
||||
it("fails closed when required checks are missing", () => {
|
||||
const result = evaluateCandidateReadiness({ ...base, checks: [] });
|
||||
expect(result.ready).toBe(false);
|
||||
expect(result.reasons).toContain("missing_check:po_resolved");
|
||||
});
|
||||
|
||||
it("rejects stale assembly and invalid pages", () => {
|
||||
const result = evaluateCandidateReadiness({ ...base, note: { ...base.note, assembly_revision: 2, pages: [0, 1, 1, 3] } });
|
||||
expect(result.ready).toBe(false);
|
||||
expect(result.reasons).toEqual(expect.arrayContaining(["stale_assembly_revision", "invalid_pages"]));
|
||||
});
|
||||
|
||||
it("requires present site and supplier ownership", () => {
|
||||
const result = evaluateCandidateReadiness({ ...base, purchaseOrderSiteId: "", purchaseOrderSupplierId: "other" });
|
||||
expect(result.reasons).toEqual(expect.arrayContaining(["purchase_order_site_missing", "purchase_order_supplier_mismatch"]));
|
||||
});
|
||||
|
||||
it("rejects an existing link whose PO item is not owned by the selected PO", () => {
|
||||
const result = evaluateCandidateReadiness({ ...base, poItems: [{ id: "item", purchase_order: "other-po" }] });
|
||||
expect(result.ready).toBe(false);
|
||||
expect(result.reasons).toContain("po_item_not_in_purchase_order:item");
|
||||
});
|
||||
});
|
||||
64
src/lib/dn-pipeline/readiness.ts
Normal file
64
src/lib/dn-pipeline/readiness.ts
Normal file
|
|
@ -0,0 +1,64 @@
|
|||
export interface ReadinessCheck { id?: unknown; passed?: unknown }
|
||||
|
||||
export interface ReadinessInput {
|
||||
batch: Record<string, unknown>;
|
||||
note: Record<string, unknown>;
|
||||
supplierId: string;
|
||||
purchaseOrderId: string;
|
||||
purchaseOrderSiteId: string;
|
||||
purchaseOrderSupplierId: string;
|
||||
poItems: readonly Record<string, unknown>[];
|
||||
checks: readonly ReadinessCheck[];
|
||||
}
|
||||
|
||||
export interface ReadinessResult { ready: boolean; reasons: string[] }
|
||||
|
||||
const requiredChecks = ["po_resolved", "product_matched", "units_convertible", "not_duplicate", "qty_plausible"] as const;
|
||||
const id = (value: unknown) => typeof value === "string" ? value.trim() : value && typeof value === "object" && !Array.isArray(value) && typeof (value as { id?: unknown }).id === "string" ? (value as { id: string }).id.trim() : "";
|
||||
|
||||
/** Fail-closed financial posting gate shared by every manual correction. */
|
||||
export function evaluateCandidateReadiness(input: ReadinessInput): ReadinessResult {
|
||||
const reasons: string[] = [];
|
||||
const note = input.note;
|
||||
const batch = input.batch;
|
||||
if (["posted", "promoted", "dismissed"].includes(id(note.status)) || ["posted", "dismissed"].includes(id(note.stage)) || Boolean(note.posted_at) || Boolean(note.delivery_note)) reasons.push("terminal_candidate");
|
||||
const batchRevision = number(batch.assembly_revision ?? batch.revision);
|
||||
const noteRevision = number(note.assembly_revision ?? note.candidate_revision ?? note.revision);
|
||||
if (batchRevision !== null && noteRevision !== null && batchRevision !== noteRevision) reasons.push("stale_assembly_revision");
|
||||
const pages = Array.isArray(note.pages) ? note.pages : [];
|
||||
const pageCount = number(batch.page_count ?? note.page_count);
|
||||
const validPages = pages.length > 0 && pages.every((page) => Number.isInteger(page) && (page as number) >= 1 && (pageCount === null || (page as number) <= pageCount));
|
||||
if (!validPages || new Set(pages).size !== pages.length) reasons.push("invalid_pages");
|
||||
if (!id(note.doc_number ?? note.delivery_note_number)) reasons.push("document_identity_missing");
|
||||
if (!id(input.supplierId) || !id(note.supplier)) reasons.push("supplier_unresolved");
|
||||
if (!id(input.purchaseOrderId) || !id(note.purchase_order)) reasons.push("purchase_order_unresolved");
|
||||
if (!id(input.purchaseOrderSiteId)) reasons.push("purchase_order_site_missing");
|
||||
else if (id(batch.site) !== input.purchaseOrderSiteId) reasons.push("purchase_order_site_mismatch");
|
||||
if (!id(input.purchaseOrderSupplierId)) reasons.push("purchase_order_supplier_missing");
|
||||
else if (input.purchaseOrderSupplierId !== input.supplierId) reasons.push("purchase_order_supplier_mismatch");
|
||||
const items = Array.isArray(note.items) ? note.items : [];
|
||||
if (items.length === 0) reasons.push("items_missing");
|
||||
const poItemById = new Map(input.poItems.map((item) => [id(item.id), item]));
|
||||
const linked = new Set<string>();
|
||||
for (const item of items) {
|
||||
if (!item || typeof item !== "object" || Array.isArray(item)) { reasons.push("invalid_item"); continue; }
|
||||
const linkedId = id((item as Record<string, unknown>).purchase_order_item ?? (item as Record<string, unknown>).purchase_order_item_id);
|
||||
if (!linkedId) { reasons.push("item_unlinked"); continue; }
|
||||
if (linked.has(linkedId)) reasons.push(`duplicate_po_item_link:${linkedId}`);
|
||||
linked.add(linkedId);
|
||||
const poItem = poItemById.get(linkedId);
|
||||
if (!poItem || id(poItem.purchase_order) !== input.purchaseOrderId) reasons.push(`po_item_not_in_purchase_order:${linkedId}`);
|
||||
}
|
||||
const byId = new Map<string, ReadinessCheck>();
|
||||
for (const check of input.checks) if (id(check.id)) byId.set(id(check.id), check);
|
||||
for (const checkId of requiredChecks) {
|
||||
const check = byId.get(checkId);
|
||||
if (!check) reasons.push(`missing_check:${checkId}`);
|
||||
else if (check.passed !== true) reasons.push(`check_not_passed:${checkId}`);
|
||||
}
|
||||
return { ready: reasons.length === 0, reasons: [...new Set(reasons)] };
|
||||
}
|
||||
|
||||
function number(value: unknown): number | null {
|
||||
return typeof value === "number" && Number.isInteger(value) ? value : null;
|
||||
}
|
||||
Loading…
Reference in a new issue