feat(collection-confirm): add service skeleton with listCollectedItems

Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
SJCoetz 2026-08-03 00:08:57 +02:00
parent 06d8294c77
commit efe10b9474
2 changed files with 477 additions and 0 deletions

View file

@ -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<string, unknown>[];
stockItems?: Record<string, unknown>[];
}) => {
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);
});
});

View file

@ -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<ConfirmRow[]> {
const pb = await getAdminPocketBase();
const [customerRows, stockRows] = await Promise.all([
pb.collection("order_items").getFullList<ConfirmRow>({
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<ConfirmRow> {
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<string, number>;
actor: ConfirmActor;
},
): Promise<{ updated: number; failed: Array<{ id: string; error: string }> }> {
void ids;
void body;
throw new Error("not implemented");
}