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
576 lines
20 KiB
TypeScript
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);
|
|
});
|
|
}
|