/** * Stock Quick-Draft service — backs the /stock-orders modal that lets * a buyer add items to draft stock_purchases in an item-first rhythm. * * Reuses `generateStockPurchaseNumber` from `stock-purchases-service.ts` * for the find-or-create open-draft step; no schema changes, no new * statuses. Admin PB only (consistent with the other service modules). */ import type PocketBase from "pocketbase"; import { getAdminPocketBase } from "./pocketbase-admin"; import { generateStockPurchaseNumber } from "./stock-purchases-service"; const STOCK_ITEMS = "stock_items"; const STOCK_PURCHASES = "stock_purchases"; const STOCK_PURCHASE_ITEMS = "stock_purchase_items"; const SEARCH_LIMIT_MAX = 25; const SEARCH_LIMIT_DEFAULT = 10; /* ============================================================================ * Minimal PB client shape this module needs. * * The route passes in the real `getAdminPocketBase()`; the unit test * passes in a tiny mock. Only the methods we actually call are typed. * ========================================================================== */ export interface StockQuickDraftPb { collection: (name: string) => { getList: ( page?: number, perPage?: number, opts?: { filter?: string; sort?: string; expand?: string } ) => Promise<{ items: Array>; totalItems: number; page: number; perPage: number; totalPages: number; }>; getFullList: (opts?: { filter?: string }) => Promise>>; getOne: (id: string, opts?: { expand?: string }) => Promise>; create: (data: Record) => Promise>; update: (id: string, data: Record) => Promise>; delete: (id: string) => Promise>; }; } /* ============================================================================ * Public types * ========================================================================== */ export interface StockItemSearchResult { id: string; sku: string; name: string; description: string; unit?: string; } export interface SupplierHistoryEntry { supplierId: string; supplierName: string; lastCost: number; lastDate: string; timesBought: number; } export interface AddQuickDraftLineNewStockItem { name: string; sku: string; description?: string; unit?: string; } export interface AddQuickDraftLineInput { supplierId: string; stockItemId?: string; quantity: number; costPrice: number; newStockItem?: AddQuickDraftLineNewStockItem; } export type AddQuickDraftLineResult = | { ok: true; purchaseId: string; purchaseNumber: string; lineId: string; createdDraft: boolean } | { ok: false; reason: string; message: string }; export type RemoveQuickDraftLineResult = | { ok: true } | { ok: false; reason: string; message: string }; /* ============================================================================ * Helpers * ========================================================================== */ /** * Escape a string so it can be safely embedded inside a PocketBase * filter expression (which uses `"..."` quoting). PB itself allows * backslash-escaped characters inside quoted strings; we only ever * interpolate this from admin-PB routes where ids are PB-generated * 15-char alphanumeric, but backfilling the pattern keeps the file * consistent and rules out accidental injection from a future * non-admin caller. */ function escapePbFilterString(s: string): string { return s.replace(/\\/g, "\\\\").replace(/"/g, '\\"'); } async function resolvePb(pb?: StockQuickDraftPb): Promise { if (pb) return pb; return (await getAdminPocketBase()) as unknown as StockQuickDraftPb; } /* ============================================================================ * searchStockItemsForQuickDraft * ========================================================================== */ /** * Typeahead search over `stock_items`. Matches `sku` OR `name` with * PocketBase's `~` (contains, case-insensitive). Excludes inactive * items. Limit defaults to 10, clamped to a max of 25. * * Returns a narrow projection — only the fields the modal needs. */ export async function searchStockItemsForQuickDraft( pb: StockQuickDraftPb, q: string, limit?: number, ): Promise { const safeLimit = Math.min(Math.max(limit ?? SEARCH_LIMIT_DEFAULT, 1), SEARCH_LIMIT_MAX); const escaped = escapePbFilterString(q); const filter = `is_active=true && (sku ~ "${escaped}" || name ~ "${escaped}")`; const result = await pb.collection(STOCK_ITEMS).getList(1, safeLimit, { filter, sort: "name", }); return result.items.map((row) => ({ id: String(row.id), sku: String(row.sku ?? ""), name: String(row.name ?? ""), description: String(row.description ?? ""), unit: row.unit ? String(row.unit) : undefined, })); } /* ============================================================================ * getStockItemSupplierHistory * ========================================================================== */ /** * Returns the per-supplier purchase history for a `stock_item`. * Reads `stock_purchase_items` joined through `stock_purchases` so * we can aggregate by supplier. Filters to lines that have actually * been received (`quantity_received > 0`) so drafts don't pollute * the "we bought this from X before" list. * * The supplier is expanded via `purchase.supplier`; if the * `stock_purchases.supplier` row no longer exists in `suppliers`, * the line is silently skipped (we can't show a name for it). */ export async function getStockItemSupplierHistory( pb: StockQuickDraftPb, stockItemId: string, ): Promise { const rows = await pb.collection(STOCK_PURCHASE_ITEMS).getFullList({ filter: `stock_item = "${escapePbFilterString(stockItemId)}" && quantity_received > 0`, }); const purchaseIds = Array.from( new Set( rows .map((r) => (r.purchase ? String(r.purchase) : null)) .filter((id): id is string => id !== null), ), ); if (purchaseIds.length === 0) return []; // Fetch purchases in one call (with supplier expanded). We use // a single getList rather than N getOnes — cheaper on PB. const purchasesResult = await pb .collection(STOCK_PURCHASES) .getList(1, purchaseIds.length, { filter: purchaseIds.map((id) => `id = "${id}"`).join(" || "), expand: "supplier", }); const purchaseById = new Map>(); for (const p of purchasesResult.items) purchaseById.set(String(p.id), p); type Agg = { supplierId: string; supplierName: string; lastCost: number; lastDate: string; timesBought: number; }; const bySupplier = new Map(); for (const row of rows) { const purchaseId = row.purchase ? String(row.purchase) : null; if (!purchaseId) continue; const purchase = purchaseById.get(purchaseId); if (!purchase) continue; const expand = purchase.expand as | { supplier?: { id: string; suppliername: string } } | undefined; const supplier = expand?.supplier; if (!supplier) continue; const supplierId = String(supplier.id); const cost = typeof row.cost_price === "number" ? row.cost_price : 0; const created = String(purchase.created ?? ""); const existing = bySupplier.get(supplierId); if (!existing) { bySupplier.set(supplierId, { supplierId, supplierName: String(supplier.suppliername ?? ""), lastCost: cost, lastDate: created, timesBought: 1, }); } else { existing.timesBought += 1; if (created > existing.lastDate) { existing.lastDate = created; existing.lastCost = cost; } } } return Array.from(bySupplier.values()).sort((a, b) => a.lastDate < b.lastDate ? 1 : a.lastDate > b.lastDate ? -1 : 0, ); } /* ============================================================================ * findOrCreateOpenDraft * ========================================================================== */ type FindOrCreateOpenDraftResult = | { ok: true; purchaseId: string; purchaseNumber: string; createdDraft: boolean; } | { ok: false; reason: string; message: string; }; async function findOrCreateOpenDraft( pb: StockQuickDraftPb, supplierId: string, ): Promise { // Look for an open draft for this supplier. The list is sorted // by `created` desc so the most recent draft wins if there are // multiple (the buyer may have accumulated them across modal // sessions, but the design guarantees only one per supplier // exists at a time in the happy path). const existing = await pb.collection(STOCK_PURCHASES).getList(1, 1, { filter: `supplier = "${escapePbFilterString(supplierId)}" && status = "draft"`, sort: "-created", }); if (existing.items.length > 0) { const row = existing.items[0]; return { ok: true, purchaseId: String(row.id), purchaseNumber: String(row.purchase_number ?? ""), createdDraft: false, }; } // No open draft — generate a number and create one. Both of these // calls can throw on PB-side errors (admin token expired, schema // mismatch, race on purchase_number collision, etc.). Map them // to the documented `create_failed` reason so the modal can show // a recoverable error rather than crash with an unhandled // rejection. try { const purchaseNumber = await generateStockPurchaseNumber( pb as unknown as Parameters[0], ); const created = await pb.collection(STOCK_PURCHASES).create({ purchase_number: purchaseNumber, supplier: supplierId, status: "draft", notes: "", }); return { ok: true, purchaseId: String(created.id), purchaseNumber, createdDraft: true, }; } catch (err) { return { ok: false, reason: "create_failed", message: err instanceof Error ? err.message : "draft creation failed", }; } } /* ============================================================================ * addQuickDraftLine * ========================================================================== */ /** * Adds a single line to a draft stock_purchase for the given supplier. * * Behaviour: * - If the caller supplied `newStockItem`, create that `stock_items` * row first (current_qty: 0, is_active: true, supplier: input.supplierId). * - Find or create an open draft for the supplier (status=draft). * - Append a `stock_purchase_items` row to that draft. * * Returns `{ ok: true, ... }` with the purchase + line ids on success, * or `{ ok: false, reason, message }` on validation / lookup / write * failure. The modal maps `reason` to a UI error category. */ export async function addQuickDraftLine( pb: StockQuickDraftPb, input: AddQuickDraftLineInput, ): Promise { // ── Validate input ───────────────────────────────────────────── if (typeof input.supplierId !== "string" || input.supplierId.length === 0) { return { ok: false, reason: "validation", message: "supplierId is required" }; } if (typeof input.quantity !== "number" || input.quantity <= 0) { return { ok: false, reason: "validation", message: "quantity must be > 0" }; } if (typeof input.costPrice !== "number" || input.costPrice < 0) { return { ok: false, reason: "validation", message: "costPrice must be >= 0" }; } if (!input.stockItemId && !input.newStockItem) { return { ok: false, reason: "validation", message: "either stockItemId or newStockItem is required", }; } // ── Confirm supplier exists ──────────────────────────────────── try { await pb.collection("suppliers").getOne(input.supplierId); } catch (err) { return { ok: false, reason: "supplier_not_found", message: err instanceof Error ? err.message : "supplier lookup failed", }; } // ── Resolve the stock_item id (create if inline) ─────────────── let stockItemId = input.stockItemId; if (!stockItemId && input.newStockItem) { if (!input.newStockItem.name || !input.newStockItem.sku) { return { ok: false, reason: "validation", message: "newStockItem.name and newStockItem.sku are required", }; } try { const created = await pb.collection(STOCK_ITEMS).create({ name: input.newStockItem.name, sku: input.newStockItem.sku, description: input.newStockItem.description ?? "", unit: input.newStockItem.unit ?? "", current_qty: 0, is_active: true, supplier: input.supplierId, }); stockItemId = String(created.id); } catch (err) { return { ok: false, reason: "create_failed", message: err instanceof Error ? err.message : "stock_item create failed", }; } } else if (stockItemId) { // ── Confirm existing stock_item exists ──────────────────────── try { await pb.collection(STOCK_ITEMS).getOne(stockItemId); } catch (err) { return { ok: false, reason: "stock_item_not_found", message: err instanceof Error ? err.message : "stock_item lookup failed", }; } } // stockItemId is now guaranteed non-null. const finalStockItemId = stockItemId as string; // ── Find or create the open draft ────────────────────────────── const draft = await findOrCreateOpenDraft(pb, input.supplierId); if (!draft.ok) { return { ok: false, reason: "create_failed", message: "could not resolve draft" }; } // ── Look up the item's name so we can denormalise onto the line ─ const stockItem = await pb.collection(STOCK_ITEMS).getOne(finalStockItemId); const description = String(stockItem.name ?? ""); // ── Append the line ──────────────────────────────────────────── try { const line = await pb.collection(STOCK_PURCHASE_ITEMS).create({ purchase: draft.purchaseId, stock_item: finalStockItemId, description, quantity: input.quantity, cost_price: input.costPrice, }); return { ok: true, purchaseId: draft.purchaseId, purchaseNumber: draft.purchaseNumber, lineId: String(line.id), createdDraft: draft.createdDraft, }; } catch (err) { return { ok: false, reason: "append_failed", message: err instanceof Error ? err.message : "line create failed", }; } } /* ============================================================================ * removeQuickDraftLine * ========================================================================== */ /** * Deletes a `stock_purchase_items` row by id. Used by the modal's * cards-region "Remove" button so the buyer can clean up mistakes * without leaving the modal. * * Returns `{ ok: true }` on success, or `{ ok: false, reason }` on * failure. The modal surfaces the message as a toast. */ export async function removeQuickDraftLine( pb: StockQuickDraftPb, lineId: string, ): Promise { try { await pb.collection(STOCK_PURCHASE_ITEMS).delete(lineId); return { ok: true }; } catch (err) { return { ok: false, reason: "line_not_found", message: err instanceof Error ? err.message : "line delete failed", }; } }