chore(dn): inbox 3 queue re-run script
Co-Authored-By: MiniMax (Pinchy)
This commit is contained in:
parent
d88e2dc0d1
commit
004fa9cf78
3 changed files with 387 additions and 0 deletions
181
scripts/inbox-three-rerun.ts
Normal file
181
scripts/inbox-three-rerun.ts
Normal file
|
|
@ -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<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);
|
||||
});
|
||||
93
src/lib/inbox-three/rerun-plan.test.ts
Normal file
93
src/lib/inbox-three/rerun-plan.test.ts
Normal file
|
|
@ -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> = {}): 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);
|
||||
});
|
||||
});
|
||||
113
src/lib/inbox-three/rerun-plan.ts
Normal file
113
src/lib/inbox-three/rerun-plan.ts
Normal file
|
|
@ -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",
|
||||
};
|
||||
});
|
||||
}
|
||||
Loading…
Reference in a new issue