MEGAINFRA-WEBAPP/scripts/inbox-three-rerun.ts
DEls 004fa9cf78 chore(dn): inbox 3 queue re-run script
Co-Authored-By: MiniMax (Pinchy)
2026-10-26 20:01:09 +02:00

181 lines
8.1 KiB
TypeScript

/**
* 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<string, unknown> | 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<string, unknown> | undefined;
let loaded: Awaited<ReturnType<typeof loadPreviewContext>> | 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<typeof supplierGuardAliases>[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);
});