WFDASH-WEBAPP/docs/superpowers/specs/2026-08-29-order-quote-linking-design.md
Dave f1c07e8682 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>
2026-08-29 10:49:06 +02:00

10 KiB
Raw Permalink Blame History

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

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.

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.