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
424 lines
15 KiB
TypeScript
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);
|
|
});
|
|
});
|