- Implemented a new split mechanism for order items, allowing operators to split quantities based on in-stock and non-in-stock statuses. - Updated the payload for creating split order items to ensure it adheres to the PocketBase schema, avoiding empty strings for relation fields and removing deprecated fields. - Enhanced error handling in the split route to log unexpected errors for better debugging. - Improved the UI to conditionally render split buttons based on item availability, ensuring a clearer user experience. - Added unit tests to validate the new splitting logic and payload structure, ensuring robustness against schema changes.
551 lines
19 KiB
TypeScript
551 lines
19 KiB
TypeScript
import { describe, it, expect, beforeEach, vi } from "vitest";
|
||
|
||
vi.mock("@/lib/server/pocketbase-admin", () => ({
|
||
getAdminPocketBase: vi.fn(),
|
||
}));
|
||
|
||
vi.mock("@/lib/server/orders-service", async (importOriginal) => {
|
||
const actual = await importOriginal<typeof import("@/lib/server/orders-service")>();
|
||
return { ...actual, getOrderItemsCollectionName: () => "order_items" };
|
||
});
|
||
|
||
import { getAdminPocketBase } from "@/lib/server/pocketbase-admin";
|
||
import {
|
||
splitOrderItemRemainder,
|
||
confirmOrderItemsCollected,
|
||
OrderItemSplitError,
|
||
} from "./order-items-service";
|
||
|
||
const baseItem = (overrides: Record<string, unknown> = {}) => ({
|
||
id: "item-1",
|
||
order: "order-1",
|
||
description: "M20 × 40 hex nuts grade 8.8",
|
||
po_description: "M20 × 40 hex nuts grade 8.8",
|
||
quantity: 100,
|
||
order_qty: 40,
|
||
selling_price: 12.5,
|
||
cost_price: 8,
|
||
markup: 56.25,
|
||
markup_percent: 56.25,
|
||
supplier: "sup-1",
|
||
item_status: "pending",
|
||
status: "active",
|
||
drv_status: "unassigned",
|
||
in_stock: false,
|
||
notes: "",
|
||
sku: "SKU-42",
|
||
pricing_source: "quote",
|
||
matched_price_list_id: "price-list-1",
|
||
pricing_notes: "",
|
||
...overrides,
|
||
});
|
||
|
||
/**
|
||
* Build a mock admin client wired for the split flow:
|
||
* - order_items.getOne → the item being split (default: 100 / 40)
|
||
* - orders.getOne → the parent order exists
|
||
* - order_items.create → echoes the payload as the new row
|
||
* Returns the mock + the create spy so callers can assert the payload.
|
||
*/
|
||
const makeAdmin = (itemOverrides: Record<string, unknown> = {}) => {
|
||
const item = baseItem(itemOverrides);
|
||
const getOne = vi.fn().mockImplementation((id: string) => {
|
||
if (id === "order-1") return Promise.resolve({ id: "order-1" });
|
||
return Promise.resolve(item);
|
||
});
|
||
const getFullList = vi.fn().mockResolvedValue([]);
|
||
const create = vi.fn().mockImplementation((payload: Record<string, unknown>) =>
|
||
Promise.resolve({ id: "item-rem-1", ...payload }),
|
||
);
|
||
const update = vi.fn().mockImplementation((id: string, payload: Record<string, unknown>) =>
|
||
Promise.resolve({ ...item, ...payload }),
|
||
);
|
||
const admin = {
|
||
collection: vi.fn().mockImplementation((name: string) => {
|
||
if (name === "order_items") {
|
||
return { getOne, getFullList, create, update };
|
||
}
|
||
return { getOne };
|
||
}),
|
||
};
|
||
return { admin, getOne, getFullList, create, update };
|
||
};
|
||
|
||
describe("splitOrderItemRemainder", () => {
|
||
beforeEach(() => {
|
||
vi.clearAllMocks();
|
||
});
|
||
|
||
it("creates a remainder line for the shortfall, supplier-less and pending", async () => {
|
||
const { admin, create } = makeAdmin();
|
||
vi.mocked(getAdminPocketBase).mockResolvedValue(admin as never);
|
||
|
||
const result = await splitOrderItemRemainder("item-1");
|
||
|
||
expect(result.remainderQty).toBe(60);
|
||
expect(result.remainder.id).toBe("item-rem-1");
|
||
expect(create).toHaveBeenCalledTimes(1);
|
||
const payload = create.mock.calls[0][0] as Record<string, unknown>;
|
||
expect(payload).toMatchObject({
|
||
order: "order-1",
|
||
description: "M20 × 40 hex nuts grade 8.8",
|
||
quantity: 60,
|
||
order_qty: 60,
|
||
selling_price: 12.5,
|
||
cost_price: 8,
|
||
supplier: null,
|
||
item_status: "pending",
|
||
// Audit-trail fields carried over…
|
||
sku: "SKU-42",
|
||
pricing_source: "quote",
|
||
matched_price_list_id: "price-list-1",
|
||
});
|
||
// …but the quote match stays on the original — `quote_item` is
|
||
// deliberately not in the create payload.
|
||
expect("quote_item" in (create.mock.calls[0][0] as Record<string, unknown>)).toBe(false);
|
||
// The remainder carries a split marker pointing back at its source.
|
||
const remainderDetails = JSON.parse(payload.order_item_details as string) as {
|
||
split: { sourceItemId: string; remainderQty: number };
|
||
};
|
||
expect(remainderDetails.split.sourceItemId).toBe("item-1");
|
||
expect(remainderDetails.split.remainderQty).toBe(60);
|
||
});
|
||
|
||
it("reduces the original's customer-facing quantity to its order_qty and stamps the source marker", async () => {
|
||
const { admin, update } = makeAdmin();
|
||
vi.mocked(getAdminPocketBase).mockResolvedValue(admin as never);
|
||
|
||
const result = await splitOrderItemRemainder("item-1");
|
||
|
||
// 100 / 40 split → original becomes 40 / 40 so 40 + 60 = 100
|
||
// (not 100 + 60 = 160), and the split button hides since
|
||
// order_qty == quantity.
|
||
expect(update).toHaveBeenCalledWith(
|
||
"item-1",
|
||
expect.objectContaining({ quantity: 40 }),
|
||
);
|
||
const updatePayload = update.mock.calls[0][1] as Record<string, unknown>;
|
||
const details = JSON.parse(updatePayload.order_item_details as string) as {
|
||
split: { remainderItemId: string; remainderQty: number };
|
||
};
|
||
expect(details.split.remainderItemId).toBe("item-rem-1");
|
||
expect(details.split.remainderQty).toBe(60);
|
||
expect(result.original.quantity).toBe(40);
|
||
});
|
||
|
||
it("rejects a duplicate split when a remainder line already exists", async () => {
|
||
const { admin, getFullList, create, update } = makeAdmin();
|
||
getFullList.mockResolvedValue([
|
||
{
|
||
id: "item-rem-legacy",
|
||
order: "order-1",
|
||
description: "M20 × 40 hex nuts grade 8.8",
|
||
quantity: 60,
|
||
order_qty: 60,
|
||
supplier: null,
|
||
sku: "SKU-42",
|
||
},
|
||
]);
|
||
vi.mocked(getAdminPocketBase).mockResolvedValue(admin as never);
|
||
|
||
const err = await splitOrderItemRemainder("item-1").catch((e) => e) as OrderItemSplitError;
|
||
expect(err).toBeInstanceOf(OrderItemSplitError);
|
||
expect(err.status).toBe(409);
|
||
expect(create).not.toHaveBeenCalled();
|
||
expect(update).not.toHaveBeenCalled();
|
||
});
|
||
|
||
it("copies po_description + in_stock + notes from the original", async () => {
|
||
const { admin, create } = makeAdmin({
|
||
po_description: "For supplier only: M20 nuts, grade 8.8",
|
||
in_stock: true,
|
||
notes: "prefer blue bins",
|
||
});
|
||
vi.mocked(getAdminPocketBase).mockResolvedValue(admin as never);
|
||
|
||
await splitOrderItemRemainder("item-1");
|
||
|
||
const payload = create.mock.calls[0][0] as Record<string, unknown>;
|
||
expect(payload.po_description).toBe("For supplier only: M20 nuts, grade 8.8");
|
||
expect(payload.in_stock).toBe(true);
|
||
expect(payload.notes).toBe("prefer blue bins");
|
||
});
|
||
|
||
it("defaults order_qty to quantity when the field is missing (no split)", async () => {
|
||
const { admin, create } = makeAdmin({ order_qty: undefined });
|
||
vi.mocked(getAdminPocketBase).mockResolvedValue(admin as never);
|
||
|
||
await expect(splitOrderItemRemainder("item-1")).rejects.toThrow(
|
||
OrderItemSplitError,
|
||
);
|
||
expect(create).not.toHaveBeenCalled();
|
||
});
|
||
|
||
it("rejects a 0 order_qty — no split when the operator ordered nothing from this supplier", async () => {
|
||
const { admin, create } = makeAdmin({ order_qty: 0 });
|
||
vi.mocked(getAdminPocketBase).mockResolvedValue(admin as never);
|
||
|
||
const err = await splitOrderItemRemainder("item-1").catch((e) => e) as OrderItemSplitError;
|
||
expect(err).toBeInstanceOf(OrderItemSplitError);
|
||
expect(err.status).toBe(422);
|
||
expect(create).not.toHaveBeenCalled();
|
||
});
|
||
|
||
it("rejects order_qty ≥ quantity — no shortfall to split", async () => {
|
||
const { admin, create } = makeAdmin({ order_qty: 100 });
|
||
vi.mocked(getAdminPocketBase).mockResolvedValue(admin as never);
|
||
|
||
const err = await splitOrderItemRemainder("item-1").catch((e) => e) as OrderItemSplitError;
|
||
expect(err.status).toBe(422);
|
||
expect(create).not.toHaveBeenCalled();
|
||
});
|
||
|
||
it("rejects items past the editable window", async () => {
|
||
const { admin, create } = makeAdmin({ item_status: "po_generated" });
|
||
vi.mocked(getAdminPocketBase).mockResolvedValue(admin as never);
|
||
|
||
const err = await splitOrderItemRemainder("item-1").catch((e) => e) as OrderItemSplitError;
|
||
expect(err.status).toBe(409);
|
||
expect(create).not.toHaveBeenCalled();
|
||
});
|
||
|
||
it("rejects a missing item with 404", async () => {
|
||
const { admin, create } = makeAdmin();
|
||
(admin.collection as unknown as ReturnType<typeof vi.fn>).mockImplementation(
|
||
(name: string) => {
|
||
if (name === "order_items") {
|
||
return {
|
||
getOne: vi.fn().mockRejectedValue(new Error("404")),
|
||
create,
|
||
};
|
||
}
|
||
return { getOne: vi.fn() };
|
||
},
|
||
);
|
||
vi.mocked(getAdminPocketBase).mockResolvedValue(admin as never);
|
||
|
||
const err = await splitOrderItemRemainder("item-1").catch((e) => e) as OrderItemSplitError;
|
||
expect(err.status).toBe(404);
|
||
expect(create).not.toHaveBeenCalled();
|
||
});
|
||
|
||
it("rejects an item with no parent order", async () => {
|
||
const { admin, create } = makeAdmin({ order: null });
|
||
vi.mocked(getAdminPocketBase).mockResolvedValue(admin as never);
|
||
|
||
const err = await splitOrderItemRemainder("item-1").catch((e) => e) as OrderItemSplitError;
|
||
expect(err.status).toBe(409);
|
||
expect(create).not.toHaveBeenCalled();
|
||
});
|
||
|
||
it("sends a clean create payload (no unknown fields, null PO link, no category unless present)", async () => {
|
||
const { admin, create } = makeAdmin();
|
||
vi.mocked(getAdminPocketBase).mockResolvedValue(admin as never);
|
||
|
||
await splitOrderItemRemainder("item-1");
|
||
|
||
const payload = create.mock.calls[0][0] as Record<string, unknown>;
|
||
// `status` and `drv_status` do not exist on the live order_items
|
||
// schema (v1 leftovers). PB silently strips unknown fields in
|
||
// older SDK builds and throws in stricter ones — don't send them.
|
||
expect(payload).not.toHaveProperty("status");
|
||
expect(payload).not.toHaveProperty("drv_status");
|
||
// Relation fields must be `null` for "no relation" — `""` is
|
||
// rejected by stricter PB builds as a malformed relation id.
|
||
expect(payload.purchase_order).toBeNull();
|
||
// `category` is optional on the schema and only inherited when
|
||
// the original line has it; the base mock has none.
|
||
expect(payload).not.toHaveProperty("category");
|
||
});
|
||
|
||
it("inherits category from the original when present", async () => {
|
||
const { admin, create } = makeAdmin({ category: "cat-fasteners" });
|
||
vi.mocked(getAdminPocketBase).mockResolvedValue(admin as never);
|
||
|
||
await splitOrderItemRemainder("item-1");
|
||
|
||
const payload = create.mock.calls[0][0] as Record<string, unknown>;
|
||
expect(payload.category).toBe("cat-fasteners");
|
||
});
|
||
});
|
||
|
||
describe("confirmOrderItemsCollected", () => {
|
||
beforeEach(() => {
|
||
vi.clearAllMocks();
|
||
});
|
||
|
||
/**
|
||
* Build a mock admin client that hands out distinct order_items rows
|
||
* keyed by id, with a per-id status override (defaults to the base
|
||
* `collection_pool` eligibility). `update` echoes the merged payload
|
||
* so the service's `update` call can be asserted on directly.
|
||
*/
|
||
const makeConfirmAdmin = (
|
||
rowsById: Record<string, Record<string, unknown>>,
|
||
) => {
|
||
const getOne = vi.fn().mockImplementation((id: string) => {
|
||
const row = rowsById[id];
|
||
if (!row) return Promise.reject(new Error(`Row ${id} missing`));
|
||
return Promise.resolve(row);
|
||
});
|
||
const update = vi.fn().mockImplementation(
|
||
(id: string, payload: Record<string, unknown>) =>
|
||
Promise.resolve({ ...rowsById[id], ...payload, id }),
|
||
);
|
||
const admin = {
|
||
collection: vi.fn().mockImplementation((name: string) => {
|
||
if (name === "order_items") return { getOne, update };
|
||
return { getOne: vi.fn() };
|
||
}),
|
||
};
|
||
return { admin, getOne, update };
|
||
};
|
||
|
||
const ACTOR = { id: "agent-1", name: "Giellie" };
|
||
|
||
it("returns no-op shape for an empty id list", async () => {
|
||
const result = await confirmOrderItemsCollected([], { actor: ACTOR });
|
||
expect(result).toEqual({ results: [], confirmed: [], failed: [] });
|
||
});
|
||
|
||
it("confirms a collection_pool row, stamping all five audit fields and a JSON note", async () => {
|
||
const { admin, update } = makeConfirmAdmin({
|
||
"item-1": {
|
||
id: "item-1",
|
||
item_status: "collection_pool",
|
||
quantity: 10,
|
||
qty_collected: 0,
|
||
notes: "",
|
||
},
|
||
});
|
||
vi.mocked(getAdminPocketBase).mockResolvedValue(admin as never);
|
||
|
||
const result = await confirmOrderItemsCollected(["item-1"], { actor: ACTOR });
|
||
|
||
expect(result.confirmed).toEqual(["item-1"]);
|
||
expect(result.failed).toEqual([]);
|
||
expect(update).toHaveBeenCalledTimes(1);
|
||
const [, payload] = update.mock.calls[0];
|
||
expect(payload).toMatchObject({
|
||
item_status: "collected",
|
||
drv_status: "collected",
|
||
qty_collected: 10,
|
||
});
|
||
expect(payload.drv_collected_at).toMatch(/^\d{4}-\d{2}-\d{2}$/);
|
||
expect(payload.collected_date).toMatch(/^\d{4}-\d{2}-\d{2}$/);
|
||
const parsedNotes = JSON.parse(payload.notes);
|
||
expect(parsedNotes).toHaveLength(1);
|
||
expect(parsedNotes[0]).toMatchObject({
|
||
actorId: "agent-1",
|
||
actorName: "Giellie",
|
||
category: "collection",
|
||
});
|
||
expect(typeof parsedNotes[0].body).toBe("string");
|
||
expect(parsedNotes[0].body).toMatch(/Confirmed collection without driver assignment/);
|
||
});
|
||
|
||
it("confirms an `assigned` row (driver-bound path also accepted)", async () => {
|
||
const { admin, update } = makeConfirmAdmin({
|
||
"item-2": {
|
||
id: "item-2",
|
||
item_status: "assigned",
|
||
quantity: 5,
|
||
qty_collected: 0,
|
||
notes: "",
|
||
},
|
||
});
|
||
vi.mocked(getAdminPocketBase).mockResolvedValue(admin as never);
|
||
|
||
const result = await confirmOrderItemsCollected(["item-2"], { actor: ACTOR });
|
||
expect(result.confirmed).toEqual(["item-2"]);
|
||
expect(update.mock.calls[0][1].item_status).toBe("collected");
|
||
});
|
||
|
||
it("supports a partial collect via quantity_collected override (additive to existing)", async () => {
|
||
const { admin, update } = makeConfirmAdmin({
|
||
"item-3": {
|
||
id: "item-3",
|
||
item_status: "collection_pool",
|
||
quantity: 100,
|
||
qty_collected: 30,
|
||
notes: "",
|
||
},
|
||
});
|
||
vi.mocked(getAdminPocketBase).mockResolvedValue(admin as never);
|
||
|
||
const result = await confirmOrderItemsCollected(
|
||
["item-3"],
|
||
{ actor: ACTOR, quantity_collected: { "item-3": 50 } },
|
||
);
|
||
|
||
expect(result.confirmed).toEqual(["item-3"]);
|
||
expect(update.mock.calls[0][1].qty_collected).toBe(80);
|
||
expect(result.results[0].previous).toEqual({ item_status: "collection_pool", qty_collected: 30 });
|
||
});
|
||
|
||
it("rejects a qty override that exceeds remaining with qty_overrun", async () => {
|
||
const { admin, update } = makeConfirmAdmin({
|
||
"item-4": {
|
||
id: "item-4",
|
||
item_status: "collection_pool",
|
||
quantity: 10,
|
||
qty_collected: 8,
|
||
notes: "",
|
||
},
|
||
});
|
||
vi.mocked(getAdminPocketBase).mockResolvedValue(admin as never);
|
||
|
||
const result = await confirmOrderItemsCollected(
|
||
["item-4"],
|
||
{ actor: ACTOR, quantity_collected: { "item-4": 5 } },
|
||
);
|
||
|
||
expect(result.confirmed).toEqual([]);
|
||
expect(result.failed).toEqual([{ id: "item-4", reason: "qty_overrun: requested 5 > remaining 2" }]);
|
||
expect(update).not.toHaveBeenCalled();
|
||
});
|
||
|
||
it("rejects rows already past the pool (collected / delivered / invoiced)", async () => {
|
||
const { admin, update } = makeConfirmAdmin({
|
||
"item-collected": {
|
||
id: "item-collected",
|
||
item_status: "collected",
|
||
quantity: 10,
|
||
qty_collected: 10,
|
||
notes: "",
|
||
},
|
||
"item-delivered": {
|
||
id: "item-delivered",
|
||
item_status: "delivered",
|
||
quantity: 10,
|
||
qty_collected: 10,
|
||
notes: "",
|
||
},
|
||
"item-invoiced": {
|
||
id: "item-invoiced",
|
||
item_status: "invoiced",
|
||
quantity: 10,
|
||
qty_collected: 10,
|
||
notes: "",
|
||
},
|
||
});
|
||
vi.mocked(getAdminPocketBase).mockResolvedValue(admin as never);
|
||
|
||
const result = await confirmOrderItemsCollected(
|
||
["item-collected", "item-delivered", "item-invoiced"],
|
||
{ actor: ACTOR },
|
||
);
|
||
|
||
expect(result.confirmed).toEqual([]);
|
||
expect(result.failed.map((f) => f.id)).toEqual([
|
||
"item-collected",
|
||
"item-delivered",
|
||
"item-invoiced",
|
||
]);
|
||
for (const f of result.failed) {
|
||
expect(f.reason).toMatch(/^wrong_status:/);
|
||
}
|
||
expect(update).not.toHaveBeenCalled();
|
||
});
|
||
|
||
it("reports per-row independence: one ok, one wrong_status, one not_found", async () => {
|
||
const { admin, update } = makeConfirmAdmin({
|
||
"item-ok": {
|
||
id: "item-ok",
|
||
item_status: "collection_pool",
|
||
quantity: 4,
|
||
qty_collected: 0,
|
||
notes: "",
|
||
},
|
||
"item-bad": {
|
||
id: "item-bad",
|
||
item_status: "cancelled",
|
||
quantity: 4,
|
||
qty_collected: 0,
|
||
notes: "",
|
||
},
|
||
// item-missing intentionally absent — getOne will reject
|
||
});
|
||
vi.mocked(getAdminPocketBase).mockResolvedValue(admin as never);
|
||
|
||
const result = await confirmOrderItemsCollected(
|
||
["item-ok", "item-bad", "item-missing"],
|
||
{ actor: ACTOR },
|
||
);
|
||
|
||
expect(result.confirmed).toEqual(["item-ok"]);
|
||
expect(result.failed.map((f) => f.id)).toEqual(["item-bad", "item-missing"]);
|
||
expect(update).toHaveBeenCalledTimes(1);
|
||
expect(result.results.find((r) => r.id === "item-ok")?.status).toBe("ok");
|
||
expect(result.results.find((r) => r.id === "item-bad")?.status).toBe("failed");
|
||
expect(result.results.find((r) => r.id === "item-missing")?.status).toBe("failed");
|
||
});
|
||
|
||
it("appends a new note without clobbering the existing notes feed", async () => {
|
||
const existingNote = {
|
||
id: "prior-1",
|
||
ts: "2026-08-01T10:00:00.000Z",
|
||
actorId: "u-1",
|
||
actorName: "Operator",
|
||
category: "note" as const,
|
||
body: "Customer requested delivery on Friday",
|
||
};
|
||
const { admin, update } = makeConfirmAdmin({
|
||
"item-5": {
|
||
id: "item-5",
|
||
item_status: "assigned",
|
||
quantity: 10,
|
||
qty_collected: 0,
|
||
notes: JSON.stringify([existingNote]),
|
||
},
|
||
});
|
||
vi.mocked(getAdminPocketBase).mockResolvedValue(admin as never);
|
||
|
||
await confirmOrderItemsCollected(["item-5"], {
|
||
actor: ACTOR,
|
||
note: "Driver didn't show — agent confirmed pickup directly",
|
||
});
|
||
|
||
const [, payload] = update.mock.calls[0];
|
||
const notes = JSON.parse(payload.notes);
|
||
expect(notes).toHaveLength(2);
|
||
expect(notes[0]).toEqual(existingNote);
|
||
expect(notes[1]).toMatchObject({
|
||
actorId: "agent-1",
|
||
category: "collection",
|
||
});
|
||
expect(notes[1].body).toContain("Driver didn't show");
|
||
});
|
||
|
||
it("throws OrderItemConfirmError when actor is missing", async () => {
|
||
await expect(
|
||
confirmOrderItemsCollected(["item-1"], { actor: { id: "", name: "" } }),
|
||
).rejects.toMatchObject({
|
||
name: "OrderItemConfirmError",
|
||
code: "missing_actor",
|
||
status: 400,
|
||
});
|
||
});
|
||
|
||
it("accepts a caller-supplied collected_at date for the stamp", async () => {
|
||
const { admin, update } = makeConfirmAdmin({
|
||
"item-6": {
|
||
id: "item-6",
|
||
item_status: "collection_pool",
|
||
quantity: 2,
|
||
qty_collected: 0,
|
||
notes: "",
|
||
},
|
||
});
|
||
vi.mocked(getAdminPocketBase).mockResolvedValue(admin as never);
|
||
|
||
await confirmOrderItemsCollected(["item-6"], {
|
||
actor: ACTOR,
|
||
collected_at: "2026-08-09T15:30:00.000Z",
|
||
});
|
||
|
||
const [, payload] = update.mock.calls[0];
|
||
expect(payload.drv_collected_at).toBe("2026-08-09");
|
||
expect(payload.collected_date).toBe("2026-08-09");
|
||
});
|
||
});
|