WFDASH-WEBAPP/src/lib/server/order-quote-link-service.test.ts
Dave 89e52bbd9d feat(orders): AI fills order lines from the matched quote
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>
2026-10-24 14:24:56 +02:00

804 lines
29 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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
});
});