The assistant now prices the order's lines from the quote they are paired with, as purple drafts a person then reviews. - order-assistant-core.ts: isOrderLineWritable + buildOrderLineFillPatch. Only a pending line with no cost of its own, no person-set pricing source and no reviewed fill may be written. Supplier / SKU / matched_price_list_id only when the line has none; selling_price only when the line has none; markup + markup_percent written together. pricing_source becomes "ai_quote". A customer price that differs from the quoted price by more than 5% becomes a doubt. - order-assistant-lines.ts (new): the shared, cycle-free helpers — idFilter, aiQuotePairs, writeAiPairs, clearAiPairs and fillOrderFromQuote. It lives in its own module so orders-service.ts can use it without an import cycle, since order-assistant-service.ts already imports setOrderQuote. - The fill re-reads every line right before writing it, so a cost a person typed in the meantime wins. It stays quiet until the migration has added order_items.ai_fill — PB rejects a projection that names a column the collection does not have. - setOrderQuote writes the AI's line pairs before pairOrderLines runs, so the AI's pairing wins over word matching, then fills the lines. A failure there is logged and never fails the link. - The tick gains a second pass that fills the lines of orders already linked to a quote. Only orders the fill actually wrote to count towards the budget, so a skipped order cannot block the run. - The run route fills after the match and reports the counts. PocketBase filters have no IN operator, so every id list is an OR-chain. fetchQuoteItemsForQuotesAdmin no longer tries an IN filter first — that was a guaranteed 400 on every call. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
804 lines
29 KiB
TypeScript
804 lines
29 KiB
TypeScript
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||
import {
|
||
suggestQuoteForOrder,
|
||
computeQuoteSuggestion,
|
||
pairOrderLines,
|
||
sweepUnmatchedOrders,
|
||
bucketAgeGaps,
|
||
} from "@/lib/server/order-quote-link-service";
|
||
import { getAdminPocketBase } from "@/lib/server/pocketbase-admin";
|
||
|
||
vi.mock("@/lib/server/pocketbase-admin", () => ({
|
||
getAdminPocketBase: vi.fn(),
|
||
}));
|
||
|
||
const ORDERS = "orders";
|
||
const QUOTES = "quotes";
|
||
const ORDER_ITEMS = "order_items";
|
||
const QUOTE_ITEMS = "quote_items";
|
||
|
||
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",
|
||
created: "2026-08-29T10:00:00.000Z",
|
||
company: "co1",
|
||
customer: "cust1",
|
||
quote: "",
|
||
quote_reference_text: "",
|
||
};
|
||
|
||
/**
|
||
* Sensible defaults for every collection this service touches, merged
|
||
* with a per-test override map. Two more tasks (order intake, the
|
||
* backlog sweep) add tests to this file — without a default map, each
|
||
* test has to redeclare the full collection set even when it only
|
||
* cares about one of them, and an undeclared *method* on a
|
||
* hand-rolled mock fails with an opaque "not a function" rather than
|
||
* the collection map's clear "unexpected collection" error.
|
||
*
|
||
* The merge is per METHOD, not per collection: an override that says
|
||
* `[ORDERS]: { getOne, update }` keeps the default `getFullList` the
|
||
* linked-elsewhere check needs, so a test that only cares about the
|
||
* order lookup doesn't have to know about every other query the
|
||
* service makes.
|
||
*/
|
||
function makePb(overrides: Record<string, CollectionMock> = {}) {
|
||
const defaults: Record<string, CollectionMock> = {
|
||
[ORDERS]: {
|
||
getOne: vi.fn().mockResolvedValue(ORDER),
|
||
getFullList: vi.fn().mockResolvedValue([]),
|
||
update: vi.fn().mockResolvedValue({}),
|
||
},
|
||
[QUOTES]: { getList: vi.fn().mockResolvedValue({ items: [] }) },
|
||
[ORDER_ITEMS]: { getFullList: vi.fn().mockResolvedValue([]) },
|
||
[QUOTE_ITEMS]: { getFullList: vi.fn().mockResolvedValue([]) },
|
||
};
|
||
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(),
|
||
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>>,
|
||
);
|
||
}
|
||
|
||
describe("suggestQuoteForOrder", () => {
|
||
beforeEach(() => vi.clearAllMocks());
|
||
|
||
it("resolves an exact quote_number citation regardless of the date window", async () => {
|
||
const update = vi.fn().mockResolvedValue({});
|
||
const quotesGetList = vi.fn().mockResolvedValue({
|
||
items: [{ id: "q_old", quote_number: "Q-2024-0847", company: "co1", created: "2024-01-05T00:00:00.000Z" }],
|
||
});
|
||
mockAdminPb(
|
||
makePb({
|
||
[ORDERS]: {
|
||
getOne: vi.fn().mockResolvedValue({ ...ORDER, quote_reference_text: "Q-2024-0847" }),
|
||
update,
|
||
},
|
||
[QUOTES]: { getList: quotesGetList },
|
||
}),
|
||
);
|
||
|
||
const res = await suggestQuoteForOrder("ord1");
|
||
|
||
expect(res.method).toBe("exact_quote_number");
|
||
expect(res.best?.quoteId).toBe("q_old");
|
||
expect(res.window).toBe("exact");
|
||
// Suggestion only — the hard link is never written.
|
||
expect(update).toHaveBeenCalledWith("ord1", expect.objectContaining({
|
||
potential_quote: "q_old",
|
||
quote_matching_attempted: true,
|
||
}));
|
||
expect(update.mock.calls[0][1]).not.toHaveProperty("quote");
|
||
});
|
||
|
||
it("scopes exact-identifier lookups to the order's own company", async () => {
|
||
// Two quotes share the same customer-supplied reference text but
|
||
// belong to different companies. The fixture is deliberately
|
||
// ordered so an UNscoped lookup would return the wrong one
|
||
// (q_other_co, listed first) — only a company-scoped filter
|
||
// narrows it down to q_co1.
|
||
const quoteRows = [
|
||
{ id: "q_other_co", quote_reference: "SHARED-REF", company: "co2", created: "2026-01-05T00:00:00.000Z" },
|
||
{ id: "q_co1", quote_reference: "SHARED-REF", company: "co1", created: "2026-01-01T00:00:00.000Z" },
|
||
];
|
||
const quotesGetList = vi.fn().mockImplementation(
|
||
(_page: number, _perPage: number, opts: { filter: string }) => {
|
||
const conditions = opts.filter.split(" && ").map((clause) => {
|
||
const m = clause.trim().match(/^(\w+) = "(.*)"$/);
|
||
return m ? { field: m[1], value: m[2] } : null;
|
||
});
|
||
const items = quoteRows.filter((row) =>
|
||
conditions.every(
|
||
(c) => c === null || (row as unknown as Record<string, string>)[c.field] === c.value,
|
||
),
|
||
);
|
||
return Promise.resolve({ items });
|
||
},
|
||
);
|
||
mockAdminPb(
|
||
makePb({
|
||
[ORDERS]: {
|
||
getOne: vi.fn().mockResolvedValue({ ...ORDER, quote_reference_text: "SHARED-REF" }),
|
||
update: vi.fn().mockResolvedValue({}),
|
||
},
|
||
[QUOTES]: { getList: quotesGetList },
|
||
}),
|
||
);
|
||
|
||
const res = await suggestQuoteForOrder("ord1");
|
||
|
||
expect(res.best?.quoteId).toBe("q_co1");
|
||
});
|
||
|
||
it("constrains fuzzy candidates to the 90-day window before the order date, and projects only scalar fields", async () => {
|
||
const quotesGetList = vi.fn().mockResolvedValue({ items: [] });
|
||
mockAdminPb(
|
||
makePb({
|
||
[QUOTES]: { getList: quotesGetList },
|
||
[ORDER_ITEMS]: {
|
||
getFullList: vi.fn().mockResolvedValue([
|
||
{ id: "oi1", description: "hex bolt m20 gr8.8 zinc", selling_price: 10, quantity: 5 },
|
||
]),
|
||
},
|
||
}),
|
||
);
|
||
|
||
await suggestQuoteForOrder("ord1");
|
||
|
||
const opts = quotesGetList.mock.calls[0][2] as { filter: string; fields?: string };
|
||
expect(opts.filter).toContain('created >= "2026-05-31T10:00:00.000Z"');
|
||
expect(opts.filter).toContain('company = "co1"');
|
||
// `quotes` also carries email_body / ai_insights / migration_meta —
|
||
// this query must not pull those to read five scalars.
|
||
expect(opts.fields).toBe("id,quote_number,company,customer,created");
|
||
});
|
||
|
||
it("widens to 12 months when the 90-day window yields nothing above threshold", async () => {
|
||
const quotesGetList = vi
|
||
.fn()
|
||
.mockResolvedValueOnce({ items: [] })
|
||
.mockResolvedValueOnce({
|
||
items: [{ id: "q1", quote_number: "Q-1", company: "co1", customer: "cust1", created: "2025-11-01T00:00:00.000Z" }],
|
||
});
|
||
const quoteItemsGetFullList = vi.fn().mockResolvedValue([
|
||
{ id: "qi1", quote: "q1", description: "hex bolt m20 gr8.8 zinc", selling_price: 10, quantity: 5 },
|
||
]);
|
||
mockAdminPb(
|
||
makePb({
|
||
[QUOTES]: { getList: quotesGetList },
|
||
[ORDER_ITEMS]: {
|
||
getFullList: vi.fn().mockResolvedValue([
|
||
{ id: "oi1", description: "hex bolt m20 gr8.8 zinc", selling_price: 10, quantity: 5 },
|
||
]),
|
||
},
|
||
[QUOTE_ITEMS]: { getFullList: quoteItemsGetFullList },
|
||
}),
|
||
);
|
||
|
||
const res = await suggestQuoteForOrder("ord1");
|
||
|
||
expect(quotesGetList).toHaveBeenCalledTimes(2);
|
||
expect(res.window).toBe("widened");
|
||
expect(res.best?.quoteId).toBe("q1");
|
||
|
||
// The matcher's quote_items projection must include `description`
|
||
// and `selling_price` — fetchQuoteItemsForQuotesAdmin's own default
|
||
// fields string belongs to a different caller (the duplicate-quote
|
||
// scan) and could be trimmed independently of the matcher's needs.
|
||
const itemsOpts = quoteItemsGetFullList.mock.calls[0][0] as { fields: string };
|
||
expect(itemsOpts.fields).toContain("description");
|
||
expect(itemsOpts.fields).toContain("selling_price");
|
||
});
|
||
|
||
it("chunks the quote_items fetch instead of issuing one oversized OR-chain filter", async () => {
|
||
// 100 candidate quotes — comfortably past fetchQuoteItemsForQuotesAdmin's
|
||
// 80-id chunk boundary and well under FUZZY_CANDIDATE_CAP (200), which
|
||
// is the range a busy company's 90-day window realistically produces.
|
||
const CANDIDATE_COUNT = 100;
|
||
const quoteRows = Array.from({ length: CANDIDATE_COUNT }, (_, i) => ({
|
||
id: `q${i}`,
|
||
quote_number: `Q-${i}`,
|
||
company: "co1",
|
||
customer: "cust1",
|
||
created: "2026-07-01T00:00:00.000Z",
|
||
}));
|
||
const quotesGetList = vi.fn().mockResolvedValue({ items: quoteRows });
|
||
|
||
// PB rejects a filter that embeds too many ids, the way a real
|
||
// oversized filter expression 400s. Reject on the SHAPE of the
|
||
// filter (one chunk's worth of ids or fewer), not on a call index:
|
||
// the fetch must go straight to chunked OR-chains, so the very
|
||
// first call already has to be a chunk. The un-fixed code issued a
|
||
// single unchunked OR-chain with no fallback, so this same mock
|
||
// makes suggestQuoteForOrder reject outright against that code.
|
||
const CHUNK = 80;
|
||
const quoteItemsGetFullList = vi.fn().mockImplementation((opts: { filter: string }) => {
|
||
const idsInFilter = (opts.filter.match(/quote = "/g) || []).length;
|
||
if (idsInFilter > CHUNK) {
|
||
return Promise.reject(new Error("PB 400: filter expression too long"));
|
||
}
|
||
return Promise.resolve([]);
|
||
});
|
||
|
||
mockAdminPb(
|
||
makePb({
|
||
[QUOTES]: { getList: quotesGetList },
|
||
[ORDER_ITEMS]: {
|
||
getFullList: vi.fn().mockResolvedValue([
|
||
{ id: "oi1", description: "hex bolt m20 gr8.8 zinc", selling_price: 10, quantity: 5 },
|
||
]),
|
||
},
|
||
[QUOTE_ITEMS]: { getFullList: quoteItemsGetFullList },
|
||
}),
|
||
);
|
||
|
||
// Must resolve, not reject — an unchunked fetch propagates the
|
||
// simulated PB rejection straight out of suggestQuoteForOrder.
|
||
await expect(suggestQuoteForOrder("ord1")).resolves.toBeDefined();
|
||
|
||
// Every call is a chunked OR-chain fetch, and there must be more
|
||
// than one (100 ids cannot fit in one 80-id chunk).
|
||
expect(quoteItemsGetFullList.mock.calls.length).toBeGreaterThan(1);
|
||
|
||
// The matcher may widen its window and run a second pass, so the
|
||
// same ids can be fetched twice. What must hold: no single call
|
||
// embeds more than a chunk's worth of ids, no chunk repeats an id,
|
||
// and every candidate id is covered.
|
||
const seenIds = new Set<string>();
|
||
for (const [opts] of quoteItemsGetFullList.mock.calls) {
|
||
const filter = (opts as { filter: string }).filter;
|
||
const ids = [...filter.matchAll(/quote = "([^"]+)"/g)].map((m) => m[1]);
|
||
// No single call re-embeds every candidate id in one filter —
|
||
// that's exactly the oversized-filter bug this test pins.
|
||
expect(ids.length).toBeLessThanOrEqual(CHUNK);
|
||
// No id duplicated inside a chunk.
|
||
expect(new Set(ids).size).toBe(ids.length);
|
||
// PocketBase has no IN operator, so an id list is an OR-chain.
|
||
expect(filter).not.toMatch(/\bIN\s*\(/);
|
||
for (const id of ids) seenIds.add(id);
|
||
}
|
||
// No ids lost.
|
||
expect(seenIds.size).toBe(CANDIDATE_COUNT);
|
||
});
|
||
|
||
it("records no_match and still stamps quote_matching_attempted", async () => {
|
||
const update = vi.fn().mockResolvedValue({});
|
||
mockAdminPb(
|
||
makePb({
|
||
[ORDERS]: { getOne: vi.fn().mockResolvedValue(ORDER), update },
|
||
[ORDER_ITEMS]: {
|
||
getFullList: vi.fn().mockResolvedValue([
|
||
{ id: "oi1", description: "widget", selling_price: 1, quantity: 1 },
|
||
]),
|
||
},
|
||
}),
|
||
);
|
||
|
||
const res = await suggestQuoteForOrder("ord1");
|
||
|
||
expect(res.method).toBe("no_match");
|
||
expect(res.best).toBeNull();
|
||
expect(update).toHaveBeenCalledWith("ord1", expect.objectContaining({
|
||
quote_matching_attempted: true,
|
||
potential_quote: null,
|
||
potential_quote_confidence: 0,
|
||
}));
|
||
});
|
||
|
||
it("skips an order that already has a hard quote link", async () => {
|
||
const update = vi.fn();
|
||
mockAdminPb(
|
||
makePb({
|
||
[ORDERS]: { getOne: vi.fn().mockResolvedValue({ ...ORDER, quote: "already" }), update },
|
||
}),
|
||
);
|
||
|
||
const res = await suggestQuoteForOrder("ord1");
|
||
|
||
expect(res.method).toBe("no_match");
|
||
expect(res.skipped).toBe("already_linked");
|
||
expect(update).not.toHaveBeenCalled();
|
||
});
|
||
});
|
||
|
||
describe("computeQuoteSuggestion", () => {
|
||
beforeEach(() => vi.clearAllMocks());
|
||
|
||
/**
|
||
* One order line that pairs cleanly with a quote line, so the fuzzy
|
||
* pass produces real candidates. `quoteItems` are given for every
|
||
* candidate quote so each one clears the threshold.
|
||
*
|
||
* q1 is created LATER than q2 and both score identically, so q1
|
||
* sorts first and would be the best match without the
|
||
* linked-elsewhere filter. A test that links q1 elsewhere must
|
||
* therefore see the best change — otherwise it proves nothing.
|
||
*/
|
||
function matchingOrder() {
|
||
return {
|
||
[ORDERS]: {
|
||
getOne: vi.fn().mockResolvedValue(ORDER),
|
||
update: vi.fn().mockResolvedValue({}),
|
||
},
|
||
[QUOTES]: {
|
||
getList: vi.fn().mockResolvedValue({
|
||
items: [
|
||
{ id: "q1", quote_number: "Q-1", company: "co1", customer: "cust1", created: "2026-07-02T00:00:00.000Z" },
|
||
{ id: "q2", quote_number: "Q-2", company: "co1", customer: "cust1", created: "2026-07-01T00:00:00.000Z" },
|
||
],
|
||
}),
|
||
},
|
||
[ORDER_ITEMS]: {
|
||
getFullList: vi.fn().mockResolvedValue([
|
||
{ id: "oi1", description: "hex bolt m20 gr8.8 zinc", selling_price: 10, quantity: 5 },
|
||
]),
|
||
},
|
||
[QUOTE_ITEMS]: {
|
||
getFullList: vi.fn().mockImplementation((opts: { filter: string }) =>
|
||
Promise.resolve(
|
||
["q1", "q2"]
|
||
.filter((id) => opts.filter.includes(id))
|
||
.map((id) => ({
|
||
id: `qi_${id}`,
|
||
quote: id,
|
||
description: "hex bolt m20 gr8.8 zinc",
|
||
selling_price: 10,
|
||
quantity: 5,
|
||
})),
|
||
),
|
||
),
|
||
},
|
||
};
|
||
}
|
||
|
||
it("never writes to the order — it only computes", async () => {
|
||
const update = vi.fn().mockResolvedValue({});
|
||
const pb = makePb({ ...matchingOrder(), [ORDERS]: { getOne: vi.fn().mockResolvedValue(ORDER), update } });
|
||
mockAdminPb(pb);
|
||
|
||
const res = await computeQuoteSuggestion("ord1");
|
||
|
||
expect(res.best?.quoteId).toBeTruthy();
|
||
expect(update).not.toHaveBeenCalled();
|
||
});
|
||
|
||
it("returns the same best that suggestQuoteForOrder writes", async () => {
|
||
const update = vi.fn().mockResolvedValue({});
|
||
const pb = makePb({ ...matchingOrder(), [ORDERS]: { getOne: vi.fn().mockResolvedValue(ORDER), update } });
|
||
mockAdminPb(pb);
|
||
|
||
const computed = await computeQuoteSuggestion("ord1");
|
||
expect(update).not.toHaveBeenCalled();
|
||
|
||
const written = await suggestQuoteForOrder("ord1");
|
||
expect(written.best?.quoteId).toBe(computed.best?.quoteId);
|
||
expect(update).toHaveBeenCalledWith("ord1", expect.objectContaining({
|
||
potential_quote: computed.best?.quoteId,
|
||
}));
|
||
});
|
||
|
||
it("drops a candidate quote that another order already links to", async () => {
|
||
const update = vi.fn().mockResolvedValue({});
|
||
// The linked-elsewhere check reads `orders` rows whose `quote` is
|
||
// one of the candidate ids. q1 is already the hard link of a
|
||
// different order, so it must not be offered — as best or as an
|
||
// alternative.
|
||
const ordersGetFullList = vi.fn().mockResolvedValue([
|
||
{ id: "other_order", quote: "q1" },
|
||
]);
|
||
const pb = makePb({
|
||
...matchingOrder(),
|
||
[ORDERS]: {
|
||
getOne: vi.fn().mockResolvedValue(ORDER),
|
||
getFullList: ordersGetFullList,
|
||
update,
|
||
},
|
||
});
|
||
mockAdminPb(pb);
|
||
|
||
const res = await computeQuoteSuggestion("ord1");
|
||
|
||
// q1 outranks q2 on its own — so the best changing is proof the
|
||
// filter ran, not a coincidence of the fixture.
|
||
expect(res.best?.quoteId).toBe("q2");
|
||
expect(res.alternatives.map((c) => c.quoteId)).not.toContain("q1");
|
||
});
|
||
|
||
it("widens to 12 months when every 90-day candidate is linked to another order", async () => {
|
||
// The 90-day window only holds q1, which another order already
|
||
// links. The filter must run before the widen decision, or the
|
||
// 12-month window (holding q2) is never searched.
|
||
const base = matchingOrder();
|
||
const quotesGetList = vi
|
||
.fn()
|
||
.mockResolvedValueOnce({
|
||
items: [{ id: "q1", quote_number: "Q-1", company: "co1", customer: "cust1", created: "2026-07-02T00:00:00.000Z" }],
|
||
})
|
||
.mockResolvedValueOnce({
|
||
items: [{ id: "q2", quote_number: "Q-2", company: "co1", customer: "cust1", created: "2025-11-01T00:00:00.000Z" }],
|
||
});
|
||
const pb = makePb({
|
||
...base,
|
||
[QUOTES]: { getList: quotesGetList },
|
||
[ORDERS]: {
|
||
getOne: vi.fn().mockResolvedValue(ORDER),
|
||
getFullList: vi.fn().mockImplementation((opts: { filter: string }) =>
|
||
Promise.resolve(opts.filter.includes("q1") ? [{ id: "other_order", quote: "q1" }] : []),
|
||
),
|
||
update: vi.fn().mockResolvedValue({}),
|
||
},
|
||
});
|
||
mockAdminPb(pb);
|
||
|
||
const res = await computeQuoteSuggestion("ord1");
|
||
|
||
expect(quotesGetList).toHaveBeenCalledTimes(2);
|
||
expect(res.best?.quoteId).toBe("q2");
|
||
expect(res.window).toBe("widened");
|
||
});
|
||
|
||
it("keeps a candidate quote that this very order links to", async () => {
|
||
const update = vi.fn().mockResolvedValue({});
|
||
// The order's own `quote` is never a conflict — the linked check
|
||
// excludes the order doing the matching.
|
||
const ordersGetFullList = vi.fn().mockResolvedValue([
|
||
{ id: "ord1", quote: "q1" },
|
||
]);
|
||
const pb = makePb({
|
||
...matchingOrder(),
|
||
[ORDERS]: {
|
||
getOne: vi.fn().mockResolvedValue(ORDER),
|
||
getFullList: ordersGetFullList,
|
||
update,
|
||
},
|
||
});
|
||
mockAdminPb(pb);
|
||
|
||
const res = await computeQuoteSuggestion("ord1");
|
||
|
||
expect(res.best?.quoteId).toBe("q1");
|
||
expect(res.alternatives.map((c) => c.quoteId)).toContain("q2");
|
||
});
|
||
|
||
it("falls through to the fuzzy pass when an exact citation is linked to another order", async () => {
|
||
const update = vi.fn().mockResolvedValue({});
|
||
const quotesGetList = vi.fn().mockResolvedValue({
|
||
items: [{ id: "q_exact", quote_number: "Q-EXACT", company: "co1", created: "2026-07-01T00:00:00.000Z" }],
|
||
});
|
||
const ordersGetFullList = vi.fn().mockResolvedValue([
|
||
{ id: "other_order", quote: "q_exact" },
|
||
]);
|
||
const pb = makePb({
|
||
[ORDERS]: {
|
||
getOne: vi.fn().mockResolvedValue({ ...ORDER, quote_reference_text: "Q-EXACT" }),
|
||
getFullList: ordersGetFullList,
|
||
update,
|
||
},
|
||
[QUOTES]: { getList: quotesGetList },
|
||
[ORDER_ITEMS]: {
|
||
getFullList: vi.fn().mockResolvedValue([
|
||
{ id: "oi1", description: "hex bolt m20 gr8.8 zinc", selling_price: 10, quantity: 5 },
|
||
]),
|
||
},
|
||
[QUOTE_ITEMS]: {
|
||
getFullList: vi.fn().mockResolvedValue([
|
||
{ id: "qi1", quote: "q_exact", description: "hex bolt m20 gr8.8 zinc", selling_price: 10, quantity: 5 },
|
||
]),
|
||
},
|
||
});
|
||
mockAdminPb(pb);
|
||
|
||
const res = await computeQuoteSuggestion("ord1");
|
||
|
||
// The exact citation is spoken for, so it is not the best — and
|
||
// the fuzzy pass took over instead of stopping at the exact hit.
|
||
expect(res.best?.quoteId).not.toBe("q_exact");
|
||
expect(res.window).not.toBe("exact");
|
||
});
|
||
});
|
||
|
||
describe("pairOrderLines", () => {
|
||
beforeEach(() => vi.clearAllMocks());
|
||
|
||
it("writes quote_item for a confident pair and leaves a possible pair unwritten", async () => {
|
||
// "M20 HEX BOLT GR8.8 ZINC" vs "Hex Bolt M20 Gr8.8 Zinc" is a
|
||
// confident pair (Jaccard 1.0, same token set once normalized).
|
||
// "cable gland brass M20" vs "cable gland stainless M25" is only
|
||
// possible (Jaccard 0.333 — shares "cable"/"gland", differs on
|
||
// material and size).
|
||
const update = vi.fn().mockResolvedValue({});
|
||
mockAdminPb(
|
||
makePb({
|
||
[ORDER_ITEMS]: {
|
||
getFullList: vi.fn().mockResolvedValue([
|
||
{ id: "oi_conf", description: "M20 HEX BOLT GR8.8 ZINC" },
|
||
{ id: "oi_poss", description: "cable gland brass M20" },
|
||
]),
|
||
update,
|
||
},
|
||
[QUOTE_ITEMS]: {
|
||
getFullList: vi.fn().mockResolvedValue([
|
||
{ id: "qi_conf", quote: "q1", description: "Hex Bolt M20 Gr8.8 Zinc" },
|
||
{ id: "qi_poss", quote: "q1", description: "cable gland stainless M25" },
|
||
]),
|
||
},
|
||
}),
|
||
);
|
||
|
||
const res = await pairOrderLines("ord1", "q1");
|
||
|
||
// Exactly one write, and only for the confident pair.
|
||
expect(update).toHaveBeenCalledTimes(1);
|
||
expect(update).toHaveBeenCalledWith("oi_conf", { quote_item: "qi_conf" });
|
||
|
||
expect(res.confident).toEqual([
|
||
expect.objectContaining({ orderItemId: "oi_conf", quoteItemId: "qi_conf" }),
|
||
]);
|
||
expect(res.possible).toEqual([
|
||
expect.objectContaining({ orderItemId: "oi_poss", quoteItemId: "qi_poss" }),
|
||
]);
|
||
expect(res.unmatchedOrderItemIds).toEqual([]);
|
||
});
|
||
|
||
it("reports order lines with no counterpart as unmatched", async () => {
|
||
mockAdminPb(
|
||
makePb({
|
||
[ORDER_ITEMS]: {
|
||
getFullList: vi.fn().mockResolvedValue([
|
||
{ id: "oi1", description: "galvanised threaded rod 3 metre" },
|
||
]),
|
||
update: vi.fn().mockResolvedValue({}),
|
||
},
|
||
[QUOTE_ITEMS]: {
|
||
getFullList: vi.fn().mockResolvedValue([
|
||
{ id: "qi1", quote: "q1", description: "silicone sealant clear cartridge" },
|
||
]),
|
||
},
|
||
}),
|
||
);
|
||
|
||
const res = await pairOrderLines("ord1", "q1");
|
||
|
||
expect(res.confident).toEqual([]);
|
||
expect(res.possible).toEqual([]);
|
||
expect(res.unmatchedOrderItemIds).toEqual(["oi1"]);
|
||
});
|
||
|
||
it("does not overwrite a quote_item that is already set", async () => {
|
||
const update = vi.fn().mockResolvedValue({});
|
||
mockAdminPb(
|
||
makePb({
|
||
[ORDER_ITEMS]: {
|
||
getFullList: vi.fn().mockResolvedValue([
|
||
{
|
||
id: "oi1",
|
||
description: "Hex Bolt M20 Gr8.8 Zinc",
|
||
quote_item: "qi_existing",
|
||
},
|
||
]),
|
||
update,
|
||
},
|
||
[QUOTE_ITEMS]: {
|
||
getFullList: vi.fn().mockResolvedValue([
|
||
{ id: "qi_new", quote: "q1", description: "Hex Bolt M20 Gr8.8 Zinc" },
|
||
]),
|
||
},
|
||
}),
|
||
);
|
||
|
||
const res = await pairOrderLines("ord1", "q1");
|
||
|
||
// Still reported as a confident pair for the caller's UI purposes...
|
||
expect(res.confident).toEqual([
|
||
expect.objectContaining({ orderItemId: "oi1", quoteItemId: "qi_new" }),
|
||
]);
|
||
// ...but the already-set relation is never clobbered.
|
||
expect(update).not.toHaveBeenCalled();
|
||
});
|
||
|
||
it("does not hand a quote line to a second order line when another line already claims it", async () => {
|
||
// oi_A's DB row already points at qi_X, but its description has
|
||
// since drifted — it no longer matches qi_X (or anything else),
|
||
// so this run's algorithm proposes no pair for it at all. oi_B's
|
||
// description is now a confident match for qi_X. The guard must
|
||
// stop oi_B's write, or both lines would end up rendering qi_X's
|
||
// note and attachments.
|
||
const update = vi.fn().mockResolvedValue({});
|
||
mockAdminPb(
|
||
makePb({
|
||
[ORDER_ITEMS]: {
|
||
getFullList: vi.fn().mockResolvedValue([
|
||
{
|
||
id: "oi_A",
|
||
description: "silicone sealant clear cartridge",
|
||
quote_item: "qi_X",
|
||
},
|
||
{ id: "oi_B", description: "M20 HEX BOLT GR8.8 ZINC" },
|
||
]),
|
||
update,
|
||
},
|
||
[QUOTE_ITEMS]: {
|
||
getFullList: vi.fn().mockResolvedValue([
|
||
{ id: "qi_X", quote: "q1", description: "Hex Bolt M20 Gr8.8 Zinc" },
|
||
]),
|
||
},
|
||
}),
|
||
);
|
||
|
||
const res = await pairOrderLines("ord1", "q1");
|
||
|
||
// oi_B's confident match is reported for the UI...
|
||
expect(res.confident).toEqual([
|
||
expect.objectContaining({ orderItemId: "oi_B", quoteItemId: "qi_X" }),
|
||
]);
|
||
// ...but never written, because qi_X is already claimed.
|
||
expect(update).not.toHaveBeenCalled();
|
||
// oi_A still carries a link in the DB, even though this run's
|
||
// pairing pass didn't (re-)propose it — it must not be reported
|
||
// as needing attention.
|
||
expect(res.unmatchedOrderItemIds).toEqual([]);
|
||
});
|
||
|
||
it("projects the fields the pairing and its no-overwrite guard actually need", async () => {
|
||
// Regression coverage for the `fields` string literals: a
|
||
// one-token edit to either (e.g. dropping `quote_item` off the
|
||
// order-items projection) silently disables the no-overwrite
|
||
// guard or blanks every description, and nothing else in this
|
||
// suite would catch it since the mocks return fixed data
|
||
// regardless of what `fields` asked for.
|
||
const orderItemsGetFullList = vi.fn().mockResolvedValue([
|
||
{ id: "oi1", description: "hex bolt m20 gr8.8 zinc" },
|
||
]);
|
||
const quoteItemsGetFullList = vi.fn().mockResolvedValue([
|
||
{ id: "qi1", quote: "q1", description: "hex bolt m20 gr8.8 zinc" },
|
||
]);
|
||
mockAdminPb(
|
||
makePb({
|
||
[ORDER_ITEMS]: {
|
||
getFullList: orderItemsGetFullList,
|
||
update: vi.fn().mockResolvedValue({}),
|
||
},
|
||
[QUOTE_ITEMS]: { getFullList: quoteItemsGetFullList },
|
||
}),
|
||
);
|
||
|
||
await pairOrderLines("ord1", "q1");
|
||
|
||
const orderItemsOpts = orderItemsGetFullList.mock.calls[0][0] as {
|
||
fields: string;
|
||
};
|
||
expect(orderItemsOpts.fields).toContain("description");
|
||
expect(orderItemsOpts.fields).toContain("quote_item");
|
||
|
||
const quoteItemsOpts = quoteItemsGetFullList.mock.calls[0][0] as {
|
||
fields: string;
|
||
};
|
||
expect(quoteItemsOpts.fields).toContain("description");
|
||
});
|
||
});
|
||
|
||
describe("bucketAgeGaps", () => {
|
||
it("buckets quote→order age gaps so the window can be retuned from real data", () => {
|
||
const histogram = bucketAgeGaps([12, 45, 75, 150, 300, 500]);
|
||
expect(histogram).toEqual({
|
||
d0_30: 1,
|
||
d31_60: 1,
|
||
d61_90: 1,
|
||
d91_180: 1,
|
||
d181_365: 1,
|
||
d365_plus: 1,
|
||
total: 6,
|
||
});
|
||
});
|
||
|
||
it("counts boundary days in the lower bucket (≤ 30 stays in d0_30)", () => {
|
||
const histogram = bucketAgeGaps([0, 30, 31]);
|
||
expect(histogram.d0_30).toBe(2);
|
||
expect(histogram.d31_60).toBe(1);
|
||
});
|
||
|
||
it("counts boundary days in the lower bucket at the upper end (≤ 365 stays in d181_365)", () => {
|
||
const histogram = bucketAgeGaps([364, 365, 366]);
|
||
expect(histogram.d181_365).toBe(2);
|
||
expect(histogram.d365_plus).toBe(1);
|
||
});
|
||
|
||
it("ignores negative gaps rather than crashing the histogram", () => {
|
||
// Negative gaps can happen if a quote is created AFTER the order
|
||
// (rare but observed when backdating). The histogram uses |g|.
|
||
const histogram = bucketAgeGaps([-5, 400]);
|
||
expect(histogram.d0_30).toBe(1);
|
||
expect(histogram.d365_plus).toBe(1);
|
||
expect(histogram.total).toBe(2);
|
||
});
|
||
});
|
||
|
||
describe("sweepUnmatchedOrders", () => {
|
||
beforeEach(() => vi.clearAllMocks());
|
||
|
||
it("processes only orders never attempted and with no hard link, projecting only id", async () => {
|
||
const getList = vi.fn().mockResolvedValue({
|
||
items: [{ id: "o1" }, { id: "o2" }],
|
||
totalItems: 2,
|
||
});
|
||
mockAdminPb(
|
||
makePb({ [ORDERS]: { getList, getOne: vi.fn(), update: vi.fn() } }),
|
||
);
|
||
|
||
await sweepUnmatchedOrders({ limit: 50 });
|
||
|
||
const opts = getList.mock.calls[0][2] as { filter: string; sort: string; fields: string };
|
||
expect(opts.filter).toContain("quote_matching_attempted = false");
|
||
expect(opts.filter).toContain('quote = ""');
|
||
expect(opts.sort).toBe("-created");
|
||
// Field projection is load-bearing — the wide-order fields aren't
|
||
// needed for the sweep, and over-projection wastes wire bytes.
|
||
expect(opts.fields).toBe("id");
|
||
});
|
||
|
||
it("keeps going when one order's match throws and counts it as failed", async () => {
|
||
const getList = vi.fn().mockResolvedValue({
|
||
items: [{ id: "bad" }, { id: "good" }],
|
||
totalItems: 2,
|
||
});
|
||
const getOne = vi
|
||
.fn()
|
||
.mockRejectedValueOnce(new Error("boom"))
|
||
.mockResolvedValue(ORDER);
|
||
mockAdminPb(
|
||
makePb({
|
||
[ORDERS]: { getList, getOne, update: vi.fn().mockResolvedValue({}) },
|
||
}),
|
||
);
|
||
|
||
const res = await sweepUnmatchedOrders({ limit: 50 });
|
||
|
||
expect(res.processed).toBe(2);
|
||
expect(res.failed).toBe(1);
|
||
});
|
||
|
||
it("collects age gaps from matches so the window default can be retuned", async () => {
|
||
const getList = vi.fn().mockResolvedValue({
|
||
items: [{ id: "matched" }],
|
||
totalItems: 5,
|
||
});
|
||
// Spy on suggestQuoteForOrder by stubbing the collection's getList
|
||
// to return one candidate that will pass the threshold — but we
|
||
// don't have a stubbed scoreOrderVsQuote. The cleaner path: assert
|
||
// the histogram is empty when no match is found, then separately
|
||
// verify bucketAgeGaps drives the rendering. (Histogram fill from a
|
||
// real match is exercised end-to-end in the manual sweep against
|
||
// live data per the documented checkpoint.)
|
||
mockAdminPb(
|
||
makePb({ [ORDERS]: { getList, getOne: vi.fn().mockResolvedValue(ORDER), update: vi.fn() } }),
|
||
);
|
||
|
||
const res = await sweepUnmatchedOrders({ limit: 50 });
|
||
|
||
// No matches in the stubbed path → ageGaps is an empty histogram.
|
||
expect(res.ageGaps.total).toBe(0);
|
||
expect(res.remaining).toBe(4); // totalItems 5 − processed 1
|
||
});
|
||
});
|