diff --git a/src/lib/server/order-quote-link-service.test.ts b/src/lib/server/order-quote-link-service.test.ts index 8c2c962..668b9e8 100644 --- a/src/lib/server/order-quote-link-service.test.ts +++ b/src/lib/server/order-quote-link-service.test.ts @@ -129,6 +129,69 @@ describe("suggestQuoteForOrder", () => { expect(res.best?.quoteId).toBe("q1"); }); + 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([]); + }); + + useP( + makePb({ + [ORDERS]: { getOne: vi.fn().mockResolvedValue(ORDER), update: vi.fn().mockResolvedValue({}) }, + [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); + }); + it("records no_match and still stamps quote_matching_attempted", async () => { const update = vi.fn().mockResolvedValue({}); useP( diff --git a/src/lib/server/order-quote-link-service.ts b/src/lib/server/order-quote-link-service.ts index b8ebf92..71695f7 100644 --- a/src/lib/server/order-quote-link-service.ts +++ b/src/lib/server/order-quote-link-service.ts @@ -19,6 +19,8 @@ */ import { getAdminPocketBase } from "@/lib/server/pocketbase-admin"; +import { fetchQuoteItemsForQuotesAdmin } from "@/lib/server/quotes-service"; +import type { QuoteItem } from "@/lib/types"; import { scoreOrderVsQuote, quoteWindowForOrder, @@ -85,14 +87,9 @@ interface LineRow { quantity?: number; } -interface QuoteLineRow extends LineRow { - quote: string; -} - const ORDER_COLLECTION = "orders"; const QUOTE_COLLECTION = "quotes"; const ORDER_ITEMS_COLLECTION = "order_items"; -const QUOTE_ITEMS_COLLECTION = "quote_items"; function esc(s: string): string { return (s || "").replace(/"/g, '\\"'); @@ -161,18 +158,20 @@ async function fuzzyPass( }); if (candidates.items.length === 0) return []; + // A naive `quote = "x" || quote = "y" || ...` OR-chain over + // FUZZY_CANDIDATE_CAP (200) ids blows PB's filter length limit + // (HTTP 400) well before hitting the cap — `fetchQuoteItemsForQuotesAdmin` + // tries PB's `IN (...)` syntax first and falls back to chunked + // OR-chains, so a company with a large quote history doesn't break + // the matcher outright. Reused (not reimplemented) from + // `quotes-service.ts`, which already has this exact chunking logic + // for its duplicate-quote scan. const quoteIds = candidates.items.map((q) => q.id); - const orFilter = quoteIds.map((qid) => `quote = "${qid}"`).join(" || "); - const quoteItems = await pb - .collection(QUOTE_ITEMS_COLLECTION) - .getFullList({ - filter: orFilter, - fields: "id,quote,description,selling_price,quantity", - batch: 1000, - }); + const quoteItems = await fetchQuoteItemsForQuotesAdmin(pb, quoteIds); - const byQuote = new Map(); + const byQuote = new Map(); for (const it of quoteItems) { + if (!it.quote) continue; const bucket = byQuote.get(it.quote); if (bucket) bucket.push(it); else byQuote.set(it.quote, [it]); diff --git a/src/lib/server/quotes-service.ts b/src/lib/server/quotes-service.ts index d9406d9..f2f00b5 100644 --- a/src/lib/server/quotes-service.ts +++ b/src/lib/server/quotes-service.ts @@ -3227,8 +3227,13 @@ export async function findDuplicateQuotes( * `fetchQuoteItemsForQuotes` in pb-helpers (client PB) and * `fetchCandidateQuoteItems` in quotes-search-service: try PB's `IN` * syntax first, fall back to chunked OR-chains when it's rejected. + * + * Exported so `order-quote-link-service.ts` can reuse it for the + * fuzzy matcher's quote-items fetch instead of building its own + * unchunked OR-chain (which blows the PB filter length limit past + * ~50 candidate quotes). */ -async function fetchQuoteItemsForQuotesAdmin( +export async function fetchQuoteItemsForQuotesAdmin( pb: Awaited>, quoteIds: string[], ): Promise {