/** * Migration — 2026-07-14-create-stock.ts * * Creates the stock management schema: `stock_items` (master list) + * `stock_ledger` (append-only movement log) + the minimal fields on * `order_items` needed for the v1 surplus split. * * ## What it does * * 1. Creates the `stock_items` collection. * - name, description, unit, location, sku, notes * - current_qty (number, denormalized cache of the ledger sum) * - min_qty (number, reorder threshold) * - supplier (relation → suppliers, default supplier) * - is_active (bool, soft hide) * * 2. Creates the `stock_ledger` collection. * - stock_item (relation → stock_items) * - change_qty (number, signed: +N for, -N out) * - reason (TEXT for now — operator picks the enum values later * in PB admin per Option 2 of the plan; the API layer enforces * the allowed set regardless) * - reference_type (text: "order_item" | "stock_order" | "manual") * - reference_id (text: PB id of the referencing record) * - notes (text) * - created_by (relation → users) * * 3. Reconciles `order_items.item_status.values` to add `stock_booked`. * * 4. Adds `surplus_qty` (number, optional) to `order_items`. * * 5. Adds `stock_booked_from` (relation → stock_items, optional) to * `order_items`. * * Idempotent — safe to re-run. * * Run with: * npx tsx scripts/migrations/2026-07-14-create-stock.ts */ /* eslint-disable */ 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 { /* fine — rely on shell env */ } } loadEnvFile(".env"); loadEnvFile(".env.local"); // NOTE: deliberately bypasses `getAdminPocketBase` from // `@/lib/server/pocketbase-admin` — that helper reads `process.env` // at module-load time, and ESM import hoisting means it runs before // our `loadEnvFile` calls. Reading env inline then constructing // PocketBase directly avoids the hoisting issue. function getAdminPocketBase(): PocketBase { 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( "Set NEXT_PUBLIC_POCKETBASE_URL, PB_ADMIN_EMAIL, PB_ADMIN_PASSWORD (sourced from .env.local).", ); } const pb = new PocketBase(url); // Synchronous-ish auth — return a promise-wrapped PB; the caller // awaits via a small wrapper. return pb; } async function authedPb(): Promise { const pb = getAdminPocketBase(); await pb .collection("_superusers") .authWithPassword( process.env.PB_ADMIN_EMAIL!, process.env.PB_ADMIN_PASSWORD!, ); return pb; } const ORDER_ITEMS = "order_items"; const SUPPLIERS = "suppliers"; const USERS = "users"; const STOCK_ITEMS = "stock_items"; const STOCK_LEDGER = "stock_ledger"; const TARGET_ITEM_STATUS_VALUES = [ "pending", "processed", "po_generated", "collection_pool", "assigned", "collected", "stock_verified", "picking", "ready_delivery", "delivered", "cancelled", "ready_invoice", "invoiced", "stock_booked", // ← new ]; export async function run(): Promise { const pb = await authedPb(); // ── 0. Resolve relation targets ───────────────────────────────── const suppliersCol = await pb.collections.getOne(SUPPLIERS); const usersCol = await pb.collections.getOne(USERS); // ── 1. Create `stock_items` if missing, or patch missing fields ─ let stockItemsCol: { id: string }; const existingStockItems = await pb.collections.getList(1, 1, { filter: `name = "${STOCK_ITEMS}"`, }); const targetStockItemFields = [ // System `created` and `updated` fields MUST be present so PB // accepts `sort=-created` / `sort=-updated` queries. Collections // created via the Go admin UI get these for free; collections // created via the JS SDK do not. { name: "created", type: "autodate", required: true, onCreate: true, onUpdate: false, }, { name: "updated", type: "autodate", required: true, onCreate: true, onUpdate: true, }, { name: "sku", type: "text", required: false, options: { max: 100 } }, { name: "name", type: "text", required: true, options: { max: 255 } }, { name: "description", type: "text", required: false, options: { max: 5000 } }, { name: "unit", type: "text", required: false, options: { max: 30 } }, { name: "location", type: "text", required: false, options: { max: 100 } }, { name: "current_qty", type: "number", required: false, options: { min: 0, noDecimal: false }, }, { name: "min_qty", type: "number", required: false, options: { min: 0, noDecimal: false }, }, { name: "supplier", type: "relation", required: false, collectionId: suppliersCol.id, cascadeDelete: false, maxSelect: 1, }, { name: "is_active", type: "bool", required: false }, { name: "notes", type: "text", required: false, options: { max: 5000 } }, ]; if (existingStockItems.items.length === 0) { stockItemsCol = await pb.collections.create({ name: STOCK_ITEMS, type: "base", fields: targetStockItemFields, listRule: "@request.auth.id != ''", viewRule: "@request.auth.id != ''", createRule: "@request.auth.id != ''", updateRule: "@request.auth.id != ''", deleteRule: "@request.auth.id != ''", }); console.log( `[migration:create-stock] ✓ created ${STOCK_ITEMS} (${stockItemsCol.id})`, ); } else { stockItemsCol = existingStockItems.items[0]; // Patch any missing fields (e.g. partial schema from earlier dev runs). const existingFields = (stockItemsCol as unknown as { fields: Array<{ name: string }>; }).fields; const haveNames = new Set(existingFields.map((f) => f.name)); const missing = targetStockItemFields.filter( (f) => !haveNames.has(f.name), ); if (missing.length > 0) { const next = [ ...(existingFields as Array>), ...missing, ]; await pb.collections.update(STOCK_ITEMS, { fields: next }); console.log( `[migration:create-stock] ✓ patched ${STOCK_ITEMS} with missing fields: ${missing.map((f) => f.name).join(", ")}`, ); } else { console.log( `[migration:create-stock] ${STOCK_ITEMS} already exists (${stockItemsCol.id}) with all expected fields — skip`, ); } } // ── 2. Create `stock_ledger` if missing ───────────────────────── const existingStockLedger = await pb.collections.getList(1, 1, { filter: `name = "${STOCK_LEDGER}"`, }); if (existingStockLedger.items.length === 0) { const ledgerCol = await pb.collections.create({ name: STOCK_LEDGER, type: "base", fields: [ // System `created` / `updated` fields. See stock_items above // for the why. Append-only ledger so onUpdate: false for // both — we don't auto-touch these after insert. { name: "created", type: "autodate", required: true, onCreate: true, onUpdate: false, }, { name: "updated", type: "autodate", required: true, onCreate: true, onUpdate: true, }, { name: "stock_item", type: "relation", required: true, collectionId: stockItemsCol.id, cascadeDelete: false, maxSelect: 1, }, { name: "change_qty", type: "number", required: true, options: { noDecimal: false }, }, // Plain text for now (Option 2 of the plan). The API layer // validates the same set of values; when the operator adds // the enum to this field in PB admin later, no code change // is needed at that point, just tighter schema-side validation. { name: "reason", type: "text", required: true, options: { max: 50 } }, { name: "reference_type", type: "text", required: false, options: { max: 50 } }, { name: "reference_id", type: "text", required: false, options: { max: 30 } }, { name: "notes", type: "text", required: false, options: { max: 5000 } }, { name: "created_by", type: "relation", required: false, collectionId: usersCol.id, cascadeDelete: false, maxSelect: 1, }, ], indexes: [ "CREATE INDEX idx_stock_ledger_stock_item ON stock_ledger (stock_item)", "CREATE INDEX idx_stock_ledger_reference ON stock_ledger (reference_type,reference_id)", ], listRule: "@request.auth.id != ''", viewRule: "@request.auth.id != ''", createRule: "@request.auth.id != ''", updateRule: "@request.auth.id != ''", deleteRule: "@request.auth.id != ''", }); console.log( `[migration:create-stock] ✓ created ${STOCK_LEDGER} (${ledgerCol.id})`, ); } else { console.log( `[migration:create-stock] ${STOCK_LEDGER} already exists — skip create`, ); } // ── 3. Reconcile `order_items.item_status.values` ────────────── const orderItemsCol = await pb.collections.getOne(ORDER_ITEMS); const fields = (orderItemsCol.fields ?? []) as Array<{ name?: string; values?: string[]; }>; let schemaChanged = false; for (const f of fields) { if (f.name !== "item_status") continue; const current = new Set(f.values ?? []); const target = new Set(TARGET_ITEM_STATUS_VALUES); const needsAdd = [...target].some((v) => !current.has(v)); const needsRemove = [...current].some((v) => !target.has(v)); if (needsAdd || needsRemove) { f.values = [...target]; schemaChanged = true; } break; } if (schemaChanged) { await pb.collections.update(ORDER_ITEMS, { fields }); console.log( `[migration:create-stock] ✓ reconciled order_items.item_status.values: ${JSON.stringify( TARGET_ITEM_STATUS_VALUES, )}`, ); } else { console.log( "[migration:create-stock] order_items.item_status already in target state — skip", ); } // ── 4. Add `surplus_qty` + `stock_booked_from` to order_items ── const oiFields = (orderItemsCol.fields ?? []) as Array<{ name: string }>; const hasSurplus = oiFields.some((f) => f.name === "surplus_qty"); const hasStockBookedFrom = oiFields.some( (f) => f.name === "stock_booked_from", ); if (!hasSurplus || !hasStockBookedFrom) { const next = (orderItemsCol.fields ?? []) as Array>; if (!hasSurplus) { next.push({ name: "surplus_qty", type: "number", required: false, options: { min: 0, noDecimal: false }, }); } if (!hasStockBookedFrom) { next.push({ name: "stock_booked_from", type: "relation", required: false, collectionId: stockItemsCol.id, cascadeDelete: false, maxSelect: 1, }); } await pb.collections.update(ORDER_ITEMS, { fields: next }); console.log( `[migration:create-stock] ✓ added ${!hasSurplus ? "surplus_qty " : ""}${!hasStockBookedFrom ? "stock_booked_from" : ""}to order_items`, ); } else { console.log( "[migration:create-stock] surplus_qty + stock_booked_from already present — skip", ); } // ── 5. Verify ─────────────────────────────────────────────────── const verifyOi = await pb.collections.getOne(ORDER_ITEMS); const verifySi = await pb.collections.getOne(STOCK_ITEMS); const verifySl = await pb.collections.getOne(STOCK_LEDGER); const oiFieldNames = (verifyOi.fields as Array<{ name: string }>).map( (f) => f.name, ); const siFieldNames = (verifySi.fields as Array<{ name: string }>).map( (f) => f.name, ); const slFieldNames = (verifySl.fields as Array<{ name: string }>).map( (f) => f.name, ); const checks: Array<[string, boolean]> = [ ["order_items.surplus_qty exists", oiFieldNames.includes("surplus_qty")], [ "order_items.stock_booked_from exists", oiFieldNames.includes("stock_booked_from"), ], [ "order_items.item_status includes stock_booked", ( (verifyOi.fields as Array<{ name: string; values?: string[] }>).find( (f) => f.name === "item_status", )?.values ?? [] ).includes("stock_booked"), ], ["stock_items collection exists", siFieldNames.length > 0], ["stock_items.current_qty exists", siFieldNames.includes("current_qty")], ["stock_ledger collection exists", slFieldNames.length > 0], ["stock_ledger.change_qty exists", slFieldNames.includes("change_qty")], ["stock_ledger.stock_item exists", slFieldNames.includes("stock_item")], ]; let allOk = true; for (const [label, ok] of checks) { console.log(` ${ok ? "✓" : "✗"} ${label}`); if (!ok) allOk = false; } if (!allOk) { throw new Error( "[migration:create-stock] verification failed — see ✗ lines above", ); } console.log("[migration:create-stock] Migration complete."); } if (require.main === module) { run().catch((err) => { console.error("[migration:create-stock] fatal:", err); process.exit(1); }); }