chore(backfill): stamp qty_collected on 301 counter delivery-note lines
Operator-authorised repair. A counter delivery note is raised when the
customer takes goods off the shelf, so the collection has already
happened — but these lines were created with `qty_collected` at 0.
`qty_collected` is what the invoice path reads to decide how many units
to bill, so every one of these would have produced a zero-quantity
invoice line, which QuickBooks rejects (n8n 400 -> the opaque 502 the
operator reported). New and edited notes are already correct
(`848c483`); this is the backlog.
found: 315 counter lines with qty_collected <= 0
fixed: 301 rows across 114 delivery notes
skipped: 14 — already invoiced, deliberately left alone
Two exclusions are load-bearing:
- Already invoiced. Rewriting a row after the invoice settled
against the old number would leave the note disagreeing with a
document the customer already holds. One carried a real
QuickBooks number (INV29681); the rest are dated "INVOICED…"
markers. Reported by the script so the skip is visible rather
than silent.
- 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 for goods that have not arrived — the exact
irregularity the field exists to prevent.
`scripts/backfill-counter-collected.mts` is DRY RUN by default and
needs `--apply` to write. It is idempotent: a re-run reports 0 to fix.
Verified after the fact that no order-linked line was touched
(0 remaining, same as before).
This commit is contained in:
parent
fd3a9296c6
commit
5b8e9e87bd
1 changed files with 188 additions and 0 deletions
188
scripts/backfill-counter-collected.mts
Normal file
188
scripts/backfill-counter-collected.mts
Normal file
|
|
@ -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<Row[]> {
|
||||
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<Row>(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<string, number>();
|
||||
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`);
|
||||
Loading…
Reference in a new issue