diff --git a/src/lib/server/stock-quick-draft-service.test.ts b/src/lib/server/stock-quick-draft-service.test.ts new file mode 100644 index 0000000..c906047 --- /dev/null +++ b/src/lib/server/stock-quick-draft-service.test.ts @@ -0,0 +1,215 @@ +import { describe, it, expect, beforeEach, vi } from "vitest"; + +const { store, pbMock, clearStore } = vi.hoisted(() => { + type StoreMap = Map>; + const store: Record = {}; + let seq = 0; + const nextId = () => `mock-${++seq}`; + + const getOrCreateMap = (name: string): StoreMap => { + if (!store[name]) store[name] = new Map(); + return store[name]; + }; + + const where = (name: string, filter: string): Record[] => { + const items = Array.from(getOrCreateMap(name).values()); + if (!filter || !filter.trim()) return items; + + // Split on &&, then for each conjunct split on ||. Parens are + // grouping-only and can be stripped — none of the filters used + // here have nested groups mixing ANDs and ORs. + const conjuncts = filter.split("&&").map((c) => c.trim()); + return items.filter((item) => + conjuncts.every((conjunct) => { + const disjuncts = conjunct.split("||").map((d) => d.trim()); + return disjuncts.some((d) => { + const clause = d.replace(/^\(/, "").replace(/\)$/, "").trim(); + const m = clause.match(/^(\w+)\s*(=|!=|~)\s*(.+)$/); + if (!m) return true; + const [, field, op, rawValue] = m; + let value: string | number | boolean; + if (rawValue.startsWith('"') && rawValue.endsWith('"')) { + value = rawValue.slice(1, -1); + } else if (rawValue === "true") { + value = true; + } else if (rawValue === "false") { + value = false; + } else { + value = Number(rawValue); + } + const actual = item[field]; + if (op === "=") return actual == value; + if (op === "!=") return actual != value; + if (op === "~") { + return ( + typeof actual === "string" && + typeof value === "string" && + actual.toLowerCase().includes(value.toLowerCase()) + ); + } + return true; + }); + }), + ); + }; + + const pbMock = { + collection: (name: string) => ({ + async getList( + _page = 1, + perPage = 25, + opts: { filter?: string; sort?: string } = {}, + ) { + let items = where(name, opts.filter || ""); + if (opts.sort) { + const desc = opts.sort.startsWith("-"); + const field = desc ? opts.sort.slice(1) : opts.sort; + items = [...items].sort((a, b) => { + const av = a[field] as number | string | undefined; + const bv = b[field] as number | string | undefined; + if (av === bv) return 0; + if (av === undefined) return 1; + if (bv === undefined) return -1; + const cmp = av < bv ? -1 : 1; + return desc ? -cmp : cmp; + }); + } + const sliced = items.slice(0, perPage); + return { items: sliced, totalItems: items.length, page: 1, perPage }; + }, + async getFullList() { + return where(name, ""); + }, + async getOne(id: string) { + const item = getOrCreateMap(name).get(id); + if (!item) { + const err = new Error("not found") as Error & { status: number }; + err.status = 404; + throw err; + } + return item; + }, + async create(data: Record) { + const item = { + id: nextId(), + created: new Date().toISOString(), + updated: new Date().toISOString(), + ...data, + }; + getOrCreateMap(name).set(item.id, item); + return item; + }, + async update(id: string, data: Record) { + const map = getOrCreateMap(name); + const existing = map.get(id); + if (!existing) { + const err = new Error("not found") as Error & { status: number }; + err.status = 404; + throw err; + } + for (const [key, value] of Object.entries(data)) { + existing[key] = value; + } + existing.updated = new Date().toISOString(); + return { ...existing }; + }, + async delete(id: string) { + const map = getOrCreateMap(name); + if (!map.has(id)) { + const err = new Error("not found") as Error & { status: number }; + err.status = 404; + throw err; + } + map.delete(id); + return { id }; + }, + }), + }; + + const clearStore = () => { + for (const key of Object.keys(store)) delete store[key]; + seq = 0; + }; + + return { store, pbMock, clearStore }; +}); + +vi.mock("pocketbase", () => ({ default: vi.fn().mockImplementation(() => pbMock) })); +vi.mock("./pocketbase-admin", () => ({ + getAdminPocketBase: vi.fn().mockResolvedValue(pbMock), +})); + +import { searchStockItemsForQuickDraft } from "./stock-quick-draft-service"; + +beforeEach(() => clearStore()); + +describe("searchStockItemsForQuickDraft", () => { + it("returns active stock_items whose sku or name matches the query", async () => { + await pbMock.collection("stock_items").create({ + sku: "STK-CD-115", + name: "115mm Cutting Disc", + is_active: true, + }); + await pbMock.collection("stock_items").create({ + sku: "STK-WR-6", + name: "Wire rope 6mm", + is_active: true, + }); + await pbMock.collection("stock_items").create({ + sku: "STK-CD-230", + name: "230mm Cutting Disc", + is_active: true, + }); + + const results = await searchStockItemsForQuickDraft(pbMock as never, "cutting", 10); + + expect(results).toHaveLength(2); + expect(results.map((r) => r.sku).sort()).toEqual(["STK-CD-115", "STK-CD-230"]); + }); + + it("excludes inactive items", async () => { + await pbMock.collection("stock_items").create({ + sku: "STK-CD-115", + name: "115mm Cutting Disc", + is_active: false, + }); + + const results = await searchStockItemsForQuickDraft(pbMock as never, "cutting", 10); + + expect(results).toEqual([]); + }); + + it("clamps limit to a max of 25", async () => { + for (let i = 0; i < 30; i++) { + await pbMock.collection("stock_items").create({ + sku: `STK-X-${i}`, + name: `Cutting disc variant ${i}`, + is_active: true, + }); + } + + const results = await searchStockItemsForQuickDraft(pbMock as never, "cutting", 100); + + expect(results).toHaveLength(25); + }); + + it("returns narrow projection fields", async () => { + const created = await pbMock.collection("stock_items").create({ + sku: "STK-CD-115", + name: "115mm Cutting Disc", + description: "For angle grinder", + unit: "ea", + is_active: true, + }); + + const results = await searchStockItemsForQuickDraft(pbMock as never, "cutting", 10); + + expect(results[0]).toEqual({ + id: created.id, + sku: "STK-CD-115", + name: "115mm Cutting Disc", + description: "For angle grinder", + unit: "ea", + }); + }); +}); \ No newline at end of file diff --git a/src/lib/server/stock-quick-draft-service.ts b/src/lib/server/stock-quick-draft-service.ts new file mode 100644 index 0000000..152352c --- /dev/null +++ b/src/lib/server/stock-quick-draft-service.ts @@ -0,0 +1,132 @@ +/** + * 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, + })); +} \ No newline at end of file