diff --git a/scripts/nss-add-invoice-columns.ts b/scripts/nss-add-invoice-columns.ts new file mode 100644 index 0000000..3ca22b4 --- /dev/null +++ b/scripts/nss-add-invoice-columns.ts @@ -0,0 +1,138 @@ +/* eslint-disable no-console */ +/** + * scripts/nss-add-invoice-columns.ts + * + * One-shot migration to add the missing `qty_invoiced`, + * `customer_invoice_number`, and `invoiced_date` columns to the + * `order_items` PB collection. The codebase has been writing to these + * fields for months and PB silently dropped every write because the + * columns didn't exist — that bug caused INV10924 on Kusasalethu PO + * 390588 to leave 5 units unaccounted-for. This script adds the + * columns so the reconcile / data-fix scripts can persist the data. + * + * Idempotent: re-running with the columns already present is a no-op + * (the script detects the field by name and skips if it already + * exists). + * + * Usage: + * npx tsx scripts/nss-add-invoice-columns.ts + */ +import PocketBase from "pocketbase"; +import { readFileSync, existsSync } from "node:fs"; +import { resolve } from "node:path"; + +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 COLLECTION = "order_items"; +const NEW_FIELDS = [ + { + name: "qty_invoiced", + type: "number" as const, + required: false, + presentable: false, + options: { min: 0, max: null, noDecimal: true }, + }, + { + name: "customer_invoice_number", + type: "text" as const, + required: false, + presentable: false, + options: { min: 0, max: 100, pattern: "" }, + }, + { + name: "invoiced_date", + type: "date" as const, + required: false, + presentable: false, + options: { min: "", max: "" }, + }, +]; + +async function main() { + const pb = new PocketBase(url); + pb.autoCancellation(false); + await pb.collection("_superusers").authWithPassword(email, password); + console.log(`Connected to ${url} as ${email}.`); + + const collection = await pb.collections.getOne(COLLECTION); + const existingNames = new Set((collection.fields ?? []).map((f) => f.name)); + const toAdd = NEW_FIELDS.filter((f) => !existingNames.has(f.name)); + + if (toAdd.length === 0) { + console.log( + `All 3 columns already present on ${COLLECTION}. No-op.`, + ); + return; + } + + console.log( + `Adding ${toAdd.length} column(s) to ${COLLECTION}: ${toAdd + .map((f) => f.name) + .join(", ")}`, + ); + const nextFields = [ + ...(collection.fields ?? []), + ...toAdd.map((f) => ({ + ...f, + system: false, + hidden: false, + })), + ]; + // PB's collection API expects the fields array (NOT `schema` — that's + // the Go API shape). The SDK serialises `fields` straight through. + await pb.collections.update(collection.id, { + fields: nextFields.map((f) => ({ + name: f.name, + type: f.type, + required: f.required, + presentable: f.presentable, + hidden: f.hidden, + system: f.system, + options: f.options, + })), + }); + + // Verify the columns landed. + const refreshed = await pb.collections.getOne(COLLECTION); + const verifiedNames = new Set((refreshed.fields ?? []).map((f) => f.name)); + for (const f of toAdd) { + if (!verifiedNames.has(f.name)) { + throw new Error(`Column "${f.name}" did not appear after update — aborting.`); + } + } + console.log(`Verified: ${toAdd.map((f) => f.name).join(", ")} present on ${COLLECTION}.`); + console.log("\nNext steps:"); + console.log(` 1. Re-run scripts/nss-fix-kusasalethu-inv10924.ts to back-fill INV10924 on the Kusasalethu PO 390588 lines.`); + console.log(` 2. The new columns are now writable from /api/v1/orders/reconcile-invoice and the DN-invoice route.`); +} + +main().catch((err) => { + console.error("FAILED:", err); + process.exit(1); +}); \ No newline at end of file diff --git a/scripts/nss-fix-kusasalethu-inv10924.ts b/scripts/nss-fix-kusasalethu-inv10924.ts new file mode 100644 index 0000000..e1f3e04 --- /dev/null +++ b/scripts/nss-fix-kusasalethu-inv10924.ts @@ -0,0 +1,162 @@ +/* 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); +}); \ No newline at end of file diff --git a/src/app/(app)/invoicing/page.test.tsx b/src/app/(app)/invoicing/page.test.tsx index d432bb8..5b66c65 100644 --- a/src/app/(app)/invoicing/page.test.tsx +++ b/src/app/(app)/invoicing/page.test.tsx @@ -1662,3 +1662,82 @@ describe("InvoicingPage", () => { }); }); }); + +describe("supplier-side line toggle (exclude_from_invoice)", () => { + beforeEach(() => { + // Each test in this block owns its row set — start clean. + mockedRows.splice(0, mockedRows.length); + }); + + it("hides rows with exclude_from_invoice=true by default", async () => { + mockedRows.push( + makeRawRow({ + id: "row-billable", + description: "Bolt with nut and washer", + }), + makeRawRow({ + id: "row-nut", + description: "Nut (supplier-side)", + exclude_from_invoice: true, + }), + makeRawRow({ + id: "row-washer", + description: "Washer (supplier-side)", + exclude_from_invoice: true, + }), + ); + + render(); + + // The billable row shows up under the To Invoice card. The + // supplier-side rows are filtered out before render — the + // invoice operator never sees them in the inbox. + expect(await screen.findByText("Bolt with nut and washer")).toBeInTheDocument(); + expect(screen.queryByText("Nut (supplier-side)")).not.toBeInTheDocument(); + expect(screen.queryByText("Washer (supplier-side)")).not.toBeInTheDocument(); + }); + + it("surfaces the hidden count via a 'N hidden' badge in the header", async () => { + mockedRows.push( + makeRawRow({ description: "Bolt" }), + makeRawRow({ id: "nut", description: "Nut", exclude_from_invoice: true }), + makeRawRow({ id: "washer", description: "Washer", exclude_from_invoice: true }), + ); + + render(); + + // 2 supplier-side rows hidden under the default (toggle OFF). + expect(await screen.findByText("2 hidden")).toBeInTheDocument(); + }); + + it("reveals supplier-side rows when the operator flips the toggle ON", async () => { + mockedRows.push( + makeRawRow({ + id: "row-nut", + description: "Nut (supplier-side)", + exclude_from_invoice: true, + }), + ); + + render(); + + expect(screen.queryByText("Nut (supplier-side)")).not.toBeInTheDocument(); + + // The toggle lives in the page header next to the search bar. + const toggle = screen.getByTestId("invoicing-show-excluded-row"); + fireEvent.click(toggle.querySelector('[role="switch"]') as HTMLElement); + + expect(await screen.findByText("Nut (supplier-side)")).toBeInTheDocument(); + }); + + it("does not show the hidden-count badge when there are no supplier-side rows", async () => { + mockedRows.push(makeRawRow({ description: "Bolt" })); + + render(); + + // Wait for the billable row to render, then assert the badge is + // absent (no supplier-side rows exist so nothing to hide). + expect(await screen.findByText("Bolt")).toBeInTheDocument(); + expect(screen.queryByText(/hidden/i)).not.toBeInTheDocument(); + }); +}); diff --git a/src/app/(app)/invoicing/page.tsx b/src/app/(app)/invoicing/page.tsx index 59604b0..bfff168 100644 --- a/src/app/(app)/invoicing/page.tsx +++ b/src/app/(app)/invoicing/page.tsx @@ -33,6 +33,7 @@ import { isGroupReadyForQb, isGroupReadyToPrint, matchesSearch, + outstandingQty, resolveInvoicePdfUrl, resolveInvoiceLineDefaults, surplusQty, @@ -931,6 +932,7 @@ function SingleOrderQbModal({ const isExcluded = row.excludeFromInvoice === true; const label = itemLabel(row); const surplus = surplusQty(row); + const short = outstandingQty(row); return (
Qty - {surplus > 0 ? ( + {short > 0 ? ( + + {short} short + + ) : surplus > 0 ? ( Qty - {surplus > 0 ? ( + {short > 0 ? ( + + {short} short + + ) : surplus > 0 ? (