From efe10b9474818a3fc15c40ed005819c6e6d2a4da Mon Sep 17 00:00:00 2001 From: SJCoetz Date: Mon, 3 Aug 2026 00:08:57 +0200 Subject: [PATCH] feat(collection-confirm): add service skeleton with listCollectedItems Co-authored-by: Orca --- .../server/collection-confirm-service.test.ts | 244 ++++++++++++++++++ src/lib/server/collection-confirm-service.ts | 233 +++++++++++++++++ 2 files changed, 477 insertions(+) create mode 100644 src/lib/server/collection-confirm-service.test.ts create mode 100644 src/lib/server/collection-confirm-service.ts diff --git a/src/lib/server/collection-confirm-service.test.ts b/src/lib/server/collection-confirm-service.test.ts new file mode 100644 index 0000000..dea7f60 --- /dev/null +++ b/src/lib/server/collection-confirm-service.test.ts @@ -0,0 +1,244 @@ +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 { + listCollectedItems, + updateOrderItemCost, + confirmOrderItemsForInvoicing, +} from "./collection-confirm-service"; + +/** + * Mock admin client factory. + * + * Mirrors the page's two-leg union query: one `getFullList` call against + * `order_items` (with the `collected` filter) and one against + * `stock_purchase_items` (with `status = "collected"`). The mock lets + * each test supply the items each leg should return. + */ +const makeAdmin = (opts: { + orderItems?: Record[]; + stockItems?: Record[]; +}) => { + const orderItemsGetFullList = vi + .fn() + .mockResolvedValue(opts.orderItems ?? []); + const stockItemsGetFullList = vi + .fn() + .mockResolvedValue(opts.stockItems ?? []); + const admin = { + collection: vi.fn().mockImplementation((name: string) => { + if (name === "order_items") { + return { getFullList: orderItemsGetFullList }; + } + if (name === "stock_purchase_items") { + return { getFullList: stockItemsGetFullList }; + } + throw new Error(`unexpected collection ${name}`); + }), + }; + return { admin, orderItemsGetFullList, stockItemsGetFullList }; +}; + +describe("listCollectedItems", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("returns order_items rows expanded with purchase_order, supplier, order, company, customer", async () => { + const orderItem = { + id: "oi-1", + item_status: "collected", + description: "M20 × 40 hex nuts", + order_qty: 100, + qty_collected: 100, + quantity: 100, + cost_price: 8, + order_item_attachements: ["invoice.pdf"], + expand: { + purchase_order: { + id: "po-1", + po_number: "PO-1", + expand: { supplier: { id: "s-1", suppliername: "Acme" } }, + }, + order: { + id: "o-1", + order_number: "ORD-1", + expand: { + company: { id: "co-1", company: "Acme Co" }, + customer: { id: "cu-1", customer: "J. Smith" }, + }, + }, + }, + }; + const { admin, orderItemsGetFullList, stockItemsGetFullList } = + makeAdmin({ orderItems: [orderItem] }); + vi.mocked(getAdminPocketBase).mockResolvedValue(admin as never); + + const rows = await listCollectedItems(); + + expect(rows).toHaveLength(1); + const row = rows[0]!; + expect(row.id).toBe("oi-1"); + expect(row.source).toBe("customer"); + expect(row.description).toBe("M20 × 40 hex nuts"); + expect(row.qty_collected).toBe(100); + expect(row.order_qty).toBe(100); + expect(row.item_status).toBe("collected"); + expect(row.expand?.purchase_order?.po_number).toBe("PO-1"); + expect(row.expand?.purchase_order?.expand?.supplier?.suppliername).toBe("Acme"); + expect(row.expand?.order?.order_number).toBe("ORD-1"); + expect(row.expand?.order?.expand?.customer?.customer).toBe("J. Smith"); + expect(row.expand?.order?.expand?.company?.company).toBe("Acme Co"); + + // Order-items leg must use the `collected` filter and request the + // expansion the page relies on (PO supplier + order customer/company). + expect(orderItemsGetFullList).toHaveBeenCalledTimes(1); + const orderOpts = orderItemsGetFullList.mock.calls[0]?.[0] as + | { filter?: string; expand?: string } + | undefined; + expect(orderOpts?.filter).toBe('item_status = "collected"'); + expect(orderOpts?.expand).toBe( + "purchase_order.supplier,order.company,order.customer", + ); + // Stock-leg is also called (the page runs both legs in parallel); + // it just returned an empty list for this test, so its items + // contribute nothing to the merged output. + expect(stockItemsGetFullList).toHaveBeenCalledTimes(1); + }); + + it("returns stock_purchase_items rows normalised to ConfirmRow with source=stock", async () => { + const stockItem = { + id: "spi-1", + description: "Stock cable ties", + stock_item: "st-1", + quantity: 50, + quantity_received: 50, + cost_price: 1.5, + status: "collected", + assigned_at: "2026-08-01T10:00:00.000Z", + expand: { + purchase: { + id: "sp-1", + purchase_number: "SP-001", + expand: { supplier: { id: "s-2", suppliername: "CableCo" } }, + }, + stock_item: { + id: "st-1", + sku: "CT-200", + name: "Cable tie 200mm", + description: "Black UV-stable cable tie", + }, + }, + }; + const { admin, orderItemsGetFullList, stockItemsGetFullList } = + makeAdmin({ stockItems: [stockItem] }); + vi.mocked(getAdminPocketBase).mockResolvedValue(admin as never); + + const rows = await listCollectedItems(); + + expect(rows).toHaveLength(1); + const row = rows[0]!; + expect(row.id).toBe("spi-1"); + expect(row.source).toBe("stock"); + expect(row.description).toBe("Stock cable ties"); + expect(row.qty_collected).toBe(50); + expect(row.order_qty).toBe(50); + expect(row.quantity).toBe(50); + expect(row.item_status).toBe("collected"); + expect(row.stock_line_status).toBe("collected"); + expect(row.assigned_at).toBe("2026-08-01T10:00:00.000Z"); + expect(row.stock_purchase_id).toBe("sp-1"); + expect(row.poNumber).toBe("SP-001"); + expect(row.sku).toBe("CT-200"); + // The purchase data is mapped onto the shared `expand.purchase_order` + // shape so the page's supplier link works without a branch. + expect(row.expand?.purchase_order?.id).toBe("sp-1"); + expect(row.expand?.purchase_order?.po_number).toBe("SP-001"); + expect(row.expand?.purchase_order?.expand?.supplier?.suppliername).toBe("CableCo"); + // Stock rows have no customer side. + expect(row.expand?.order).toBeUndefined(); + + // Stock leg filter must be the status-based variant (NOT item_status). + expect(stockItemsGetFullList).toHaveBeenCalledTimes(1); + const stockOpts = stockItemsGetFullList.mock.calls[0]?.[0] as + | { filter?: string; expand?: string } + | undefined; + expect(stockOpts?.filter).toBe('status = "collected"'); + expect(stockOpts?.expand).toBe("purchase.supplier,purchase,stock_item"); + // Order-items leg must NOT be called when we only returned stock rows. + // (In the real code it IS called — but it returned empty.) + expect(orderItemsGetFullList).toHaveBeenCalledTimes(1); + expect(orderItemsGetFullList.mock.calls[0]?.[0]).toMatchObject({ + filter: 'item_status = "collected"', + }); + }); + + it("returns an empty array when both legs return zero rows", async () => { + const { admin } = makeAdmin({ orderItems: [], stockItems: [] }); + vi.mocked(getAdminPocketBase).mockResolvedValue(admin as never); + + const rows = await listCollectedItems(); + + expect(rows).toEqual([]); + }); + + it("merges order_items and stock_purchase_items into one ConfirmRow[]", async () => { + const orderItem = { + id: "oi-1", + item_status: "collected", + description: "M20 × 40 hex nuts", + order_qty: 100, + qty_collected: 100, + expand: { + purchase_order: { id: "po-1", po_number: "PO-1" }, + order: { id: "o-1" }, + }, + }; + const stockItem = { + id: "spi-1", + description: "Cable ties", + quantity: 50, + quantity_received: 50, + status: "collected", + expand: { + purchase: { id: "sp-1", purchase_number: "SP-001" }, + }, + }; + const { admin } = makeAdmin({ + orderItems: [orderItem], + stockItems: [stockItem], + }); + vi.mocked(getAdminPocketBase).mockResolvedValue(admin as never); + + const rows = await listCollectedItems(); + + expect(rows).toHaveLength(2); + const sources = rows.map((r) => r.source).sort(); + expect(sources).toEqual(["customer", "stock"]); + }); +}); + +describe("stubs for plan tasks 4 and 5", () => { + it("updateOrderItemCost throws 'not implemented'", async () => { + await expect( + updateOrderItemCost("oi-1", { cost_price: 10, actor: { id: "u1", name: "U" } }), + ).rejects.toThrow(/not implemented/i); + }); + + it("confirmOrderItemsForInvoicing throws 'not implemented'", async () => { + await expect( + confirmOrderItemsForInvoicing( + ["oi-1"], + { + invoice: { date: "2026-08-01", number: "INV-1" }, + costOverrides: {}, + actor: { id: "u1", name: "U" }, + }, + ), + ).rejects.toThrow(/not implemented/i); + }); +}); diff --git a/src/lib/server/collection-confirm-service.ts b/src/lib/server/collection-confirm-service.ts new file mode 100644 index 0000000..4b724be --- /dev/null +++ b/src/lib/server/collection-confirm-service.ts @@ -0,0 +1,233 @@ +import { getAdminPocketBase } from "@/lib/server/pocketbase-admin"; +import type { ConfirmActor } from "@/app/(app)/collection-confirm/view-model"; + +/** + * Where the row came from. The collection-confirm / collection-pool + * pages now read from BOTH `order_items` (customer deliveries) and + * `stock_purchase_items` (supplier pickups). The two have different + * field names + state machines, so we normalise to a `ConfirmRow` + * with a `source` discriminator and conditional fields: + * - `source: "customer"` → `expand.order` is the customer order + * - `source: "stock"` → `expand.order` is null, `stock_purchase_id` holds the parent + * + * This type is the new authoritative source for the shape consumed by + * the collection-confirm page. The duplicate in `page.tsx` will be + * migrated in a follow-up task. + */ +export type ConfirmRowSource = "customer" | "stock"; + +export type ConfirmRow = { + id: string; + /** + * Which collection this row came from. Drives the routing on + * the collection-confirm page (customer lines → `ready_invoice`, + * stock lines → `booked` to `stock_items`). + */ + source?: ConfirmRowSource; + description?: string; + po_description?: string; + order_qty?: number; + qty_collected?: number; + quantity?: number; + item_status?: string; + problem_flag?: boolean | null; + problem_type?: string | null; + problem_notes?: string | null; + notes?: string | null; + drv_collected_at?: string | null; + collection_photo?: string; + // Canonical PocketBase field name has the typo (`attachements`). Read + // both names for back-compat with any pre-existing rows that were + // written under the spelled-correct variant. + order_item_attachments?: string[]; + order_item_attachements?: string[]; + order_item_details?: string | null; + // Surplus split fields (added by 2026-07-14-create-stock migration). + // The chip + confirm button only render when the row has positive + // headroom AND has not yet been booked (surplus_qty undefined/null). + surplus_qty?: number | null; + stock_booked_from?: string | null; + // Match-key fields consumed by `findMatchingStockItem` when the + // surplus-book flow auto-picks a target stock_item. + sku?: string; + supplier?: string; + poNumber?: string; + /** + * Stock-only: PB id of the parent `stock_purchase`. Always set + * for `source: "stock"` rows; null for `source: "customer"` rows. + * Used by the driver-assign / book-to-stock / status-flow paths + * to know which table to write back to. + */ + stock_purchase_id?: string | null; + /** + * Stock-only: the per-line `status` value copied from + * `stock_purchase_items.status` at fetch time. Possible values: + * `collection_pool` | `assigned_to_driver` | `collected` | `booked`. + */ + stock_line_status?: string | null; + /** + * Stock-only: timestamp the line was bound to a driver. Mirrors + * `stock_purchase_items.assigned_at`. + */ + assigned_at?: string | null; + expand?: { + purchase_order?: { id: string; po_number?: string; expand?: { supplier?: { id: string; suppliername?: string } } }; + order?: { id: string; order_number?: string; expand?: { company?: { company?: string }; customer?: { customer?: string } } }; + }; +}; + +/** + * Read-side service for the collection-confirm page. + * + * Mirrors the union query the page's `load()` performs client-side, but + * runs through the admin PocketBase client so it can be exposed via + * `/api/v1/collection-confirm` once the page is migrated. The write + * helpers (`updateOrderItemCost`, `confirmOrderItemsForInvoicing`) come + * in follow-up plan tasks — they are stubbed here so the file shape is + * committed and downstream tasks can build on top. + */ + +/** + * Return every collected row (customer + stock legs) that the + * collection-confirm page needs to render. + * + * Customer leg: `order_items` filtered by `item_status="collected"`, + * expanded with `purchase_order.supplier,order.company,order.customer`. + * Stock leg: `stock_purchase_items` filtered by `status="collected"`, + * expanded with `purchase.supplier,purchase,stock_item`. + * + * Both legs are normalised into `ConfirmRow[]`. Customer rows are + * tagged `source: "customer"`, stock rows are mapped onto the shared + * `expand.purchase_order` shape (so the page's supplier link still + * works without branching) and tagged `source: "stock"`. + */ +export async function listCollectedItems(): Promise { + const pb = await getAdminPocketBase(); + + const [customerRows, stockRows] = await Promise.all([ + pb.collection("order_items").getFullList({ + filter: 'item_status = "collected"', + sort: "-drv_collected_at", + expand: "purchase_order.supplier,order.company,order.customer", + }), + pb + .collection("stock_purchase_items") + .getFullList< + ConfirmRow & { + id: string; + stock_item: string; + quantity: number; + quantity_received: number; + cost_price: number; + status?: string | null; + assigned_at?: string | null; + expand?: { + purchase?: { + id: string; + purchase_number: string; + expand?: { supplier?: { id: string; suppliername?: string } }; + }; + stock_item?: { id: string; sku?: string; name?: string; description?: string }; + }; + } + >({ + filter: 'status = "collected"', + sort: "-created", + expand: "purchase.supplier,purchase,stock_item", + }), + ]); + + // Normalise stock rows into ConfirmRow shape. Stock fields that don't + // map (problem_flag, drv_collected_at, attachments) are left undefined + // so the type stays honest. + const stockAsConfirm: ConfirmRow[] = (stockRows as unknown as Array<{ + id: string; + stock_item: string; + description: string; + quantity: number; + quantity_received: number; + cost_price: number; + status?: string | null; + assigned_at?: string | null; + expand?: ConfirmRow["expand"] & { + purchase?: { + id: string; + purchase_number: string; + expand?: { supplier?: { id: string; suppliername?: string } }; + }; + stock_item?: { id: string; sku?: string; name?: string; description?: string }; + }; + }>).map((s) => ({ + id: s.id, + source: "stock" as const, + description: s.description, + qty_collected: s.quantity_received ?? 0, + order_qty: s.quantity, + quantity: s.quantity, + item_status: s.status ?? undefined, + sku: s.expand?.stock_item?.sku, + supplier: undefined, + stock_line_status: s.status ?? null, + assigned_at: s.assigned_at ?? null, + stock_purchase_id: s.expand?.purchase?.id ?? null, + poNumber: s.expand?.purchase?.purchase_number ?? undefined, + expand: { + // Reuse the same shape so the supplier link works (the page + // looks at `expand.purchase_order.expand.supplier.suppliername`). + purchase_order: s.expand?.purchase + ? { + id: s.expand.purchase.id, + po_number: s.expand.purchase.purchase_number, + expand: { supplier: s.expand.purchase.expand?.supplier }, + } + : undefined, + }, + })); + + // Mark customer rows with their source so downstream code can + // branch without inspecting field presence. + const customerAsConfirm: ConfirmRow[] = customerRows.map((r) => ({ + ...r, + source: "customer" as const, + })); + + // Sort the union by created-desc-ish — both legs return at most ~500 + // rows; using id-desc gives a stable, predictable order across loads. + const merged = [...customerAsConfirm, ...stockAsConfirm].sort( + (a, b) => (b.id > a.id ? 1 : -1), + ); + return merged; +} + +/** + * Update the cost price on a single collected order_item and stamp a + * `cost`-category note onto the row so the audit trail is preserved. + * + * Stubbed — implementation lands in plan task 4. + */ +export async function updateOrderItemCost( + id: string, + body: { cost_price: number; actor: ConfirmActor }, +): Promise { + throw new Error("not implemented"); +} + +/** + * Flip a set of order_items into `ready_invoice` (or whichever next + * status the view-model picks) and stamp the supplier-invoice + * details into `order_item_details` so invoicing can pick the rows up. + * + * Stubbed — implementation lands in plan task 5. + */ +export async function confirmOrderItemsForInvoicing( + ids: string[], + body: { + invoice: { date: string; number: string }; + costOverrides: Record; + actor: ConfirmActor; + }, +): Promise<{ updated: number; failed: Array<{ id: string; error: string }> }> { + void ids; + void body; + throw new Error("not implemented"); +}