WFDASH-WEBAPP/scripts/migrations/2026-07-14-create-stock.ts
SJCoetz e9cf8aaf04 feat(stock): implement stock management workflow
This commit adds the complete v2/v3 stock management functionality:
- add new stock-specific item statuses and update the status visual map
- implement full stock purchase API suite (create, list, retrieve, send, receive)
- reorganize sidebar navigation to group stock-related pages together
- add stock item CRUD APIs, status utilities, low stock filtering, and surplus booking logic
- add database migration for the `stock_items.target_qty` field
- update type definitions and add comprehensive tests for all new stock components
2026-07-15 21:53:20 +02:00

418 lines
14 KiB
TypeScript

/**
* 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<PocketBase> {
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<void> {
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<Record<string, unknown>>),
...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<Record<string, unknown>>;
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);
});
}