import type { StockItem, Supplier } from "@/lib/types"; /** * PB client type kept loose so both the real `PocketBase` instance * and the test stub satisfy the contract. The service uses * `getFullList` (stocktake listing) plus `getOne` / `update` * (`updateStockItem`). */ export type StockItemsPb = { collection: (id: string) => { getFullList: (opts?: Record) => Promise; getOne: ( id: string, opts?: Record, ) => Promise; update: ( id: string, patch: Record, ) => Promise; }; }; /** * Fetch every active stock item sorted by supplier name (then by * item name within each supplier), with the supplier relation * pre-expanded. The "Stocktake PDF" download endpoint uses this to * render the printable stocktake list. * * Differs from the inline `stock_items` reads on the dashboard * page (which filter by `is_active = true` AND sort by `current_qty`) * because a stocktake wants all SKUs grouped by supplier so the * operator can do a single walk-through per vendor, not the * low-stock ones first. * * PB's `sort` parameter sorts by the supplier relation field (the * ID), not by the expanded supplier's name — so we re-sort in JS by * `expand.supplier.suppliername` to get the printable grouping * right. Items without a supplier sort to the end. Name is the * secondary sort so items within the same supplier stay * alphabetically grouped. * * No pagination — the stocktake is always the whole list. If the * catalogue grows past `getFullList`'s default page size, PB will * fetch subsequent pages transparently. The PDF renderer downstream * is the only consumer; if it becomes a bottleneck, this is the * place to swap in a paginated stream. */ export async function listStockItemsForStocktake( pb: StockItemsPb ): Promise { const items = (await pb.collection("stock_items").getFullList({ filter: "is_active = true", // PB-side sort by supplier (relation ID), then by name. The JS // re-sort below overrides this with the expanded supplier name, // but the PB-side sort keeps the input roughly grouped so the // JS comparator does fewer swaps on large lists. sort: "supplier,name", expand: "supplier", })) as StockItem[]; return items.sort((a, b) => { const aName = a.expand?.supplier?.suppliername ?? ""; const bName = b.expand?.supplier?.suppliername ?? ""; // Items without a supplier sort to the end of the PDF. if (aName === "" && bName !== "") return 1; if (aName !== "" && bName === "") return -1; const cmp = aName.localeCompare(bName); if (cmp !== 0) return cmp; return a.name.localeCompare(b.name); }); } // Re-export the type so the route can alias it without poking // into `@/lib/types` directly. export type { StockItem, Supplier }; // ─── updateStockItem (catalogue patch) ─────────────────────────────────── /** * Fields a caller may patch on a `stock_items` row. Anything * outside this set returns `forbidden_field`. * * Catalogue fields only. Inventory quantities are managed via * `stock_items.adjust`; the supplier relation is fixed once the * item is created; `cost_history` and `cost_price` are owned by * the booking flow and not user-editable from this surface. */ export const STOCK_ITEM_PATCHABLE_FIELDS = [ "sku", "description", "notes", "unit", "selling_price", "markup", ] as const; export type StockItemPatch = Partial< Pick< StockItem, "sku" | "description" | "notes" | "unit" | "selling_price" | "markup" > >; export type UpdateStockItemInput = { id: string; patch: StockItemPatch; }; export type UpdateStockItemResult = | { ok: true; item: StockItem; previous: StockItemPatch } | { ok: false; reason: "forbidden_field" | "validation_error" | "not_found" | "pb_error"; message: string; field?: string; }; /** * Patch whitelisted catalogue fields on a single `stock_items` row. * * Used by the operator's external agent (Hermes) to keep the * catalogue in sync with the supplier's invoice — for example, * when the supplier renames a SKU on the invoice, Hermes can * mirror the rename onto the master catalogue row. * * Inventory (`current_qty`) is NOT patchable here — use * `stock_items.adjust` so the ledger stays the source of truth. * * Returns `{ ok: true, item, previous }` on success or * `{ ok: false, reason, message, field? }` on rejection. The MCP * route maps `reason` to HTTP status: * - `not_found` → 404 * - `forbidden_field` → 400 * - `validation_error` → 400 * - `pb_error` → 422 */ export async function updateStockItem( input: UpdateStockItemInput, ): Promise { // Lazy import so the production code can boot without dragging // pocketbase-admin into pages that only use stocktake listing. const { getAdminPocketBase } = await import("./pocketbase-admin"); const pb = await getAdminPocketBase(); return updateStockItemWithPb(pb, input); } /** * PB-injectable variant. Public so the route can pass the live * admin PB in production and tests can pass a stub. */ export async function updateStockItemWithPb( pb: StockItemsPb, input: UpdateStockItemInput, ): Promise { if (!input.id) { return { ok: false, reason: "validation_error", message: "id is required", }; } if (!input.patch || typeof input.patch !== "object") { return { ok: false, reason: "validation_error", message: "patch object is required", }; } // 1. Validate the patch against the whitelist. for (const key of Object.keys(input.patch)) { if (!STOCK_ITEM_PATCHABLE_FIELDS.includes(key as (typeof STOCK_ITEM_PATCHABLE_FIELDS)[number])) { return { ok: false, reason: "forbidden_field", message: `forbidden_field: ${key}`, field: key, }; } } // 2. Validate numeric fields. for (const field of ["selling_price", "markup"] as const) { if (field in input.patch) { const value = input.patch[field]; if (typeof value !== "number" || !Number.isFinite(value)) { return { ok: false, reason: "validation_error", message: `${field} must be a finite number`, field, }; } } } // 3. Load the existing row for the return value + not_found check. let before: StockItem; try { before = (await pb .collection("stock_items") .getOne(input.id)) as StockItem; } catch (err) { const message = err instanceof Error ? err.message : String(err); if (/not found|404/i.test(message)) { return { ok: false, reason: "not_found", message: `stock_items row ${input.id} not found`, }; } return { ok: false, reason: "pb_error", message: `Failed to load stock_items row ${input.id}: ${message}`, }; } const previous: StockItemPatch = {}; for (const key of STOCK_ITEM_PATCHABLE_FIELDS) { if (key in input.patch) { (previous as Record)[key] = ( before as unknown as Record )[key]; } } // 4. Write the patch. let updated: StockItem; try { updated = (await pb .collection("stock_items") .update(input.id, input.patch as Record)) as StockItem; } catch (err) { const message = err instanceof Error ? err.message : String(err); return { ok: false, reason: "pb_error", message: `Failed to update stock_items row ${input.id}: ${message}`, }; } return { ok: true, item: updated, previous }; }