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
418 lines
14 KiB
TypeScript
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);
|
|
});
|
|
}
|