The matcher only runs on orders created from now on. This sweeps the
historical backlog plus any order whose lines changed after creation.
- sweepUnmatchedOrders in order-quote-link-service.ts walks
quote_matching_attempted = false && quote = '' (the codebase's
pattern for an empty relation, verified against orders-service.ts
usage) in batches, projecting only id (the wider order fields are
re-fetched inside suggestQuoteForOrder).
- bucketAgeGaps exposes the histogram that lets FUZZY_WINDOW_DAYS be
re-tuned from real data instead of the 90-day estimate.
- POST /api/v1/orders/quote-match/sweep is the agent-auth surface
with maxDuration=300 for the 500-order worst case.
Tests cover the filter shape, the field projection (load-bearing —
over-projection wastes wire bytes), the no-op on no-match path, the
fault tolerance (one row throwing doesn't kill the batch), and all
six histogram bucket boundaries including the ≤ N vs < N edges.
Co-Authored-By: Claude <noreply@anthropic.com>
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.
Adds pairOrderLines to order-quote-link-service.ts: pairs an order's
lines against a quote's lines via pairQuoteLines, writes quote_item
only for confident (>=0.5 Jaccard) pairs, and never overwrites a
line's existing quote_item. Possible-tier pairs are returned but left
unwritten so an operator has to confirm them before a buyer sees a
note attached on a guess.
Reuses fetchQuoteItemsForQuotesAdmin (already used by the fuzzy pass
in this file) to fetch quote_items rather than reintroducing a raw
QUOTE_ITEMS_COLLECTION query. Writes stay sequential, matching the
existing bulk-update convention in orders-service.ts.
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).
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.
Ports the PocketBase half of the order<->quote matcher off the
browser's PB singleton and onto getAdminPocketBase(), so matching can
run at order intake and from a backlog sweep instead of only on a
button click. Reuses the pure scoring in order-quote-match.ts
untouched (scoreOrderVsQuote, quoteWindowForOrder,
quoteToOrderAgeDays).
suggestQuoteForOrder tries the four exact-identifier fields first
(never date-limited), then falls back to a company/customer/
description/price fuzzy scan windowed to 90 days before the order
date, widening to 365 days only if that comes up empty.
Writes only the soft suggestion (potential_quote,
potential_quote_confidence, quote_matching_attempted,
quote_match_method) - never orders.quote, the hard link. Orders that
already carry a hard link are skipped untouched.
pb-helpers.ts's findMatchingQuotesForOrder is left in place; a later
task migrates its last UI caller and removes it.