Three faults stopped the staged pipeline from ingesting anything once it met a live PocketBase rather than a test double. PocketBase treats 0 as blank for a required number field, and two staged fields legitimately hold zero: dn_batch_sources.ordinal is 0 for the first source in a batch, and dn_batch_pages.region is 0 for a whole-page read. Both creates were rejected with validation_required, so the first file of every upload failed and no page ever persisted. The migration now declares them optional, and pb-relax-dn-zero-fields.py relaxes instances that already have them required. The staged runner verifies its batch lease and commits the protected write in one transaction, which needs PocketBase's batch API. That is off by default, so the runner failed closed with dn_pipeline_transactions_required and no deployment note explained why. pb-enable-batch-api.py turns it on and raises the 3s transaction timeout, which is tight for a commit carrying page images. compare-dn-pipelines used top-level await while resolving as CJS, so the command in the rollout runbook always failed to transform. Renamed .mts. The tests missed all of this because they mock PocketBase, and the mock accepts what the real server rejects. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
74 lines
4.2 KiB
TypeScript
74 lines
4.2 KiB
TypeScript
// .mts, not .ts: this script uses top-level await, and the package has no
|
|
// "type": "module", so tsx would otherwise transform it as CJS and fail.
|
|
import { pbAdmin } from "../src/lib/pb";
|
|
import { classifyUnit, poMatchCounts, verifyPurchaseOrderIds } from "../src/lib/dn-pipeline/compare";
|
|
|
|
type Row = Record<string, any>;
|
|
|
|
const args = process.argv.slice(2);
|
|
const batchId = args.find((arg) => !arg.startsWith("-"));
|
|
const all = args.includes("--all");
|
|
if (!batchId && !all) {
|
|
console.error("Usage: npx tsx scripts/compare-dn-pipelines.ts <batch-id> | --all");
|
|
process.exit(2);
|
|
}
|
|
|
|
const pb = await pbAdmin();
|
|
const batches = (all ? await pb.collection("dn_batches").getFullList({ sort: "created" }) : [await pb.collection("dn_batches").getOne(batchId!)]).map((row) => row as Row);
|
|
const reports: Row[] = [];
|
|
|
|
for (const batch of batches) {
|
|
const [pages, notes] = await Promise.all([
|
|
pb.collection("dn_batch_pages").getFullList({ filter: `batch = ${JSON.stringify(batch.id)}`, fields: "id,read_status" }),
|
|
pb.collection("dn_batch_notes").getFullList({ filter: `batch = ${JSON.stringify(batch.id)}`, sort: "note_index", fields: "id,stage,status,pages,purchase_order,items,checks,doc_number,delivery_note_number" }),
|
|
]);
|
|
const poIds = [...new Set((notes as Row[]).map((note) => typeof note.purchase_order === "string" ? note.purchase_order : "").filter(Boolean))];
|
|
const verifiedPoIds = await verifyPurchaseOrderIds(poIds, async (po) => {
|
|
try { const row = await pb.collection("purchase_orders").getOne(po, { fields: "id" }) as Row; return { id: String(row.id) }; }
|
|
catch (error) { throw new Error(`purchase order verification failed for ${po}: ${error instanceof Error ? error.message : String(error)}`, { cause: error }); }
|
|
});
|
|
const poItems = (await Promise.all([...verifiedPoIds].map((po) => pb.collection("purchase_order_items").getFullList({ filter: `purchase_order = ${JSON.stringify(po)}`, fields: "id,purchase_order,unit,qty,rate,description" })))).flat() as Row[];
|
|
const itemById = new Map(poItems.map((item) => [item.id, item]));
|
|
let matchedItems = 0;
|
|
let itemCount = 0;
|
|
let unitCompatible = 0;
|
|
let unitIncompatible = 0;
|
|
let unitUnknown = 0;
|
|
let quantity = 0;
|
|
let computedValue = 0;
|
|
for (const note of notes as Row[]) {
|
|
for (const line of Array.isArray(note.items) ? note.items : []) {
|
|
if (!line || typeof line !== "object") continue;
|
|
itemCount++;
|
|
const linked = typeof line.purchase_order_item === "string" ? line.purchase_order_item : "";
|
|
const poItem = itemById.get(linked);
|
|
const qty = typeof line.qty === "number" ? line.qty : Number(line.qty ?? 0);
|
|
quantity += Number.isFinite(qty) ? qty : 0;
|
|
if (poItem) {
|
|
matchedItems++;
|
|
const compatibility = classifyUnit(line.unit, poItem.unit);
|
|
if (compatibility === "compatible") unitCompatible++;
|
|
else if (compatibility === "incompatible") unitIncompatible++;
|
|
else unitUnknown++;
|
|
const rate = Number(poItem.rate);
|
|
if (Number.isFinite(rate) && Number.isFinite(qty)) computedValue += qty * rate;
|
|
}
|
|
}
|
|
}
|
|
const coveredPages = new Set((notes as Row[]).flatMap((note) => Array.isArray(note.pages) ? note.pages.filter((page: unknown) => Number.isInteger(page)) : []));
|
|
reports.push({
|
|
batchId: batch.id,
|
|
site: batch.site ?? null,
|
|
pageCoverage: { total: pages.length, assigned: coveredPages.size, open: Math.max(0, pages.length - coveredPages.size), read: (pages as Row[]).filter((page) => page.read_status === "read").length },
|
|
candidateCount: notes.length,
|
|
stages: Object.fromEntries([...new Set((notes as Row[]).map((note) => String(note.stage ?? note.status ?? "unknown")))].map((stage) => [stage, (notes as Row[]).filter((note) => String(note.stage ?? note.status ?? "unknown") === stage).length])),
|
|
poMatch: poMatchCounts(notes as Row[], verifiedPoIds),
|
|
itemMatch: { matched: matchedItems, total: itemCount },
|
|
units: { compatible: unitCompatible, incompatible: unitIncompatible, unknown: unitUnknown, matchedItems },
|
|
quantity,
|
|
computedValue: Math.round(computedValue * 100) / 100,
|
|
writes: false,
|
|
});
|
|
}
|
|
|
|
console.log(JSON.stringify({ generatedAt: new Date().toISOString(), reports }, null, 2));
|