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