An order line the AI filled from its quote now gets its Item Intel immediately, so the blue price-check marker on the order Process page has something to show. The anchor is the SKU the line ended up with. The quote assistant deliberately keeps the POS SKU off the line's own `sku` column — it lives on the quote line's `ai_fill` — so that is the fallback when the order line has no SKU of its own. `quote_items.ai_fill` is only projected once the schema shows the column exists, because PocketBase rejects a projection naming a column the collection does not have. The step is additive and never throws: a failure is logged and the line still counts as filled. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
414 lines
13 KiB
TypeScript
414 lines
13 KiB
TypeScript
import { beforeEach, describe, expect, it, vi } from "vitest";
|
|
|
|
vi.mock("@/lib/server/pocketbase-admin", () => ({
|
|
getAdminPocketBase: vi.fn(),
|
|
}));
|
|
|
|
vi.mock("@/lib/server/quotes-service", () => ({
|
|
fetchQuoteItemsForQuotesAdmin: vi.fn(),
|
|
}));
|
|
|
|
// The intel step runs for real against PB in production; here it is
|
|
// stubbed so the test can assert the anchor it was handed.
|
|
const populateIntel = vi.fn(async () => ({}) as never);
|
|
vi.mock("@/lib/server/item-intel-service", async (importOriginal) => ({
|
|
...(await importOriginal<typeof import("@/lib/server/item-intel-service")>()),
|
|
populateItemIntel: (...args: unknown[]) => populateIntel(...(args as [])),
|
|
}));
|
|
|
|
import {
|
|
fillOrderFromQuote,
|
|
getOrderAssistantCounts,
|
|
idFilter,
|
|
resetOrderFillFieldCheck,
|
|
writeAiPairs,
|
|
} from "@/lib/server/order-assistant-lines";
|
|
import { getAdminPocketBase } from "@/lib/server/pocketbase-admin";
|
|
import { fetchQuoteItemsForQuotesAdmin } from "@/lib/server/quotes-service";
|
|
|
|
const ORDERS = "orders";
|
|
const ORDER_ITEMS = "order_items";
|
|
const QUOTES = "quotes";
|
|
|
|
const ORDER = {
|
|
id: "ord1",
|
|
quote: "q1",
|
|
ai_quote_match: {
|
|
quote_number: "Q-2026-10-014",
|
|
model: "test-model",
|
|
lines: [{ order_line_id: "ol1", quote_line_id: "ql1", reason: "the AI said so" }],
|
|
},
|
|
};
|
|
|
|
const ORDER_LINE = {
|
|
id: "ol1",
|
|
order: "ord1",
|
|
quote_item: "ql1",
|
|
item_status: "pending",
|
|
cost_price: 0,
|
|
selling_price: 0,
|
|
markup: 0,
|
|
supplier: "",
|
|
sku: "",
|
|
matched_price_list_id: "",
|
|
pricing_source: "",
|
|
ai_fill: null,
|
|
};
|
|
|
|
const QUOTE_LINE = {
|
|
id: "ql1",
|
|
quote: "q1",
|
|
description: "M16 x 60 bolt",
|
|
cost_price: 18.2,
|
|
selling_price: 18.2,
|
|
supplier: "sup1",
|
|
sku: "56020510",
|
|
matched_price_list_id: "pl1",
|
|
};
|
|
|
|
function makePb(overrides: Record<string, Record<string, unknown>> = {}) {
|
|
const base: Record<string, Record<string, unknown>> = {
|
|
[ORDERS]: {
|
|
getOne: vi.fn().mockResolvedValue({ ...ORDER }),
|
|
getList: vi.fn().mockResolvedValue({ items: [] }),
|
|
getFullList: vi.fn().mockResolvedValue([
|
|
{ id: "q1", quote_number: "Q-2026-10-014" },
|
|
]),
|
|
update: vi.fn().mockResolvedValue({}),
|
|
},
|
|
[ORDER_ITEMS]: {
|
|
getOne: vi.fn().mockResolvedValue({ ...ORDER_LINE }),
|
|
getList: vi.fn().mockResolvedValue({ items: [] }),
|
|
getFullList: vi.fn().mockResolvedValue([{ ...ORDER_LINE }]),
|
|
update: vi.fn().mockResolvedValue({}),
|
|
},
|
|
[QUOTES]: {
|
|
getFullList: vi.fn().mockResolvedValue([
|
|
{ id: "q1", quote_number: "Q-2026-10-014" },
|
|
]),
|
|
},
|
|
};
|
|
const merged: Record<string, Record<string, unknown>> = {};
|
|
for (const name of new Set([...Object.keys(base), ...Object.keys(overrides)])) {
|
|
merged[name] = { ...(base[name] ?? {}), ...(overrides[name] ?? {}) };
|
|
}
|
|
return {
|
|
autoCancellation: vi.fn(),
|
|
// The fill checks that the live PB has the two columns it reads and
|
|
// writes before it projects them.
|
|
collections: {
|
|
getOne: vi.fn().mockImplementation(async (name: string) => ({
|
|
fields:
|
|
name === "order_items"
|
|
? [{ name: "ai_fill" }, { name: "quote_item" }]
|
|
: [{ name: "ai_quote_match" }, { name: "quote" }],
|
|
})),
|
|
},
|
|
collection: (name: string) => {
|
|
const c = merged[name];
|
|
if (!c) throw new Error(`unexpected collection: ${name}`);
|
|
return c;
|
|
},
|
|
};
|
|
}
|
|
|
|
function mockAdminPb(pb: ReturnType<typeof makePb>) {
|
|
vi.mocked(getAdminPocketBase).mockResolvedValue(
|
|
pb as unknown as Awaited<ReturnType<typeof getAdminPocketBase>>,
|
|
);
|
|
}
|
|
|
|
beforeEach(() => {
|
|
vi.clearAllMocks();
|
|
resetOrderFillFieldCheck();
|
|
populateIntel.mockClear();
|
|
populateIntel.mockResolvedValue({} as never);
|
|
vi.mocked(fetchQuoteItemsForQuotesAdmin).mockResolvedValue([
|
|
QUOTE_LINE,
|
|
] as never);
|
|
});
|
|
|
|
describe("idFilter", () => {
|
|
it("builds an OR-chain — PocketBase has no IN operator", () => {
|
|
expect(idFilter(["a", "b"])).toBe('id = "a" || id = "b"');
|
|
expect(idFilter([])).toBe("");
|
|
});
|
|
|
|
it("escapes quotes in the id", () => {
|
|
expect(idFilter(['a"b'])).toBe('id = "a\\"b"');
|
|
});
|
|
});
|
|
|
|
describe("writeAiPairs", () => {
|
|
it("writes the pairing onto a line with no quote_item and returns the ids", async () => {
|
|
const pb = makePb({
|
|
[ORDER_ITEMS]: {
|
|
getFullList: vi.fn().mockResolvedValue([{ ...ORDER_LINE, quote_item: "" }]),
|
|
update: vi.fn().mockResolvedValue({}),
|
|
},
|
|
});
|
|
const update = pb.collection(ORDER_ITEMS).update as ReturnType<typeof vi.fn>;
|
|
const written = await writeAiPairs(pb as never, "ord1", [
|
|
{ order_line_id: "ol1", quote_line_id: "ql1", quote_id: "q1", same_item: "sure", reason: "" },
|
|
]);
|
|
expect(update).toHaveBeenCalledWith("ol1", { quote_item: "ql1" });
|
|
expect(written).toEqual(["ol1"]);
|
|
});
|
|
|
|
it("leaves a line a person already paired alone", async () => {
|
|
const pb = makePb({
|
|
[ORDER_ITEMS]: {
|
|
getFullList: vi.fn().mockResolvedValue([{ ...ORDER_LINE, quote_item: "ql-other" }]),
|
|
update: vi.fn().mockResolvedValue({}),
|
|
},
|
|
});
|
|
const update = pb.collection(ORDER_ITEMS).update as ReturnType<typeof vi.fn>;
|
|
const written = await writeAiPairs(pb as never, "ord1", [
|
|
{ order_line_id: "ol1", quote_line_id: "ql1", quote_id: "q1", same_item: "sure", reason: "" },
|
|
]);
|
|
expect(update).not.toHaveBeenCalled();
|
|
expect(written).toEqual([]);
|
|
});
|
|
});
|
|
|
|
describe("fillOrderFromQuote", () => {
|
|
it("fills the line from its quote line", async () => {
|
|
const pb = makePb();
|
|
mockAdminPb(pb);
|
|
|
|
const res = await fillOrderFromQuote("ord1");
|
|
|
|
expect(res).toEqual({ filled: 1, skipped: 0 });
|
|
const update = pb.collection(ORDER_ITEMS).update as ReturnType<typeof vi.fn>;
|
|
expect(update).toHaveBeenCalledTimes(1);
|
|
const patch = update.mock.calls[0][1] as {
|
|
cost_price: number;
|
|
supplier: string;
|
|
sku: string;
|
|
pricing_source: string;
|
|
ai_fill: {
|
|
status: string;
|
|
quote_item_id: string;
|
|
quote_id: string;
|
|
quote_number: string;
|
|
reason: string;
|
|
model: string;
|
|
};
|
|
};
|
|
expect(patch.cost_price).toBe(18.2);
|
|
expect(patch.supplier).toBe("sup1");
|
|
expect(patch.sku).toBe("56020510");
|
|
expect(patch.pricing_source).toBe("ai_quote");
|
|
expect(patch.ai_fill.status).toBe("filled");
|
|
expect(patch.ai_fill.quote_item_id).toBe("ql1");
|
|
expect(patch.ai_fill.quote_id).toBe("q1");
|
|
expect(patch.ai_fill.quote_number).toBe("Q-2026-10-014");
|
|
expect(patch.ai_fill.reason).toBe("the AI said so");
|
|
expect(patch.ai_fill.model).toBe("test-model");
|
|
});
|
|
|
|
it("skips a line whose re-read shows a cost the person typed", async () => {
|
|
const pb = makePb({
|
|
[ORDER_ITEMS]: {
|
|
// The candidate list still says "no cost"…
|
|
getFullList: vi.fn().mockResolvedValue([{ ...ORDER_LINE }]),
|
|
// …but the re-read right before the write shows a cost.
|
|
getOne: vi.fn().mockResolvedValue({ ...ORDER_LINE, cost_price: 12.8 }),
|
|
update: vi.fn().mockResolvedValue({}),
|
|
},
|
|
});
|
|
mockAdminPb(pb);
|
|
|
|
const res = await fillOrderFromQuote("ord1");
|
|
|
|
expect(res).toEqual({ filled: 0, skipped: 1 });
|
|
expect(pb.collection(ORDER_ITEMS).update).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("skips a line that is no longer pending", async () => {
|
|
const pb = makePb({
|
|
[ORDER_ITEMS]: {
|
|
getFullList: vi.fn().mockResolvedValue([{ ...ORDER_LINE }]),
|
|
getOne: vi.fn().mockResolvedValue({ ...ORDER_LINE, item_status: "po_generated" }),
|
|
update: vi.fn().mockResolvedValue({}),
|
|
},
|
|
});
|
|
mockAdminPb(pb);
|
|
|
|
const res = await fillOrderFromQuote("ord1");
|
|
expect(res).toEqual({ filled: 0, skipped: 1 });
|
|
});
|
|
|
|
it("skips a line whose quote line has no cost", async () => {
|
|
vi.mocked(fetchQuoteItemsForQuotesAdmin).mockResolvedValue([
|
|
{ ...QUOTE_LINE, cost_price: 0 },
|
|
] as never);
|
|
const pb = makePb();
|
|
mockAdminPb(pb);
|
|
|
|
const res = await fillOrderFromQuote("ord1");
|
|
expect(res).toEqual({ filled: 0, skipped: 1 });
|
|
});
|
|
|
|
it("does nothing when the order has no quote", async () => {
|
|
const pb = makePb({
|
|
[ORDERS]: {
|
|
getOne: vi.fn().mockResolvedValue({ id: "ord1", quote: "" }),
|
|
getList: vi.fn().mockResolvedValue({ items: [] }),
|
|
getFullList: vi.fn().mockResolvedValue([]),
|
|
update: vi.fn().mockResolvedValue({}),
|
|
},
|
|
});
|
|
mockAdminPb(pb);
|
|
|
|
const res = await fillOrderFromQuote("ord1");
|
|
expect(res).toEqual({ filled: 0, skipped: 0 });
|
|
expect(pb.collection(ORDER_ITEMS).getFullList).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("does nothing when the live PB has no ai_fill column yet", async () => {
|
|
// The migration has not run: PB would reject a projection that
|
|
// names a column the collection does not have, so the fill has to
|
|
// stay quiet instead of throwing on every linked order.
|
|
const pb = makePb();
|
|
pb.collections.getOne = vi.fn().mockResolvedValue({
|
|
fields: [{ name: "quote" }],
|
|
}) as never;
|
|
mockAdminPb(pb);
|
|
|
|
const res = await fillOrderFromQuote("ord1");
|
|
|
|
expect(res).toEqual({ filled: 0, skipped: 0 });
|
|
expect(pb.collection(ORDER_ITEMS).getFullList).not.toHaveBeenCalled();
|
|
expect(pb.collection(ORDER_ITEMS).update).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("never uses an IN filter (PocketBase has no IN operator)", async () => {
|
|
const pb = makePb();
|
|
mockAdminPb(pb);
|
|
|
|
await fillOrderFromQuote("ord1");
|
|
|
|
const filters = [ORDERS, ORDER_ITEMS, QUOTES].flatMap((name) =>
|
|
(pb.collection(name).getFullList as ReturnType<typeof vi.fn>).mock.calls.map(
|
|
(c) => (c[0] as { filter?: string })?.filter ?? "",
|
|
),
|
|
);
|
|
expect(filters.length).toBeGreaterThan(0);
|
|
for (const f of filters) expect(f).not.toMatch(/\bIN\s*\(/);
|
|
});
|
|
|
|
it("populates the item intel for a filled line, anchored to the filled sku", async () => {
|
|
const pb = makePb();
|
|
mockAdminPb(pb);
|
|
|
|
const res = await fillOrderFromQuote("ord1");
|
|
|
|
expect(res).toEqual({ filled: 1, skipped: 0 });
|
|
expect(populateIntel).toHaveBeenCalledTimes(1);
|
|
expect(populateIntel).toHaveBeenCalledWith("ol1", "order_items", {
|
|
write: true,
|
|
anchor: { sku: "56020510" },
|
|
});
|
|
});
|
|
|
|
it("anchors to the quote line's POS sku when the order line has none of its own", async () => {
|
|
// The quote assistant keeps the POS sku off the line's own `sku`
|
|
// column — it lives on the quote line's ai_fill.
|
|
vi.mocked(fetchQuoteItemsForQuotesAdmin).mockResolvedValue([
|
|
{ ...QUOTE_LINE, sku: "", ai_fill: { status: "filled", stock_item_sku: "POS-SKU-1" } },
|
|
] as never);
|
|
const pb = makePb();
|
|
mockAdminPb(pb);
|
|
|
|
await fillOrderFromQuote("ord1");
|
|
|
|
expect(populateIntel).toHaveBeenCalledWith("ol1", "order_items", {
|
|
write: true,
|
|
anchor: { sku: "POS-SKU-1" },
|
|
});
|
|
});
|
|
|
|
it("never fails the fill when the item intel step fails", async () => {
|
|
const pb = makePb();
|
|
mockAdminPb(pb);
|
|
populateIntel.mockRejectedValueOnce(new Error("intel boom"));
|
|
|
|
const res = await fillOrderFromQuote("ord1");
|
|
|
|
expect(res).toEqual({ filled: 1, skipped: 0 });
|
|
});
|
|
});
|
|
|
|
describe("getOrderAssistantCounts", () => {
|
|
const FILLED = {
|
|
status: "filled",
|
|
source: "quote",
|
|
quote_item_id: "ql1",
|
|
quote_id: "q1",
|
|
quote_number: "Q-2026-10-014",
|
|
cost_price: 18.2,
|
|
selling_price: 16.64,
|
|
markup: -8.57,
|
|
supplier_id: "sup1",
|
|
sku: "56020510",
|
|
reason: "same item",
|
|
doubts: [],
|
|
model: "m",
|
|
attempted_at: "2026-10-24T10:00:00.000Z",
|
|
};
|
|
|
|
it("counts filled lines per order and how many still need a review", async () => {
|
|
const pb = makePb({
|
|
[ORDER_ITEMS]: {
|
|
getFullList: vi.fn().mockResolvedValue([
|
|
// Pending: filled by the AI, nobody has looked at it.
|
|
{ id: "ol1", order: "ord1", ai_fill: FILLED, pricing_source: "ai_quote" },
|
|
// Reviewed: the AI filled it and a person confirmed the prices.
|
|
{
|
|
id: "ol2",
|
|
order: "ord1",
|
|
ai_fill: { ...FILLED, reviewed_at: "2026-10-24T11:00:00.000Z" },
|
|
pricing_source: "ai_quote",
|
|
},
|
|
// Another order's pending line.
|
|
{ id: "ol3", order: "ord2", ai_fill: FILLED, pricing_source: "ai_quote" },
|
|
// Not an AI line at all.
|
|
{ id: "ol4", order: "ord1", ai_fill: null, pricing_source: "" },
|
|
]),
|
|
},
|
|
});
|
|
mockAdminPb(pb);
|
|
|
|
const counts = await getOrderAssistantCounts(["ord1", "ord2"]);
|
|
|
|
expect(counts.ord1).toEqual({ pending: 1, total: 2 });
|
|
expect(counts.ord2).toEqual({ pending: 1, total: 1 });
|
|
});
|
|
|
|
it("returns nothing when the live PB has no ai_fill column yet", async () => {
|
|
const pb = makePb();
|
|
pb.collections.getOne = vi.fn().mockResolvedValue({
|
|
fields: [{ name: "quote" }],
|
|
}) as never;
|
|
mockAdminPb(pb);
|
|
|
|
expect(await getOrderAssistantCounts(["ord1"])).toEqual({});
|
|
expect(pb.collection(ORDER_ITEMS).getFullList).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("never uses an IN filter (PocketBase has no IN operator)", async () => {
|
|
const pb = makePb({
|
|
[ORDER_ITEMS]: { getFullList: vi.fn().mockResolvedValue([]) },
|
|
});
|
|
mockAdminPb(pb);
|
|
|
|
await getOrderAssistantCounts(["ord1", "ord2"]);
|
|
|
|
const filters = (
|
|
pb.collection(ORDER_ITEMS).getFullList as ReturnType<typeof vi.fn>
|
|
).mock.calls.map((c) => (c[0] as { filter?: string })?.filter ?? "");
|
|
expect(filters.length).toBeGreaterThan(0);
|
|
for (const f of filters) expect(f).not.toMatch(/\bIN\s*\(/);
|
|
});
|
|
});
|