181 lines
8.1 KiB
TypeScript
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);
|
|
});
|