feat(collection-confirm): add service skeleton with listCollectedItems
Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
parent
06d8294c77
commit
efe10b9474
2 changed files with 477 additions and 0 deletions
244
src/lib/server/collection-confirm-service.test.ts
Normal file
244
src/lib/server/collection-confirm-service.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
233
src/lib/server/collection-confirm-service.ts
Normal file
233
src/lib/server/collection-confirm-service.ts
Normal 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");
|
||||
}
|
||||
Loading…
Reference in a new issue