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
212 lines
7.2 KiB
TypeScript
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);
|
|
});
|