WFDASH-WEBAPP/scripts/migrations/2026-07-14-create-stock-purchases.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

576 lines
20 KiB
TypeScript

/**
* Migration — 2026-07-14-create-stock-purchases.ts
*
* Creates the v2 stock-orders schema: a `stock_purchases` collection
* (pure stock purchases, NO customer order attached) + a
* `stock_purchase_items` child collection for the line items.
*
* ## Why a new collection
*
* The existing `purchase_orders` collection is **tied to a customer
* order** via the required `order` field (relation → orders). v2 of
* the stock-orders flow is for *pure* stock purchases — supplier +
* lines, no customer order. Reusing `purchase_orders` would force us
* to add a nullable `order` and nullability churns the existing
* customer-order query path (the order-form picks up
* `status="draft"` POs that aren't really customer-related).
*
* ## What it does
*
* 1. Creates the `stock_purchases` collection.
* - `purchase_number` (text, required, max 50) — human-friendly
* number, format `SP-YYYYMMDDNN`. Generated by the create
* endpoint, not here.
* - `supplier` (relation → suppliers, required, top-level
* collectionId)
* - `status` (select: "draft" | "sent" | "received" | "cancelled")
* - `notes` (text, max 5000)
* - `email_payload` (json) — outbound email composition; mirrored
* on the legacy `purchase_orders` field for a future stream
* that wires up SMTP.
* - `email_error` (text, max 990) — last SMTP failure reason
* - `email_message_id` (text, max 200) — RFC 5322 Message-ID of
* the last successful send
* - Rules: `@request.auth.id != ''` for all four
* - Index on `status` (the list page filters by status)
*
* 2. Creates the `stock_purchase_items` collection.
* - `purchase` (relation → stock_purchases, required)
* - `stock_item` (relation → stock_items, required) — what gets
* booked when received. If the operator is buying a new SKU
* they create a `stock_items` row first; this is the v1 model
* and we don't support "buy a new SKU" inline.
* - `description` (text, required, max 5000) — denormalised from
* `stock_item.name` at PO time so the PO doesn't change if the
* master row is renamed.
* - `quantity` (number, required) — ordered qty
* - `quantity_received` (number, default 0) — accumulated from
* the receive flow. v1 only supports "receive all" so this
* converges to `quantity` on receive; the field is here for the
* future partial-receive flow.
* - `cost_price` (number) — per-unit cost
* - `notes` (text, max 5000)
* - Index on `purchase` and `stock_item`
*
* Idempotent — safe to re-run. Patches missing fields if a partial
* schema is already there (mirrors 2026-07-14-create-stock.ts).
*
* Run with:
* npx tsx scripts/migrations/2026-07-14-create-stock-purchases.ts
*
* Or via:
* npm run pb:migrate:stock-purchases
*/
/* 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);
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 SUPPLIERS = "suppliers";
const STOCK_ITEMS = "stock_items";
const STOCK_PURCHASES = "stock_purchases";
const STOCK_PURCHASE_ITEMS = "stock_purchase_items";
const PURCHASE_STATUS_VALUES = ["draft", "sent", "received", "cancelled"];
const TARGET_STOCK_PURCHASE_FIELDS = [
// 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. We add them explicitly so
// any list/page that does `sort="-created"` works.
// See pb-helpers / pb-helpers for the getList + sort calls.
{
name: "created",
type: "autodate",
required: true,
onCreate: true,
onUpdate: false,
},
{
name: "updated",
type: "autodate",
required: true,
onCreate: true,
onUpdate: true,
},
{
name: "purchase_number",
type: "text",
required: true,
options: { max: 50 },
},
{
name: "supplier",
type: "relation",
required: true,
// relation's collectionId lives at the TOP level of the field
// object (not inside `options`). The /api/v1/quotes/items code
// path uses the same convention; see
// scripts/migrations/2026-07-14-create-stock.ts.
collectionId: "", // patched after we resolve suppliers below
cascadeDelete: false,
maxSelect: 1,
},
{
name: "status",
type: "select",
required: true,
// `maxSelect` and `values` belong at the TOP level of a select
// field — same convention as the existing `order_items.item_status`
// field. Putting them inside `options` makes PB reject the
// collection create with `fields.3.values: validation_required`
// because the server reads them from the top level, not options.
maxSelect: 1,
values: PURCHASE_STATUS_VALUES,
},
{
name: "notes",
type: "text",
required: false,
options: { max: 5000 },
},
{
name: "email_payload",
type: "json",
required: false,
options: {},
},
{
name: "email_error",
type: "text",
required: false,
options: { max: 990 },
},
{
name: "email_message_id",
type: "text",
required: false,
options: { max: 200 },
},
];
const TARGET_STOCK_PURCHASE_ITEM_FIELDS = [
// See TARGET_STOCK_PURCHASE_FIELDS for the why on the system
// created/updated autodate fields.
{
name: "created",
type: "autodate",
required: true,
onCreate: true,
onUpdate: false,
},
{
name: "updated",
type: "autodate",
required: true,
onCreate: true,
onUpdate: true,
},
{
name: "purchase",
type: "relation",
required: true,
collectionId: "", // patched after we create stock_purchases
cascadeDelete: true,
maxSelect: 1,
},
{
name: "stock_item",
type: "relation",
required: true,
collectionId: "", // patched after we resolve stock_items below
cascadeDelete: false,
maxSelect: 1,
},
{
name: "description",
type: "text",
required: true,
options: { max: 5000 },
},
{
name: "quantity",
type: "number",
required: true,
options: { min: 0, noDecimal: false },
},
{
name: "quantity_received",
type: "number",
required: false,
options: { min: 0, noDecimal: false },
},
{
name: "cost_price",
type: "number",
required: false,
options: { min: 0, noDecimal: false },
},
{
name: "notes",
type: "text",
required: false,
options: { max: 5000 },
},
];
/**
* Returns `existing_fields` augmented with any fields from
* `target_fields` that are not yet present (by `name`). Used for the
* "patch a partial schema" branch of the idempotency contract.
*
* If a field name is present but missing a relation `collectionId`
* (i.e. a previous run wrote the field but the relation id was
* unresolved at the time), we re-set the `collectionId` to the
* expected target. This handles the case where the migration
* previously failed mid-step after creating the collection but
* before linking its relations.
*/
function reconcileFields(
existingFields: Array<Record<string, unknown>>,
targetFields: Array<Record<string, unknown>>,
relationIds: Record<string, string>
): { next: Array<Record<string, unknown>>; added: string[]; fixed: string[] } {
const next: Array<Record<string, unknown>> = [...existingFields];
const haveNames = new Set(
next.map((f) => f.name as string).filter((n) => typeof n === "string")
);
const targetByName = new Map(
targetFields.map((f) => [f.name as string, f])
);
const added: string[] = [];
const fixed: string[] = [];
for (const [name, target] of targetByName.entries()) {
if (!haveNames.has(name)) {
// New field — apply relation collectionId from the relationIds
// map if the target is a relation field.
if (target.type === "relation") {
const relName = target.collectionId === "" ? "purchase" : "stock_item";
// For stock_purchases the relation is `supplier` (set when
// the caller invokes reconcileFields for that collection).
// The simplest contract: target.collectionId is the
// relation's *name* until reconcileFields resolves it. We
// replace it with the id from `relationIds`.
const id = relationIds[relName];
if (!id) {
throw new Error(
`[migration:create-stock-purchases] reconcileFields: no relationId registered for ${relName}`,
);
}
next.push({ ...target, collectionId: id });
} else {
next.push({ ...target });
}
added.push(name);
} else {
// Field exists — check if a relation `collectionId` was left
// unset by a previous failed run.
const idx = next.findIndex((f) => f.name === name);
const existing = next[idx];
if (
target.type === "relation" &&
(!existing.collectionId || existing.collectionId === "")
) {
const relName = name;
const id = relationIds[relName];
if (id) {
next[idx] = { ...existing, collectionId: id };
fixed.push(name);
}
}
}
}
return { next, added, fixed };
}
export async function run(): Promise<void> {
const pb = await authedPb();
// ── 0. Resolve relation targets ─────────────────────────────────
const suppliersCol = await pb.collections.getOne(SUPPLIERS);
const stockItemsCol = await pb.collections.getOne(STOCK_ITEMS);
// Map of relation-field name → target collection id. We use the
// field name as the key (e.g. "supplier", "stock_item", "purchase")
// so the reconcile logic can patch the right id back in.
const relationIds: Record<string, string> = {
supplier: suppliersCol.id,
stock_item: stockItemsCol.id,
};
// ── 1. Create `stock_purchases` if missing, or patch missing fields ─
let stockPurchasesCol: { id: string };
const existingStockPurchases = await pb.collections.getList(1, 1, {
filter: `name = "${STOCK_PURCHASES}"`,
});
if (existingStockPurchases.items.length === 0) {
const fields = TARGET_STOCK_PURCHASE_FIELDS.map((f) => {
if (f.type === "relation") {
return { ...f, collectionId: suppliersCol.id };
}
return f;
});
stockPurchasesCol = await pb.collections.create({
name: STOCK_PURCHASES,
type: "base",
fields,
indexes: [
"CREATE INDEX `idx_sp_status` ON `stock_purchases` (`status`)",
],
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-purchases] ✓ created ${STOCK_PURCHASES} (${stockPurchasesCol.id})`,
);
} else {
stockPurchasesCol = existingStockPurchases.items[0];
const existingFields = (stockPurchasesCol as unknown as {
fields: Array<Record<string, unknown>>;
}).fields;
const { next, added, fixed } = reconcileFields(
existingFields,
TARGET_STOCK_PURCHASE_FIELDS,
{ supplier: suppliersCol.id }
);
if (added.length > 0 || fixed.length > 0) {
await pb.collections.update(STOCK_PURCHASES, { fields: next });
console.log(
`[migration:create-stock-purchases] ✓ patched ${STOCK_PURCHASES}: added=[${added.join(", ")}] fixed=[${fixed.join(", ")}]`,
);
} else {
console.log(
`[migration:create-stock-purchases] ${STOCK_PURCHASES} already exists (${stockPurchasesCol.id}) with all expected fields — skip`,
);
}
// Ensure the `status` index is present. PocketBase stores indexes
// as a separate field on the collection, not in the schema's
// fields list. The pattern (mirror) is from
// `2026-07-02-add-sprint-categories-and-claims.ts`.
const spFresh = await pb.collections.getOne(STOCK_PURCHASES);
const spIdx = (spFresh.indexes ?? []) as string[];
if (!spIdx.some((i) => i.includes("idx_sp_status"))) {
await pb.collections.update(spFresh.id, {
indexes: [
...spIdx,
"CREATE INDEX `idx_sp_status` ON `stock_purchases` (`status`)",
],
});
console.log(
`[migration:create-stock-purchases] ✓ added idx_sp_status to ${STOCK_PURCHASES}`,
);
}
}
// Now we can resolve the `purchase` relation for the items
// collection — it points to the stock_purchases id we just
// resolved above.
relationIds.purchase = stockPurchasesCol.id;
// ── 2. Create `stock_purchase_items` if missing, or patch missing fields ─
const existingStockPurchaseItems = await pb.collections.getList(1, 1, {
filter: `name = "${STOCK_PURCHASE_ITEMS}"`,
});
if (existingStockPurchaseItems.items.length === 0) {
const fields = TARGET_STOCK_PURCHASE_ITEM_FIELDS.map((f) => {
if (f.type === "relation") {
// The `purchase` and `stock_item` fields both need their
// collectionId set from the relationIds map.
if (f.name === "purchase") {
return { ...f, collectionId: stockPurchasesCol.id };
}
if (f.name === "stock_item") {
return { ...f, collectionId: stockItemsCol.id };
}
}
return f;
});
await pb.collections.create({
name: STOCK_PURCHASE_ITEMS,
type: "base",
fields,
indexes: [
"CREATE INDEX `idx_spi_purchase` ON `stock_purchase_items` (`purchase`)",
"CREATE INDEX `idx_spi_stock_item` ON `stock_purchase_items` (`stock_item`)",
],
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-purchases] ✓ created ${STOCK_PURCHASE_ITEMS}`,
);
} else {
const itemsCol = existingStockPurchaseItems.items[0];
const existingFields = (itemsCol as unknown as {
fields: Array<Record<string, unknown>>;
}).fields;
const { next, added, fixed } = reconcileFields(
existingFields,
TARGET_STOCK_PURCHASE_ITEM_FIELDS,
{
purchase: stockPurchasesCol.id,
stock_item: stockItemsCol.id,
}
);
if (added.length > 0 || fixed.length > 0) {
await pb.collections.update(STOCK_PURCHASE_ITEMS, { fields: next });
console.log(
`[migration:create-stock-purchases] ✓ patched ${STOCK_PURCHASE_ITEMS}: added=[${added.join(", ")}] fixed=[${fixed.join(", ")}]`,
);
} else {
console.log(
`[migration:create-stock-purchases] ${STOCK_PURCHASE_ITEMS} already exists with all expected fields — skip`,
);
}
// Ensure the two indexes are present.
const spiFresh = await pb.collections.getOne(STOCK_PURCHASE_ITEMS);
const spiIdx = (spiFresh.indexes ?? []) as string[];
const newIndexes: string[] = [];
if (!spiIdx.some((i) => i.includes("idx_spi_purchase"))) {
newIndexes.push(
"CREATE INDEX `idx_spi_purchase` ON `stock_purchase_items` (`purchase`)",
);
}
if (!spiIdx.some((i) => i.includes("idx_spi_stock_item"))) {
newIndexes.push(
"CREATE INDEX `idx_spi_stock_item` ON `stock_purchase_items` (`stock_item`)",
);
}
if (newIndexes.length > 0) {
await pb.collections.update(spiFresh.id, {
indexes: [...spiIdx, ...newIndexes],
});
console.log(
`[migration:create-stock-purchases] ✓ added indexes to ${STOCK_PURCHASE_ITEMS}: ${newIndexes
.map((i) => i.match(/`([^`]+)`/)?.[1])
.filter(Boolean)
.join(", ")}`,
);
}
}
// ── 3. Verify ───────────────────────────────────────────────────
const verifySp = await pb.collections.getOne(STOCK_PURCHASES);
const verifySpi = await pb.collections.getOne(STOCK_PURCHASE_ITEMS);
const spFieldNames = (verifySp.fields as Array<{ name: string }>).map(
(f) => f.name
);
const spiFieldNames = (verifySpi.fields as Array<{ name: string }>).map(
(f) => f.name
);
const spIndexes = (verifySp.indexes ?? []) as string[];
const spiIndexes = (verifySpi.indexes ?? []) as string[];
const statusValues = (
(verifySp.fields as Array<{ name: string; values?: string[]; options?: { values?: string[] } }>).find(
(f) => f.name === "status"
)?.values ?? []
);
const checks: Array<[string, boolean]> = [
[`${STOCK_PURCHASES} exists`, spFieldNames.length > 0],
[`${STOCK_PURCHASES}.purchase_number exists`, spFieldNames.includes("purchase_number")],
[`${STOCK_PURCHASES}.supplier exists`, spFieldNames.includes("supplier")],
[`${STOCK_PURCHASES}.status exists`, spFieldNames.includes("status")],
[`${STOCK_PURCHASES}.notes exists`, spFieldNames.includes("notes")],
[`${STOCK_PURCHASES}.email_payload exists`, spFieldNames.includes("email_payload")],
[`${STOCK_PURCHASES}.email_error exists`, spFieldNames.includes("email_error")],
[`${STOCK_PURCHASES}.email_message_id exists`, spFieldNames.includes("email_message_id")],
[`${STOCK_PURCHASES}.status includes draft`, statusValues.includes("draft")],
[`${STOCK_PURCHASES}.status includes sent`, statusValues.includes("sent")],
[`${STOCK_PURCHASES}.status includes received`, statusValues.includes("received")],
[`${STOCK_PURCHASES}.status includes cancelled`, statusValues.includes("cancelled")],
[`${STOCK_PURCHASES} idx_sp_status present`, spIndexes.some((i) => i.includes("idx_sp_status"))],
[`${STOCK_PURCHASE_ITEMS} exists`, spiFieldNames.length > 0],
[`${STOCK_PURCHASE_ITEMS}.purchase exists`, spiFieldNames.includes("purchase")],
[`${STOCK_PURCHASE_ITEMS}.stock_item exists`, spiFieldNames.includes("stock_item")],
[`${STOCK_PURCHASE_ITEMS}.description exists`, spiFieldNames.includes("description")],
[`${STOCK_PURCHASE_ITEMS}.quantity exists`, spiFieldNames.includes("quantity")],
[`${STOCK_PURCHASE_ITEMS}.quantity_received exists`, spiFieldNames.includes("quantity_received")],
[`${STOCK_PURCHASE_ITEMS}.cost_price exists`, spiFieldNames.includes("cost_price")],
[`${STOCK_PURCHASE_ITEMS}.notes exists`, spiFieldNames.includes("notes")],
[`${STOCK_PURCHASE_ITEMS} idx_spi_purchase present`, spiIndexes.some((i) => i.includes("idx_spi_purchase"))],
[`${STOCK_PURCHASE_ITEMS} idx_spi_stock_item present`, spiIndexes.some((i) => i.includes("idx_spi_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-purchases] verification failed — see ✗ lines above",
);
}
console.log("[migration:create-stock-purchases] Migration complete.");
}
if (require.main === module) {
run().catch((err) => {
console.error("[migration:create-stock-purchases] fatal:", err);
process.exit(1);
});
}