From 463917d75500bc85efb8019e0e9b509a2fc52ca0 Mon Sep 17 00:00:00 2001 From: SJCoetz Date: Wed, 29 Jul 2026 19:52:41 +0200 Subject: [PATCH] feat(buyer-tasks): listBuyerTasks service with grouping Co-authored-by: Orca --- src/lib/server/buyer-tasks-service.test.ts | 239 +++++++++++++++++++++ src/lib/server/buyer-tasks-service.ts | 111 ++++++++++ 2 files changed, 350 insertions(+) create mode 100644 src/lib/server/buyer-tasks-service.test.ts create mode 100644 src/lib/server/buyer-tasks-service.ts diff --git a/src/lib/server/buyer-tasks-service.test.ts b/src/lib/server/buyer-tasks-service.test.ts new file mode 100644 index 0000000..83cf2a2 --- /dev/null +++ b/src/lib/server/buyer-tasks-service.test.ts @@ -0,0 +1,239 @@ +import { describe, it, expect, beforeEach, vi } from "vitest"; + +vi.mock("@/lib/server/pocketbase-admin", () => ({ + getAdminPocketBase: vi.fn(), +})); + +import { getAdminPocketBase } from "@/lib/server/pocketbase-admin"; +import { listBuyerTasks } from "./buyer-tasks-service"; + +const baseCompany = { + id: "comp-1", + company: "Acme", +}; + +const baseCustomer = { + id: "cust-1", + customer: "J. Smith", + company: "comp-1", +}; + +const baseOrder = { + id: "order-1", + order_number: "ORD-001", + customer: "cust-1", + company: "comp-1", + expand: { + customer: baseCustomer, + company: baseCompany, + }, +}; + +const baseSupplier = { + id: "sup-1", + suppliername: "Steel Co", +}; + +const basePo = { + id: "po-1", + status: "draft", +}; + +const baseItem = (overrides: Record = {}) => ({ + id: "item-1", + order: "order-1", + supplier: "sup-1", + purchase_order: "", + item_status: "processed", + description: "HSS drill", + quantity: 2, + cost_price: 25, + selling_price: 50, + expand: { + order: baseOrder, + supplier: baseSupplier, + purchase_order: undefined, + }, + ...overrides, +}); + +describe("listBuyerTasks", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("returns groups for items in needs_po state", async () => { + const admin = { + collection: vi.fn().mockReturnValue({ + getList: vi.fn().mockResolvedValue({ + items: [baseItem()], + totalItems: 1, + }), + }), + }; + vi.mocked(getAdminPocketBase).mockResolvedValue(admin as never); + + const groups = await listBuyerTasks("needs_po"); + + expect(groups).toHaveLength(1); + expect(groups[0]).toMatchObject({ + orderId: "order-1", + supplierId: "sup-1", + supplierName: "Steel Co", + customerName: "J. Smith", + companyName: "Acme", + itemCount: 1, + totalCost: 50, + step: "needs_po", + }); + }); + + it("groups multiple items from the same (order, supplier) pair", async () => { + const admin = { + collection: vi.fn().mockReturnValue({ + getList: vi.fn().mockResolvedValue({ + items: [ + baseItem(), + baseItem({ id: "item-2", quantity: 3, cost_price: 10 }), + ], + totalItems: 2, + }), + }), + }; + vi.mocked(getAdminPocketBase).mockResolvedValue(admin as never); + + const groups = await listBuyerTasks("needs_po"); + + expect(groups).toHaveLength(1); + expect(groups[0].itemCount).toBe(2); + expect(groups[0].totalCost).toBe(80); // 2*25 + 3*10 = 80 + }); + + it("skips items with no supplier", async () => { + const admin = { + collection: vi.fn().mockReturnValue({ + getList: vi.fn().mockResolvedValue({ + items: [ + baseItem(), + baseItem({ id: "item-2", supplier: "", expand: { order: baseOrder, supplier: undefined, purchase_order: undefined } }), + ], + totalItems: 2, + }), + }), + }; + vi.mocked(getAdminPocketBase).mockResolvedValue(admin as never); + + const groups = await listBuyerTasks("needs_po"); + + expect(groups).toHaveLength(1); + expect(groups[0].itemCount).toBe(1); + }); + + it("returns groups filtered by po_draft tab", async () => { + const admin = { + collection: vi.fn().mockReturnValue({ + getList: vi.fn().mockResolvedValue({ + items: [ + baseItem({ + purchase_order: "po-1", + item_status: "po_generated", + expand: { + order: baseOrder, + supplier: baseSupplier, + purchase_order: basePo, + }, + }), + ], + totalItems: 1, + }), + }), + }; + vi.mocked(getAdminPocketBase).mockResolvedValue(admin as never); + + const groups = await listBuyerTasks("po_draft"); + + expect(groups).toHaveLength(1); + expect(groups[0].step).toBe("po_draft"); + expect(groups[0].purchaseOrderId).toBe("po-1"); + expect(groups[0].poStatus).toBe("draft"); + }); + + it("excludes sent/cancelled POs from po_draft tab", async () => { + const admin = { + collection: vi.fn().mockReturnValue({ + getList: vi.fn().mockResolvedValue({ + items: [ + baseItem({ + purchase_order: "po-1", + item_status: "po_generated", + expand: { + order: baseOrder, + supplier: baseSupplier, + purchase_order: { id: "po-1", status: "sent" }, + }, + }), + ], + totalItems: 1, + }), + }), + }; + vi.mocked(getAdminPocketBase).mockResolvedValue(admin as never); + + const groups = await listBuyerTasks("po_draft"); + + expect(groups).toHaveLength(0); + }); + + it("returns groups for po_sent tab", async () => { + const admin = { + collection: vi.fn().mockReturnValue({ + getList: vi.fn().mockResolvedValue({ + items: [ + baseItem({ + purchase_order: "po-1", + item_status: "po_generated", + expand: { + order: baseOrder, + supplier: baseSupplier, + purchase_order: { id: "po-1", status: "sent" }, + }, + }), + ], + totalItems: 1, + }), + }), + }; + vi.mocked(getAdminPocketBase).mockResolvedValue(admin as never); + + const groups = await listBuyerTasks("po_sent"); + + expect(groups).toHaveLength(1); + expect(groups[0].step).toBe("po_sent"); + }); + + it("excludes ready_delivery items from po_sent tab", async () => { + const admin = { + collection: vi.fn().mockReturnValue({ + getList: vi.fn().mockResolvedValue({ + items: [ + baseItem({ + purchase_order: "po-1", + item_status: "ready_delivery", + expand: { + order: baseOrder, + supplier: baseSupplier, + purchase_order: { id: "po-1", status: "sent" }, + }, + }), + ], + totalItems: 1, + }), + }), + }; + vi.mocked(getAdminPocketBase).mockResolvedValue(admin as never); + + const groups = await listBuyerTasks("po_sent"); + + expect(groups).toHaveLength(0); + }); +}); diff --git a/src/lib/server/buyer-tasks-service.ts b/src/lib/server/buyer-tasks-service.ts new file mode 100644 index 0000000..03ce3bd --- /dev/null +++ b/src/lib/server/buyer-tasks-service.ts @@ -0,0 +1,111 @@ +import { getAdminPocketBase } from "@/lib/server/pocketbase-admin"; + +export type BuyerTaskTab = "needs_po" | "po_draft" | "po_sent"; + +export type BuyerTaskGroup = { + groupId: string; + orderId: string; + orderNumber: string; + supplierId: string; + supplierName: string; + customerName: string; + companyName: string; + items: Array<{ + id: string; + description: string; + quantity: number; + costPrice: number; + sellingPrice: number; + }>; + itemCount: number; + totalCost: number; + purchaseOrderId: string | null; + poStatus: "draft" | "sent" | "sending" | "received" | "mail_failed" | "cancelled" | null; + step: BuyerTaskTab; +}; + +/** + * List buyer task groups for the given tab. + * + * Filter strategy: PB filters can't traverse relations, so we fetch + * candidate items with the broadest filter we can build from the + * item_status / purchase_order fields, then post-filter by PO + * status (which lives on the related purchase_orders record). + */ +export async function listBuyerTasks(tab: BuyerTaskTab): Promise { + const admin = await getAdminPocketBase(); + + // Item-level filter narrows the candidates. PO-status filtering + // happens post-fetch because the field lives on a related record. + const itemFilter = tab === "needs_po" + ? `item_status = "processed" && purchase_order = ""` + : `purchase_order != "" && item_status != "ready_delivery"`; + + const itemsRes = await admin.collection("order_items").getList(1, 500, { + filter: itemFilter, + expand: "order,order.customer,order.customer.company,order.company,supplier,purchase_order", + }); + + // Group by (orderId, supplierId); skip items with no supplier. + const groups = new Map(); + for (const item of itemsRes.items) { + const orderId = item.order; + const supplierId = item.supplier; + if (!orderId || !supplierId) continue; + + const po = item.expand?.purchase_order as + | { id: string; status: BuyerTaskGroup["poStatus"] } + | undefined; + + // Post-filter by PO status (tab-specific). + if (tab === "po_draft" && po?.status !== "draft") continue; + if (tab === "po_sent" && !["sent", "sending"].includes(po?.status ?? "")) continue; + // Exclude ready_delivery items from po_sent (already delivered). + if (tab === "po_sent" && item.item_status === "ready_delivery") continue; + + const groupId = `${orderId}::${supplierId}`; + let group = groups.get(groupId); + if (!group) { + const order = item.expand?.order as { order_number?: string } | undefined; + const supplier = item.expand?.supplier as { suppliername?: string } | undefined; + const customer = (item.expand?.order as { customer?: unknown } | undefined) + ?.expand?.customer as { customer?: string } | undefined; + const company = (customer?.expand?.company as { company?: string } | undefined) + ?? (item.expand?.order as { expand?: { company?: { company?: string } } })?.expand?.company; + + group = { + groupId, + orderId, + orderNumber: order?.order_number ?? "", + supplierId, + supplierName: supplier?.suppliername ?? "", + customerName: customer?.customer ?? "", + companyName: company?.company ?? "", + items: [], + itemCount: 0, + totalCost: 0, + purchaseOrderId: po?.id ?? null, + poStatus: po?.status ?? null, + step: tab, + }; + groups.set(groupId, group); + } + + const quantity = Number(item.quantity) || 0; + const cost = Number(item.cost_price) || 0; + group.items.push({ + id: item.id, + description: item.description ?? "", + quantity, + costPrice: cost, + sellingPrice: Number(item.selling_price) || 0, + }); + group.itemCount += 1; + group.totalCost += quantity * cost; + } + + return Array.from(groups.values()).sort((a, b) => { + if (a.orderNumber !== b.orderNumber) return a.orderNumber.localeCompare(b.orderNumber); + return a.supplierName.localeCompare(b.supplierName); + }); +}