/** * Re-run the Inbox 3 queue. * * npx tsx --env-file=.env.local scripts/inbox-three-rerun.ts # dry run: prints, writes nothing * npx tsx --env-file=.env.local scripts/inbox-three-rerun.ts --write # saves the refreshed checks, preview and stage * * A dry run is what happens with no flags: it reads every open note, * re-runs the supplier guard, refreshes the saved match and prints what the * queue would look like. Nothing is written unless `--write` is passed. * * It never posts a receipt. No line is confirmed here, and nothing calls * the posting path: the office still presses Confirm. */ import { pbAdmin } from "../src/lib/pb"; import { loadPreviewContext } from "../src/lib/dn-pipeline/ai-preview-context"; import { refreshPreview } from "../src/lib/dn-pipeline/matching"; import { readyIndexes } from "../src/lib/inbox-three/readiness"; import { withBatchLease } from "../src/lib/dn-pipeline/lock"; import { supplierGuardAliases, supplierGuardOverride, supplierGuardSupplier, supplierMatchesPo, } from "../src/lib/dn-pipeline/checks"; import { planRerunPatches, planRerunRows, type RerunNote } from "../src/lib/inbox-three/rerun-plan"; import type { CheckResult } from "../src/lib/dn-pipeline/types"; const ACTOR = "inbox-three-rerun"; const ACTION = "inbox_three_rerun"; const CLOSED_STATUS = ["posted", "promoted", "dismissed"]; const CLOSED_STAGE = ["posted", "dismissed"]; const text = (value: unknown): string => (typeof value === "string" ? value : ""); /** The note's own checks, with the supplier guard replaced by a fresh one. */ function checksWithGuard(existing: unknown, guard: CheckResult): CheckResult[] { const list = Array.isArray(existing) ? (existing as CheckResult[]) : []; return [...list.filter((check) => check?.id !== "supplier_matches_po"), guard]; } async function main() { const flags = process.argv.slice(2); const unknown = flags.filter((flag) => flag !== "--write"); if (unknown.length) throw Error(`Unknown flag: ${unknown.join(", ")}. Use --write to save; without it nothing is written.`); const write = flags.includes("--write"); const pb = await pbAdmin(); const all = await pb.collection("dn_batch_notes").getFullList({ expand: "supplier,purchase_order,batch,batch.site", sort: "created", }); const open = all.filter((note) => Boolean(note.supplier) && Boolean(note.purchase_order) && !CLOSED_STATUS.includes(String(note.status)) && !CLOSED_STAGE.includes(String(note.stage))); const suppliers = await pb.collection("suppliers").getFullList({ fields: "id,name,registration,registration_number,registration_no,company_registration", }); const aliases = await pb.collection("supplier_document_aliases").getFullList({ fields: "supplier,supplier_id,raw_value,normalised_key", }); const planned: RerunNote[] = []; for (const note of open) { const batchId = String(note.batch ?? ""); const batch = note.expand?.batch as Record | undefined; const site = String((batch?.expand as { site?: { name?: string } } | undefined)?.site?.name ?? batch?.site_name ?? ""); const supplierId = String(note.supplier ?? ""); const po = note.expand?.purchase_order as Record | undefined; let loaded: Awaited> | null = null; try { loaded = await loadPreviewContext(pb, batchId, String(note.id)); } catch (error) { // A note that cannot be read yet is not a failure of the queue: it is // a note waiting for a person. console.log(`- ${String(note.doc_number || note.id)} · skipped: ${error instanceof Error ? error.message : "could not be read"}`); continue; } if (!loaded) continue; const { context, stored } = loaded; // Re-run the supplier guard: the slip's own company against the // company its order belongs to. const linkedSupplier = supplierGuardSupplier(suppliers, supplierId); const guard = supplierMatchesPo({ supplierNameRaw: text(note.supplier_name_raw) || null, supplierRegistrationRaw: text(note.supplier_registration_raw) || null, linkedSupplier: linkedSupplier ?? { id: supplierId, name: text(context.supplier.name), registration: null }, aliases: supplierGuardAliases(aliases as unknown as Parameters[0], supplierId), poSupplierRef: text(po?.supplier_ref) || text(po?.supplier_id) || null, override: supplierGuardOverride(note.manual_overrides), }); const checks = checksWithGuard(note.checks, { id: "supplier_matches_po", passed: guard.passed, reason: guard.reason }); // Refresh the saved match. Without a stored preview there is nothing // to refresh: the matching model is never called here. const preview = stored ? refreshPreview(context, stored) : null; const ready = preview ? readyIndexes(context, preview) : []; planned.push({ id: String(note.id), site, docNumber: String(note.doc_number || note.delivery_note_number || note.id), supplier: text(context.supplier.name) || text(po?.supplier), po: text(po?.purchase_order_number) || text(note.po_number_raw), totalLines: preview ? preview.allocations.length : context.lines.length, readyLines: ready.length, checks, preview, }); } const rows = planRerunRows(planned); const headers = ["site", "slip", "supplier", "PO", "lines", "ready", "open", "guard", "stage"]; const cells = rows.map((row) => [ row.site, row.docNumber, row.supplier, row.po, String(row.totalLines), String(row.readyLines), String(row.openLines), row.supplierGuard, row.stage, ]); const widths = headers.map((header, index) => Math.max(header.length, ...cells.map((cell) => (cell[index] ?? "").length))); const line = (values: string[]) => values.map((value, index) => (value ?? "").padEnd(widths[index])).join(" "); console.log(""); console.log(write ? "Inbox 3 queue — re-run (writing)" : "Inbox 3 queue — re-run (dry run, nothing written)"); console.log(line(headers)); console.log(widths.map((width) => "-".repeat(width)).join(" ")); for (const cell of cells) console.log(line(cell)); console.log(""); console.log(`${rows.length} open note(s) · ${rows.filter((row) => row.stage === "ready_to_post").length} ready to confirm · ${rows.filter((row) => row.stage === "needs AI preview").length} need the matching model`); if (!write) { console.log(""); console.log("Dry run: nothing was written. Re-run with --write to save the refreshed checks, preview and stage."); return; } // Save, note by note, under the batch lease — the same lock the matching // route takes, so a person working on the same batch cannot be overwritten. const patches = planRerunPatches(planned); const batchOf = new Map(planned.map((note) => [note.id, batchIdOf(note.id, all)])); let written = 0; for (const patch of patches) { const batchId = batchOf.get(patch.noteId) ?? ""; if (!batchId) continue; const result = await withBatchLease(pb, batchId, ACTOR, async (lease) => { await lease.batch([ { collection: "dn_batch_notes", method: "update", id: patch.noteId, data: { checks: patch.checks, reconciliation_preview: patch.preview, status: patch.status, stage: patch.stage, } }, { collection: "dn_review_events", method: "create", data: { batch: batchId, note: patch.noteId, actor: ACTOR, action: ACTION, reason: "Re-ran the Inbox 3 queue: refreshed checks, preview and stage. No receipt was posted.", } }, ]); return true; }); if (result.acquired) { written += 1; console.log(`saved ${patch.noteId} → ${patch.stage}`); } else { console.log(`skipped ${patch.noteId}: the batch is busy`); } } console.log(""); console.log(`${written} note(s) saved. No receipt was posted.`); } /** The batch a note belongs to. */ function batchIdOf(noteId: string, notes: { id: string; batch?: string }[]): string { return String(notes.find((note) => note.id === noteId)?.batch ?? ""); } main().catch((error) => { console.error(error instanceof Error ? error.message : error); process.exit(1); });