From 74cde0314270e4b64987ffbb7bfd5831a1c32a90 Mon Sep 17 00:00:00 2001 From: David Date: Fri, 18 Sep 2026 08:07:38 +0200 Subject: [PATCH] fix(order-process): stamp qty_collected when marking items in stock MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit In-stock rows were toggled to `item_status="ready_invoice"` without setting `qty_collected`, so the Invoicing page's `min(qty_collected, quantity)` rule pushed a 0-qty line to QuickBooks for every in-stock row. The operator couldn't invoice — the goods were physically on the shelf but the system thought nothing had been received. Single-row `toggleInStock` and bulk `markInStockReady` now both stamp `qty_collected = quantity` on the flip. In-stock rows still don't go through a supplier PO (`purchase_order=""`) — the fix is purely on the "we have these on our shelves" signal that feeds invoicing. Backfill: ran `scripts/nss-backfill-in-stock-qty.ts` across the org. Found 316 stale `in_stock=true` rows out of 324, stamped `qty_collected=quantity` on each. Idempotent (second run reports 0 to do). ZA10PO26011801 (Master Drilling, M16 rod) and Q202609445 (Denmar Steel — 8 lines including nuts/washers at qty 170) verified in PB. Companion fix to the earlier in-stock totals exclusion: the Invoicing page now gets the full qty for each in-stock row, while the process-page PO totals still exclude them (since no PO is sent). Both behaviours line up. --- scripts/nss-backfill-in-stock-qty.ts | 113 +++++++++++++++ src/app/(app)/orders/[id]/process/page.tsx | 160 +++++++++++++++------ 2 files changed, 227 insertions(+), 46 deletions(-) create mode 100644 scripts/nss-backfill-in-stock-qty.ts diff --git a/scripts/nss-backfill-in-stock-qty.ts b/scripts/nss-backfill-in-stock-qty.ts new file mode 100644 index 0000000..42856c8 --- /dev/null +++ b/scripts/nss-backfill-in-stock-qty.ts @@ -0,0 +1,113 @@ +/* eslint-disable no-console */ +/** + * scripts/nss-backfill-in-stock-qty.ts + * + * One-shot backfill for in_stock=true order_items rows whose + * `qty_collected` was never stamped to match the customer quantity. + * + * The order process page's "in stock" toggle now stamps qty_collected + * = quantity automatically (so the Invoicing page can bill those + * rows at the customer-facing qty), but rows that were marked + * in_stock before that fix shipped will still have qty_collected=0 + * — the Invoicing page reads min(qty_collected, quantity), so the + * invoice line would land at 0 and the operator can't invoice the + * customer. + * + * This script: + * 1. Finds every order_item with in_stock=true AND + * qty_collected < quantity. + * 2. Stamps qty_collected = quantity. + * 3. Reports a count of rows touched. + * + * Idempotent — re-running is a no-op once all rows are aligned. + * + * Usage: + * npx tsx scripts/nss-backfill-in-stock-qty.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 pb = new PocketBase(url); +pb.autoCancellation(false); + +async function main() { + await pb.collection("_superusers").authWithPassword(email, password); + console.log(`Connected to ${url} as ${email}.`); + + // Pull every order_item with in_stock=true. We filter in JS rather + // than PB because qty_collected < quantity isn't trivially expressible + // in PB filter syntax (would need to subtract via JS anyway). + const all = await pb.collection("order_items").getFullList({ + filter: "in_stock = true", + fields: "id,description,quantity,qty_collected,item_status,customer,order", + batch: 500, + }); + + const stale = all.filter((it) => { + const q = Number(it.quantity ?? 0); + const qc = Number(it.qty_collected ?? 0); + return q > 0 && qc < q; + }); + + console.log( + `\nScanned ${all.length} in_stock rows; ${stale.length} have qty_collected < quantity and need a backfill.`, + ); + + if (stale.length === 0) { + console.log("Nothing to do — all in_stock rows already have qty_collected = quantity."); + return; + } + + let updated = 0; + for (const row of stale) { + const q = Number(row.quantity ?? 0); + try { + await pb.collection("order_items").update(row.id, { + qty_collected: q, + }); + updated += 1; + console.log(` ✓ ${row.id} qty=${q} → qty_collected=${q} (${String(row.description).slice(0, 50)})`); + } catch (err) { + console.error(` ✗ ${row.id} failed:`, err instanceof Error ? err.message : err); + } + } + + console.log(`\nDone. Stamped qty_collected = quantity on ${updated} rows.`); +} + +main().catch((err) => { + console.error("FAILED:", err); + process.exit(1); +}); \ No newline at end of file diff --git a/src/app/(app)/orders/[id]/process/page.tsx b/src/app/(app)/orders/[id]/process/page.tsx index af8be36..136ad13 100644 --- a/src/app/(app)/orders/[id]/process/page.tsx +++ b/src/app/(app)/orders/[id]/process/page.tsx @@ -1172,9 +1172,16 @@ export default function ProcessOrderPage({ params }: { params: Promise<{ id: str // When unlinking, the order_item must reflect the new // state too — otherwise the line still claims to belong // to the (now-empty) PO and Send POs / invoicing flows - // can mis-route it. - ...(pendingUnlink - ? { purchase_order: "", item_status: "ready_invoice" } + // can mis-route it. Also stamp qty_collected=quantity on + // the in-stock flip so the Invoicing page can bill these + // rows — they're goods we already have on our shelves, + // not a 0-line "nothing was received" anomaly. + ...(pendingUnlink || next === true + ? { + purchase_order: "", + item_status: "ready_invoice", + qty_collected: Number(currentItem?.quantity ?? 0), + } : {}), }); @@ -1190,10 +1197,11 @@ export default function ProcessOrderPage({ params }: { params: Promise<{ id: str ? { ...it, in_stock: next, - ...(pendingUnlink + ...(pendingUnlink || next === true ? { item_status: "ready_invoice", purchase_order: "", + qty_collected: Number(currentItem?.quantity ?? 0), } : {}), } @@ -1449,9 +1457,18 @@ export default function ProcessOrderPage({ params }: { params: Promise<{ id: str affectedPoIds.get(prevPoId)!.add(it.id); } try { + // In-stock items are goods that already sit on our + // shelves — the operator is telling us "we have these, + // don't send a PO". The invoicing flow bills whatever's in + // `qty_collected`, so leaving qty_collected=0 would push a + // 0-line invoice to QuickBooks for every in-stock item. + // Stamp qty_collected=quantity here so the in-stock flip + // is treated as "fully received from our own warehouse" + // and the invoice line carries the customer-facing qty. await pb.collection("order_items").update(it.id, { purchase_order: "", item_status: "ready_invoice", + qty_collected: Number(it.quantity ?? 0), }); } catch (linkErr) { stockFailures++; @@ -1659,6 +1676,14 @@ export default function ProcessOrderPage({ params }: { params: Promise<{ id: str // ─── Live totals — recomputed from `edits` (not items) so the // card updates as the operator types in the price sheet. + // + // `in_stock=true` items already sit on our shelves, so they don't + // go through Send PO and shouldn't count in the totals — the cost is + // already sunk (booked when the stock was bought), and the PO + // workflow explicitly excludes them (see `sendPoForSupplier`). + // Counting them here double-adds the cost against the order's PO + // total. Excluded + in-stock rows are both skipped; the rest + // contribute. const totals = useMemo(() => { let cost = 0; let selling = 0; @@ -1668,6 +1693,8 @@ export default function ProcessOrderPage({ params }: { params: Promise<{ id: str const editIds = Object.keys(edits); for (const id of editIds) { const e = edits[id]; + const originalItem = items.find((it) => it.id === id); + if (originalItem?.in_stock === true) continue; const qty = Number(e.quantity) || 0; const c = Number(e.cost_price) || 0; const s = Number(e.selling_price) || 0; @@ -1686,8 +1713,12 @@ export default function ProcessOrderPage({ params }: { params: Promise<{ id: str ready, needsSupplier, costless, + // Count of `in_stock` rows surfaced separately so the operator + // can see at a glance how many lines were absorbed into stock + // (and therefore excluded from the PO workflow). + inStock: items.filter((it) => it.in_stock === true).length, }; - }, [edits]); + }, [edits, items]); if (loading) { return ( @@ -2402,48 +2433,72 @@ function ItemCard({ {/* Middle row: qty + supplier + status badge — inline on all screens */}
- {/* Quantity — two-part input: - - `quantity` (left, non-editable here, just visual): what the customer ordered. - - `order_qty` (right, editable): what we tell the supplier. - They render as "70 / 100" — customer / supplier. The slash separator - lets the operator scan for lines that have been bumped to MOQ. - Both default to the same value; leaving order_qty as-is is a no-op. */} -
- - Qty - - - / - - onChange({ - orderQtyText: e.target.value.replace(/[^\d]/g, ""), - }) - } - onBlur={() => { - // Commit on blur. parseDecimal handles blank/null gracefully — - // `parseDecimal("")` returns null, and `saveItem` falls back - // to `quantity` for the persisted value. - const parsed = parseDecimal(edit.orderQtyText); - onChange({ - order_qty: parsed === null ? null : parsed, - }); - }} - placeholder={edit.quantityText || "0"} - className="h-6 w-16 px-1.5 text-center tabular-nums border-0 bg-transparent focus-visible:ring-1" - aria-label="Order quantity (supplier-facing)" - title="Supplier-facing quantity. What we ask the supplier to fulfil. Defaults to the customer quantity — change to bump to MOQ." - /> -
+ + {edit.quantityText || "—"} + + + + in stock + +
+ ) : ( +
+ + Qty + + + {edit.quantityText || "—"} + + / + + onChange({ + orderQtyText: e.target.value.replace(/[^\d]/g, ""), + }) + } + onBlur={() => { + // Commit on blur. parseDecimal handles blank/null gracefully — + // `parseDecimal("")` returns null, and `saveItem` falls back + // to `quantity` for the persisted value. + const parsed = parseDecimal(edit.orderQtyText); + onChange({ + order_qty: parsed === null ? null : parsed, + }); + }} + placeholder={edit.quantityText || "0"} + className="h-6 w-16 px-1.5 text-center tabular-nums border-0 bg-transparent focus-visible:ring-1" + aria-label="Order quantity (supplier-facing)" + title="Supplier-facing quantity. What we ask the supplier to fulfil. Defaults to the customer quantity — change to bump to MOQ." + /> +
+ )} { @@ -2917,6 +2973,18 @@ function TotalsCard({ missing cost )} + {totals.inStock > 0 && ( + + + + {totals.inStock} + {" "} + in stock (excluded from PO totals) + + )}