WFDASH-WEBAPP/scripts/nss-fix-kusasalethu-inv10924.ts

162 lines
6.4 KiB
TypeScript
Raw Permalink Normal View History

fix(invoicing): bill only what's collected, support partial pickups + kit splits The invoice qty rule used to read the customer's order qty regardless of how much the driver actually collected. QuickBooks ended up billing 8-of-10 on one line and 7-of-10 on the other for Kusasalethu PO 390588 (INV10924) — a 5-unit overbilling that the mine's receiving team flags as suspicious. The rule now caps the invoice qty at min(qty_collected, quantity): - Short pickup → bill what's collected, the row stays at item_status="ready_invoice" with qty_invoiced < quantity, and a follow-up invoice fires automatically when the rest arrives. - MOQ overflow → still capped at the customer's order qty; the surplus-book flow continues to route the overflow to stock. The Invoicing page now shows "N short" / "N units short" badges on short-pickup rows, a "N hidden" badge when supplier-side rows are suppressed, and a "Show supplier-side lines" toggle (default OFF) so the invoicing operator doesn't see kit-component remainders unless she needs to. A split-remainder dialog on the order process page lets the operator explicitly flag a remainder as exclude_from_invoice=true when the original line was a kit (e.g. "M20 bolt comes with nut and washer" split across 3 suppliers). PB schema additions (one-time, run scripts/nss-add-invoice-columns.ts): - order_items.qty_invoiced (number) - order_items.customer_invoice_number (text) - order_items.invoiced_date (date) These three columns were referenced throughout the codebase but never existed on the live schema, so writes were being silently dropped. The schema fix unblocks the reconcile flow that stamps them after every QB webhook returns. Backfill for the live Kusasalethu order: scripts/nss-fix-kusasalethu-inv10924.ts updates order_items.quantity 10→8 / 10→7 to match what was collected, stamps the new columns, and flips order.invoiced=true. New server helper reconcileOrderItemInvoicedStatus() runs after each QB webhook and per-row decides: - item_status = "invoiced" if qty_invoiced >= quantity (fully billed) - item_status = "ready_invoice" otherwise (still partial — stays in the to-invoice tab for a follow-up invoice once the rest arrives) Wired into both the bulk invoicing page and the delivery-note-invoice route. markOrderInvoiced now compares against qty_invoiced vs quantity rather than the flat item_status check, and its error message names the outstanding shortfall per row.
2026-09-17 11:53:36 +00:00
/* eslint-disable no-console */
/**
* scripts/nss-fix-kusasalethu-inv10924.ts
*
* One-shot data-fix for the Kusasalethu PO 390588 invoice INV10924
* incident. The driver collected 8 of one line and 7 of another (15
* total), but the QuickBooks invoice billed 10 of each (20 total) — 5
* units were over-billed. This script back-fills PB so the row state
* agrees with reality:
*
* - `quantity` on each line is dropped to match what was collected
* (10 → 8 on the 0161HDAKWF line, 10 → 7 on the 2161HDAKWF line).
* Without this, `quantity - qty_collected` stays positive and the
* new "outstanding qty" badge would mis-report the shortfall.
*
* - `qty_invoiced` is stamped on both rows (= qty_collected).
*
* - `customer_invoice_number` and `invoiced_date` are stamped so
* the operator-facing pages cite INV10924 instead of showing a
* blank.
*
* - `item_status` flips to "invoiced" (matching what the operator
* sees in QuickBooks) so the rows drop off the to-invoice tab.
* The order's `invoiced` flag is also flipped to true.
*
* QuickBooks itself is NOT touched — leaving INV10924 as-is and
* raising a follow-up invoice once the supplier delivers the rest is
* the agreed remediation. If you'd rather credit-note 5 units, do
* that in QuickBooks manually and tell me the credit-memo number so
* I can stamp `customer_credit_number` (would need a schema add).
*
* Usage:
* npx tsx scripts/nss-fix-kusasalethu-inv10924.ts
*
* Requires PB_ADMIN_EMAIL / PB_ADMIN_PASSWORD in .env.local (already
* present in this repo).
*/
import PocketBase from "pocketbase";
import { readFileSync, existsSync } from "node:fs";
import { resolve } from "node:path";
// Load .env.local (or .env) so PB_ADMIN_EMAIL / PB_ADMIN_PASSWORD are
// populated the same way the rest of the app does.
for (const file of [".env.local", ".env"]) {
const path = resolve(process.cwd(), file);
if (existsSync(path)) {
const text = readFileSync(path, "utf8");
for (const rawLine of text.split(/\r?\n/)) {
const line = rawLine.trim();
if (!line || line.startsWith("#")) continue;
const eq = line.indexOf("=");
if (eq <= 0) continue;
const key = line.slice(0, eq).trim();
let value = line.slice(eq + 1).trim();
if ((value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'"))) {
value = value.slice(1, -1);
}
if (!process.env[key]) process.env[key] = value;
}
break;
}
}
const url = process.env.NEXT_PUBLIC_POCKETBASE_URL ?? "http://127.0.0.1:8090";
const email = process.env.PB_ADMIN_EMAIL;
const password = process.env.PB_ADMIN_PASSWORD;
if (!email || !password) {
console.error("PB_ADMIN_EMAIL / PB_ADMIN_PASSWORD are required (set in .env.local).");
process.exit(1);
}
const ORDER_ID = "hqj5nhtkezs53ff";
const ITEM_FIXES: Array<{
id: string;
description: string;
newQuantity: number;
qtyJustInvoiced: number;
}> = [
{
id: "9rqpomdhdhoxr45",
description: "TOPTUL 0161HDAKWF 1/2\" X 10 mm Hex Bit Impact",
newQuantity: 8,
qtyJustInvoiced: 8,
},
{
id: "ynnvo3csqfq7dyg",
description: "TOPTUL 2161HDAKWF 1/2\" x 12 mm Hex Bit Impact",
newQuantity: 7,
qtyJustInvoiced: 7,
},
];
const INVOICE_NUMBER = "INV10924";
const INVOICE_DATE = "2026-09-16";
const pb = new PocketBase(url);
pb.autoCancellation(false);
async function main() {
await pb.collection("_superusers").authWithPassword(email, password);
console.log(`Connected to ${url} as ${email}.`);
// 1. Sanity-check the order exists and matches what we expect.
const order = await pb.collection("orders").getOne(ORDER_ID);
console.log(`\n[order] ${order.id} ${order.order_number} (${order.contact_name})`);
console.log(` invoiced=${order.invoiced} status=${order.status || "(empty)"}`);
// 2. Update each order_item.
for (const fix of ITEM_FIXES) {
const before = await pb.collection("order_items").getOne(fix.id);
console.log(
`\n[item ] ${before.id} ${fix.description}`,
`\n quantity ${before.quantity} → ${fix.newQuantity}`,
`\n qty_collected ${before.qty_collected} (preserved)`,
`\n qty_invoiced ${before.qty_invoiced ?? "(unset)"} → ${fix.qtyJustInvoiced}`,
`\n item_status ${before.item_status} → "invoiced"`,
`\n customer_invoice_number ${before.customer_invoice_number ?? "(unset)"} → ${INVOICE_NUMBER}`,
`\n invoiced_date ${before.invoiced_date ?? "(unset)"} → ${INVOICE_DATE}`,
);
await pb.collection("order_items").update(fix.id, {
quantity: fix.newQuantity,
qty_invoiced: fix.qtyJustInvoiced,
customer_invoice_number: INVOICE_NUMBER,
invoiced_date: INVOICE_DATE,
item_status: "invoiced",
});
}
// 3. Flip the parent order's invoiced flag.
if (!order.invoiced) {
console.log(
`\n[order ] ${order.id} flipping invoiced → true (both lines are fully invoiced now)`,
);
await pb.collection("orders").update(ORDER_ID, { invoiced: true });
} else {
console.log(`\n[order ] ${order.id} already invoiced — leaving alone.`);
}
// 4. Audit summary so the operator can sanity-check what changed.
console.log(`\n--- Final state ---`);
const finalOrder = await pb.collection("orders").getOne(ORDER_ID);
console.log(`order.invoiced = ${finalOrder.invoiced}`);
for (const fix of ITEM_FIXES) {
const after = await pb.collection("order_items").getOne(fix.id);
console.log(
`${fix.id}: quantity=${after.quantity}, qty_collected=${after.qty_collected}, qty_invoiced=${after.qty_invoiced}, item_status=${after.item_status}, customer_invoice_number=${after.customer_invoice_number}, invoiced_date=${after.invoiced_date}`,
);
}
console.log(`\nDone. Next steps:`);
console.log(` - If QuickBooks INV10924 stays as-is: nothing else needed. The next driver pickup of the`);
console.log(` outstanding 5 units (2 of 0161HDAKWF + 3 of 2161HDAKWF) will re-surface these lines in`);
console.log(` the Invoicing page and a follow-up invoice goes out.`);
console.log(` - If you'd rather credit-note the 5 units: do that in QuickBooks manually, then either`);
console.log(` re-run this script with the credit-memo number (would need a schema add) or leave PB alone`);
console.log(` and treat PB as the audit record of what was physically delivered.`);
}
main().catch((err) => {
console.error("FAILED:", err);
process.exit(1);
});