docs(orders): spec order↔quote auto-linking and per-line quote context

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>
This commit is contained in:
Dave 2026-08-29 10:49:06 +02:00
parent fd5c3c8510
commit f1c07e8682

View file

@ -0,0 +1,243 @@
# 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:
1. Orders sit unlinked, so the quote's pricing, supplier choice and
history are invisible when the buyer places the order.
2. 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 a `ScoreBreakdown`
that 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
returns `matchedQuoteItems` (including `notes`) and
`perItemAttachments` keyed by `quote_item` id.
### 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, writes
`potential_quote`, `potential_quote_confidence`, `quote_match_method`
and `quote_matching_attempted: true`. Never writes the hard `quote`
relation.
- `linkQuoteToOrder(orderId, quoteId)` — the one-click confirm. Sets
the hard link, then calls the pairer.
- `pairOrderLines(orderId, quoteId)` — writes `order_items.quote_item`
for confident pairs; returns doubtful pairs without writing them.
- `getOrderQuoteContext(orderId)` — per-line notes, attachments and
pairing state keyed by `order_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-0847` should resolve however old that
quote is — that is a citation, not a guess.
- **Widen rather than give up.** If no candidate clears
`MATCH_CONFIDENCE_THRESHOLD` in 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_attempted`
- `order_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.ts` files.
- 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_quote` already sets both the header link and
`quote_item` per line correctly).
- Any change to the quote-side pages.