WFDASH-WEBAPP/scripts/migrations/2026-08-09-repair-stock-double-booking.ts
Dave d9c60aff6d feat: add split views, stock picking slips, and invoice viewer
Comprehensive changes including:
- Refactor picking slip line handling for multiple document types across print endpoints
- Add new `/api/v1/stock-purchases/picking-slip` API for stock booking picking slips
- Implement reusable Splitter component and useSplitRatio hook for resizable panels
- Add floating/fullscreen invoice viewer to collection confirm page
- Add real-time stock booking update event for invoicing tab
- Add full test coverage for all new components and APIs
- Include temporary chat attachment images for testing purposes
2026-08-09 23:16:55 +02:00

212 lines
7.2 KiB
TypeScript

/* eslint-disable */
/**
* One-off data repair: undo the historical double-booking bug on
* stock purchases.
*
* ## Background
*
* Before 2026-08-09, two code paths each wrote a `stock_ledger`
* row + bumped `stock_items.current_qty` for the same PO:
*
* - `receiveStockPurchase` (warehouse / API / MCP / OCR match)
* stamped `created_by: ""` on its ledger rows — we call these
* the "System" rows.
* - `bookStockFromInvoice` (accountant "Book into stock")
* stamped `created_by: <actor.id>` — the legit rows.
*
* Every PO that went through both paths had its current_qty
* double-counted. Four POs were affected at the time of writing
* (PO26080502STK, PO26080501STK, PO26073110STK, PO26073109STK).
*
* ## What this script does
*
* For every bogus ledger row (`created_by = ""` AND
* `reference_type = "stock_purchase"`):
*
* 1. Subtract its `change_qty` from the matching
* `stock_items.current_qty`.
* 2. Delete the bogus row.
*
* The legit (`created_by != ""`) rows are left alone — they hold
* the correct booking.
*
* ## Usage
*
* Preview only (default — safe to run anytime):
*
* npx tsx scripts/migrations/2026-08-09-repair-stock-double-booking.ts
*
* Apply the repair:
*
* npx tsx scripts/migrations/2026-08-09-repair-stock-double-booking.ts --apply
*
* The script is idempotent: re-running it after a successful apply
* finds zero bogus rows and exits cleanly.
*
* ## Why we leave an audit trail
*
* This script never edits a "legit" row. Every patch + delete is
* logged to stdout so the operator can verify the effect before
* re-running, or roll back by manually inserting reverse ledger
* rows if needed (the receipt-side total is preserved in
* `stock_purchase_items.quantity_received`, which is the source of
* truth for what physically arrived).
*/
import PocketBase from "pocketbase";
import { readFileSync } from "node:fs";
import { resolve } from "node:path";
function loadEnvFile(name: string) {
try {
const raw = readFileSync(resolve(process.cwd(), name), "utf8");
for (const line of raw.split("\n")) {
const trimmed = line.trim();
if (!trimmed || trimmed.startsWith("#")) continue;
const m = trimmed.match(/^([A-Z0-9_]+)\s*=\s*(.+)$/i);
if (m && !process.env[m[1]]) {
process.env[m[1]] = m[2].replace(/^["']|["']$/g, "").trim();
}
}
} catch {
// missing file — fine
}
}
loadEnvFile(".env");
loadEnvFile(".env.local");
const APPLY = process.argv.includes("--apply");
async function main() {
const url = process.env.NEXT_PUBLIC_POCKETBASE_URL;
const email = process.env.PB_ADMIN_EMAIL;
const password = process.env.PB_ADMIN_PASSWORD;
if (!url || !email || !password) {
throw new Error(
"Missing NEXT_PUBLIC_POCKETBASE_URL / PB_ADMIN_EMAIL / PB_ADMIN_PASSWORD in env",
);
}
const pb = new PocketBase(url);
await pb.admins.authWithPassword(email, password);
// 1. Collect bogus rows. A "bogus" row is one that was written by
// the legacy receiveStockPurchase path (created_by empty)
// and points at a stock purchase. Other reasons (stocktake,
// adjustment) are NOT touched — only the receive-side booking
// is in scope.
const bogusRows = (await pb.collection("stock_ledger").getFullList({
filter: 'created_by = "" && reference_type = "stock_purchase"',
sort: "created",
})) as Array<{
id: string;
stock_item: string;
change_qty: number;
reference_id: string;
notes: string;
created: string;
}>;
if (bogusRows.length === 0) {
console.log("No bogus ledger rows found — nothing to repair.");
return;
}
// 2. Group by stock_item so we can compute a single current_qty
// patch per item. Also tally the per-PO totals for the report.
const perItem = new Map<string, number>();
const perPurchase = new Map<string, number>();
for (const row of bogusRows) {
perItem.set(row.stock_item, (perItem.get(row.stock_item) ?? 0) + Number(row.change_qty));
perPurchase.set(row.reference_id, (perPurchase.get(row.reference_id) ?? 0) + Number(row.change_qty));
}
console.log("");
console.log(`Found ${bogusRows.length} bogus ledger row(s) on ${perPurchase.size} purchase(s).`);
console.log("");
console.log("Per-PO bogus total (to subtract from inventory):");
for (const [poId, total] of Array.from(perPurchase.entries()).sort((a, b) => b[1] - a[1])) {
console.log(` ${poId} → -${total}`);
}
console.log("");
console.log("Per-stock-item bogus total (to subtract from current_qty):");
for (const [siId, total] of Array.from(perItem.entries()).sort((a, b) => b[1] - a[1])) {
console.log(` ${siId} → -${total}`);
}
console.log("");
// 3. Snapshot the affected stock_items so the operator can see
// before/after at a glance.
const snapshots: Array<{ id: string; current_qty_before: number; target: number }> = [];
for (const [siId, bogusTotal] of perItem.entries()) {
const item = (await pb.collection("stock_items").getOne(siId)) as {
current_qty?: number;
};
const currentQty = Number(item.current_qty ?? 0);
snapshots.push({
id: siId,
current_qty_before: currentQty,
target: currentQty - bogusTotal,
});
console.log(
` stock_item ${siId}: current_qty ${currentQty} → ${currentQty - bogusTotal} (delta -${bogusTotal})`,
);
}
console.log("");
if (!APPLY) {
console.log("DRY RUN — pass --apply to perform the repair.");
console.log("");
console.log("Re-run with: npx tsx scripts/migrations/2026-08-09-repair-stock-double-booking.ts --apply");
return;
}
// 4. Patch each stock_item.current_qty. Done one-by-one so a
// single PB write failure doesn't poison the whole batch.
console.log("Applying current_qty patches...");
for (const snap of snapshots) {
try {
await pb.collection("stock_items").update(snap.id, {
current_qty: snap.target,
});
console.log(` ✓ ${snap.id}: ${snap.current_qty_before} → ${snap.target}`);
} catch (err) {
console.error(` ✗ ${snap.id} update failed:`, err);
throw err;
}
}
// 5. Delete the bogus ledger rows. PB doesn't have a batch
// delete endpoint exposed via the SDK for collections, so we
// delete them one at a time. ~30 rows at most — fast enough.
console.log("");
console.log("Deleting bogus ledger rows...");
let deleted = 0;
for (const row of bogusRows) {
try {
await pb.collection("stock_ledger").delete(row.id);
deleted++;
} catch (err) {
console.error(` ✗ delete ${row.id} failed:`, err);
throw err;
}
}
console.log(` ✓ Deleted ${deleted} bogus ledger row(s).`);
// 6. Re-query to confirm zero bogus rows remain. The script is
// idempotent — a re-run should find nothing and exit cleanly.
const remaining = await pb.collection("stock_ledger").getFullList({
filter: 'created_by = "" && reference_type = "stock_purchase"',
});
console.log("");
console.log(`Repair complete. Bogus rows remaining: ${remaining.length}`);
if (remaining.length > 0) {
console.error("WARNING: bogus rows still present — investigate before re-running.");
process.exit(1);
}
}
main().catch((err) => {
console.error(err);
process.exit(1);
});