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:
parent
fd5c3c8510
commit
f1c07e8682
1 changed files with 243 additions and 0 deletions
243
docs/superpowers/specs/2026-08-29-order-quote-linking-design.md
Normal file
243
docs/superpowers/specs/2026-08-29-order-quote-linking-design.md
Normal 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.
|
||||
Loading…
Reference in a new issue