- Add new POST API endpoints at `/api/v1/stock-purchase-items/[id]/sku` and `/api/v1/stock-purchase-items/[id]/cost` for updating stock purchase item details - Update ItemsTable to support cost and SKU edits for all row types, add supplier SKU column and editing for stock rows - Extend PoCard to pass edit callbacks and add stock-specific invoice upload functionality - Update all related test suites to cover new editing and UI features - Adjust invoice upload messaging to differentiate between customer and stock purchase orders
233 lines
No EOL
7.5 KiB
TypeScript
233 lines
No EOL
7.5 KiB
TypeScript
import { describe, it, expect } from "vitest";
|
|
import {
|
|
scanPurchaseOrders,
|
|
type ScanPb,
|
|
} from "@/lib/server/purchase-order-agent-service";
|
|
|
|
/**
|
|
* Tests for the agent's read-side triage service.
|
|
*
|
|
* Mock strategy: stub the two `getFullList` paths the service uses
|
|
* (per-kind header read + per-PO line-item read) and assert on
|
|
* the returned shape. Mocking both at the collection level keeps
|
|
* the tests resilient to PB field drift — the service uses
|
|
* generic `Record<string, unknown>` access throughout.
|
|
*/
|
|
|
|
interface MockState {
|
|
stockHeaders: Array<Record<string, unknown>>;
|
|
stockLinesByPurchase: Record<string, Array<Record<string, unknown>>>;
|
|
customerHeaders: Array<Record<string, unknown>>;
|
|
customerLinesByPurchase: Record<string, Array<Record<string, unknown>>>;
|
|
}
|
|
|
|
function buildMockPb(state: MockState): ScanPb & {
|
|
calls: { collection: string; filter: string }[];
|
|
} {
|
|
const calls: { collection: string; filter: string }[] = [];
|
|
const collection = (name: string) => ({
|
|
getFullList: async (opts?: {
|
|
filter?: string;
|
|
expand?: string;
|
|
fields?: string;
|
|
}) => {
|
|
const filter = opts?.filter ?? "";
|
|
calls.push({ collection: name, filter });
|
|
if (name === "stock_purchases") return state.stockHeaders;
|
|
if (name === "purchase_orders") return state.customerHeaders;
|
|
if (name === "stock_purchase_items") {
|
|
// Filter shape: `purchase = "<id>"`. Match by id literal.
|
|
const match = /purchase = "([^"]+)"/.exec(filter);
|
|
const id = match?.[1] ?? "";
|
|
return state.stockLinesByPurchase[id] ?? [];
|
|
}
|
|
if (name === "order_items") {
|
|
const match = /purchase_order = "([^"]+)"/.exec(filter);
|
|
const id = match?.[1] ?? "";
|
|
return state.customerLinesByPurchase[id] ?? [];
|
|
}
|
|
return [];
|
|
},
|
|
getList: async () => ({
|
|
items: [],
|
|
totalItems: 0,
|
|
page: 1,
|
|
perPage: 0,
|
|
totalPages: 0,
|
|
}),
|
|
});
|
|
return { collection, calls } as unknown as ScanPb & {
|
|
calls: { collection: string; filter: string }[];
|
|
};
|
|
}
|
|
|
|
describe("scanPurchaseOrders", () => {
|
|
it("returns empty arrays when both kinds have no rows", async () => {
|
|
const pb = buildMockPb({
|
|
stockHeaders: [],
|
|
stockLinesByPurchase: {},
|
|
customerHeaders: [],
|
|
customerLinesByPurchase: {},
|
|
});
|
|
const result = await scanPurchaseOrders(pb);
|
|
expect(result.total_needing_attention).toBe(0);
|
|
expect(result.by_kind).toEqual({ stock: 0, customer: 0 });
|
|
expect(result.pos).toEqual([]);
|
|
});
|
|
|
|
it("scans stock_purchases and computes per-line diagnostics", async () => {
|
|
const pb = buildMockPb({
|
|
stockHeaders: [
|
|
{
|
|
id: "sp-1",
|
|
purchase_number: "PO26080601STK",
|
|
status: "sent",
|
|
updated: "2026-08-13T10:00:00.000Z",
|
|
supplier: "sup-1",
|
|
additional_attachments: [],
|
|
expand: { supplier: { id: "sup-1", suppliername: "Steel Co" } },
|
|
},
|
|
],
|
|
stockLinesByPurchase: {
|
|
"sp-1": [
|
|
{ id: "si-1", sku: "B-001", supplier_sku: "EP-BOLT-10", cost_price: 2.5 },
|
|
{ id: "si-2", sku: "", supplier_sku: "EP-WASH-08", cost_price: 0 },
|
|
],
|
|
},
|
|
customerHeaders: [],
|
|
customerLinesByPurchase: {},
|
|
});
|
|
const result = await scanPurchaseOrders(pb, { kind: "stock" });
|
|
expect(result.pos).toHaveLength(1);
|
|
expect(result.pos[0]).toMatchObject({
|
|
id: "sp-1",
|
|
kind: "stock",
|
|
po_number: "PO26080601STK",
|
|
status: "sent",
|
|
attachment_filenames: [],
|
|
diagnostics: {
|
|
line_count: 2,
|
|
lines_missing_sku: 1,
|
|
lines_missing_cost: 1,
|
|
lines_missing_supplier_sku: 0,
|
|
},
|
|
});
|
|
});
|
|
|
|
it("scans purchase_orders (customer POs) when kind is 'customer'", async () => {
|
|
const pb = buildMockPb({
|
|
stockHeaders: [],
|
|
stockLinesByPurchase: {},
|
|
customerHeaders: [
|
|
{
|
|
id: "po-1",
|
|
po_number: "PO-2026-08-013",
|
|
status: "sent",
|
|
updated: "2026-08-13T09:00:00.000Z",
|
|
supplier: "sup-2",
|
|
additional_attachments: ["inv-1.pdf", "inv-2.pdf"],
|
|
expand: { supplier: { id: "sup-2", suppliername: "Acme" } },
|
|
},
|
|
],
|
|
customerLinesByPurchase: {
|
|
"po-1": [{ id: "oi-1", sku: "ABC", supplier_sku: "", cost_price: 100 }],
|
|
},
|
|
});
|
|
const result = await scanPurchaseOrders(pb, { kind: "customer" });
|
|
expect(result.pos).toHaveLength(1);
|
|
expect(result.pos[0]).toMatchObject({
|
|
id: "po-1",
|
|
kind: "customer",
|
|
po_number: "PO-2026-08-013",
|
|
attachment_filenames: ["inv-1.pdf", "inv-2.pdf"],
|
|
diagnostics: {
|
|
line_count: 1,
|
|
lines_missing_sku: 0,
|
|
lines_missing_cost: 0,
|
|
lines_missing_supplier_sku: 1,
|
|
},
|
|
});
|
|
});
|
|
|
|
it("covers both kinds when kind is 'both' (default)", async () => {
|
|
const pb = buildMockPb({
|
|
stockHeaders: [
|
|
{
|
|
id: "sp-1",
|
|
purchase_number: "PO26080601STK",
|
|
status: "sent",
|
|
updated: "2026-08-13T10:00:00.000Z",
|
|
supplier: "sup-1",
|
|
additional_attachments: [],
|
|
expand: { supplier: { id: "sup-1", suppliername: "Steel Co" } },
|
|
},
|
|
],
|
|
stockLinesByPurchase: {
|
|
"sp-1": [{ id: "si-1", sku: "", supplier_sku: "", cost_price: 0 }],
|
|
},
|
|
customerHeaders: [
|
|
{
|
|
id: "po-1",
|
|
po_number: "PO-2026-08-013",
|
|
status: "sent",
|
|
updated: "2026-08-13T11:00:00.000Z",
|
|
supplier: "sup-2",
|
|
additional_attachments: [],
|
|
expand: { supplier: { id: "sup-2", suppliername: "Acme" } },
|
|
},
|
|
],
|
|
customerLinesByPurchase: {
|
|
"po-1": [{ id: "oi-1", sku: "ABC", supplier_sku: "DEF", cost_price: 50 }],
|
|
},
|
|
});
|
|
const result = await scanPurchaseOrders(pb);
|
|
expect(result.total_needing_attention).toBe(2);
|
|
expect(result.by_kind).toEqual({ stock: 1, customer: 1 });
|
|
// Most recently updated first.
|
|
expect(result.pos[0]?.id).toBe("po-1");
|
|
expect(result.pos[1]?.id).toBe("sp-1");
|
|
});
|
|
|
|
it("normalises stringified additional_attachments into a filename array", async () => {
|
|
const pb = buildMockPb({
|
|
stockHeaders: [
|
|
{
|
|
id: "sp-1",
|
|
purchase_number: "PO26080601STK",
|
|
status: "sent",
|
|
updated: "2026-08-13T10:00:00.000Z",
|
|
supplier: "sup-1",
|
|
// Legacy shape: PB may return a comma-joined string instead
|
|
// of a JSON array. The service should still surface them
|
|
// so the agent can grep for invoice filenames.
|
|
additional_attachments: "inv-1.pdf, inv-2.pdf",
|
|
expand: { supplier: { id: "sup-1", suppliername: "Steel Co" } },
|
|
},
|
|
],
|
|
stockLinesByPurchase: { "sp-1": [] },
|
|
customerHeaders: [],
|
|
customerLinesByPurchase: {},
|
|
});
|
|
const result = await scanPurchaseOrders(pb, { kind: "stock" });
|
|
expect(result.pos[0]?.attachment_filenames).toEqual([
|
|
"inv-1.pdf",
|
|
"inv-2.pdf",
|
|
]);
|
|
});
|
|
|
|
it("filters by status — only 'sent' rows surface when only 'sent' is requested", async () => {
|
|
const pb = buildMockPb({
|
|
stockHeaders: [],
|
|
stockLinesByPurchase: {},
|
|
customerHeaders: [],
|
|
customerLinesByPurchase: {},
|
|
});
|
|
// Spy on the first stock_purchases getFullList call to capture
|
|
// the filter clause the service emitted.
|
|
await scanPurchaseOrders(pb, { status: ["sent"] });
|
|
const filterCall = pb.calls.find(
|
|
(c) => c.collection === "stock_purchases",
|
|
);
|
|
expect(filterCall?.filter).toBe('status = "sent"');
|
|
});
|
|
}); |