diff --git a/scripts/backfill-counter-collected.mts b/scripts/backfill-counter-collected.mts new file mode 100644 index 0000000..bcc9229 --- /dev/null +++ b/scripts/backfill-counter-collected.mts @@ -0,0 +1,188 @@ +/** + * Backfill `qty_collected` on COUNTER delivery-note lines. + * + * ## Why + * + * A counter delivery note is raised when the customer takes goods off + * the shelf: there is no driver pickup, so the collection has already + * happened by the time the note exists. Those notes were being + * created with `qty_collected` left at 0. + * + * `qty_collected` is what the invoice path reads to decide how many + * units to bill (`resolveInvoiceLineDefaults`), so a counter note + * invoiced with the field at 0 produced a **zero-quantity invoice + * line**, which QuickBooks rejects outright — n8n answers 400, which + * the invoice route surfaced to the operator as an opaque 502. + * That's the failure this backfill clears. + * + * New and edited notes are already correct (`848c483` stamps the + * field at creation and follows the quantity on edit). This is the + * backlog. + * + * ## Scope — deliberately narrow + * + * Touches ONLY a row when ALL of these hold: + * + * - it is on a delivery note (`delivery_note_number` set), and + * - it has NO parent order (`order` empty/null), and + * - `qty_collected <= 0`, and + * - `quantity > 0` (there is a number to copy), and + * - it has NOT already been invoiced. + * + * Two exclusions are load-bearing: + * + * - **Parent order.** On an order-linked note `qty_collected` means + * "the driver has this many in the van". Overwriting it with the + * order quantity would bill the customer for goods that have not + * arrived — the exact financial irregularity the field exists to + * prevent. + * + * - **Already invoiced.** If a line carries an invoice number or a + * billed qty, the invoice has already been settled against the + * old number. Rewriting the row afterwards would make the note + * disagree with a document the customer already holds. These are + * left alone and reported separately. + * + * ## Safety + * + * - `--dry-run` is the DEFAULT. Nothing is written without `--apply`. + * - Idempotent: a row that already matches is skipped, so a re-run + * is a no-op. + * - Writes are one field on one row. No deletes, ever. + * - Prints a before/after count and the invoice number of every + * delivery note touched. + * + * Usage: + * node --env-file=.env.local $(which npx) tsx scripts/backfill-counter-collected.ts + * node --env-file=.env.local $(which npx) tsx scripts/backfill-counter-collected.ts --apply + */ + +import PocketBase from "pocketbase"; + +const APPLY = process.argv.includes("--apply"); +const BASE = process.env.NEXT_PUBLIC_POCKETBASE_URL?.replace(/\/$/, "") ?? ""; +const EMAIL = process.env.PB_ADMIN_EMAIL ?? ""; +const PASSWORD = process.env.PB_ADMIN_PASSWORD ?? ""; + +if (!BASE || !EMAIL || !PASSWORD) { + console.error( + "Missing NEXT_PUBLIC_POCKETBASE_URL / PB_ADMIN_EMAIL / PB_ADMIN_PASSWORD.\n" + + "Run with: node --env-file=.env.local $(which npx) tsx scripts/backfill-counter-collected.ts", + ); + process.exit(1); +} + +type Row = { + id: string; + delivery_note_number?: string; + order?: string; + quantity?: number; + qty_collected?: number; + qty_invoiced?: number; + customer_invoice_number?: string; + description?: string; + sku?: string; +}; + +const pb = new PocketBase(BASE); +await pb.collection("_superusers").authWithPassword(EMAIL, PASSWORD); + +/** + * Parentless is "counter". PB's `order=""` doesn't match a null/empty + * relation, so both spellings are tried and unioned. + */ +const FILTER_PARENTLESS = '(order = "" || order = null)'; +const FILTER_DN = 'delivery_note_number != ""'; +const FILTER_UNSET = "qty_collected <= 0"; +const FILTER_QTY = "quantity > 0"; + +async function fetchAll(filter: string): Promise { + const out: Row[] = []; + let page = 1; + // PB pages results; 200 per page keeps this to a handful of round + // trips for the realistic volume. + for (;;) { + const res = await pb.collection("order_items").getList(page, 200, { + filter, + fields: "id,delivery_note_number,order,quantity,qty_collected,qty_invoiced,customer_invoice_number,description,sku", + }); + out.push(...res.items); + if (page >= res.totalPages) break; + page++; + } + return out; +} + +const candidates = await fetchAll( + `${FILTER_DN} && ${FILTER_PARENTLESS} && ${FILTER_UNSET} && ${FILTER_QTY}`, +); + +const alreadyInvoiced = candidates.filter( + (r) => + (typeof r.qty_invoiced === "number" && r.qty_invoiced > 0) || + (typeof r.customer_invoice_number === "string" && + r.customer_invoice_number.trim() !== ""), +); + +const toFix = candidates.filter((r) => !alreadyInvoiced.includes(r)); + +const byNote = new Map(); +for (const r of toFix) { + const n = r.delivery_note_number ?? "(none)"; + byNote.set(n, (byNote.get(n) ?? 0) + 1); +} + +console.log(`\nmode: ${APPLY ? "APPLY" : "DRY RUN (no writes)"}`); +console.log(`counter lines found: ${candidates.length}`); +console.log(` to fix: ${toFix.length}`); +console.log( + ` skipped, already invoiced: ${alreadyInvoiced.length}` + + (alreadyInvoiced.length ? " ← left untouched on purpose" : ""), +); +console.log(`delivery notes hit: ${byNote.size}`); + +if (!APPLY) { + console.log(`\nFirst 15 rows that WOULD change (qty_collected 0 -> quantity):`); + for (const r of toFix.slice(0, 15)) { + console.log( + ` ${(r.delivery_note_number ?? "?").padEnd(14)} qty ${String( + r.quantity, + ).padEnd(7)} ${(r.description ?? "").slice(0, 44)}`, + ); + } + if (toFix.length > 15) console.log(` … and ${toFix.length - 15} more`); + if (alreadyInvoiced.length) { + console.log(`\nSkipped as already invoiced:`); + for (const r of alreadyInvoiced.slice(0, 10)) { + console.log( + ` ${(r.delivery_note_number ?? "?").padEnd(14)} ${(r.description ?? "").slice(0, 40)}` + + ` (inv ${r.customer_invoice_number || r.qty_invoiced})`, + ); + } + if (alreadyInvoiced.length > 10) { + console.log(` … and ${alreadyInvoiced.length - 10} more`); + } + } + console.log(`\nNo writes. Re-run with --apply to make the change.\n`); + process.exit(0); +} + +let written = 0; +const failed: string[] = []; +for (const r of toFix) { + try { + await pb.collection("order_items").update(r.id, { + qty_collected: Number(r.quantity), + }); + written++; + } catch (err) { + failed.push(`${r.id} (${r.delivery_note_number}): ${String(err)}`); + } +} + +console.log(`\nwritten: ${written} / ${toFix.length}`); +if (failed.length) { + console.log(`FAILED: ${failed.length}`); + for (const f of failed.slice(0, 10)) console.log(` ${f}`); +} +console.log(`\nDone. Re-run without --apply to confirm it is now a no-op.\n`);