WFDASH-WEBAPP/src/lib/server/quote-items-insights-service.test.ts
SJCoetz 63048e51c9 feat: add AI quote processing pipeline and admin settings UI
Add complete end-to-end AI-powered quote extraction workflow:
- Disable PB SDK auto-cancellation to support parallel batch requests
- Add required runtime dependencies for AI processing and document parsing
- Implement AI settings backend, operator UI, and REST API routes
- Add quote ingestion, insight population, and manual re-run AI endpoints
- Build service layers for quote items, AI settings, and structured item insights
- Fix attachment upload handling and quote item history blob parsing
- Add supporting migrations, smoke tests, and shared utility functions
- Export previously private helper functions for cross-service reuse
2026-08-09 16:17:29 +02:00

424 lines
15 KiB
TypeScript

import { describe, it, expect, vi } from "vitest";
import {
normaliseInsightSku,
populateQuoteItemInsights,
populateQuoteInsightsForQuote,
} from "@/lib/server/quote-items-insights-service";
import { getAdminPocketBase } from "@/lib/server/pocketbase-admin";
vi.mock("@/lib/server/pocketbase-admin", () => ({
getAdminPocketBase: vi.fn(),
}));
// ─── Pure helpers ───────────────────────────────────────────────────
describe("normaliseInsightSku", () => {
it("trims, uppercases, and normalises separators", () => {
expect(normaliseInsightSku(" drl-6mm ")).toBe("DRL-6MM");
expect(normaliseInsightSku("DRL 6MM")).toBe("DRL-6MM");
expect(normaliseInsightSku("DRL_6MM")).toBe("DRL-6MM");
});
it("strips leading zero runs after dash or start", () => {
expect(normaliseInsightSku("DRL-006MM")).toBe("DRL-6MM");
expect(normaliseInsightSku("BOLT-006")).toBe("BOLT-6");
expect(normaliseInsightSku("000123")).toBe("123");
});
it("leaves single leading zeros alone (those are real SKUs)", () => {
expect(normaliseInsightSku("DRL-06")).toBe("DRL-06");
expect(normaliseInsightSku("0DRL")).toBe("0DRL");
});
it("returns empty string for falsy input", () => {
expect(normaliseInsightSku("")).toBe("");
// @ts-expect-error — test the defensive guard
expect(normaliseInsightSku(undefined)).toBe("");
// @ts-expect-error
expect(normaliseInsightSku(null)).toBe("");
});
});
// ─── Service — happy paths ──────────────────────────────────────────
function buildPb(opts: {
target: Record<string, unknown>;
quoteItems?: Array<Record<string, unknown>>;
orderItems?: Array<Record<string, unknown>>;
priceList?: Array<Record<string, unknown>>;
stockItems?: Array<Record<string, unknown>>;
pos?: Array<Record<string, unknown>>;
suppliers?: Array<{ id: string; suppliername: string }>;
}) {
const target = opts.target;
const quoteItems = opts.quoteItems ?? [];
const orderItems = opts.orderItems ?? [];
const priceList = opts.priceList ?? [];
const stockItems = opts.stockItems ?? [];
const pos = opts.pos ?? [];
const suppliers = opts.suppliers ?? [];
const emptyList = { items: [], totalItems: 0 };
const updated: Array<{ id: string; patch: Record<string, unknown> }> = [];
const collection = (name: string) => {
if (name === "quote_items") {
return {
getOne: vi.fn().mockResolvedValue(target),
getList: vi.fn().mockImplementation(
(_page: number, _perPage: number, listOpts?: { filter?: string; sort?: string }) => {
const f = listOpts?.filter || "";
// Match by sku="..." in filter
const skuMatch = /sku = "([^"]+)"/.exec(f);
const wantedSku = skuMatch?.[1] || "";
const items = quoteItems.filter((it) => {
if (wantedSku && it.sku !== wantedSku) return false;
return true;
});
const sorted = [...items].sort((a, b) => {
if (listOpts?.sort === "-created") {
return String(b.created).localeCompare(String(a.created));
}
if (listOpts?.sort === "-updated") {
return String(b.updated).localeCompare(String(a.updated));
}
return 0;
});
return { items: sorted, totalItems: sorted.length };
}
),
getFullList: vi.fn().mockResolvedValue(quoteItems),
update: vi.fn().mockImplementation(async (id: string, patch: Record<string, unknown>) => {
updated.push({ id, patch });
return { ...target, ...patch };
}),
};
}
if (name === "order_items") {
return {
getOne: vi.fn(),
getList: vi.fn().mockImplementation(
(_page: number, _perPage: number, listOpts?: { filter?: string; sort?: string }) => {
const f = listOpts?.filter || "";
const skuMatch = /sku = "([^"]+)"/.exec(f);
const wantedSku = skuMatch?.[1] || "";
const items = orderItems.filter((it) => {
if (wantedSku && it.sku !== wantedSku) return false;
if (f.includes("item_status != \"cancelled\"")) {
if (it.item_status === "cancelled" || it.item_status === "removed") return false;
}
if (f.includes("invoiced_date != null")) {
if (!it.invoiced_date) return false;
if (!it.supplier_invoice_number && !it.customer_invoice_number) return false;
}
return true;
});
const sorted = [...items].sort((a, b) => {
if (listOpts?.sort === "-updated") {
return String(b.updated).localeCompare(String(a.updated));
}
if (listOpts?.sort === "-invoiced_date") {
return String(b.invoiced_date).localeCompare(String(a.invoiced_date));
}
return 0;
});
return { items: sorted, totalItems: sorted.length };
}
),
update: vi.fn(),
};
}
if (name === "price_list") {
return {
getList: vi.fn().mockImplementation(
(_page: number, _perPage: number, listOpts?: { filter?: string; sort?: string }) => {
const f = listOpts?.filter || "";
// Support both `sku = "X"` and the OR form `(sku = "X" || sku = "Y")`
// used by the normalised-SKU query path.
const wantedSkus: string[] = [];
const re = /sku = "([^"]+)"/g;
let m: RegExpExecArray | null;
while ((m = re.exec(f))) wantedSkus.push(m[1]);
const items = wantedSkus.length
? priceList.filter((it) => wantedSkus.includes(String(it.sku || "")))
: [];
return { items, totalItems: items.length };
}
),
};
}
if (name === "stock_items") {
return {
getList: vi.fn().mockImplementation(
(_page: number, _perPage: number, listOpts?: { filter?: string }) => {
const f = listOpts?.filter || "";
const skuMatch = /sku = "([^"]+)"/.exec(f);
const wantedSku = skuMatch?.[1] || "";
const items = stockItems.filter((it) => wantedSku && it.sku === wantedSku);
return { items, totalItems: items.length };
}
),
};
}
if (name === "pos") {
return {
getList: vi.fn().mockImplementation(
(_page: number, _perPage: number, listOpts?: { filter?: string }) => {
const f = listOpts?.filter || "";
const skuMatch = /sku = "([^"]+)"/.exec(f);
const wantedSku = skuMatch?.[1] || "";
const items = pos.filter((it) => wantedSku && it.sku === wantedSku);
return { items, totalItems: items.length };
}
),
};
}
if (name === "suppliers") {
return {
getList: vi.fn().mockResolvedValue({ items: suppliers, totalItems: suppliers.length }),
};
}
return { getOne: vi.fn(), getList: vi.fn().mockResolvedValue(emptyList), update: vi.fn() };
};
return { collection, updated };
}
describe("populateQuoteItemInsights — SKU-exact happy path", () => {
it("answers all four questions when SKU + history exist", async () => {
const pb = buildPb({
target: {
id: "qi-target",
sku: "M16-BOLT",
description: "M16 bolt",
cost_price: 100,
selling_price: 150,
},
priceList: [
{
id: "pl-1",
sku: "M16-BOLT",
cost_price: 90,
selling_price: 130,
supplier: "sup-acme",
brand: "Acme",
category: "Bolts",
subcategory: "M16",
updated: "2026-07-10T10:00:00Z",
},
],
quoteItems: [
{
id: "qi-old",
quote: "q-old",
sku: "M16-BOLT",
cost_price: 95,
selling_price: 145,
created: "2026-06-01T10:00:00Z",
expand: { quote: { quote_number: "Q202606010" } },
},
],
orderItems: [
{
id: "oi-1",
order: "o-1",
sku: "M16-BOLT",
supplier: "sup-acme",
cost_price: 90,
selling_price: 140,
item_status: "delivered",
updated: "2026-05-15T10:00:00Z",
delivered_at: "2026-05-20",
invoiced_date: "2026-05-25T10:00:00Z",
supplier_invoice_number: "INV-42",
customer_invoice_number: "C-INV-7",
quantity: 5,
qty_invoiced: 5,
},
],
stockItems: [
{
id: "si-1",
name: "M16 Bolt",
sku: "M16-BOLT",
current_qty: 100,
min_qty: 20,
selling_price: 130,
markup: 44,
},
],
suppliers: [{ id: "sup-acme", suppliername: "Acme Fasteners" }],
});
vi.mocked(getAdminPocketBase).mockResolvedValue(pb as never);
const result = await populateQuoteItemInsights("qi-target");
expect(result.match_kind).toBe("sku_exact");
expect(result.found_data).toBe(true);
const i = result.insights;
expect(i.match.sku).toBe("M16-BOLT");
expect(i.match.match_confidence).toBe(1);
// last_quoted
expect(i.last_quoted?.quote_item_id).toBe("qi-old");
expect(i.last_quoted?.quote_number).toBe("Q202606010");
// on_price_list + entries
expect(i.on_price_list).toBe(true);
expect(i.price_list_entries).toHaveLength(1);
expect(i.price_list_entries![0].supplier_name).toBe("Acme Fasteners");
// last_order
expect(i.last_order?.order_item_id).toBe("oi-1");
expect(i.last_order?.item_status).toBe("delivered");
expect(i.last_order?.supplier_name).toBe("Acme Fasteners");
// last_invoiced
expect(i.last_invoiced?.supplier_invoice_number).toBe("INV-42");
expect(i.last_invoiced?.customer_invoice_number).toBe("C-INV-7");
// stock
expect(i.stock?.status).toBe("in_stock");
expect(i.stock?.current_qty).toBe(100);
// suppliers
expect(i.suppliers).toHaveLength(1);
expect(i.suppliers![0].last_cost).toBe(90);
});
it("writes the blob back to PB by default and skips when write=false", async () => {
const pb = buildPb({
target: { id: "qi-t", sku: "X1", description: "X", cost_price: 0, selling_price: 0 },
suppliers: [],
});
vi.mocked(getAdminPocketBase).mockResolvedValue(pb as never);
const r1 = await populateQuoteItemInsights("qi-t");
expect(pb.updated).toHaveLength(1);
expect((pb.updated[0].patch as { ai_item_insights: unknown }).ai_item_insights).toEqual(
r1.insights
);
pb.updated.length = 0;
await populateQuoteItemInsights("qi-t", { write: false });
expect(pb.updated).toHaveLength(0);
});
});
describe("populateQuoteItemInsights — description fallback", () => {
it("recovers an SKU from description when target SKU is empty", async () => {
const pb = buildPb({
target: {
id: "qi-target",
sku: "",
description: "M16 hex bolt grade 8.8 zinc plated",
cost_price: 0,
selling_price: 0,
},
quoteItems: [
// Fuzzy candidate from earlier: same description almost exactly,
// carrying a SKU the target doesn't have.
{
id: "qi-cand",
sku: "M16-HEX-8.8",
description: "M16 hex bolt grade 8.8 zinc plated steel",
created: "2026-06-01T10:00:00Z",
},
// Distractor
{ id: "qi-other", sku: "M8-BOLT", description: "M8 socket cap screw" },
],
orderItems: [
{
id: "oi-1",
order: "o-1",
sku: "M16-HEX-8.8",
supplier: "sup-a",
cost_price: 90,
selling_price: 140,
item_status: "delivered",
updated: "2026-05-15T10:00:00Z",
},
],
suppliers: [{ id: "sup-a", suppliername: "Acme" }],
});
vi.mocked(getAdminPocketBase).mockResolvedValue(pb as never);
const result = await populateQuoteItemInsights("qi-target");
expect(result.match_kind).toBe("description_fuzzy");
expect(result.insights.match.sku).toBe("M16-HEX-8.8");
expect(result.found_data).toBe(true);
expect(result.insights.last_order?.order_item_id).toBe("oi-1");
});
it("returns match_kind=none when there is no SKU and no fuzzy match", async () => {
const pb = buildPb({
target: { id: "qi-t", sku: "", description: "Brand new widget", cost_price: 0, selling_price: 0 },
quoteItems: [
{ id: "qi-x", sku: "OTHER", description: "Completely different product line" },
],
suppliers: [],
});
vi.mocked(getAdminPocketBase).mockResolvedValue(pb as never);
const result = await populateQuoteItemInsights("qi-t");
expect(result.match_kind).toBe("none");
expect(result.found_data).toBe(false);
expect(result.insights.summary).toMatch(/no history/i);
});
});
describe("populateQuoteItemInsights — SKU normalisation", () => {
it("matches price_list rows whose SKU is the normalised form", async () => {
const pb = buildPb({
target: { id: "qi-t", sku: "DRL-006MM", description: "Drill", cost_price: 0, selling_price: 0 },
// The exact `DRL-006MM` isn't in price_list; only the normalised `DRL-6MM` is.
priceList: [
{
id: "pl-1",
sku: "DRL-6MM",
cost_price: 50,
selling_price: 75,
supplier: "sup-d",
brand: "DrillCo",
category: "Drills",
subcategory: "6mm",
updated: "2026-07-10T10:00:00Z",
},
],
suppliers: [{ id: "sup-d", suppliername: "DrillCo" }],
});
vi.mocked(getAdminPocketBase).mockResolvedValue(pb as never);
const result = await populateQuoteItemInsights("qi-t");
expect(result.insights.on_price_list).toBe(true);
expect(result.insights.price_list_entries).toHaveLength(1);
expect(result.insights.price_list_entries![0].sku).toBe("DRL-6MM");
});
});
describe("populateQuoteInsightsForQuote — batch", () => {
it("iterates every active item on the quote and rolls up counts", async () => {
const target = { id: "qi-1", sku: "X1", description: "X", cost_price: 0, selling_price: 0 };
const items = [{ id: "qi-1" }, { id: "qi-2" }];
const pb = {
collection: vi.fn((name: string) => {
if (name === "quote_items") {
return {
getOne: vi.fn().mockResolvedValue(target),
getFullList: vi.fn().mockResolvedValue(items),
getList: vi.fn().mockResolvedValue({ items: [], totalItems: 0 }),
update: vi.fn(),
};
}
return { getOne: vi.fn(), getList: vi.fn().mockResolvedValue({ items: [] }) };
}),
};
vi.mocked(getAdminPocketBase).mockResolvedValue(pb as never);
const batch = await populateQuoteInsightsForQuote("q-1");
expect(batch.total).toBe(2);
expect(batch.results).toHaveLength(2);
expect(batch.populated + batch.no_match).toBe(2);
});
});