Design for closing three gaps in the existing order↔quote matcher:
- The matcher only runs when an operator clicks it, so the ~85% of
orders that arrive against a quote stay unlinked. Adds a call from
createOrder plus a catch-up sweep for the backlog.
- order_items.quote_item is only written when an order is created
from a quote, so post-hoc links never pair lines. Adds persistence
for confident pairs.
- findMatchedQuoteItem re-guesses the pairing at render time using
exact/substring matching, so buyer notes and per-line attachments
silently vanish on any wording difference. Replaced with the
tokenised matcher already in the repo.
Fuzzy candidates are windowed to 90 days before the order's own date
(widening to 12 months only when nothing clears the threshold);
exact-identifier lookups stay unwindowed.
No PocketBase schema changes required.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
10 KiB
Order ↔ quote linking: auto-detection and per-line quote context
Date: 2026-08-29 Status: Approved design, not yet implemented
Problem
Roughly 85% of orders arrive against a quote we already produced, but nothing records that unless an operator links it by hand. Two consequences:
- Orders sit unlinked, so the quote's pricing, supplier choice and history are invisible when the buyer places the order.
- Where a link does exist, the buyer note and attachments the estimator recorded against a quote line frequently fail to appear on the corresponding order line.
Current state
More exists than is apparent. What is already built:
src/lib/server/order-quote-match.ts— pure scoring. Scores a quote against an order on company, customer, per-line description overlap (Jaccard), price tolerance and quantity, returning aScoreBreakdownthat explains why a quote was chosen.src/lib/pb-helpers.ts:530— the PocketBase half of the matcher. Five lookup paths:quote_number,quote_reference,rfq_number,external_number, then a fuzzy company+description+price fallback.src/app/(app)/orders/page.tsx:701— the orders list already renders a green "linked quote" pill and an amber "suggested match" pill.src/app/(app)/orders/[id]/process/page.tsx:2449— the process page already renders the matched quote line's description, buyer note and per-line attachments.src/app/api/v1/orders/[id]/matched-attachments/route.ts— already returnsmatchedQuoteItems(includingnotes) andperItemAttachmentskeyed byquote_itemid.
The three actual gaps
1. The matcher never runs on its own. It fires only when an
operator opens /orders/[id] and clicks the button
(orders/[id]/page.tsx:331). createOrder
(src/lib/server/orders-service.ts:2080) — the path the n8n email
intake uses — hard-codes quote_matching_attempted: false and
potential_quote: null. Intake orders arrive unmatched and stay
unmatched.
2. The per-line link is never persisted. order_items.quote_item
exists in the schema but is written only when an order is created
from a quote (orders-service.ts:1292, pb-helpers.ts:1249). When a
quote is linked to an existing order after the fact, the line-to-line
pairing is never saved.
3. The pairing is re-guessed at render time, badly.
findMatchedQuoteItem (process/page.tsx:3159) does case-insensitive
exact match, then either-side substring. No tokenising, no fuzzy —
despite tokenize/jaccard living in the same repo. So
M20 HEX BOLT GR8.8 vs Hex Bolt M20 Gr 8.8 resolves to nothing, and
the note and attachments silently disappear with no "no match" state.
The order detail page shows no quote context at all.
Decisions
| Decision | Choice |
|---|---|
| Auto-link policy | Suggest only (potential_quote); hard link needs one human click |
| Trigger | At intake, plus a catch-up sweep for stragglers and backlog |
| Fuzzy search window | 90 days before the order's date; widen to 12 months only if nothing clears the bar |
| Line pairing | Save confident pairs to order_items.quote_item; surface doubtful ones flagged, unsaved |
| Where context shows | Order detail items table and the process page |
Design
1. src/lib/server/order-quote-link-service.ts (new)
Ports the PocketBase-dependent half of the matcher out of
pb-helpers.ts into the service layer on getAdminPocketBase(), per
the data-access rules in AGENTS.md. The pure scoring in
order-quote-match.ts is reused untouched.
suggestQuoteForOrder(orderId)— runs the lookup paths, writespotential_quote,potential_quote_confidence,quote_match_methodandquote_matching_attempted: true. Never writes the hardquoterelation.linkQuoteToOrder(orderId, quoteId)— the one-click confirm. Sets the hard link, then calls the pairer.pairOrderLines(orderId, quoteId)— writesorder_items.quote_itemfor confident pairs; returns doubtful pairs without writing them.getOrderQuoteContext(orderId)— per-line notes, attachments and pairing state keyed byorder_item_id.
2. Shared line-pairing algorithm
pairLinesByDescription (order-quote-match.ts:169) is currently
private. Export it so the line pairer and the order-level scorer share
one algorithm rather than growing a second one — the same
"DO NOT duplicate" discipline order-item-context-service.ts follows.
Two tiers on its existing Jaccard score:
- ≥ 0.5 — confident. Written to
order_items.quote_item. - 0.3–0.5 — possible. Surfaced with a confirm button. Not written.
- < 0.3 — no match. Shown as an explicit empty state.
Both thresholds already exist in the file (descriptionFull awards at
0.5; the candidate floor is 0.3), so this adds no new magic numbers.
3. Auto-detection at intake
createOrder calls suggestQuoteForOrder after its items are created,
wrapped so a matcher failure can never fail an order creation.
The current fuzzy path pulls up to FUZZY_CANDIDATE_CAP (200) quotes
plus their items, which is too slow to sit inline in the intake
response. The 90-day window (§4) is expected to cut the candidate set
to a handful, which likely makes inline execution fine.
To verify during implementation: measure real candidate counts
under the window. If they are still large, move the call to this Next
version's post-response work API — check
node_modules/next/dist/docs/ for whether after() exists here and
under what name, rather than assuming.
4. Date-windowed fuzzy search
The FUZZY_CANDIDATE_CAP of 200 most-recent quotes behaves
inconsistently across customers: for a high-volume company it reaches
back weeks, for a quiet one it reaches back years. A date window
normalises this and matches how the business works — a quote is
normally converted within about two months.
Rules:
- Measured from the order's
created, not from now. A "last 90 days from today" filter would find zero candidates for every order in the backlog sweep. The filter is quotes created within 90 days before the order arrived, plus exactly 7 days forward grace for a quote raised just after the PO landed. - Fuzzy path only. The four exact-identifier lookups are never date
limited. A PO that cites
Q-2024-0847should resolve however old that quote is — that is a citation, not a guess. - Widen rather than give up. If no candidate clears
MATCH_CONFIDENCE_THRESHOLDin the 90-day window, retry once at 12 months before reporting no match. The extra query only runs on orders that were about to fail anyway.
The window is an exported constant beside FUZZY_CANDIDATE_CAP so it
can be tuned in one place.
To verify during implementation: PocketBase's accepted date filter
format (created >= "YYYY-MM-DD HH:MM:SS") against the running
instance.
5. Catch-up sweep
POST /api/v1/orders/quote-match/sweep — processes orders where
quote_matching_attempted = false and no hard link exists, in batches,
returning counts of suggested vs no-match.
Mirrored as an MCP tool, because the n8n/Hermes integration drops the
API key on direct REST calls and must go through mcp__wfdash-mcp__*.
The response also reports the distribution of quote-to-order age gaps across the matches it found (e.g. "94% within 60 days, 99% within 120"). This lets the 90-day window be re-tuned from real data instead of an estimate. No schema change — it is a count computed during the sweep.
6. Per-line context API
matched-attachments/route.ts gains one additive field: the resolved
pairing keyed by order_item_id, carrying the paired quote_item id,
its confidence, and whether it is saved or a guess. Additive so the
existing MCP orders.list_attachments consumer is unaffected.
This makes findMatchedQuoteItem redundant; it is deleted.
7. UI
Order detail items table (orders/[id]/page.tsx:507) — each row
gains a compact indicator in the Description cell: a note icon when the
quote line carries a buyer note, a paperclip with a count when it has
attachments. Clicking expands a row beneath showing the quote line's
description, the note and the attachment list. Rows with no paired
quote line show nothing extra.
Process page — keeps its existing "Quote source" panel, now fed by the server-side pairing. Two states it lacks today:
- "Possible match — is this the right quote line?" with a confirm
button, for 0.3–0.5 pairs. Confirming writes
quote_item. - "No matching line on the linked quote" instead of rendering nothing.
Both pages currently call pb-helpers.ts. Their quote-match calls move
to the new service in the same commit, per the migration rule in
AGENTS.md.
Schema
No PocketBase schema changes are required. Every field used already exists:
orders:quote,potential_quote,potential_quote_confidence,quote_match_confidence,quote_match_method,quote_matching_attemptedorder_items:quote_item
quote_match_method is a text field, not a select, so new method
strings can be written without a PB admin change. (AGENTS.md warns
that select fields require manual admin work first — that hazard does
not apply here.)
Testing
- Pairing tiers: unit tests against the existing fixtures in
order-quote-match.test.ts. - Service functions:
order-quote-link-service.test.ts, following the pattern of the sibling*-service.test.tsfiles. - Date window: explicit cases for an order older than the window, a quote inside it, a quote outside it, and the widening fallback.
- UI: verified in the browser against the dev server on port 4001.
Known consequences
The first sweep will surface suggestions across a large number of historical orders at once, so the amber pill will appear widely on the orders list. That is information rather than damage, but the list will look noisy initially.
The 0.5 / 0.3 pairing tiers and the 90-day window are reasoned estimates, not measurements. The sweep's age-gap histogram exists specifically so both can be re-tuned against real data after the backlog run.
Out of scope
- Automatic hard-linking under any confidence. Every hard link stays a human click.
- Changing how orders are created from quotes
(
orders.create_from_quotealready sets both the header link andquote_itemper line correctly). - Any change to the quote-side pages.