fix(order-process): stamp qty_collected when marking items in stock
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.
This commit is contained in:
parent
dce54934f0
commit
74cde03142
2 changed files with 227 additions and 46 deletions
113
scripts/nss-backfill-in-stock-qty.ts
Normal file
113
scripts/nss-backfill-in-stock-qty.ts
Normal file
|
|
@ -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);
|
||||||
|
});
|
||||||
|
|
@ -1172,9 +1172,16 @@ export default function ProcessOrderPage({ params }: { params: Promise<{ id: str
|
||||||
// When unlinking, the order_item must reflect the new
|
// When unlinking, the order_item must reflect the new
|
||||||
// state too — otherwise the line still claims to belong
|
// state too — otherwise the line still claims to belong
|
||||||
// to the (now-empty) PO and Send POs / invoicing flows
|
// to the (now-empty) PO and Send POs / invoicing flows
|
||||||
// can mis-route it.
|
// can mis-route it. Also stamp qty_collected=quantity on
|
||||||
...(pendingUnlink
|
// the in-stock flip so the Invoicing page can bill these
|
||||||
? { purchase_order: "", item_status: "ready_invoice" }
|
// 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,
|
...it,
|
||||||
in_stock: next,
|
in_stock: next,
|
||||||
...(pendingUnlink
|
...(pendingUnlink || next === true
|
||||||
? {
|
? {
|
||||||
item_status: "ready_invoice",
|
item_status: "ready_invoice",
|
||||||
purchase_order: "",
|
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);
|
affectedPoIds.get(prevPoId)!.add(it.id);
|
||||||
}
|
}
|
||||||
try {
|
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, {
|
await pb.collection("order_items").update(it.id, {
|
||||||
purchase_order: "",
|
purchase_order: "",
|
||||||
item_status: "ready_invoice",
|
item_status: "ready_invoice",
|
||||||
|
qty_collected: Number(it.quantity ?? 0),
|
||||||
});
|
});
|
||||||
} catch (linkErr) {
|
} catch (linkErr) {
|
||||||
stockFailures++;
|
stockFailures++;
|
||||||
|
|
@ -1659,6 +1676,14 @@ export default function ProcessOrderPage({ params }: { params: Promise<{ id: str
|
||||||
|
|
||||||
// ─── Live totals — recomputed from `edits` (not items) so the
|
// ─── Live totals — recomputed from `edits` (not items) so the
|
||||||
// card updates as the operator types in the price sheet.
|
// 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(() => {
|
const totals = useMemo(() => {
|
||||||
let cost = 0;
|
let cost = 0;
|
||||||
let selling = 0;
|
let selling = 0;
|
||||||
|
|
@ -1668,6 +1693,8 @@ export default function ProcessOrderPage({ params }: { params: Promise<{ id: str
|
||||||
const editIds = Object.keys(edits);
|
const editIds = Object.keys(edits);
|
||||||
for (const id of editIds) {
|
for (const id of editIds) {
|
||||||
const e = edits[id];
|
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 qty = Number(e.quantity) || 0;
|
||||||
const c = Number(e.cost_price) || 0;
|
const c = Number(e.cost_price) || 0;
|
||||||
const s = Number(e.selling_price) || 0;
|
const s = Number(e.selling_price) || 0;
|
||||||
|
|
@ -1686,8 +1713,12 @@ export default function ProcessOrderPage({ params }: { params: Promise<{ id: str
|
||||||
ready,
|
ready,
|
||||||
needsSupplier,
|
needsSupplier,
|
||||||
costless,
|
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) {
|
if (loading) {
|
||||||
return (
|
return (
|
||||||
|
|
@ -2402,12 +2433,35 @@ function ItemCard({
|
||||||
|
|
||||||
{/* Middle row: qty + supplier + status badge — inline on all screens */}
|
{/* Middle row: qty + supplier + status badge — inline on all screens */}
|
||||||
<div className="flex items-center flex-wrap gap-1.5 sm:gap-2 mt-2.5">
|
<div className="flex items-center flex-wrap gap-1.5 sm:gap-2 mt-2.5">
|
||||||
{/* Quantity — two-part input:
|
{/* Quantity display:
|
||||||
- `quantity` (left, non-editable here, just visual): what the customer ordered.
|
- Normal rows: "70 / 100" — customer / supplier. The slash
|
||||||
- `order_qty` (right, editable): what we tell the supplier.
|
separator lets the operator scan for lines that have
|
||||||
They render as "70 / 100" — customer / supplier. The slash separator
|
been bumped to MOQ.
|
||||||
lets the operator scan for lines that have been bumped to MOQ.
|
- In-stock rows (already on our shelves, no PO needed):
|
||||||
Both default to the same value; leaving order_qty as-is is a no-op. */}
|
only the customer qty is shown, plus an "in stock"
|
||||||
|
chip. The supplier-facing order_qty is irrelevant
|
||||||
|
here and the input is hidden so the operator can't
|
||||||
|
accidentally try to type into it. Mirrors the totals
|
||||||
|
fix above — in_stock rows are excluded from the PO
|
||||||
|
totals entirely.
|
||||||
|
Both default to the same value; leaving order_qty as-is
|
||||||
|
on a non-in-stock row is a no-op. */}
|
||||||
|
{edit.in_stock ? (
|
||||||
|
<div
|
||||||
|
className="flex items-center gap-1.5 rounded-md border bg-muted/30 px-1.5 py-0.5"
|
||||||
|
title="Already in stock — no PO needed"
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className="h-6 min-w-[2.5rem] px-1.5 text-center tabular-nums text-sm text-muted-foreground"
|
||||||
|
>
|
||||||
|
{edit.quantityText || "—"}
|
||||||
|
</span>
|
||||||
|
<span className="inline-flex items-center gap-1 text-[10px] uppercase tracking-wide text-info-foreground dark:text-info">
|
||||||
|
<Package className="h-3 w-3" />
|
||||||
|
in stock
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
<div className="flex items-center gap-1 rounded-md border bg-background px-1.5 py-0.5">
|
<div className="flex items-center gap-1 rounded-md border bg-background px-1.5 py-0.5">
|
||||||
<span className="text-[10px] uppercase tracking-wide text-muted-foreground hidden sm:inline">
|
<span className="text-[10px] uppercase tracking-wide text-muted-foreground hidden sm:inline">
|
||||||
Qty
|
Qty
|
||||||
|
|
@ -2444,6 +2498,7 @@ function ItemCard({
|
||||||
title="Supplier-facing quantity. What we ask the supplier to fulfil. Defaults to the customer quantity — change to bump to MOQ."
|
title="Supplier-facing quantity. What we ask the supplier to fulfil. Defaults to the customer quantity — change to bump to MOQ."
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
<SupplierCell
|
<SupplierCell
|
||||||
value={edit.supplier}
|
value={edit.supplier}
|
||||||
suppliers={suppliers}
|
suppliers={suppliers}
|
||||||
|
|
@ -2843,6 +2898,7 @@ function TotalsCard({
|
||||||
ready: number;
|
ready: number;
|
||||||
needsSupplier: number;
|
needsSupplier: number;
|
||||||
costless: number;
|
costless: number;
|
||||||
|
inStock: number;
|
||||||
};
|
};
|
||||||
}) {
|
}) {
|
||||||
const marginColor = (() => {
|
const marginColor = (() => {
|
||||||
|
|
@ -2917,6 +2973,18 @@ function TotalsCard({
|
||||||
missing cost
|
missing cost
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
|
{totals.inStock > 0 && (
|
||||||
|
<span className="flex items-center gap-1">
|
||||||
|
<span
|
||||||
|
className="inline-block h-1.5 w-1.5 rounded-full bg-info"
|
||||||
|
aria-hidden
|
||||||
|
/>
|
||||||
|
<span className="tabular-nums font-medium text-foreground">
|
||||||
|
{totals.inStock}
|
||||||
|
</span>{" "}
|
||||||
|
in stock (excluded from PO totals)
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue