diff --git a/docs/superpowers/specs/2026-08-29-order-quote-linking-design.md b/docs/superpowers/specs/2026-08-29-order-quote-linking-design.md new file mode 100644 index 0000000..37268d0 --- /dev/null +++ b/docs/superpowers/specs/2026-08-29-order-quote-linking-design.md @@ -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.