From 004fa9cf789be35a76ef13257dfdbe0299057049 Mon Sep 17 00:00:00 2001 From: DEls Date: Mon, 26 Oct 2026 20:01:09 +0200 Subject: [PATCH] chore(dn): inbox 3 queue re-run script Co-Authored-By: MiniMax (Pinchy) --- scripts/inbox-three-rerun.ts | 181 +++++++++++++++++++++++++ src/lib/inbox-three/rerun-plan.test.ts | 93 +++++++++++++ src/lib/inbox-three/rerun-plan.ts | 113 +++++++++++++++ 3 files changed, 387 insertions(+) create mode 100644 scripts/inbox-three-rerun.ts create mode 100644 src/lib/inbox-three/rerun-plan.test.ts create mode 100644 src/lib/inbox-three/rerun-plan.ts diff --git a/scripts/inbox-three-rerun.ts b/scripts/inbox-three-rerun.ts new file mode 100644 index 0000000..f224b73 --- /dev/null +++ b/scripts/inbox-three-rerun.ts @@ -0,0 +1,181 @@ +/** + * 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); +}); diff --git a/src/lib/inbox-three/rerun-plan.test.ts b/src/lib/inbox-three/rerun-plan.test.ts new file mode 100644 index 0000000..fb2cf76 --- /dev/null +++ b/src/lib/inbox-three/rerun-plan.test.ts @@ -0,0 +1,93 @@ +import { describe, expect, it } from "vitest"; +import { buildPreview, type PreviewContext } from "@/lib/dn-pipeline/ai-preview"; +import { planRerunPatches, planRerunRows, rerunStage, supplierGuardState, type RerunNote } from "./rerun-plan"; +import type { CheckResult } from "@/lib/dn-pipeline/types"; + +const PO_ITEM = "itemabcdefghijk"; + +const context = (): PreviewContext => ({ + note: { id: "noteabcdefghijk", batch: "batchabcdefghij", pages: [1], doc_number: "DQ0016214", items: [] }, + pages: [], + supplier: { id: "supplierabcdefg", name: "Raumix" }, + po: { id: "poabcdefghijklm", purchase_order_number: "MEGCPO9476", total_value: 26333.85 }, + poItems: [{ id: PO_ITEM, description: "Builder Sand", unit: "m³", qty: 10, rate: 150, disc_percent: 0, vat_percent: 15 }], + lines: [ + { sourceLineId: "a", description: "Builder Sand", qty: 1, unit: "m³", unit_price: 150, line_amount: 150, product_code: null }, + { sourceLineId: "b", description: "Builder Sand", qty: 1, unit: "m³", unit_price: 150, line_amount: 150, product_code: null }, + { sourceLineId: "c", description: "Builder Sand", qty: 1, unit: "m³", unit_price: 150, line_amount: 150, product_code: null }, + ], + priorDeliveries: [], + priorItems: [], +}); + +const preview = (c: PreviewContext) => buildPreview(c, { allocations: [ + { sourceLineIds: ["a"], purchaseOrderItemId: PO_ITEM, quantityBasis: "direct", factor: 1, amountBasis: "po_rate", sourceAmountBasis: "unknown", ruleKeys: [], explanation: "Builder Sand", reviewQuestion: null }, + { sourceLineIds: ["b"], purchaseOrderItemId: PO_ITEM, quantityBasis: "direct", factor: 1, amountBasis: "po_rate", sourceAmountBasis: "unknown", ruleKeys: [], explanation: "Builder Sand", reviewQuestion: null }, + { sourceLineIds: ["c"], purchaseOrderItemId: null, quantityBasis: "none", factor: null, amountBasis: "unknown", sourceAmountBasis: "unknown", ruleKeys: [], explanation: "Builder Sand", reviewQuestion: null }, +] }); + +const check = (passed: boolean | null, reason = "because"): CheckResult => ({ id: "supplier_matches_po", passed, reason }); + +const note = (over: Partial = {}): RerunNote => { + const c = context(); + return { + id: "noteabcdefghijk", + site: "The Hills", + docNumber: "DQ0016214", + supplier: "Raumix", + po: "MEGCPO9476", + totalLines: 3, + readyLines: 2, + checks: [check(true)], + preview: preview(c), + ...over, + }; +}; + +describe("planRerunRows", () => { + it("shows a failed supplier guard and keeps the note open", () => { + const rows = planRerunRows([note({ checks: [check(false, "another supplier")] })]); + expect(rows[0].supplierGuard).toBe("FAIL"); + expect(rows[0].stage).toBe("needs_review"); + }); + + it("shows a guard that is not there as missing, and keeps the note open", () => { + const rows = planRerunRows([note({ checks: [] })]); + expect(rows[0].supplierGuard).toBe("missing"); + expect(rows[0].stage).toBe("needs_review"); + }); + + it("counts the lines that are ready and the lines still open", () => { + const rows = planRerunRows([note({ readyLines: 2 })]); + expect(rows[0]).toMatchObject({ totalLines: 3, readyLines: 2, openLines: 1, supplierGuard: "pass", stage: "ready_to_post" }); + }); + + it("says a note with no preview needs the matching model", () => { + const rows = planRerunRows([note({ preview: null, readyLines: 0 })]); + expect(rows[0].stage).toBe("needs AI preview"); + expect(rerunStage(note({ preview: null }))).toBe("needs AI preview"); + }); + + it("reads the guard from the checks", () => { + expect(supplierGuardState([check(true)])).toBe("pass"); + expect(supplierGuardState([check(false)])).toBe("FAIL"); + expect(supplierGuardState([check(null)])).toBe("missing"); + expect(supplierGuardState([])).toBe("missing"); + }); +}); + +describe("planRerunPatches", () => { + it("saves a ready note as ready to post", () => { + const patches = planRerunPatches([note()]); + expect(patches[0]).toMatchObject({ noteId: "noteabcdefghijk", status: "candidate", stage: "ready_to_post" }); + }); + + it("saves a note whose guard failed as blocked", () => { + const patches = planRerunPatches([note({ checks: [check(false)] })]); + expect(patches[0]).toMatchObject({ status: "blocked", stage: "needs_review" }); + }); + + it("leaves a note with no preview alone", () => { + expect(planRerunPatches([note({ preview: null })])).toHaveLength(0); + }); +}); diff --git a/src/lib/inbox-three/rerun-plan.ts b/src/lib/inbox-three/rerun-plan.ts new file mode 100644 index 0000000..7ca63b2 --- /dev/null +++ b/src/lib/inbox-three/rerun-plan.ts @@ -0,0 +1,113 @@ +/** + * The Inbox 3 queue re-run, planned. + * + * Pure: given what each note carries, it says what the queue would look + * like and what would be written to it. The script does the reading and the + * writing; this decides — so the decision can be tested without a database. + */ +import type { CheckResult } from "@/lib/dn-pipeline/types"; +import type { ProcessingPreview } from "@/lib/dn-pipeline/ai-preview"; + +/** The supplier guard: did the slip's own company match the order's? */ +export type SupplierGuardState = "pass" | "FAIL" | "missing"; + +/** Where the note would stand after the re-run. */ +export type RerunStage = "ready_to_post" | "needs_review" | "needs AI preview"; + +export interface RerunNote { + id: string; + site: string; + docNumber: string; + supplier: string; + po: string; + /** The lines on the slip, as the office sees them. */ + totalLines: number; + /** The lines that could be confirmed as they stand. */ + readyLines: number; + /** The checks after the re-run. */ + checks: CheckResult[]; + /** The refreshed preview, or null when there is nothing to refresh. */ + preview: ProcessingPreview | null; +} + +export interface RerunRow { + site: string; + docNumber: string; + supplier: string; + po: string; + totalLines: number; + readyLines: number; + openLines: number; + supplierGuard: SupplierGuardState; + stage: RerunStage; +} + +export interface RerunPatch { + noteId: string; + checks: CheckResult[]; + preview: ProcessingPreview | null; + status: "candidate" | "blocked"; + stage: "ready_to_post" | "needs_review"; +} + +/** + * What the supplier guard says. + * + * A guard that is not there is not a pass: a slip whose company has not + * been compared with its order stays open, because nobody has said the two + * belong together. + */ +export function supplierGuardState(checks: readonly CheckResult[]): SupplierGuardState { + const guard = checks.find((check) => check.id === "supplier_matches_po"); + if (!guard) return "missing"; + if (guard.passed === false) return "FAIL"; + if (guard.passed !== true) return "missing"; + return "pass"; +} + +/** + * Where the note would stand. + * + * No preview means no lines to count: the note still needs the matching + * model, which this re-run never calls. A guard that did not pass keeps the + * note open however many of its lines look ready. + */ +export function rerunStage(note: RerunNote): RerunStage { + if (!note.preview) return "needs AI preview"; + if (supplierGuardState(note.checks) !== "pass") return "needs_review"; + return note.readyLines > 0 ? "ready_to_post" : "needs_review"; +} + +/** The table the script prints. */ +export function planRerunRows(notes: readonly RerunNote[]): RerunRow[] { + return notes.map((note) => ({ + site: note.site, + docNumber: note.docNumber, + supplier: note.supplier, + po: note.po, + totalLines: note.totalLines, + readyLines: note.readyLines, + openLines: Math.max(0, note.totalLines - note.readyLines), + supplierGuard: supplierGuardState(note.checks), + stage: rerunStage(note), + })); +} + +/** + * What `--write` would save. + * + * A note with no preview is left alone: there is nothing to refresh, and + * writing a stage for it would claim work that was never done. + */ +export function planRerunPatches(notes: readonly RerunNote[]): RerunPatch[] { + return notes.filter((note) => note.preview).map((note) => { + const ready = rerunStage(note) === "ready_to_post"; + return { + noteId: note.id, + checks: note.checks, + preview: note.preview, + status: ready ? "candidate" : "blocked", + stage: ready ? "ready_to_post" : "needs_review", + }; + }); +}