From 3eef60f34cb933c2e68a4fc2ab3fa055865a815a Mon Sep 17 00:00:00 2001 From: SJCoetz Date: Thu, 30 Jul 2026 21:50:04 +0200 Subject: [PATCH] feat(stock-quick-draft): getStockItemSupplierHistory Co-authored-by: Orca --- .../server/stock-quick-draft-service.test.ts | 168 +++++++++++++++++- src/lib/server/stock-quick-draft-service.ts | 92 ++++++++++ 2 files changed, 255 insertions(+), 5 deletions(-) diff --git a/src/lib/server/stock-quick-draft-service.test.ts b/src/lib/server/stock-quick-draft-service.test.ts index c906047..d01397a 100644 --- a/src/lib/server/stock-quick-draft-service.test.ts +++ b/src/lib/server/stock-quick-draft-service.test.ts @@ -24,7 +24,7 @@ const { store, pbMock, clearStore } = vi.hoisted(() => { 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*(.+)$/); + const m = clause.match(/^(\w+)\s*(=|!=|~|>=?|<=?)\s*(.+)$/); if (!m) return true; const [, field, op, rawValue] = m; let value: string | number | boolean; @@ -47,6 +47,8 @@ const { store, pbMock, clearStore } = vi.hoisted(() => { actual.toLowerCase().includes(value.toLowerCase()) ); } + if (op === ">") return Number(actual) > Number(value); + if (op === "<") return Number(actual) < Number(value); return true; }); }), @@ -58,7 +60,7 @@ const { store, pbMock, clearStore } = vi.hoisted(() => { async getList( _page = 1, perPage = 25, - opts: { filter?: string; sort?: string } = {}, + opts: { filter?: string; sort?: string; expand?: string } = {}, ) { let items = where(name, opts.filter || ""); if (opts.sort) { @@ -75,10 +77,28 @@ const { store, pbMock, clearStore } = vi.hoisted(() => { }); } const sliced = items.slice(0, perPage); + if (opts.expand) { + const expandFields = opts.expand.split(",").map((f) => f.trim()); + for (const item of sliced) { + const expandObj: Record = {}; + for (const field of expandFields) { + const relatedId = item[field]; + if (relatedId === undefined || relatedId === null) continue; + for (const collName of Object.keys(store)) { + const related = store[collName].get(String(relatedId)); + if (related) { + expandObj[field] = related; + break; + } + } + } + item.expand = expandObj; + } + } return { items: sliced, totalItems: items.length, page: 1, perPage }; }, - async getFullList() { - return where(name, ""); + async getFullList(opts: { filter?: string } = {}) { + return where(name, opts.filter || ""); }, async getOne(id: string) { const item = getOrCreateMap(name).get(id); @@ -139,7 +159,10 @@ vi.mock("./pocketbase-admin", () => ({ getAdminPocketBase: vi.fn().mockResolvedValue(pbMock), })); -import { searchStockItemsForQuickDraft } from "./stock-quick-draft-service"; +import { + getStockItemSupplierHistory, + searchStockItemsForQuickDraft, +} from "./stock-quick-draft-service"; beforeEach(() => clearStore()); @@ -212,4 +235,139 @@ describe("searchStockItemsForQuickDraft", () => { unit: "ea", }); }); +}); + +describe("getStockItemSupplierHistory", () => { + it("returns empty when the item has no purchase history", async () => { + await pbMock.collection("stock_items").create({ + id: "item-1", + sku: "STK-CD-115", + name: "115mm Cutting Disc", + is_active: true, + }); + + const history = await getStockItemSupplierHistory(pbMock as never, "item-1"); + + expect(history).toEqual([]); + }); + + it("aggregates by supplier: lastCost, lastDate, timesBought", async () => { + const supplierId = ( + await pbMock.collection("suppliers").create({ suppliername: "Steel & Pipe Co" }) + ).id; + await pbMock.collection("stock_items").create({ + id: "item-1", + sku: "STK-CD-115", + name: "115mm Cutting Disc", + is_active: true, + }); + const purchase1 = await pbMock.collection("stock_purchases").create({ + supplier: supplierId, + status: "received", + purchase_number: "PO26070101STK", + created: "2026-06-10T08:00:00.000Z", + }); + const purchase2 = await pbMock.collection("stock_purchases").create({ + supplier: supplierId, + status: "received", + purchase_number: "PO26071401STK", + created: "2026-07-14T08:00:00.000Z", + }); + await pbMock.collection("stock_purchase_items").create({ + purchase: purchase1.id, + stock_item: "item-1", + cost_price: 18.5, + quantity: 10, + quantity_received: 10, + }); + await pbMock.collection("stock_purchase_items").create({ + purchase: purchase2.id, + stock_item: "item-1", + cost_price: 19.0, + quantity: 20, + quantity_received: 20, + }); + + const history = await getStockItemSupplierHistory(pbMock as never, "item-1"); + + expect(history).toHaveLength(1); + expect(history[0]).toEqual({ + supplierId, + supplierName: "Steel & Pipe Co", + lastCost: 19.0, + lastDate: "2026-07-14T08:00:00.000Z", + timesBought: 2, + }); + }); + + it("returns one entry per supplier, sorted by lastDate desc", async () => { + const supA = ( + await pbMock.collection("suppliers").create({ suppliername: "Supplier A" }) + ).id; + const supB = ( + await pbMock.collection("suppliers").create({ suppliername: "Supplier B" }) + ).id; + await pbMock.collection("stock_items").create({ + id: "item-1", + sku: "STK-CD-115", + is_active: true, + }); + const purchaseA = await pbMock.collection("stock_purchases").create({ + supplier: supA, + status: "received", + purchase_number: "PO26070101STK", + created: "2026-06-10T08:00:00.000Z", + }); + const purchaseB = await pbMock.collection("stock_purchases").create({ + supplier: supB, + status: "received", + purchase_number: "PO26071501STK", + created: "2026-07-15T08:00:00.000Z", + }); + await pbMock.collection("stock_purchase_items").create({ + purchase: purchaseA.id, + stock_item: "item-1", + cost_price: 18.5, + quantity_received: 10, + }); + await pbMock.collection("stock_purchase_items").create({ + purchase: purchaseB.id, + stock_item: "item-1", + cost_price: 21.0, + quantity_received: 5, + }); + + const history = await getStockItemSupplierHistory(pbMock as never, "item-1"); + + expect(history).toHaveLength(2); + expect(history[0].supplierId).toBe(supB); + expect(history[1].supplierId).toBe(supA); + }); + + it("only counts lines with quantity_received > 0", async () => { + const supA = ( + await pbMock.collection("suppliers").create({ suppliername: "Supplier A" }) + ).id; + await pbMock.collection("stock_items").create({ + id: "item-1", + sku: "STK-CD-115", + is_active: true, + }); + const purchase = await pbMock.collection("stock_purchases").create({ + supplier: supA, + status: "draft", + purchase_number: "PO26070101STK", + created: "2026-07-01T08:00:00.000Z", + }); + await pbMock.collection("stock_purchase_items").create({ + purchase: purchase.id, + stock_item: "item-1", + cost_price: 18.5, + quantity_received: 0, + }); + + const history = await getStockItemSupplierHistory(pbMock as never, "item-1"); + + expect(history).toEqual([]); + }); }); \ 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 index 152352c..5e66ec8 100644 --- a/src/lib/server/stock-quick-draft-service.ts +++ b/src/lib/server/stock-quick-draft-service.ts @@ -129,4 +129,96 @@ export async function searchStockItemsForQuickDraft( 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, + ); } \ No newline at end of file