2026-08-29 11:22:52 +00:00
|
|
|
|
import { describe, it, expect, vi, beforeEach } from "vitest";
|
2026-08-29 11:49:39 +00:00
|
|
|
|
import {
|
|
|
|
|
|
suggestQuoteForOrder,
|
|
|
|
|
|
pairOrderLines,
|
2026-08-30 06:45:23 +00:00
|
|
|
|
sweepUnmatchedOrders,
|
|
|
|
|
|
bucketAgeGaps,
|
2026-08-29 11:49:39 +00:00
|
|
|
|
} from "@/lib/server/order-quote-link-service";
|
2026-08-29 11:22:52 +00:00
|
|
|
|
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>;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
fix(orders): pin matcher's field deps, project queries, and stop swallowing real errors
Six review findings on suggestQuoteForOrder, all bite hardest once
order intake and the backlog sweep start calling it:
1. fetchQuoteItemsForQuotesAdmin's `fields` string was a hardcoded
constant owned by the duplicate-quote scan. The matcher now passes
its own `fields` (an added, defaulted parameter) instead of
depending on that string never being trimmed out from under it —
losing `description`/`selling_price` would have collapsed every
confidence score toward zero with no exception and no failing
test.
2. The candidate-quote query in fuzzyPass had no `fields`, so it
pulled full quote records (email_body, ai_insights,
migration_meta) for up to 200 candidates, twice per order when the
window widens, to read five scalars. Added
`fields: "id,quote_number,company,customer,created"`.
3. findExactMatch's bare `catch {}` swallowed everything, not just
the "field doesn't exist on an older PB schema" case it claimed to
guard (all four fields exist per pb_schema-2.json). An expired
admin token or a PB blip during the sweep would silently fall
through to the fuzzy path, and writeSuggestion would then persist
quote_matching_attempted: true / quote_match_method: "no_match" -
permanently hiding an order that had a valid citation from the
sweep's retry filter. Narrowed to rethrow anything that isn't a
ClientResponseError with status 400.
4. Exact-identifier lookups (quote_reference_text, rfq_number are
customer free text) weren't scoped to the order's company, so a
reference-number collision across two customers could return a
confidence-1.0 suggestion pointing at the wrong company's quote.
Added `company = "..."` to the filter when the order has one.
5. SuggestQuoteResult.window used the literal "90d", hardcoding
FUZZY_WINDOW_DAYS. Renamed to "initial" so retuning the constant
can't make the sweep's reporting lie.
6. order-quote-link-service.test.ts's makePb forced every test to
redeclare the full collection map. Gave it a default map merged
with a per-test override and renamed useP to mockAdminPb, since
two more tasks add tests to this file.
Adds tests for #1 (matcher's quote_items fields include description
and selling_price), #2 (candidate query's fields), and #4 (a
same-reference quote belonging to another company is not returned).
2026-08-29 11:44:56 +00:00
|
|
|
|
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.
|
|
|
|
|
|
*/
|
|
|
|
|
|
function makePb(overrides: Record<string, CollectionMock> = {}) {
|
|
|
|
|
|
const defaults: Record<string, CollectionMock> = {
|
|
|
|
|
|
[ORDERS]: {
|
|
|
|
|
|
getOne: vi.fn().mockResolvedValue(ORDER),
|
|
|
|
|
|
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> = { ...defaults, ...overrides };
|
2026-08-29 11:22:52 +00:00
|
|
|
|
return {
|
|
|
|
|
|
autoCancellation: vi.fn(),
|
|
|
|
|
|
collection: (name: string) => {
|
fix(orders): pin matcher's field deps, project queries, and stop swallowing real errors
Six review findings on suggestQuoteForOrder, all bite hardest once
order intake and the backlog sweep start calling it:
1. fetchQuoteItemsForQuotesAdmin's `fields` string was a hardcoded
constant owned by the duplicate-quote scan. The matcher now passes
its own `fields` (an added, defaulted parameter) instead of
depending on that string never being trimmed out from under it —
losing `description`/`selling_price` would have collapsed every
confidence score toward zero with no exception and no failing
test.
2. The candidate-quote query in fuzzyPass had no `fields`, so it
pulled full quote records (email_body, ai_insights,
migration_meta) for up to 200 candidates, twice per order when the
window widens, to read five scalars. Added
`fields: "id,quote_number,company,customer,created"`.
3. findExactMatch's bare `catch {}` swallowed everything, not just
the "field doesn't exist on an older PB schema" case it claimed to
guard (all four fields exist per pb_schema-2.json). An expired
admin token or a PB blip during the sweep would silently fall
through to the fuzzy path, and writeSuggestion would then persist
quote_matching_attempted: true / quote_match_method: "no_match" -
permanently hiding an order that had a valid citation from the
sweep's retry filter. Narrowed to rethrow anything that isn't a
ClientResponseError with status 400.
4. Exact-identifier lookups (quote_reference_text, rfq_number are
customer free text) weren't scoped to the order's company, so a
reference-number collision across two customers could return a
confidence-1.0 suggestion pointing at the wrong company's quote.
Added `company = "..."` to the filter when the order has one.
5. SuggestQuoteResult.window used the literal "90d", hardcoding
FUZZY_WINDOW_DAYS. Renamed to "initial" so retuning the constant
can't make the sweep's reporting lie.
6. order-quote-link-service.test.ts's makePb forced every test to
redeclare the full collection map. Gave it a default map merged
with a per-test override and renamed useP to mockAdminPb, since
two more tasks add tests to this file.
Adds tests for #1 (matcher's quote_items fields include description
and selling_price), #2 (candidate query's fields), and #4 (a
same-reference quote belonging to another company is not returned).
2026-08-29 11:44:56 +00:00
|
|
|
|
const c = merged[name];
|
2026-08-29 11:22:52 +00:00
|
|
|
|
if (!c) throw new Error(`unexpected collection: ${name}`);
|
|
|
|
|
|
return c;
|
|
|
|
|
|
},
|
|
|
|
|
|
};
|
|
|
|
|
|
}
|
|
|
|
|
|
|
fix(orders): pin matcher's field deps, project queries, and stop swallowing real errors
Six review findings on suggestQuoteForOrder, all bite hardest once
order intake and the backlog sweep start calling it:
1. fetchQuoteItemsForQuotesAdmin's `fields` string was a hardcoded
constant owned by the duplicate-quote scan. The matcher now passes
its own `fields` (an added, defaulted parameter) instead of
depending on that string never being trimmed out from under it —
losing `description`/`selling_price` would have collapsed every
confidence score toward zero with no exception and no failing
test.
2. The candidate-quote query in fuzzyPass had no `fields`, so it
pulled full quote records (email_body, ai_insights,
migration_meta) for up to 200 candidates, twice per order when the
window widens, to read five scalars. Added
`fields: "id,quote_number,company,customer,created"`.
3. findExactMatch's bare `catch {}` swallowed everything, not just
the "field doesn't exist on an older PB schema" case it claimed to
guard (all four fields exist per pb_schema-2.json). An expired
admin token or a PB blip during the sweep would silently fall
through to the fuzzy path, and writeSuggestion would then persist
quote_matching_attempted: true / quote_match_method: "no_match" -
permanently hiding an order that had a valid citation from the
sweep's retry filter. Narrowed to rethrow anything that isn't a
ClientResponseError with status 400.
4. Exact-identifier lookups (quote_reference_text, rfq_number are
customer free text) weren't scoped to the order's company, so a
reference-number collision across two customers could return a
confidence-1.0 suggestion pointing at the wrong company's quote.
Added `company = "..."` to the filter when the order has one.
5. SuggestQuoteResult.window used the literal "90d", hardcoding
FUZZY_WINDOW_DAYS. Renamed to "initial" so retuning the constant
can't make the sweep's reporting lie.
6. order-quote-link-service.test.ts's makePb forced every test to
redeclare the full collection map. Gave it a default map merged
with a per-test override and renamed useP to mockAdminPb, since
two more tasks add tests to this file.
Adds tests for #1 (matcher's quote_items fields include description
and selling_price), #2 (candidate query's fields), and #4 (a
same-reference quote belonging to another company is not returned).
2026-08-29 11:44:56 +00:00
|
|
|
|
function mockAdminPb(pb: ReturnType<typeof makePb>) {
|
2026-08-29 11:22:52 +00:00
|
|
|
|
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({
|
fix(orders): pin matcher's field deps, project queries, and stop swallowing real errors
Six review findings on suggestQuoteForOrder, all bite hardest once
order intake and the backlog sweep start calling it:
1. fetchQuoteItemsForQuotesAdmin's `fields` string was a hardcoded
constant owned by the duplicate-quote scan. The matcher now passes
its own `fields` (an added, defaulted parameter) instead of
depending on that string never being trimmed out from under it —
losing `description`/`selling_price` would have collapsed every
confidence score toward zero with no exception and no failing
test.
2. The candidate-quote query in fuzzyPass had no `fields`, so it
pulled full quote records (email_body, ai_insights,
migration_meta) for up to 200 candidates, twice per order when the
window widens, to read five scalars. Added
`fields: "id,quote_number,company,customer,created"`.
3. findExactMatch's bare `catch {}` swallowed everything, not just
the "field doesn't exist on an older PB schema" case it claimed to
guard (all four fields exist per pb_schema-2.json). An expired
admin token or a PB blip during the sweep would silently fall
through to the fuzzy path, and writeSuggestion would then persist
quote_matching_attempted: true / quote_match_method: "no_match" -
permanently hiding an order that had a valid citation from the
sweep's retry filter. Narrowed to rethrow anything that isn't a
ClientResponseError with status 400.
4. Exact-identifier lookups (quote_reference_text, rfq_number are
customer free text) weren't scoped to the order's company, so a
reference-number collision across two customers could return a
confidence-1.0 suggestion pointing at the wrong company's quote.
Added `company = "..."` to the filter when the order has one.
5. SuggestQuoteResult.window used the literal "90d", hardcoding
FUZZY_WINDOW_DAYS. Renamed to "initial" so retuning the constant
can't make the sweep's reporting lie.
6. order-quote-link-service.test.ts's makePb forced every test to
redeclare the full collection map. Gave it a default map merged
with a per-test override and renamed useP to mockAdminPb, since
two more tasks add tests to this file.
Adds tests for #1 (matcher's quote_items fields include description
and selling_price), #2 (candidate query's fields), and #4 (a
same-reference quote belonging to another company is not returned).
2026-08-29 11:44:56 +00:00
|
|
|
|
items: [{ id: "q_old", quote_number: "Q-2024-0847", company: "co1", created: "2024-01-05T00:00:00.000Z" }],
|
2026-08-29 11:22:52 +00:00
|
|
|
|
});
|
fix(orders): pin matcher's field deps, project queries, and stop swallowing real errors
Six review findings on suggestQuoteForOrder, all bite hardest once
order intake and the backlog sweep start calling it:
1. fetchQuoteItemsForQuotesAdmin's `fields` string was a hardcoded
constant owned by the duplicate-quote scan. The matcher now passes
its own `fields` (an added, defaulted parameter) instead of
depending on that string never being trimmed out from under it —
losing `description`/`selling_price` would have collapsed every
confidence score toward zero with no exception and no failing
test.
2. The candidate-quote query in fuzzyPass had no `fields`, so it
pulled full quote records (email_body, ai_insights,
migration_meta) for up to 200 candidates, twice per order when the
window widens, to read five scalars. Added
`fields: "id,quote_number,company,customer,created"`.
3. findExactMatch's bare `catch {}` swallowed everything, not just
the "field doesn't exist on an older PB schema" case it claimed to
guard (all four fields exist per pb_schema-2.json). An expired
admin token or a PB blip during the sweep would silently fall
through to the fuzzy path, and writeSuggestion would then persist
quote_matching_attempted: true / quote_match_method: "no_match" -
permanently hiding an order that had a valid citation from the
sweep's retry filter. Narrowed to rethrow anything that isn't a
ClientResponseError with status 400.
4. Exact-identifier lookups (quote_reference_text, rfq_number are
customer free text) weren't scoped to the order's company, so a
reference-number collision across two customers could return a
confidence-1.0 suggestion pointing at the wrong company's quote.
Added `company = "..."` to the filter when the order has one.
5. SuggestQuoteResult.window used the literal "90d", hardcoding
FUZZY_WINDOW_DAYS. Renamed to "initial" so retuning the constant
can't make the sweep's reporting lie.
6. order-quote-link-service.test.ts's makePb forced every test to
redeclare the full collection map. Gave it a default map merged
with a per-test override and renamed useP to mockAdminPb, since
two more tasks add tests to this file.
Adds tests for #1 (matcher's quote_items fields include description
and selling_price), #2 (candidate query's fields), and #4 (a
same-reference quote belonging to another company is not returned).
2026-08-29 11:44:56 +00:00
|
|
|
|
mockAdminPb(
|
2026-08-29 11:22:52 +00:00
|
|
|
|
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");
|
|
|
|
|
|
});
|
|
|
|
|
|
|
fix(orders): pin matcher's field deps, project queries, and stop swallowing real errors
Six review findings on suggestQuoteForOrder, all bite hardest once
order intake and the backlog sweep start calling it:
1. fetchQuoteItemsForQuotesAdmin's `fields` string was a hardcoded
constant owned by the duplicate-quote scan. The matcher now passes
its own `fields` (an added, defaulted parameter) instead of
depending on that string never being trimmed out from under it —
losing `description`/`selling_price` would have collapsed every
confidence score toward zero with no exception and no failing
test.
2. The candidate-quote query in fuzzyPass had no `fields`, so it
pulled full quote records (email_body, ai_insights,
migration_meta) for up to 200 candidates, twice per order when the
window widens, to read five scalars. Added
`fields: "id,quote_number,company,customer,created"`.
3. findExactMatch's bare `catch {}` swallowed everything, not just
the "field doesn't exist on an older PB schema" case it claimed to
guard (all four fields exist per pb_schema-2.json). An expired
admin token or a PB blip during the sweep would silently fall
through to the fuzzy path, and writeSuggestion would then persist
quote_matching_attempted: true / quote_match_method: "no_match" -
permanently hiding an order that had a valid citation from the
sweep's retry filter. Narrowed to rethrow anything that isn't a
ClientResponseError with status 400.
4. Exact-identifier lookups (quote_reference_text, rfq_number are
customer free text) weren't scoped to the order's company, so a
reference-number collision across two customers could return a
confidence-1.0 suggestion pointing at the wrong company's quote.
Added `company = "..."` to the filter when the order has one.
5. SuggestQuoteResult.window used the literal "90d", hardcoding
FUZZY_WINDOW_DAYS. Renamed to "initial" so retuning the constant
can't make the sweep's reporting lie.
6. order-quote-link-service.test.ts's makePb forced every test to
redeclare the full collection map. Gave it a default map merged
with a per-test override and renamed useP to mockAdminPb, since
two more tasks add tests to this file.
Adds tests for #1 (matcher's quote_items fields include description
and selling_price), #2 (candidate query's fields), and #4 (a
same-reference quote belonging to another company is not returned).
2026-08-29 11:44:56 +00:00
|
|
|
|
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 () => {
|
2026-08-29 11:22:52 +00:00
|
|
|
|
const quotesGetList = vi.fn().mockResolvedValue({ items: [] });
|
fix(orders): pin matcher's field deps, project queries, and stop swallowing real errors
Six review findings on suggestQuoteForOrder, all bite hardest once
order intake and the backlog sweep start calling it:
1. fetchQuoteItemsForQuotesAdmin's `fields` string was a hardcoded
constant owned by the duplicate-quote scan. The matcher now passes
its own `fields` (an added, defaulted parameter) instead of
depending on that string never being trimmed out from under it —
losing `description`/`selling_price` would have collapsed every
confidence score toward zero with no exception and no failing
test.
2. The candidate-quote query in fuzzyPass had no `fields`, so it
pulled full quote records (email_body, ai_insights,
migration_meta) for up to 200 candidates, twice per order when the
window widens, to read five scalars. Added
`fields: "id,quote_number,company,customer,created"`.
3. findExactMatch's bare `catch {}` swallowed everything, not just
the "field doesn't exist on an older PB schema" case it claimed to
guard (all four fields exist per pb_schema-2.json). An expired
admin token or a PB blip during the sweep would silently fall
through to the fuzzy path, and writeSuggestion would then persist
quote_matching_attempted: true / quote_match_method: "no_match" -
permanently hiding an order that had a valid citation from the
sweep's retry filter. Narrowed to rethrow anything that isn't a
ClientResponseError with status 400.
4. Exact-identifier lookups (quote_reference_text, rfq_number are
customer free text) weren't scoped to the order's company, so a
reference-number collision across two customers could return a
confidence-1.0 suggestion pointing at the wrong company's quote.
Added `company = "..."` to the filter when the order has one.
5. SuggestQuoteResult.window used the literal "90d", hardcoding
FUZZY_WINDOW_DAYS. Renamed to "initial" so retuning the constant
can't make the sweep's reporting lie.
6. order-quote-link-service.test.ts's makePb forced every test to
redeclare the full collection map. Gave it a default map merged
with a per-test override and renamed useP to mockAdminPb, since
two more tasks add tests to this file.
Adds tests for #1 (matcher's quote_items fields include description
and selling_price), #2 (candidate query's fields), and #4 (a
same-reference quote belonging to another company is not returned).
2026-08-29 11:44:56 +00:00
|
|
|
|
mockAdminPb(
|
2026-08-29 11:22:52 +00:00
|
|
|
|
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");
|
|
|
|
|
|
|
fix(orders): pin matcher's field deps, project queries, and stop swallowing real errors
Six review findings on suggestQuoteForOrder, all bite hardest once
order intake and the backlog sweep start calling it:
1. fetchQuoteItemsForQuotesAdmin's `fields` string was a hardcoded
constant owned by the duplicate-quote scan. The matcher now passes
its own `fields` (an added, defaulted parameter) instead of
depending on that string never being trimmed out from under it —
losing `description`/`selling_price` would have collapsed every
confidence score toward zero with no exception and no failing
test.
2. The candidate-quote query in fuzzyPass had no `fields`, so it
pulled full quote records (email_body, ai_insights,
migration_meta) for up to 200 candidates, twice per order when the
window widens, to read five scalars. Added
`fields: "id,quote_number,company,customer,created"`.
3. findExactMatch's bare `catch {}` swallowed everything, not just
the "field doesn't exist on an older PB schema" case it claimed to
guard (all four fields exist per pb_schema-2.json). An expired
admin token or a PB blip during the sweep would silently fall
through to the fuzzy path, and writeSuggestion would then persist
quote_matching_attempted: true / quote_match_method: "no_match" -
permanently hiding an order that had a valid citation from the
sweep's retry filter. Narrowed to rethrow anything that isn't a
ClientResponseError with status 400.
4. Exact-identifier lookups (quote_reference_text, rfq_number are
customer free text) weren't scoped to the order's company, so a
reference-number collision across two customers could return a
confidence-1.0 suggestion pointing at the wrong company's quote.
Added `company = "..."` to the filter when the order has one.
5. SuggestQuoteResult.window used the literal "90d", hardcoding
FUZZY_WINDOW_DAYS. Renamed to "initial" so retuning the constant
can't make the sweep's reporting lie.
6. order-quote-link-service.test.ts's makePb forced every test to
redeclare the full collection map. Gave it a default map merged
with a per-test override and renamed useP to mockAdminPb, since
two more tasks add tests to this file.
Adds tests for #1 (matcher's quote_items fields include description
and selling_price), #2 (candidate query's fields), and #4 (a
same-reference quote belonging to another company is not returned).
2026-08-29 11:44:56 +00:00
|
|
|
|
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");
|
2026-08-29 11:22:52 +00:00
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
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" }],
|
|
|
|
|
|
});
|
fix(orders): pin matcher's field deps, project queries, and stop swallowing real errors
Six review findings on suggestQuoteForOrder, all bite hardest once
order intake and the backlog sweep start calling it:
1. fetchQuoteItemsForQuotesAdmin's `fields` string was a hardcoded
constant owned by the duplicate-quote scan. The matcher now passes
its own `fields` (an added, defaulted parameter) instead of
depending on that string never being trimmed out from under it —
losing `description`/`selling_price` would have collapsed every
confidence score toward zero with no exception and no failing
test.
2. The candidate-quote query in fuzzyPass had no `fields`, so it
pulled full quote records (email_body, ai_insights,
migration_meta) for up to 200 candidates, twice per order when the
window widens, to read five scalars. Added
`fields: "id,quote_number,company,customer,created"`.
3. findExactMatch's bare `catch {}` swallowed everything, not just
the "field doesn't exist on an older PB schema" case it claimed to
guard (all four fields exist per pb_schema-2.json). An expired
admin token or a PB blip during the sweep would silently fall
through to the fuzzy path, and writeSuggestion would then persist
quote_matching_attempted: true / quote_match_method: "no_match" -
permanently hiding an order that had a valid citation from the
sweep's retry filter. Narrowed to rethrow anything that isn't a
ClientResponseError with status 400.
4. Exact-identifier lookups (quote_reference_text, rfq_number are
customer free text) weren't scoped to the order's company, so a
reference-number collision across two customers could return a
confidence-1.0 suggestion pointing at the wrong company's quote.
Added `company = "..."` to the filter when the order has one.
5. SuggestQuoteResult.window used the literal "90d", hardcoding
FUZZY_WINDOW_DAYS. Renamed to "initial" so retuning the constant
can't make the sweep's reporting lie.
6. order-quote-link-service.test.ts's makePb forced every test to
redeclare the full collection map. Gave it a default map merged
with a per-test override and renamed useP to mockAdminPb, since
two more tasks add tests to this file.
Adds tests for #1 (matcher's quote_items fields include description
and selling_price), #2 (candidate query's fields), and #4 (a
same-reference quote belonging to another company is not returned).
2026-08-29 11:44:56 +00:00
|
|
|
|
const quoteItemsGetFullList = vi.fn().mockResolvedValue([
|
|
|
|
|
|
{ id: "qi1", quote: "q1", description: "hex bolt m20 gr8.8 zinc", selling_price: 10, quantity: 5 },
|
|
|
|
|
|
]);
|
|
|
|
|
|
mockAdminPb(
|
2026-08-29 11:22:52 +00:00
|
|
|
|
makePb({
|
|
|
|
|
|
[QUOTES]: { getList: quotesGetList },
|
|
|
|
|
|
[ORDER_ITEMS]: {
|
|
|
|
|
|
getFullList: vi.fn().mockResolvedValue([
|
|
|
|
|
|
{ id: "oi1", description: "hex bolt m20 gr8.8 zinc", selling_price: 10, quantity: 5 },
|
|
|
|
|
|
]),
|
|
|
|
|
|
},
|
fix(orders): pin matcher's field deps, project queries, and stop swallowing real errors
Six review findings on suggestQuoteForOrder, all bite hardest once
order intake and the backlog sweep start calling it:
1. fetchQuoteItemsForQuotesAdmin's `fields` string was a hardcoded
constant owned by the duplicate-quote scan. The matcher now passes
its own `fields` (an added, defaulted parameter) instead of
depending on that string never being trimmed out from under it —
losing `description`/`selling_price` would have collapsed every
confidence score toward zero with no exception and no failing
test.
2. The candidate-quote query in fuzzyPass had no `fields`, so it
pulled full quote records (email_body, ai_insights,
migration_meta) for up to 200 candidates, twice per order when the
window widens, to read five scalars. Added
`fields: "id,quote_number,company,customer,created"`.
3. findExactMatch's bare `catch {}` swallowed everything, not just
the "field doesn't exist on an older PB schema" case it claimed to
guard (all four fields exist per pb_schema-2.json). An expired
admin token or a PB blip during the sweep would silently fall
through to the fuzzy path, and writeSuggestion would then persist
quote_matching_attempted: true / quote_match_method: "no_match" -
permanently hiding an order that had a valid citation from the
sweep's retry filter. Narrowed to rethrow anything that isn't a
ClientResponseError with status 400.
4. Exact-identifier lookups (quote_reference_text, rfq_number are
customer free text) weren't scoped to the order's company, so a
reference-number collision across two customers could return a
confidence-1.0 suggestion pointing at the wrong company's quote.
Added `company = "..."` to the filter when the order has one.
5. SuggestQuoteResult.window used the literal "90d", hardcoding
FUZZY_WINDOW_DAYS. Renamed to "initial" so retuning the constant
can't make the sweep's reporting lie.
6. order-quote-link-service.test.ts's makePb forced every test to
redeclare the full collection map. Gave it a default map merged
with a per-test override and renamed useP to mockAdminPb, since
two more tasks add tests to this file.
Adds tests for #1 (matcher's quote_items fields include description
and selling_price), #2 (candidate query's fields), and #4 (a
same-reference quote belonging to another company is not returned).
2026-08-29 11:44:56 +00:00
|
|
|
|
[QUOTE_ITEMS]: { getFullList: quoteItemsGetFullList },
|
2026-08-29 11:22:52 +00:00
|
|
|
|
}),
|
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
|
|
const res = await suggestQuoteForOrder("ord1");
|
|
|
|
|
|
|
|
|
|
|
|
expect(quotesGetList).toHaveBeenCalledTimes(2);
|
|
|
|
|
|
expect(res.window).toBe("widened");
|
|
|
|
|
|
expect(res.best?.quoteId).toBe("q1");
|
fix(orders): pin matcher's field deps, project queries, and stop swallowing real errors
Six review findings on suggestQuoteForOrder, all bite hardest once
order intake and the backlog sweep start calling it:
1. fetchQuoteItemsForQuotesAdmin's `fields` string was a hardcoded
constant owned by the duplicate-quote scan. The matcher now passes
its own `fields` (an added, defaulted parameter) instead of
depending on that string never being trimmed out from under it —
losing `description`/`selling_price` would have collapsed every
confidence score toward zero with no exception and no failing
test.
2. The candidate-quote query in fuzzyPass had no `fields`, so it
pulled full quote records (email_body, ai_insights,
migration_meta) for up to 200 candidates, twice per order when the
window widens, to read five scalars. Added
`fields: "id,quote_number,company,customer,created"`.
3. findExactMatch's bare `catch {}` swallowed everything, not just
the "field doesn't exist on an older PB schema" case it claimed to
guard (all four fields exist per pb_schema-2.json). An expired
admin token or a PB blip during the sweep would silently fall
through to the fuzzy path, and writeSuggestion would then persist
quote_matching_attempted: true / quote_match_method: "no_match" -
permanently hiding an order that had a valid citation from the
sweep's retry filter. Narrowed to rethrow anything that isn't a
ClientResponseError with status 400.
4. Exact-identifier lookups (quote_reference_text, rfq_number are
customer free text) weren't scoped to the order's company, so a
reference-number collision across two customers could return a
confidence-1.0 suggestion pointing at the wrong company's quote.
Added `company = "..."` to the filter when the order has one.
5. SuggestQuoteResult.window used the literal "90d", hardcoding
FUZZY_WINDOW_DAYS. Renamed to "initial" so retuning the constant
can't make the sweep's reporting lie.
6. order-quote-link-service.test.ts's makePb forced every test to
redeclare the full collection map. Gave it a default map merged
with a per-test override and renamed useP to mockAdminPb, since
two more tasks add tests to this file.
Adds tests for #1 (matcher's quote_items fields include description
and selling_price), #2 (candidate query's fields), and #4 (a
same-reference quote belonging to another company is not returned).
2026-08-29 11:44:56 +00:00
|
|
|
|
|
|
|
|
|
|
// 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");
|
2026-08-29 11:22:52 +00:00
|
|
|
|
});
|
|
|
|
|
|
|
2026-08-29 11:32:23 +00:00
|
|
|
|
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 });
|
|
|
|
|
|
|
|
|
|
|
|
// Simulate PB rejecting the first attempt (the `quote IN (...)`
|
|
|
|
|
|
// syntax) the way a too-long filter expression would 400 in real
|
|
|
|
|
|
// PB, then succeeding on every chunked retry. The un-fixed code
|
|
|
|
|
|
// issued a single unchunked OR-chain call with no fallback, so
|
|
|
|
|
|
// this same mock makes suggestQuoteForOrder reject outright
|
|
|
|
|
|
// against that code — it only resolves once the fetch is chunked.
|
|
|
|
|
|
const quoteItemsGetFullList = vi.fn().mockImplementation(() => {
|
|
|
|
|
|
if (quoteItemsGetFullList.mock.calls.length === 1) {
|
|
|
|
|
|
return Promise.reject(new Error("PB 400: filter expression too long"));
|
|
|
|
|
|
}
|
|
|
|
|
|
return Promise.resolve([]);
|
|
|
|
|
|
});
|
|
|
|
|
|
|
fix(orders): pin matcher's field deps, project queries, and stop swallowing real errors
Six review findings on suggestQuoteForOrder, all bite hardest once
order intake and the backlog sweep start calling it:
1. fetchQuoteItemsForQuotesAdmin's `fields` string was a hardcoded
constant owned by the duplicate-quote scan. The matcher now passes
its own `fields` (an added, defaulted parameter) instead of
depending on that string never being trimmed out from under it —
losing `description`/`selling_price` would have collapsed every
confidence score toward zero with no exception and no failing
test.
2. The candidate-quote query in fuzzyPass had no `fields`, so it
pulled full quote records (email_body, ai_insights,
migration_meta) for up to 200 candidates, twice per order when the
window widens, to read five scalars. Added
`fields: "id,quote_number,company,customer,created"`.
3. findExactMatch's bare `catch {}` swallowed everything, not just
the "field doesn't exist on an older PB schema" case it claimed to
guard (all four fields exist per pb_schema-2.json). An expired
admin token or a PB blip during the sweep would silently fall
through to the fuzzy path, and writeSuggestion would then persist
quote_matching_attempted: true / quote_match_method: "no_match" -
permanently hiding an order that had a valid citation from the
sweep's retry filter. Narrowed to rethrow anything that isn't a
ClientResponseError with status 400.
4. Exact-identifier lookups (quote_reference_text, rfq_number are
customer free text) weren't scoped to the order's company, so a
reference-number collision across two customers could return a
confidence-1.0 suggestion pointing at the wrong company's quote.
Added `company = "..."` to the filter when the order has one.
5. SuggestQuoteResult.window used the literal "90d", hardcoding
FUZZY_WINDOW_DAYS. Renamed to "initial" so retuning the constant
can't make the sweep's reporting lie.
6. order-quote-link-service.test.ts's makePb forced every test to
redeclare the full collection map. Gave it a default map merged
with a per-test override and renamed useP to mockAdminPb, since
two more tasks add tests to this file.
Adds tests for #1 (matcher's quote_items fields include description
and selling_price), #2 (candidate query's fields), and #4 (a
same-reference quote belonging to another company is not returned).
2026-08-29 11:44:56 +00:00
|
|
|
|
mockAdminPb(
|
2026-08-29 11:32:23 +00:00
|
|
|
|
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 — the un-fixed code has no fallback and
|
|
|
|
|
|
// propagates the simulated PB rejection straight out of
|
|
|
|
|
|
// suggestQuoteForOrder.
|
|
|
|
|
|
await expect(suggestQuoteForOrder("ord1")).resolves.toBeDefined();
|
|
|
|
|
|
|
|
|
|
|
|
// First call is the failed IN(...) attempt; every call after it is
|
|
|
|
|
|
// a chunked OR-chain retry, so there must be more than one.
|
|
|
|
|
|
expect(quoteItemsGetFullList.mock.calls.length).toBeGreaterThan(1);
|
|
|
|
|
|
|
|
|
|
|
|
const chunkCalls = quoteItemsGetFullList.mock.calls.slice(1);
|
|
|
|
|
|
let idsAcrossChunks = 0;
|
|
|
|
|
|
for (const [opts] of chunkCalls) {
|
|
|
|
|
|
const filter = (opts as { filter: string }).filter;
|
|
|
|
|
|
const idsInThisChunk = (filter.match(/quote = "/g) || []).length;
|
|
|
|
|
|
// No single retry re-embeds every candidate id in one filter —
|
|
|
|
|
|
// that's exactly the oversized-filter bug this test pins.
|
|
|
|
|
|
expect(idsInThisChunk).toBeLessThanOrEqual(80);
|
|
|
|
|
|
idsAcrossChunks += idsInThisChunk;
|
|
|
|
|
|
}
|
|
|
|
|
|
// No ids lost or duplicated across chunks.
|
|
|
|
|
|
expect(idsAcrossChunks).toBe(CANDIDATE_COUNT);
|
|
|
|
|
|
});
|
|
|
|
|
|
|
2026-08-29 11:22:52 +00:00
|
|
|
|
it("records no_match and still stamps quote_matching_attempted", async () => {
|
|
|
|
|
|
const update = vi.fn().mockResolvedValue({});
|
fix(orders): pin matcher's field deps, project queries, and stop swallowing real errors
Six review findings on suggestQuoteForOrder, all bite hardest once
order intake and the backlog sweep start calling it:
1. fetchQuoteItemsForQuotesAdmin's `fields` string was a hardcoded
constant owned by the duplicate-quote scan. The matcher now passes
its own `fields` (an added, defaulted parameter) instead of
depending on that string never being trimmed out from under it —
losing `description`/`selling_price` would have collapsed every
confidence score toward zero with no exception and no failing
test.
2. The candidate-quote query in fuzzyPass had no `fields`, so it
pulled full quote records (email_body, ai_insights,
migration_meta) for up to 200 candidates, twice per order when the
window widens, to read five scalars. Added
`fields: "id,quote_number,company,customer,created"`.
3. findExactMatch's bare `catch {}` swallowed everything, not just
the "field doesn't exist on an older PB schema" case it claimed to
guard (all four fields exist per pb_schema-2.json). An expired
admin token or a PB blip during the sweep would silently fall
through to the fuzzy path, and writeSuggestion would then persist
quote_matching_attempted: true / quote_match_method: "no_match" -
permanently hiding an order that had a valid citation from the
sweep's retry filter. Narrowed to rethrow anything that isn't a
ClientResponseError with status 400.
4. Exact-identifier lookups (quote_reference_text, rfq_number are
customer free text) weren't scoped to the order's company, so a
reference-number collision across two customers could return a
confidence-1.0 suggestion pointing at the wrong company's quote.
Added `company = "..."` to the filter when the order has one.
5. SuggestQuoteResult.window used the literal "90d", hardcoding
FUZZY_WINDOW_DAYS. Renamed to "initial" so retuning the constant
can't make the sweep's reporting lie.
6. order-quote-link-service.test.ts's makePb forced every test to
redeclare the full collection map. Gave it a default map merged
with a per-test override and renamed useP to mockAdminPb, since
two more tasks add tests to this file.
Adds tests for #1 (matcher's quote_items fields include description
and selling_price), #2 (candidate query's fields), and #4 (a
same-reference quote belonging to another company is not returned).
2026-08-29 11:44:56 +00:00
|
|
|
|
mockAdminPb(
|
2026-08-29 11:22:52 +00:00
|
|
|
|
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();
|
fix(orders): pin matcher's field deps, project queries, and stop swallowing real errors
Six review findings on suggestQuoteForOrder, all bite hardest once
order intake and the backlog sweep start calling it:
1. fetchQuoteItemsForQuotesAdmin's `fields` string was a hardcoded
constant owned by the duplicate-quote scan. The matcher now passes
its own `fields` (an added, defaulted parameter) instead of
depending on that string never being trimmed out from under it —
losing `description`/`selling_price` would have collapsed every
confidence score toward zero with no exception and no failing
test.
2. The candidate-quote query in fuzzyPass had no `fields`, so it
pulled full quote records (email_body, ai_insights,
migration_meta) for up to 200 candidates, twice per order when the
window widens, to read five scalars. Added
`fields: "id,quote_number,company,customer,created"`.
3. findExactMatch's bare `catch {}` swallowed everything, not just
the "field doesn't exist on an older PB schema" case it claimed to
guard (all four fields exist per pb_schema-2.json). An expired
admin token or a PB blip during the sweep would silently fall
through to the fuzzy path, and writeSuggestion would then persist
quote_matching_attempted: true / quote_match_method: "no_match" -
permanently hiding an order that had a valid citation from the
sweep's retry filter. Narrowed to rethrow anything that isn't a
ClientResponseError with status 400.
4. Exact-identifier lookups (quote_reference_text, rfq_number are
customer free text) weren't scoped to the order's company, so a
reference-number collision across two customers could return a
confidence-1.0 suggestion pointing at the wrong company's quote.
Added `company = "..."` to the filter when the order has one.
5. SuggestQuoteResult.window used the literal "90d", hardcoding
FUZZY_WINDOW_DAYS. Renamed to "initial" so retuning the constant
can't make the sweep's reporting lie.
6. order-quote-link-service.test.ts's makePb forced every test to
redeclare the full collection map. Gave it a default map merged
with a per-test override and renamed useP to mockAdminPb, since
two more tasks add tests to this file.
Adds tests for #1 (matcher's quote_items fields include description
and selling_price), #2 (candidate query's fields), and #4 (a
same-reference quote belonging to another company is not returned).
2026-08-29 11:44:56 +00:00
|
|
|
|
mockAdminPb(
|
2026-08-29 11:22:52 +00:00
|
|
|
|
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();
|
|
|
|
|
|
});
|
|
|
|
|
|
});
|
2026-08-29 11:49:39 +00:00
|
|
|
|
|
|
|
|
|
|
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();
|
|
|
|
|
|
});
|
fix(orders): close cross-line quote_item collision, pin pairing's field projections
pairOrderLines' no-overwrite guard only checked whether an order
line's own row already carried a quote_item, not whether the quote
line it was about to claim was already held by a different order
line. A description drift (or a hand-confirmed link) on one line let
a fresh confident match on another line silently double-assign the
same quote_item, so two parts would render the same estimator's note
and attachments — the exact failure this feature exists to prevent,
reached from the other direction. Fixed by seeding a claimed-quote-item
set from the fetched order rows before the write loop, and skipping
any confident pair that targets an already-claimed quote line.
unmatchedOrderItemIds now also excludes any line that already carries
a link, even when this run's pairing pass didn't re-propose it.
Also names the two field projections (PAIR_ORDER_ITEM_FIELDS,
PAIR_QUOTE_ITEM_FIELDS) next to the existing MATCHER_QUOTE_ITEM_FIELDS
constant, and adds a test that asserts on the actual `fields` string
passed to each getFullList call. Previously nothing distinguished
`fields: "id,description"` from `fields: "id,description,quote_item"`
at the order-items query — a one-token trim silently disabled the
no-overwrite guard (every row read as unlinked) with the full suite
staying green, since the existing behavioral tests supply `quote_item`
values directly in their fixtures rather than through the query. Verified
by re-running that exact mutation against the new test: it fails, and
neither pre-existing behavioral test catches it on its own.
2026-08-29 12:01:13 +00:00
|
|
|
|
|
|
|
|
|
|
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");
|
|
|
|
|
|
});
|
2026-08-29 11:49:39 +00:00
|
|
|
|
});
|
2026-08-30 06:45:23 +00:00
|
|
|
|
|
|
|
|
|
|
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
|
|
|
|
|
|
});
|
|
|
|
|
|
});
|