From cefa52c3e3303df81322b926f600e52360ae93e1 Mon Sep 17 00:00:00 2001 From: Dave Date: Sat, 29 Aug 2026 13:32:23 +0200 Subject: [PATCH] fix(orders): chunk the fuzzy matcher's quote_items fetch, don't blow the filter length fuzzyPass built a single `quote = "x" || quote = "y" || ...` OR-chain across up to FUZZY_CANDIDATE_CAP (200) candidate quotes to fetch their items. The predecessor in pb-helpers.ts documents exactly why that doesn't scale: PB's filter expression has a max length, and a naive OR-chain 400s past roughly 50 ids. There was no try/catch, so getFullList's rejection propagated straight out of suggestQuoteForOrder - the matcher failed outright for precisely the high-volume companies where auto-matching matters most. Reuse fetchQuoteItemsForQuotesAdmin (now exported from quotes-service.ts) instead of a third copy of the same chunking logic. It already does this for the duplicate-quote scan: try PB's `IN (...)` syntax first, fall back to 80-id chunked OR-chains on rejection. Adds a regression test that simulates PB rejecting an oversized filter across 100 candidate quotes and asserts the fetch recovers via multiple chunked calls rather than rejecting outright. Confirmed it fails against the pre-fix code (uncaught rejection propagates out of suggestQuoteForOrder) before verifying it passes against the fix. --- .../server/order-quote-link-service.test.ts | 63 +++++++++++++++++++ src/lib/server/order-quote-link-service.ts | 27 ++++---- src/lib/server/quotes-service.ts | 7 ++- 3 files changed, 82 insertions(+), 15 deletions(-) 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 {