fix: harden delivery note correction gates

This commit is contained in:
DEls 2026-09-19 18:31:48 +02:00
parent 70117628a6
commit 8f2bb1fc13
9 changed files with 183 additions and 24 deletions

View file

@ -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" });
});
});

View file

@ -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;

View file

@ -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);
});
});

View file

@ -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;

View file

@ -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,
});
});
});

View file

@ -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">

View file

@ -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:

View 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");
});
});

View 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;
}