/** * 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`);