Task 8 of the AI order assistant plan.
- new AiQuoteMatchPanel: "Linked by AI" / "AI suggests Q…", the
reason, the doubts and the line-pair table (order line -> quote line
with same_item)
- the quote match card gains a "Match with AI" button in every state,
posts to /api/v1/orders/{id}/assistant/run with the PB token, toasts
the 409 message verbatim, then reloads the order
- new card state: the AI suggested a quote -> the AI panel + Accept
(links the AI's quote) / Match with AI / Dismiss
- the AI suggestion marks the order as quote_matching_attempted so the
deterministic sweep cannot overwrite it
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
514 lines
15 KiB
TypeScript
514 lines
15 KiB
TypeScript
import { beforeEach, describe, expect, it, vi } from "vitest";
|
|
|
|
vi.mock("@/lib/server/pocketbase-admin", () => ({
|
|
getAdminPocketBase: vi.fn(),
|
|
}));
|
|
|
|
vi.mock("@/lib/server/ai-settings-service", () => ({
|
|
getActiveAiConfig: vi.fn(),
|
|
getAiSettings: vi.fn(),
|
|
recordOrderAssistantRun: vi.fn(),
|
|
clearAiSettingsCache: vi.fn(),
|
|
AiDisabledError: class AiDisabledError extends Error {
|
|
readonly code = "ai_disabled";
|
|
constructor(message: string) {
|
|
super(message);
|
|
this.name = "AiDisabledError";
|
|
}
|
|
},
|
|
}));
|
|
|
|
vi.mock("@/lib/server/quotes-service", () => ({
|
|
fetchQuoteItemsForQuotesAdmin: vi.fn(),
|
|
}));
|
|
|
|
import {
|
|
matchOrder,
|
|
runOrderAssistantTick,
|
|
resetAiQuoteMatchFieldCheck,
|
|
} from "@/lib/server/order-assistant-service";
|
|
import { getAdminPocketBase } from "@/lib/server/pocketbase-admin";
|
|
import { getAiSettings, recordOrderAssistantRun } from "@/lib/server/ai-settings-service";
|
|
import { fetchQuoteItemsForQuotesAdmin } from "@/lib/server/quotes-service";
|
|
|
|
const ORDERS = "orders";
|
|
const ORDER_ITEMS = "order_items";
|
|
const QUOTES = "quotes";
|
|
const SUPPLIERS = "suppliers";
|
|
|
|
interface CollectionMock {
|
|
getOne?: ReturnType<typeof vi.fn>;
|
|
getList?: ReturnType<typeof vi.fn>;
|
|
getFullList?: ReturnType<typeof vi.fn>;
|
|
update?: ReturnType<typeof vi.fn>;
|
|
}
|
|
|
|
const ORDER = {
|
|
id: "ord1",
|
|
order_number: "ORD-2026-10-001",
|
|
created: "2026-10-20T10:00:00.000Z",
|
|
company: "co1",
|
|
customer: "cust1",
|
|
quote: "",
|
|
ai_quote_match: null,
|
|
};
|
|
|
|
const ORDER_LINES = [
|
|
{
|
|
id: "ol1",
|
|
order: "ord1",
|
|
description: "M16 bolt 60mm",
|
|
po_description: "BOLT M16X60",
|
|
quantity: 100,
|
|
item_status: "pending",
|
|
selling_price: 350,
|
|
sku: "",
|
|
quote_item: "",
|
|
},
|
|
{
|
|
id: "ol2",
|
|
order: "ord1",
|
|
description: "M16 nut",
|
|
po_description: "",
|
|
quantity: 100,
|
|
item_status: "pending",
|
|
selling_price: 50,
|
|
sku: "",
|
|
quote_item: "",
|
|
},
|
|
];
|
|
|
|
const QUOTE_ITEMS = [
|
|
{
|
|
id: "ql1",
|
|
quote: "q1",
|
|
description: "M16 x 60 bolt",
|
|
quantity: 200,
|
|
selling_price: 340,
|
|
sku: "56020510",
|
|
supplier: "sup1",
|
|
},
|
|
{
|
|
id: "ql2",
|
|
quote: "q1",
|
|
description: "M16 nut",
|
|
quantity: 200,
|
|
selling_price: 45,
|
|
sku: "",
|
|
supplier: "sup1",
|
|
},
|
|
];
|
|
|
|
/**
|
|
* The makePb pattern from `order-quote-link-service.test.ts`: sensible
|
|
* defaults for every collection, merged per METHOD with the per-test
|
|
* overrides so a test only has to declare what it cares about.
|
|
*/
|
|
function makePb(overrides: Record<string, CollectionMock> = {}) {
|
|
const defaults: Record<string, CollectionMock> = {
|
|
[ORDERS]: {
|
|
getOne: vi.fn().mockResolvedValue({ ...ORDER }),
|
|
getList: vi.fn().mockResolvedValue({ items: [] }),
|
|
getFullList: vi.fn().mockResolvedValue([]),
|
|
update: vi.fn().mockResolvedValue({}),
|
|
},
|
|
[ORDER_ITEMS]: {
|
|
getFullList: vi.fn().mockResolvedValue(ORDER_LINES.map((l) => ({ ...l }))),
|
|
update: vi.fn().mockResolvedValue({}),
|
|
},
|
|
[QUOTES]: {
|
|
getFullList: vi.fn().mockResolvedValue([
|
|
{ id: "q1", quote_number: "Q-2026-10-014", company: "co1", created: "2026-10-01T00:00:00.000Z" },
|
|
]),
|
|
},
|
|
[SUPPLIERS]: {
|
|
getFullList: vi.fn().mockResolvedValue([{ id: "sup1", suppliername: "Afrisam" }]),
|
|
},
|
|
};
|
|
const merged: Record<string, CollectionMock> = {};
|
|
for (const name of new Set([...Object.keys(defaults), ...Object.keys(overrides)])) {
|
|
merged[name] = { ...(defaults[name] ?? {}), ...(overrides[name] ?? {}) };
|
|
}
|
|
return {
|
|
autoCancellation: vi.fn(),
|
|
collections: {
|
|
getOne: vi.fn().mockResolvedValue({
|
|
fields: [{ 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>>,
|
|
);
|
|
}
|
|
|
|
const SUGGESTION = {
|
|
orderId: "ord1",
|
|
best: {
|
|
quoteId: "q1",
|
|
quoteNumber: "Q-2026-10-014",
|
|
quoteCreated: "2026-10-01T00:00:00.000Z",
|
|
confidence: 0.8,
|
|
method: "company_customer_desc_price" as const,
|
|
},
|
|
alternatives: [],
|
|
method: "company_customer_desc_price" as const,
|
|
window: "initial" as const,
|
|
ageGapDays: 3,
|
|
};
|
|
|
|
function aiDecision(over: Record<string, unknown> = {}) {
|
|
return {
|
|
decision: {
|
|
quote_id: "q1",
|
|
confidence: "high" as const,
|
|
reason: "same items",
|
|
doubts: [],
|
|
lines: [
|
|
{ order_line_id: "ol1", quote_line_id: "ql1", same_item: "sure" as const, reason: "same" },
|
|
{ order_line_id: "ol2", quote_line_id: "ql2", same_item: "sure" as const, reason: "same" },
|
|
],
|
|
...over,
|
|
},
|
|
model: "test-model",
|
|
};
|
|
}
|
|
|
|
function baseSettings(over: Record<string, unknown> = {}) {
|
|
return {
|
|
enabled: true,
|
|
order_assistant_enabled: true,
|
|
order_assistant_interval_minutes: 10,
|
|
order_assistant_auto_link: true,
|
|
order_assistant_fill_lines: true,
|
|
order_assistant_prompt: "",
|
|
order_assistant_last_run_at: "",
|
|
order_assistant_last_run_summary: null,
|
|
...over,
|
|
};
|
|
}
|
|
|
|
beforeEach(() => {
|
|
vi.clearAllMocks();
|
|
resetAiQuoteMatchFieldCheck();
|
|
vi.mocked(getAiSettings).mockResolvedValue(baseSettings() as never);
|
|
vi.mocked(recordOrderAssistantRun).mockResolvedValue(undefined);
|
|
vi.mocked(fetchQuoteItemsForQuotesAdmin).mockResolvedValue(QUOTE_ITEMS as never);
|
|
});
|
|
|
|
describe("matchOrder", () => {
|
|
it("auto-links when the AI is sure and writes the pairing onto empty lines", async () => {
|
|
const pb = makePb();
|
|
mockAdminPb(pb);
|
|
const link = vi.fn().mockResolvedValue({ order: {}, previous: { quote: "" } });
|
|
const match = vi.fn().mockResolvedValue(aiDecision());
|
|
const suggest = vi.fn().mockResolvedValue(SUGGESTION);
|
|
|
|
const res = await matchOrder("ord1", { deps: { link, match, suggest } });
|
|
|
|
expect(res.outcome).toBe("linked");
|
|
expect(res.quoteId).toBe("q1");
|
|
expect(link).toHaveBeenCalledTimes(1);
|
|
expect(link.mock.calls[0][0]).toMatchObject({ orderId: "ord1", quoteId: "q1" });
|
|
|
|
const orderUpdate = pb.collection(ORDERS).update as ReturnType<typeof vi.fn>;
|
|
expect(orderUpdate).toHaveBeenCalledWith(
|
|
"ord1",
|
|
expect.objectContaining({ quote_match_method: "ai_auto" }),
|
|
);
|
|
|
|
// The AI pairing is written onto the lines.
|
|
const itemUpdate = pb.collection(ORDER_ITEMS).update as ReturnType<typeof vi.fn>;
|
|
expect(itemUpdate).toHaveBeenCalledWith("ol1", { quote_item: "ql1" });
|
|
expect(itemUpdate).toHaveBeenCalledWith("ol2", { quote_item: "ql2" });
|
|
});
|
|
|
|
it("never overwrites a quote_item a person set", async () => {
|
|
const pb = makePb({
|
|
[ORDER_ITEMS]: {
|
|
getFullList: vi.fn().mockResolvedValue([
|
|
{ ...ORDER_LINES[0], quote_item: "ql-person" },
|
|
{ ...ORDER_LINES[1] },
|
|
]),
|
|
update: vi.fn().mockResolvedValue({}),
|
|
},
|
|
});
|
|
mockAdminPb(pb);
|
|
const link = vi.fn().mockResolvedValue({ order: {}, previous: { quote: "" } });
|
|
|
|
await matchOrder("ord1", {
|
|
deps: {
|
|
link,
|
|
match: vi.fn().mockResolvedValue(aiDecision()),
|
|
suggest: vi.fn().mockResolvedValue(SUGGESTION),
|
|
},
|
|
});
|
|
|
|
const itemUpdate = pb.collection(ORDER_ITEMS).update as ReturnType<typeof vi.fn>;
|
|
expect(itemUpdate).not.toHaveBeenCalledWith("ol1", expect.anything());
|
|
expect(itemUpdate).toHaveBeenCalledWith("ol2", { quote_item: "ql2" });
|
|
});
|
|
|
|
it("falls back to a suggestion when the link is refused with 409", async () => {
|
|
const pb = makePb();
|
|
mockAdminPb(pb);
|
|
const err = new Error("locked");
|
|
(err as Error & { status?: number }).status = 409;
|
|
const link = vi.fn().mockRejectedValue(err);
|
|
|
|
const res = await matchOrder("ord1", {
|
|
deps: {
|
|
link,
|
|
match: vi.fn().mockResolvedValue(aiDecision()),
|
|
suggest: vi.fn().mockResolvedValue(SUGGESTION),
|
|
},
|
|
});
|
|
|
|
expect(res.outcome).toBe("suggested");
|
|
expect(res.quoteId).toBe("q1");
|
|
const orderUpdate = pb.collection(ORDERS).update as ReturnType<typeof vi.fn>;
|
|
expect(orderUpdate).toHaveBeenCalledWith(
|
|
"ord1",
|
|
expect.objectContaining({
|
|
potential_quote: "q1",
|
|
potential_quote_confidence: 0.9,
|
|
quote_match_method: "ai_suggest",
|
|
}),
|
|
);
|
|
});
|
|
|
|
it("marks the order as attempted so the sweep leaves the suggestion alone", async () => {
|
|
const pb = makePb();
|
|
mockAdminPb(pb);
|
|
|
|
await matchOrder("ord1", {
|
|
deps: {
|
|
link: vi.fn(),
|
|
match: vi.fn().mockResolvedValue(aiDecision({ confidence: "medium" })),
|
|
suggest: vi.fn().mockResolvedValue(SUGGESTION),
|
|
},
|
|
});
|
|
|
|
const orderUpdate = pb.collection(ORDERS).update as ReturnType<typeof vi.fn>;
|
|
expect(orderUpdate).toHaveBeenCalledWith(
|
|
"ord1",
|
|
expect.objectContaining({ quote_matching_attempted: true }),
|
|
);
|
|
});
|
|
|
|
it("suggests (and does not link) on medium confidence", async () => { const pb = makePb();
|
|
mockAdminPb(pb);
|
|
const link = vi.fn();
|
|
|
|
const res = await matchOrder("ord1", {
|
|
deps: {
|
|
link,
|
|
match: vi.fn().mockResolvedValue(aiDecision({ confidence: "medium" })),
|
|
suggest: vi.fn().mockResolvedValue(SUGGESTION),
|
|
},
|
|
});
|
|
|
|
expect(res.outcome).toBe("suggested");
|
|
expect(link).not.toHaveBeenCalled();
|
|
const orderUpdate = pb.collection(ORDERS).update as ReturnType<typeof vi.fn>;
|
|
expect(orderUpdate).toHaveBeenCalledWith(
|
|
"ord1",
|
|
expect.objectContaining({ potential_quote_confidence: 0.7, quote_match_method: "ai_suggest" }),
|
|
);
|
|
});
|
|
|
|
it("suggests (and does not link) when auto-link is switched off", async () => {
|
|
vi.mocked(getAiSettings).mockResolvedValue(
|
|
baseSettings({ order_assistant_auto_link: false }) as never,
|
|
);
|
|
const pb = makePb();
|
|
mockAdminPb(pb);
|
|
const link = vi.fn();
|
|
|
|
const res = await matchOrder("ord1", {
|
|
deps: {
|
|
link,
|
|
match: vi.fn().mockResolvedValue(aiDecision()),
|
|
suggest: vi.fn().mockResolvedValue(SUGGESTION),
|
|
},
|
|
});
|
|
|
|
expect(res.outcome).toBe("suggested");
|
|
expect(link).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("does not call the AI when the shortlist is empty", async () => {
|
|
const pb = makePb();
|
|
mockAdminPb(pb);
|
|
const match = vi.fn();
|
|
|
|
const res = await matchOrder("ord1", {
|
|
deps: {
|
|
link: vi.fn(),
|
|
match,
|
|
suggest: vi.fn().mockResolvedValue({
|
|
...SUGGESTION,
|
|
best: null,
|
|
alternatives: [],
|
|
}),
|
|
},
|
|
});
|
|
|
|
expect(res.outcome).toBe("no_match");
|
|
expect(match).not.toHaveBeenCalled();
|
|
const orderUpdate = pb.collection(ORDERS).update as ReturnType<typeof vi.fn>;
|
|
expect(orderUpdate).toHaveBeenCalledWith(
|
|
"ord1",
|
|
expect.objectContaining({ quote_match_method: "ai_no_match" }),
|
|
);
|
|
});
|
|
|
|
it("records an AI failure on the order and does not throw", async () => {
|
|
const pb = makePb();
|
|
mockAdminPb(pb);
|
|
|
|
const res = await matchOrder("ord1", {
|
|
deps: {
|
|
link: vi.fn(),
|
|
match: vi.fn().mockRejectedValue(new Error("AI exploded")),
|
|
suggest: vi.fn().mockResolvedValue(SUGGESTION),
|
|
},
|
|
});
|
|
|
|
expect(res.outcome).toBe("error");
|
|
expect(res.error).toBe("AI exploded");
|
|
const orderUpdate = pb.collection(ORDERS).update as ReturnType<typeof vi.fn>;
|
|
const patch = orderUpdate.mock.calls[0][1] as { ai_quote_match: { status: string; error: string } };
|
|
expect(patch.ai_quote_match.status).toBe("error");
|
|
expect(patch.ai_quote_match.error).toBe("AI exploded");
|
|
});
|
|
|
|
it("skips a placeholder-only order and writes nothing", async () => {
|
|
const pb = makePb({
|
|
[ORDER_ITEMS]: {
|
|
getFullList: vi.fn().mockResolvedValue([
|
|
{ ...ORDER_LINES[0], description: "See attached" },
|
|
]),
|
|
update: vi.fn().mockResolvedValue({}),
|
|
},
|
|
});
|
|
mockAdminPb(pb);
|
|
const match = vi.fn();
|
|
|
|
const res = await matchOrder("ord1", {
|
|
deps: { link: vi.fn(), match, suggest: vi.fn().mockResolvedValue(SUGGESTION) },
|
|
});
|
|
|
|
expect(res.outcome).toBe("skipped");
|
|
expect(match).not.toHaveBeenCalled();
|
|
expect(pb.collection(ORDERS).update).not.toHaveBeenCalled();
|
|
expect(pb.collection(ORDER_ITEMS).update).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("skips an order that already has a quote", async () => {
|
|
const pb = makePb({
|
|
[ORDERS]: {
|
|
getOne: vi.fn().mockResolvedValue({ ...ORDER, quote: "q-other" }),
|
|
getList: vi.fn().mockResolvedValue({ items: [] }),
|
|
getFullList: vi.fn().mockResolvedValue([]),
|
|
update: vi.fn().mockResolvedValue({}),
|
|
},
|
|
});
|
|
mockAdminPb(pb);
|
|
|
|
const res = await matchOrder("ord1", {
|
|
deps: {
|
|
link: vi.fn(),
|
|
match: vi.fn(),
|
|
suggest: vi.fn(),
|
|
},
|
|
});
|
|
|
|
expect(res.outcome).toBe("skipped");
|
|
expect(pb.collection(ORDERS).update).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("reports that the migration has not run yet", async () => {
|
|
const pb = makePb();
|
|
pb.collections.getOne = vi.fn().mockResolvedValue({ fields: [{ name: "quote" }] });
|
|
mockAdminPb(pb);
|
|
|
|
const res = await matchOrder("ord1", { deps: {} });
|
|
|
|
expect(res.outcome).toBe("error");
|
|
expect(res.error).toContain("pb:migrate:order-assistant");
|
|
});
|
|
});
|
|
|
|
describe("runOrderAssistantTick", () => {
|
|
it("returns disabled when the order assistant is off", async () => {
|
|
vi.mocked(getAiSettings).mockResolvedValue(
|
|
baseSettings({ order_assistant_enabled: false }) as never,
|
|
);
|
|
const res = await runOrderAssistantTick();
|
|
expect(res).toEqual({ ran: false, reason: "disabled" });
|
|
});
|
|
|
|
it("returns disabled when the global AI switch is off", async () => {
|
|
vi.mocked(getAiSettings).mockResolvedValue(baseSettings({ enabled: false }) as never);
|
|
const res = await runOrderAssistantTick();
|
|
expect(res).toEqual({ ran: false, reason: "disabled" });
|
|
});
|
|
|
|
it("returns not_due when the interval has not elapsed", async () => {
|
|
vi.mocked(getAiSettings).mockResolvedValue(
|
|
baseSettings({ order_assistant_last_run_at: new Date().toISOString() }) as never,
|
|
);
|
|
const res = await runOrderAssistantTick();
|
|
expect(res).toEqual({ ran: false, reason: "not_due" });
|
|
});
|
|
|
|
it("stops after 10 orders", async () => {
|
|
const pb = makePb({
|
|
[ORDERS]: {
|
|
getOne: vi.fn().mockResolvedValue({ ...ORDER }),
|
|
// Deliberately ignores perPage — the tick's own cap must stop it.
|
|
getList: vi.fn().mockResolvedValue({
|
|
items: Array.from({ length: 25 }, (_, i) => ({ ...ORDER, id: `ord${i}` })),
|
|
}),
|
|
getFullList: vi.fn().mockResolvedValue([]),
|
|
update: vi.fn().mockResolvedValue({}),
|
|
},
|
|
});
|
|
mockAdminPb(pb);
|
|
|
|
const res = await runOrderAssistantTick({
|
|
suggest: vi.fn().mockResolvedValue(SUGGESTION),
|
|
match: vi.fn().mockResolvedValue(aiDecision()),
|
|
link: vi.fn(),
|
|
});
|
|
|
|
expect(res.ran).toBe(true);
|
|
expect(res.summary?.orders_seen).toBe(10);
|
|
});
|
|
|
|
it("writes a summary with the run stamp", async () => {
|
|
const pb = makePb();
|
|
mockAdminPb(pb);
|
|
|
|
const res = await runOrderAssistantTick({
|
|
suggest: vi.fn().mockResolvedValue(SUGGESTION),
|
|
match: vi.fn().mockResolvedValue(aiDecision()),
|
|
link: vi.fn(),
|
|
});
|
|
|
|
expect(res.ran).toBe(true);
|
|
expect(recordOrderAssistantRun).toHaveBeenCalledWith({ last_run_at: expect.any(String) });
|
|
expect(recordOrderAssistantRun).toHaveBeenCalledWith({
|
|
summary: expect.objectContaining({ orders_seen: expect.any(Number) }),
|
|
});
|
|
});
|
|
});
|