/** * 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 * ========================================================================== */ 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 = q.replace(/"/g, '\\"'); 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 = "${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, ); }