From 88fa8e978e5944264175144de8439fe50fc6e1d7 Mon Sep 17 00:00:00 2001 From: Dave Date: Sat, 29 Aug 2026 10:59:37 +0200 Subject: [PATCH] docs(orders): add implementation plan for quote auto-linking 13 TDD tasks across two phases. Phase 1 (backend) ends with orders matched automatically at intake plus a backlog sweep; Phase 2 surfaces the paired quote line's note and attachments on both order pages. Resolves the spec's two open questions: `after` is stable in next/server on Next 16.2.6, and PocketBase date filters take `.toISOString()`. Two deviations from the spec, both noted in the plan: line pairing hooks into the existing setOrderQuote rather than a new linkQuoteToOrder, and the per-line context extends the existing matched-attachments response rather than adding an endpoint. Co-Authored-By: Claude Opus 5 --- .../plans/2026-08-29-order-quote-linking.md | 2431 +++++++++++++++++ 1 file changed, 2431 insertions(+) create mode 100644 docs/superpowers/plans/2026-08-29-order-quote-linking.md diff --git a/docs/superpowers/plans/2026-08-29-order-quote-linking.md b/docs/superpowers/plans/2026-08-29-order-quote-linking.md new file mode 100644 index 0000000..a82699b --- /dev/null +++ b/docs/superpowers/plans/2026-08-29-order-quote-linking.md @@ -0,0 +1,2431 @@ +# Order ↔ Quote Auto-Linking Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Every incoming order is automatically matched to its originating quote as a one-click-confirmable suggestion, and each order line shows the buyer note and attachments from its paired quote line. + +**Architecture:** The pure scoring math already exists in `src/lib/server/order-quote-match.ts` and is reused untouched. A new `src/lib/server/order-quote-link-service.ts` owns the PocketBase half (currently stranded in the browser-side `pb-helpers.ts`, which is why it can't run at intake). `createOrder` calls it via Next's `after()` so intake latency is unaffected. Line pairing is persisted to the existing `order_items.quote_item` relation when confident, and surfaced flagged-but-unsaved when doubtful. + +**Tech Stack:** Next.js 16.2.6 (App Router), TypeScript, PocketBase (via `getAdminPocketBase()`), Vitest, Tailwind + shadcn/ui, lucide-react icons. + +**Spec:** `docs/superpowers/specs/2026-08-29-order-quote-linking-design.md` + +--- + +## Pre-flight: things already verified + +Do not re-investigate these; they were confirmed before the plan was written. + +- **`after` is stable** in this Next version, imported from `next/server`. Docs: `node_modules/next/dist/docs/01-app/03-api-reference/04-functions/after.md`. Not `unstable_after`. +- **PocketBase date filters** take an ISO string: `` filter: `created >= "${d.toISOString()}"` ``. Used widely already, e.g. `src/lib/server/dashboard-service.ts:234`. +- **No PocketBase schema changes are needed.** `orders.potential_quote`, `orders.potential_quote_confidence`, `orders.quote_match_method`, `orders.quote_matching_attempted`, `orders.quote_match_confidence` and `order_items.quote_item` all exist. `quote_match_method` is a `text` field, **not** a `select`, so new method strings need no PB admin change. +- **Tests run with Vitest:** `npm run test:run`. Dev server is on **port 4001**, not 3000. + +## Design refinements discovered during planning + +Two places where the plan deliberately departs from the spec: + +**1. No `linkQuoteToOrder()`.** `setOrderQuote()` already exists at `src/lib/server/orders-service.ts:3633`, is already exposed at `POST /api/v1/orders/[id]/quote` and as the MCP tool `orders.set_quote`, and already handles the 404 / `order_locked` / `quote_already_linked` gates. **Task 5 extends that function instead of adding a parallel one.** Every existing caller then gets line pairing for free. + +**2. No standalone `getOrderQuoteContext()`.** The spec listed it as a fourth service function, but `matched-attachments/route.ts` already assembles exactly this payload and already has both UI consumers. **Task 10 adds `linePairs` to that existing response** rather than introducing a second endpoint returning overlapping data. + +## File structure + +| File | Responsibility | +|---|---| +| `src/lib/server/order-quote-match.ts` *(modify)* | Pure math. Gains line-pairing tiers and the date-window helper. No I/O. | +| `src/lib/server/order-quote-link-service.ts` *(create)* | All PocketBase reads/writes for matching, suggesting and pairing. | +| `src/lib/server/order-quote-link-service.test.ts` *(create)* | Unit tests with a mocked admin PB. | +| `src/lib/server/orders-service.ts` *(modify)* | `createOrder` fires the matcher; `setOrderQuote` pairs lines. | +| `src/app/api/v1/orders/quote-match/sweep/route.ts` *(create)* | Backlog sweep + age-gap histogram. | +| `src/app/api/v1/orders/[id]/matched-attachments/route.ts` *(modify)* | Gains the `linePairs` field. | +| `src/app/api/v1/orders/[id]/quote-line/route.ts` *(create)* | Confirms a doubtful pair (writes `quote_item`). | +| `src/app/api/mcp/route.ts` *(modify)* | `orders.quote_match_sweep` tool. | +| `src/components/orders/quote-line-context.tsx` *(create)* | Shared presentation of a paired quote line + its attachments. Used by both order pages. | +| `src/app/(app)/orders/[id]/process/page.tsx` *(modify)* | Uses server pairing; deletes `findMatchedQuoteItem`. | +| `src/app/(app)/orders/[id]/page.tsx` *(modify)* | Items table indicators + expandable row; migrates off `pb-helpers`. | +| `src/lib/pb-helpers.ts` *(modify)* | Matcher block deleted once the last caller is migrated. | + +`AttachmentList` currently lives inside `process/page.tsx`. Task 9 extracts it into the shared component so the detail page can render attachments identically rather than growing a second copy. + +## Phase boundaries + +- **Phase 1 — Tasks 1–8.** Backend. Ends with orders being matched automatically and lines paired. Independently valuable and verifiable without any UI work. +- **Phase 2 — Tasks 9–13.** Surfacing. Depends on Phase 1. + +Stop and review at the end of Phase 1. + +--- + +# PHASE 1 — Backend + +## Task 1: Line-pairing tiers (pure) + +`pairLinesByDescription` is private at `src/lib/server/order-quote-match.ts:169` and already returns only pairs scoring ≥ 0.3 Jaccard. Expose a tiered wrapper so the line pairer and the order-level scorer share one algorithm. + +**Files:** +- Modify: `src/lib/server/order-quote-match.ts` +- Test: `src/lib/server/order-quote-match.test.ts` + +- [ ] **Step 1: Write the failing test** + +Append to `src/lib/server/order-quote-match.test.ts`: + +```ts +describe("pairQuoteLines", () => { + it("tiers a near-identical line as confident", () => { + const pairs = pairQuoteLines( + [{ description: "M20 HEX BOLT GR8.8 ZINC" }], + [{ description: "Hex Bolt M20 Gr8.8 Zinc" }], + ); + expect(pairs).toHaveLength(1); + expect(pairs[0].orderLineIdx).toBe(0); + expect(pairs[0].quoteLineIdx).toBe(0); + expect(pairs[0].tier).toBe("confident"); + }); + + it("tiers a partial-overlap line as possible", () => { + const pairs = pairQuoteLines( + [{ description: "cable gland brass M20 nickel plated weatherproof" }], + [{ description: "cable gland stainless M25" }], + ); + expect(pairs).toHaveLength(1); + expect(pairs[0].tier).toBe("possible"); + expect(pairs[0].jaccard).toBeGreaterThanOrEqual(LINE_PAIR_POSSIBLE); + expect(pairs[0].jaccard).toBeLessThan(LINE_PAIR_CONFIDENT); + }); + + it("returns no pair when descriptions do not overlap", () => { + const pairs = pairQuoteLines( + [{ description: "galvanised threaded rod 3 metre" }], + [{ description: "silicone sealant clear cartridge" }], + ); + expect(pairs).toEqual([]); + }); + + it("never pairs one quote line to two order lines", () => { + const pairs = pairQuoteLines( + [ + { description: "hex bolt m20 gr8.8 zinc" }, + { description: "hex bolt m20 gr8.8 zinc" }, + ], + [{ description: "hex bolt m20 gr8.8 zinc" }], + ); + expect(pairs).toHaveLength(1); + const quoteIdxs = pairs.map((p) => p.quoteLineIdx); + expect(new Set(quoteIdxs).size).toBe(quoteIdxs.length); + }); +}); +``` + +Add `pairQuoteLines`, `LINE_PAIR_CONFIDENT` and `LINE_PAIR_POSSIBLE` to the existing import block at the top of that test file. + +- [ ] **Step 2: Run test to verify it fails** + +Run: `npx vitest run src/lib/server/order-quote-match.test.ts -t "pairQuoteLines"` +Expected: FAIL — `pairQuoteLines is not exported` / is not a function. + +- [ ] **Step 3: Write minimal implementation** + +In `src/lib/server/order-quote-match.ts`, change the `pairLinesByDescription` declaration from `function` to `export function` (leave its body untouched), then append at the end of the file: + +```ts +/* ============================================================================ + * Line-pairing tiers + * + * `pairLinesByDescription` already greedily pairs order lines to quote + * lines and drops anything below 0.3 Jaccard. These constants split + * what survives into two tiers: + * + * >= 0.5 confident — safe to persist onto `order_items.quote_item` + * 0.3–0.5 possible — shown to the operator with a confirm button, + * never written without their say-so + * + * Both numbers already existed in this file (0.5 is the + * `descriptionFull` award boundary, 0.3 is the candidate floor) — they + * are named here rather than reintroduced. + * ========================================================================== */ + +export const LINE_PAIR_CONFIDENT = 0.5; +export const LINE_PAIR_POSSIBLE = 0.3; + +export type LinePairTier = "confident" | "possible"; + +export interface QuoteLinePair { + orderLineIdx: number; + quoteLineIdx: number; + jaccard: number; + tier: LinePairTier; +} + +/** Pair order lines to quote lines by description overlap, tiered by + * confidence. Delegates to `pairLinesByDescription` so there is + * exactly one pairing algorithm in the codebase. */ +export function pairQuoteLines( + orderItems: ReadonlyArray<{ description: string }>, + quoteItems: ReadonlyArray<{ description: string }>, +): QuoteLinePair[] { + return pairLinesByDescription(orderItems, quoteItems).map((p) => ({ + orderLineIdx: p.oi, + quoteLineIdx: p.qi, + jaccard: p.jaccard, + tier: p.jaccard >= LINE_PAIR_CONFIDENT ? "confident" : "possible", + })); +} +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `npx vitest run src/lib/server/order-quote-match.test.ts` +Expected: PASS, including all pre-existing tests in the file. + +- [ ] **Step 5: Commit** + +```bash +git add src/lib/server/order-quote-match.ts src/lib/server/order-quote-match.test.ts +git commit -m "feat(orders): expose tiered quote line pairing" +``` + +--- + +## Task 2: Date-window helper (pure) + +**Files:** +- Modify: `src/lib/server/order-quote-match.ts` +- Test: `src/lib/server/order-quote-match.test.ts` + +- [ ] **Step 1: Write the failing test** + +Append to `src/lib/server/order-quote-match.test.ts`: + +```ts +describe("quoteWindowForOrder", () => { + const ORDER_DATE = "2026-08-29T10:00:00.000Z"; + + it("looks back the requested number of days from the ORDER date, not from now", () => { + const w = quoteWindowForOrder(ORDER_DATE, 90); + expect(w.from).toBe("2026-05-31T10:00:00.000Z"); + }); + + it("extends forward by the grace period so a quote raised just after the PO still matches", () => { + const w = quoteWindowForOrder(ORDER_DATE, 90); + expect(w.to).toBe("2026-09-05T10:00:00.000Z"); + }); + + it("widens correctly for the fallback pass", () => { + const w = quoteWindowForOrder(ORDER_DATE, FUZZY_WINDOW_WIDEN_DAYS); + expect(new Date(w.from).getTime()).toBeLessThan( + new Date(quoteWindowForOrder(ORDER_DATE, FUZZY_WINDOW_DAYS).from).getTime(), + ); + }); + + it("throws on an unparseable order date rather than silently matching everything", () => { + expect(() => quoteWindowForOrder("not-a-date", 90)).toThrow(/unparseable/i); + }); +}); +``` + +Add `quoteWindowForOrder`, `FUZZY_WINDOW_DAYS` and `FUZZY_WINDOW_WIDEN_DAYS` to the test file's import block. + +- [ ] **Step 2: Run test to verify it fails** + +Run: `npx vitest run src/lib/server/order-quote-match.test.ts -t "quoteWindowForOrder"` +Expected: FAIL — `quoteWindowForOrder is not a function`. + +- [ ] **Step 3: Write minimal implementation** + +Append to `src/lib/server/order-quote-match.ts`: + +```ts +/* ============================================================================ + * Fuzzy search window + * + * The 200-candidate cap alone behaves inconsistently: for a high-volume + * company it reaches back weeks, for a quiet one it reaches back years. + * Anchoring on a date window normalises that and matches how the + * business works — a quote normally converts within about two months. + * + * The window is measured from the ORDER's own created date, never from + * `Date.now()`. A "last 90 days from today" filter would find zero + * candidates for every order in the backlog sweep. + * + * This applies ONLY to the fuzzy description+price path. The four + * exact-identifier lookups are never date limited — a PO citing + * Q-2024-0847 should resolve however old that quote is. + * ========================================================================== */ + +export const FUZZY_WINDOW_DAYS = 90; +export const FUZZY_WINDOW_WIDEN_DAYS = 365; +/** Forward grace for a quote raised just after the PO landed. */ +export const FUZZY_WINDOW_GRACE_DAYS = 7; + +const DAY_MS = 24 * 60 * 60 * 1000; + +export interface QuoteWindow { + /** Inclusive lower bound, ISO 8601. */ + from: string; + /** Inclusive upper bound, ISO 8601. */ + to: string; +} + +/** Build the candidate-quote date window for one order. */ +export function quoteWindowForOrder( + orderCreated: string, + days: number, +): QuoteWindow { + const anchor = new Date(orderCreated); + if (Number.isNaN(anchor.getTime())) { + throw new Error( + `quoteWindowForOrder: unparseable order date ${JSON.stringify(orderCreated)}`, + ); + } + return { + from: new Date(anchor.getTime() - days * DAY_MS).toISOString(), + to: new Date(anchor.getTime() + FUZZY_WINDOW_GRACE_DAYS * DAY_MS).toISOString(), + }; +} + +/** Whole days between a quote's creation and an order's creation. + * Positive means the quote came first (the normal case). Used by the + * sweep's age-gap histogram. */ +export function quoteToOrderAgeDays( + quoteCreated: string, + orderCreated: string, +): number { + const q = new Date(quoteCreated).getTime(); + const o = new Date(orderCreated).getTime(); + if (Number.isNaN(q) || Number.isNaN(o)) return 0; + return Math.round((o - q) / DAY_MS); +} +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `npx vitest run src/lib/server/order-quote-match.test.ts` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add src/lib/server/order-quote-match.ts src/lib/server/order-quote-match.test.ts +git commit -m "feat(orders): add order-anchored fuzzy search window" +``` + +--- + +## Task 3: `suggestQuoteForOrder` — the service + +Ports the PocketBase half of the matcher out of `pb-helpers.ts:530-700` onto `getAdminPocketBase()`, adding the date window. `pb-helpers.ts` is left alone in this task — Task 13 deletes it once the last caller is migrated. Two copies coexist briefly; that is deliberate so every task commits green. + +**Files:** +- Create: `src/lib/server/order-quote-link-service.ts` +- Test: `src/lib/server/order-quote-link-service.test.ts` + +- [ ] **Step 1: Write the failing test** + +Create `src/lib/server/order-quote-link-service.test.ts`: + +```ts +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { suggestQuoteForOrder } from "@/lib/server/order-quote-link-service"; +import { getAdminPocketBase } from "@/lib/server/pocketbase-admin"; + +vi.mock("@/lib/server/pocketbase-admin", () => ({ + getAdminPocketBase: vi.fn(), +})); + +const ORDERS = "orders"; +const QUOTES = "quotes"; +const ORDER_ITEMS = "order_items"; +const QUOTE_ITEMS = "quote_items"; + +interface CollectionMock { + getOne?: ReturnType; + getList?: ReturnType; + getFullList?: ReturnType; + update?: ReturnType; +} + +function makePb(collections: Record) { + return { + autoCancellation: vi.fn(), + collection: (name: string) => { + const c = collections[name]; + if (!c) throw new Error(`unexpected collection: ${name}`); + return c; + }, + }; +} + +function useP(pb: ReturnType) { + vi.mocked(getAdminPocketBase).mockResolvedValue( + pb as unknown as Awaited>, + ); +} + +const ORDER = { + id: "ord1", + created: "2026-08-29T10:00:00.000Z", + company: "co1", + customer: "cust1", + quote: "", + quote_reference_text: "", +}; + +describe("suggestQuoteForOrder", () => { + beforeEach(() => vi.clearAllMocks()); + + it("resolves an exact quote_number citation regardless of the date window", async () => { + const update = vi.fn().mockResolvedValue({}); + const quotesGetList = vi.fn().mockResolvedValue({ + items: [{ id: "q_old", quote_number: "Q-2024-0847", created: "2024-01-05T00:00:00.000Z" }], + }); + useP( + makePb({ + [ORDERS]: { + getOne: vi.fn().mockResolvedValue({ ...ORDER, quote_reference_text: "Q-2024-0847" }), + update, + }, + [QUOTES]: { getList: quotesGetList }, + [ORDER_ITEMS]: { getFullList: vi.fn().mockResolvedValue([]) }, + }), + ); + + const res = await suggestQuoteForOrder("ord1"); + + expect(res.method).toBe("exact_quote_number"); + expect(res.best?.quoteId).toBe("q_old"); + expect(res.window).toBe("exact"); + // Suggestion only — the hard link is never written. + expect(update).toHaveBeenCalledWith("ord1", expect.objectContaining({ + potential_quote: "q_old", + quote_matching_attempted: true, + })); + expect(update.mock.calls[0][1]).not.toHaveProperty("quote"); + }); + + it("constrains fuzzy candidates to the 90-day window before the order date", async () => { + const quotesGetList = vi.fn().mockResolvedValue({ items: [] }); + useP( + makePb({ + [ORDERS]: { getOne: vi.fn().mockResolvedValue(ORDER), update: vi.fn().mockResolvedValue({}) }, + [QUOTES]: { getList: quotesGetList }, + [ORDER_ITEMS]: { + getFullList: vi.fn().mockResolvedValue([ + { id: "oi1", description: "hex bolt m20 gr8.8 zinc", selling_price: 10, quantity: 5 }, + ]), + }, + [QUOTE_ITEMS]: { getFullList: vi.fn().mockResolvedValue([]) }, + }), + ); + + await suggestQuoteForOrder("ord1"); + + const filter = quotesGetList.mock.calls[0][2].filter as string; + expect(filter).toContain('created >= "2026-05-31T10:00:00.000Z"'); + expect(filter).toContain('company = "co1"'); + }); + + it("widens to 12 months when the 90-day window yields nothing above threshold", async () => { + const quotesGetList = vi + .fn() + // 90-day pass: no candidates + .mockResolvedValueOnce({ items: [] }) + // widened pass: the real match + .mockResolvedValueOnce({ + items: [{ id: "q1", quote_number: "Q-1", company: "co1", customer: "cust1", created: "2025-11-01T00:00:00.000Z" }], + }); + useP( + makePb({ + [ORDERS]: { getOne: vi.fn().mockResolvedValue(ORDER), update: vi.fn().mockResolvedValue({}) }, + [QUOTES]: { getList: quotesGetList }, + [ORDER_ITEMS]: { + getFullList: vi.fn().mockResolvedValue([ + { id: "oi1", description: "hex bolt m20 gr8.8 zinc", selling_price: 10, quantity: 5 }, + ]), + }, + [QUOTE_ITEMS]: { + getFullList: vi.fn().mockResolvedValue([ + { id: "qi1", quote: "q1", description: "hex bolt m20 gr8.8 zinc", selling_price: 10, quantity: 5 }, + ]), + }, + }), + ); + + const res = await suggestQuoteForOrder("ord1"); + + expect(quotesGetList).toHaveBeenCalledTimes(2); + expect(res.window).toBe("widened"); + expect(res.best?.quoteId).toBe("q1"); + }); + + it("records no_match and still stamps quote_matching_attempted", async () => { + const update = vi.fn().mockResolvedValue({}); + useP( + makePb({ + [ORDERS]: { getOne: vi.fn().mockResolvedValue(ORDER), update }, + [QUOTES]: { getList: vi.fn().mockResolvedValue({ items: [] }) }, + [ORDER_ITEMS]: { + getFullList: vi.fn().mockResolvedValue([ + { id: "oi1", description: "widget", selling_price: 1, quantity: 1 }, + ]), + }, + [QUOTE_ITEMS]: { getFullList: vi.fn().mockResolvedValue([]) }, + }), + ); + + const res = await suggestQuoteForOrder("ord1"); + + expect(res.method).toBe("no_match"); + expect(res.best).toBeNull(); + expect(update).toHaveBeenCalledWith("ord1", expect.objectContaining({ + quote_matching_attempted: true, + potential_quote: null, + potential_quote_confidence: 0, + })); + }); + + it("skips an order that already has a hard quote link", async () => { + const update = vi.fn(); + useP( + makePb({ + [ORDERS]: { getOne: vi.fn().mockResolvedValue({ ...ORDER, quote: "already" }), update }, + }), + ); + + const res = await suggestQuoteForOrder("ord1"); + + expect(res.method).toBe("no_match"); + expect(res.skipped).toBe("already_linked"); + expect(update).not.toHaveBeenCalled(); + }); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `npx vitest run src/lib/server/order-quote-link-service.test.ts` +Expected: FAIL — cannot resolve `@/lib/server/order-quote-link-service`. + +- [ ] **Step 3: Write minimal implementation** + +Create `src/lib/server/order-quote-link-service.ts`: + +```ts +/** + * order-quote-link-service — server-side order ↔ quote matching. + * + * The PocketBase half of the matcher. Its predecessor lives in + * `src/lib/pb-helpers.ts` and runs on the browser's PB singleton, + * which is why matching could only ever happen when an operator + * clicked a button. This version runs on `getAdminPocketBase()` so it + * can be called from `createOrder`, from the backlog sweep, and from + * route handlers. + * + * The pure scoring (`scoreOrderVsQuote`, `pairQuoteLines`, + * `quoteWindowForOrder`) lives in `order-quote-match.ts` and is reused + * untouched — DO NOT reimplement any of it here. + * + * Policy: this module NEVER writes `orders.quote`. It only ever writes + * the soft suggestion (`potential_quote`). Promoting a suggestion to a + * hard link is a deliberate human action that goes through + * `setOrderQuote` in `orders-service.ts`. + */ + +import { getAdminPocketBase } from "@/lib/server/pocketbase-admin"; +import { + scoreOrderVsQuote, + quoteWindowForOrder, + quoteToOrderAgeDays, + MATCH_CONFIDENCE_THRESHOLD, + FUZZY_CANDIDATE_CAP, + FUZZY_WINDOW_DAYS, + FUZZY_WINDOW_WIDEN_DAYS, + type ScoreBreakdown, +} from "@/lib/server/order-quote-match"; + +export type QuoteMatchMethod = + | "exact_quote_number" + | "exact_quote_reference" + | "exact_rfq_number" + | "exact_external_number" + | "company_customer_desc_price" + | "no_match"; + +export interface QuoteMatchCandidate { + quoteId: string; + quoteNumber: string; + quoteCreated: string; + confidence: number; + breakdown?: ScoreBreakdown; + method: Exclude; +} + +export interface SuggestQuoteResult { + orderId: string; + best: QuoteMatchCandidate | null; + alternatives: QuoteMatchCandidate[]; + method: QuoteMatchMethod; + /** Which pass produced the result — for the sweep's reporting. */ + window: "exact" | "90d" | "widened" | "none"; + /** Days from the matched quote's creation to the order's. null when unmatched. */ + ageGapDays: number | null; + /** Set when the order was deliberately not processed. */ + skipped?: "already_linked"; +} + +interface OrderRow { + id: string; + created: string; + company?: string; + customer?: string; + quote?: string; + quote_reference_text?: string; + rfq_number?: string; +} + +interface QuoteRow { + id: string; + quote_number?: string; + company?: string; + customer?: string; + created: string; +} + +interface LineRow { + id: string; + description?: string; + selling_price?: number; + quantity?: number; +} + +interface QuoteLineRow extends LineRow { + quote: string; +} + +const ORDER_COLLECTION = "orders"; +const QUOTE_COLLECTION = "quotes"; +const ORDER_ITEMS_COLLECTION = "order_items"; +const QUOTE_ITEMS_COLLECTION = "quote_items"; + +function esc(s: string): string { + return (s || "").replace(/"/g, '\\"'); +} + +function project(rows: ReadonlyArray) { + return rows.map((r) => ({ + description: r.description || "", + selling_price: Number(r.selling_price) || 0, + quantity: Number(r.quantity) || 1, + })); +} + +/** Try the four exact-identifier fields, in priority order. Never date + * limited — a citation is evidence regardless of age. */ +async function findExactMatch( + pb: Awaited>, + ref: string, +): Promise<{ quote: QuoteRow; method: QuoteMatchCandidate["method"] } | null> { + const trimmed = (ref || "").trim(); + if (!trimmed) return null; + const fields = [ + ["quote_number", "exact_quote_number"], + ["quote_reference", "exact_quote_reference"], + ["rfq_number", "exact_rfq_number"], + ["external_number", "exact_external_number"], + ] as const; + for (const [field, method] of fields) { + try { + const list = await pb + .collection(QUOTE_COLLECTION) + .getList(1, 5, { filter: `${field} = "${esc(trimmed)}"` }); + if (list.items.length > 0) return { quote: list.items[0], method }; + } catch { + // Field may not exist on older PB deploys — try the next. + } + } + return null; +} + +/** Score every candidate quote inside one date window. */ +async function fuzzyPass( + pb: Awaited>, + order: OrderRow, + orderItems: LineRow[], + windowDays: number, +): Promise { + if (orderItems.length === 0) return []; + + const win = quoteWindowForOrder(order.created, windowDays); + const filters: string[] = [ + `created >= "${win.from}"`, + `created <= "${win.to}"`, + "is_template != true", + ]; + if (order.company) filters.push(`company = "${esc(order.company)}"`); + if (order.customer) { + filters.push(`(customer = "${esc(order.customer)}" || customer = "")`); + } + + const candidates = await pb + .collection(QUOTE_COLLECTION) + .getList(1, FUZZY_CANDIDATE_CAP, { + filter: filters.join(" && "), + sort: "-created", + }); + if (candidates.items.length === 0) return []; + + const quoteIds = candidates.items.map((q) => q.id); + const orFilter = quoteIds.map((qid) => `quote = "${qid}"`).join(" || "); + const quoteItems = await pb + .collection(QUOTE_ITEMS_COLLECTION) + .getFullList({ + filter: orFilter, + fields: "id,quote,description,selling_price,quantity", + batch: 1000, + }); + + const byQuote = new Map(); + for (const it of quoteItems) { + const bucket = byQuote.get(it.quote); + if (bucket) bucket.push(it); + else byQuote.set(it.quote, [it]); + } + + const projectedOrder = project(orderItems); + const scored: QuoteMatchCandidate[] = []; + for (const q of candidates.items) { + const breakdown = scoreOrderVsQuote( + { companyId: order.company, customerId: order.customer, items: projectedOrder }, + { company: q.company, customer: q.customer, items: project(byQuote.get(q.id) ?? []) }, + ); + if (breakdown.confidence >= MATCH_CONFIDENCE_THRESHOLD) { + scored.push({ + quoteId: q.id, + quoteNumber: q.quote_number || "", + quoteCreated: q.created, + confidence: breakdown.confidence, + breakdown, + method: "company_customer_desc_price", + }); + } + } + scored.sort((a, b) => + b.confidence !== a.confidence + ? b.confidence - a.confidence + : (b.quoteCreated || "").localeCompare(a.quoteCreated || ""), + ); + return scored; +} + +/** + * Find the best candidate quote for an order and write it as a soft + * suggestion. Idempotent and safe to re-run. + */ +export async function suggestQuoteForOrder( + orderId: string, +): Promise { + const pb = await getAdminPocketBase(); + pb.autoCancellation(false); + + const order = await pb.collection(ORDER_COLLECTION).getOne(orderId); + + // An order with a confirmed hard link is settled. Never second-guess it. + if (order.quote) { + return { + orderId, + best: null, + alternatives: [], + method: "no_match", + window: "none", + ageGapDays: null, + skipped: "already_linked", + }; + } + + // 1. Exact identifier citation, if the order carries reference text. + const ref = (order.quote_reference_text || order.rfq_number || "").trim(); + const exact = await findExactMatch(pb, ref); + if (exact) { + const best: QuoteMatchCandidate = { + quoteId: exact.quote.id, + quoteNumber: exact.quote.quote_number || "", + quoteCreated: exact.quote.created, + confidence: 1, + method: exact.method, + }; + await writeSuggestion(pb, orderId, best); + return { + orderId, + best, + alternatives: [], + method: exact.method, + window: "exact", + ageGapDays: quoteToOrderAgeDays(exact.quote.created, order.created), + }; + } + + // 2. Fuzzy — 90-day window first, widening only if it comes up empty. + const orderItems = await pb + .collection(ORDER_ITEMS_COLLECTION) + .getFullList({ + filter: `order = "${esc(orderId)}"`, + fields: "id,description,selling_price,quantity", + batch: 1000, + }); + + let scored = await fuzzyPass(pb, order, orderItems, FUZZY_WINDOW_DAYS); + let window: SuggestQuoteResult["window"] = "90d"; + if (scored.length === 0) { + scored = await fuzzyPass(pb, order, orderItems, FUZZY_WINDOW_WIDEN_DAYS); + window = "widened"; + } + + const best = scored[0] ?? null; + await writeSuggestion(pb, orderId, best); + + return { + orderId, + best, + alternatives: scored.slice(1, 6), + method: best ? "company_customer_desc_price" : "no_match", + window: best ? window : "none", + ageGapDays: best ? quoteToOrderAgeDays(best.quoteCreated, order.created) : null, + }; +} + +/** Persist (or clear) the soft suggestion. Never touches `quote`. */ +async function writeSuggestion( + pb: Awaited>, + orderId: string, + best: QuoteMatchCandidate | null, +): Promise { + await pb.collection(ORDER_COLLECTION).update(orderId, { + quote_matching_attempted: true, + quote_match_method: best ? best.method : "no_match", + potential_quote: best ? best.quoteId : null, + potential_quote_confidence: best ? best.confidence : 0, + }); +} +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `npx vitest run src/lib/server/order-quote-link-service.test.ts` +Expected: PASS, 5 tests. + +- [ ] **Step 5: Typecheck and commit** + +```bash +npx tsc --noEmit +git add src/lib/server/order-quote-link-service.ts src/lib/server/order-quote-link-service.test.ts +git commit -m "feat(orders): add server-side quote suggestion service" +``` + +--- + +## Task 4: `pairOrderLines` — persist confident pairs + +**Files:** +- Modify: `src/lib/server/order-quote-link-service.ts` +- Test: `src/lib/server/order-quote-link-service.test.ts` + +- [ ] **Step 1: Write the failing test** + +Append to `src/lib/server/order-quote-link-service.test.ts`: + +```ts +describe("pairOrderLines", () => { + beforeEach(() => vi.clearAllMocks()); + + it("writes quote_item for a confident pair and leaves a possible pair unwritten", async () => { + const update = vi.fn().mockResolvedValue({}); + useP( + makePb({ + [ORDER_ITEMS]: { + getFullList: vi.fn().mockResolvedValue([ + { id: "oi_conf", description: "M20 HEX BOLT GR8.8 ZINC" }, + { id: "oi_poss", description: "cable gland brass M20 nickel plated weatherproof" }, + ]), + update, + }, + [QUOTE_ITEMS]: { + getFullList: vi.fn().mockResolvedValue([ + { id: "qi_conf", quote: "q1", description: "Hex Bolt M20 Gr8.8 Zinc" }, + { id: "qi_poss", quote: "q1", description: "cable gland stainless M25" }, + ]), + }, + }), + ); + + const res = await pairOrderLines("ord1", "q1"); + + expect(update).toHaveBeenCalledTimes(1); + expect(update).toHaveBeenCalledWith("oi_conf", { quote_item: "qi_conf" }); + + expect(res.confident).toEqual([ + expect.objectContaining({ orderItemId: "oi_conf", quoteItemId: "qi_conf" }), + ]); + expect(res.possible).toEqual([ + expect.objectContaining({ orderItemId: "oi_poss", quoteItemId: "qi_poss" }), + ]); + }); + + it("reports order lines with no counterpart as unmatched", async () => { + useP( + makePb({ + [ORDER_ITEMS]: { + getFullList: vi.fn().mockResolvedValue([ + { id: "oi1", description: "galvanised threaded rod 3 metre" }, + ]), + update: vi.fn().mockResolvedValue({}), + }, + [QUOTE_ITEMS]: { + getFullList: vi.fn().mockResolvedValue([ + { id: "qi1", quote: "q1", description: "silicone sealant clear cartridge" }, + ]), + }, + }), + ); + + const res = await pairOrderLines("ord1", "q1"); + + expect(res.confident).toEqual([]); + expect(res.possible).toEqual([]); + expect(res.unmatchedOrderItemIds).toEqual(["oi1"]); + }); + + it("does not overwrite a quote_item that is already set", async () => { + const update = vi.fn().mockResolvedValue({}); + useP( + makePb({ + [ORDER_ITEMS]: { + getFullList: vi.fn().mockResolvedValue([ + { id: "oi1", description: "Hex Bolt M20 Gr8.8 Zinc", quote_item: "qi_existing" }, + ]), + update, + }, + [QUOTE_ITEMS]: { + getFullList: vi.fn().mockResolvedValue([ + { id: "qi_new", quote: "q1", description: "Hex Bolt M20 Gr8.8 Zinc" }, + ]), + }, + }), + ); + + await pairOrderLines("ord1", "q1"); + + expect(update).not.toHaveBeenCalled(); + }); +}); +``` + +Add `pairOrderLines` to the test file's import from the service. + +- [ ] **Step 2: Run test to verify it fails** + +Run: `npx vitest run src/lib/server/order-quote-link-service.test.ts -t "pairOrderLines"` +Expected: FAIL — `pairOrderLines is not a function`. + +- [ ] **Step 3: Write minimal implementation** + +Add `pairQuoteLines` to the existing import from `order-quote-match` at the top of `order-quote-link-service.ts`, then append: + +```ts +/* ============================================================================ + * Per-line pairing + * + * `order_items.quote_item` has existed all along but was only ever + * written by `createOrderFromQuote`. When a quote is linked to an + * order after the fact, nothing paired the lines — so the process page + * re-guessed the pairing on every render with an exact/substring + * match, and any wording difference silently hid the buyer's note. + * + * Confident pairs are persisted here so the guess happens once. + * Possible pairs are returned but NOT written: the operator confirms + * them, so a buyer is never shown a note from the wrong line without + * being told it is a guess. + * ========================================================================== */ + +export interface PairedLine { + orderItemId: string; + quoteItemId: string; + /** Jaccard overlap of the two descriptions, 0..1. */ + confidence: number; +} + +export interface PairOrderLinesResult { + confident: PairedLine[]; + possible: PairedLine[]; + unmatchedOrderItemIds: string[]; +} + +/** + * Pair an order's lines against a quote's lines, persisting only the + * confident ones. Safe to re-run: a line whose `quote_item` is already + * set is never overwritten. + */ +export async function pairOrderLines( + orderId: string, + quoteId: string, +): Promise { + const pb = await getAdminPocketBase(); + pb.autoCancellation(false); + + const [orderItems, quoteItems] = await Promise.all([ + pb.collection(ORDER_ITEMS_COLLECTION).getFullList({ + filter: `order = "${esc(orderId)}"`, + fields: "id,description,quote_item", + batch: 1000, + }), + pb.collection(QUOTE_ITEMS_COLLECTION).getFullList({ + filter: `quote = "${esc(quoteId)}"`, + fields: "id,quote,description", + batch: 1000, + }), + ]); + + const pairs = pairQuoteLines( + orderItems.map((r) => ({ description: r.description || "" })), + quoteItems.map((r) => ({ description: r.description || "" })), + ); + + const confident: PairedLine[] = []; + const possible: PairedLine[] = []; + const pairedOrderIdx = new Set(); + + for (const p of pairs) { + pairedOrderIdx.add(p.orderLineIdx); + const entry: PairedLine = { + orderItemId: orderItems[p.orderLineIdx].id, + quoteItemId: quoteItems[p.quoteLineIdx].id, + confidence: p.jaccard, + }; + if (p.tier === "confident") confident.push(entry); + else possible.push(entry); + } + + // Persist confident pairs only, and never clobber an existing link + // (a line paired by createOrderFromQuote, or confirmed by hand). + for (const c of confident) { + const row = orderItems.find((r) => r.id === c.orderItemId); + if (row?.quote_item) continue; + await pb.collection(ORDER_ITEMS_COLLECTION).update(c.orderItemId, { + quote_item: c.quoteItemId, + }); + } + + return { + confident, + possible, + unmatchedOrderItemIds: orderItems + .filter((_, idx) => !pairedOrderIdx.has(idx)) + .map((r) => r.id), + }; +} +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `npx vitest run src/lib/server/order-quote-link-service.test.ts` +Expected: PASS, 8 tests. + +- [ ] **Step 5: Commit** + +```bash +git add src/lib/server/order-quote-link-service.ts src/lib/server/order-quote-link-service.test.ts +git commit -m "feat(orders): persist confident order-line to quote-line pairs" +``` + +--- + +## Task 5: Pair lines whenever a quote is hard-linked + +`setOrderQuote` (`src/lib/server/orders-service.ts:3633`) is the single choke point for hard links — used by the REST route, the MCP tool, and (after Task 13) the UI. Hooking here means every path pairs lines. + +**Files:** +- Modify: `src/lib/server/orders-service.ts` (the `setOrderQuote` return block, currently lines 3740-3744) +- Test: `src/lib/server/orders-service.test.ts` + +- [ ] **Step 1: Write the failing test** + +`orders-service.test.ts` already has a full in-memory PocketBase harness (`pbMock`, `store`, `clearStore`, set up in the `vi.hoisted` block at the top). Use it — do not write a new mock. + +Add this alongside the file's existing `vi.mock("./pocketbase-admin", ...)` call at line 242 (relative path, matching the file's convention): + +```ts +vi.mock("./order-quote-link-service", () => ({ + pairOrderLines: vi.fn().mockResolvedValue({ + confident: [], possible: [], unmatchedOrderItemIds: [], + }), + suggestQuoteForOrder: vi.fn().mockResolvedValue({ best: null }), +})); +``` + +Then append this describe block, modelled on the existing `setOrderQuote` tests at line 2518: + +```ts +describe("setOrderQuote line pairing", () => { + it("pairs order lines to the quote's lines after writing the hard link", async () => { + const { pairOrderLines } = await import("./order-quote-link-service"); + await pbMock.collection("quotes").create({ id: "q-1" }); + await pbMock.collection("orders").create({ + id: "o-1", order_number: "ORD-1", quote: "", created: "2026-08-21T00:00:00Z", + }); + await pbMock.collection("order_items").create({ + id: "oi-1", order: "o-1", description: "hex bolt m20", item_status: "pending", + }); + + const result = await setOrderQuote({ orderId: "o-1", quoteId: "q-1" }); + + expect(pairOrderLines).toHaveBeenCalledWith("o-1", "q-1"); + expect(result.order.quote).toBe("q-1"); + }); + + it("does not fail the link when pairing throws", async () => { + const { pairOrderLines } = await import("./order-quote-link-service"); + vi.mocked(pairOrderLines).mockRejectedValueOnce(new Error("PB down")); + + await pbMock.collection("quotes").create({ id: "q-1" }); + await pbMock.collection("orders").create({ + id: "o-1", order_number: "ORD-1", quote: "", created: "2026-08-21T00:00:00Z", + }); + await pbMock.collection("order_items").create({ + id: "oi-1", order: "o-1", description: "hex bolt m20", item_status: "pending", + }); + + const result = await setOrderQuote({ orderId: "o-1", quoteId: "q-1" }); + + // The link still landed despite the pairing failure. + expect(result.order.quote).toBe("q-1"); + }); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `npx vitest run src/lib/server/orders-service.test.ts -t "setOrderQuote line pairing"` +Expected: FAIL — `pairOrderLines` never called. + +- [ ] **Step 3: Write minimal implementation** + +In `src/lib/server/orders-service.ts`, replace the tail of `setOrderQuote`: + +```ts + await pb.collection(ORDER_COLLECTION).update(orderId, update); + const updated = await pb.collection(ORDER_COLLECTION).getOne(orderId); + + return { order: updated, previous }; +} +``` + +with: + +```ts + await pb.collection(ORDER_COLLECTION).update(orderId, update); + const updated = await pb.collection(ORDER_COLLECTION).getOne(orderId); + + // Pair the order's lines to the quote's lines now that the link is + // real. Confident pairs persist onto `order_items.quote_item`; + // doubtful ones are surfaced in the UI for confirmation instead. + // + // Never fatal: a pairing failure must not undo or fail a link the + // operator just made. The pairing can be redone at any time by + // re-running this endpoint. + try { + await pairOrderLines(orderId, quoteId); + } catch (err) { + console.error( + `[setOrderQuote] line pairing failed for order ${orderId} / quote ${quoteId}:`, + err instanceof Error ? err.message : err, + ); + } + + return { order: updated, previous }; +} +``` + +Add to the imports at the top of `orders-service.ts`: + +```ts +import { pairOrderLines } from "@/lib/server/order-quote-link-service"; +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `npx vitest run src/lib/server/orders-service.test.ts` +Expected: PASS, including every pre-existing test in the file. + +- [ ] **Step 5: Commit** + +```bash +git add src/lib/server/orders-service.ts src/lib/server/orders-service.test.ts +git commit -m "feat(orders): pair lines when a quote is linked to an order" +``` + +--- + +## Task 6: Auto-detect at intake + +**Files:** +- Modify: `src/lib/server/orders-service.ts` (`createOrder` return block, currently lines 2213-2219) +- Test: `src/lib/server/orders-service.test.ts` + +`createOrder` is called from route handlers, so `after()` is available and the matcher runs once the response has been sent. Intake latency is unaffected. + +- [ ] **Step 1: Write the failing test** + +Add this mock next to the file's other `vi.mock` calls, so `after`'s callback runs inline under test: + +```ts +vi.mock("next/server", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + after: vi.fn((cb: () => unknown) => { void cb(); }), + }; +}); +``` + +Then append, reusing the `pbMock` harness exactly as the existing `createOrder` tests at line 578 do: + +```ts +describe("createOrder quote auto-detection", () => { + it("schedules a quote suggestion after the response", async () => { + const { suggestQuoteForOrder } = await import("./order-quote-link-service"); + const { after } = await import("next/server"); + await pbMock.collection("customers").create({ id: "cust-x", name: "Acme" }); + await pbMock.collection("companies").create({ id: "comp-x", name: "Acme (Pty) Ltd" }); + + const created = await createOrder({ + customer_id: "cust-x", + company_id: "comp-x", + items: [{ description: "M16 Bolt", quantity: 50 }], + }); + + expect(after).toHaveBeenCalled(); + expect(suggestQuoteForOrder).toHaveBeenCalledWith(created.order.id); + }); + + it("never fails order creation when the matcher throws", async () => { + const { suggestQuoteForOrder } = await import("./order-quote-link-service"); + vi.mocked(suggestQuoteForOrder).mockRejectedValueOnce(new Error("PB down")); + await pbMock.collection("customers").create({ id: "cust-x", name: "Acme" }); + await pbMock.collection("companies").create({ id: "comp-x", name: "Acme (Pty) Ltd" }); + + const result = await createOrder({ + customer_id: "cust-x", + company_id: "comp-x", + items: [{ description: "M16 Bolt", quantity: 50 }], + }); + + expect(result.order.id).toBeDefined(); + expect(result.items_created.length).toBe(1); + }); +}); +``` + +**Note on the pre-existing test at line 578:** it asserts `result.order.quote_matching_attempted === false` immediately after `createOrder`. That stays correct — `result.order` is the snapshot returned by the create call, and the suggestion writes to PB afterwards without mutating it. Do not "fix" that assertion. + +- [ ] **Step 2: Run test to verify it fails** + +Run: `npx vitest run src/lib/server/orders-service.test.ts -t "createOrder quote auto-detection"` +Expected: FAIL — `after` never called. + +- [ ] **Step 3: Write minimal implementation** + +In `src/lib/server/orders-service.ts`, replace the `createOrder` return block: + +```ts + return { + order, + order_items_collection: orderItemsCollection, + items_created: itemsResult.created, + items_failed: itemsResult.failed, + }; +} +``` + +with: + +```ts + // Quote auto-detection. ~85% of orders arrive against a quote we + // already produced, but nothing recorded that unless an operator + // linked it by hand — this is what closes that gap. + // + // Runs via `after()` so the ~200-candidate scan never sits in the + // intake response's critical path. Wrapped because a matcher failure + // must never fail an order creation: an unmatched order is a minor + // inconvenience, a lost order is not. + // + // Writes only the soft suggestion (`potential_quote`). Promoting it + // to a hard link stays a deliberate human click. + after(async () => { + try { + await suggestQuoteForOrder(order.id); + } catch (err) { + console.error( + `[createOrder] quote auto-detection failed for order ${order.id}:`, + err instanceof Error ? err.message : err, + ); + } + }); + + return { + order, + order_items_collection: orderItemsCollection, + items_created: itemsResult.created, + items_failed: itemsResult.failed, + }; +} +``` + +Extend the imports at the top of `orders-service.ts`: + +```ts +import { after } from "next/server"; +import { + pairOrderLines, + suggestQuoteForOrder, +} from "@/lib/server/order-quote-link-service"; +``` + +(The `pairOrderLines` import was added in Task 5 — merge, don't duplicate the import statement.) + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `npx vitest run src/lib/server/orders-service.test.ts` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add src/lib/server/orders-service.ts src/lib/server/orders-service.test.ts +git commit -m "feat(orders): auto-detect the source quote at order intake" +``` + +--- + +## Task 7: Backlog sweep endpoint + +**Files:** +- Create: `src/app/api/v1/orders/quote-match/sweep/route.ts` +- Modify: `src/lib/server/order-quote-link-service.ts` +- Test: `src/lib/server/order-quote-link-service.test.ts` + +- [ ] **Step 1: Write the failing test** + +Append to `src/lib/server/order-quote-link-service.test.ts`: + +```ts +describe("sweepUnmatchedOrders", () => { + beforeEach(() => vi.clearAllMocks()); + + it("processes only orders that have never been attempted and have no hard link", async () => { + const getList = vi.fn().mockResolvedValue({ + items: [{ id: "o1" }, { id: "o2" }], + totalItems: 2, + }); + useP(makePb({ [ORDERS]: { getList, getOne: vi.fn(), update: vi.fn() } })); + + await sweepUnmatchedOrders({ limit: 50 }); + + const filter = getList.mock.calls[0][2].filter as string; + expect(filter).toContain("quote_matching_attempted = false"); + expect(filter).toContain('quote = ""'); + }); + + it("buckets age gaps so the window can be retuned from real data", async () => { + const histogram = bucketAgeGaps([12, 45, 75, 150, 300, 500]); + expect(histogram).toEqual({ + d0_30: 1, d31_60: 1, d61_90: 1, + d91_180: 1, d181_365: 1, d365_plus: 1, + total: 6, + }); + }); + + it("keeps going when one order's match throws", async () => { + const getList = vi.fn().mockResolvedValue({ + items: [{ id: "bad" }, { id: "good" }], + totalItems: 2, + }); + const getOne = vi + .fn() + .mockRejectedValueOnce(new Error("boom")) + .mockResolvedValue({ + id: "good", created: "2026-08-29T10:00:00.000Z", + company: "co1", customer: "", quote: "", + }); + useP( + makePb({ + [ORDERS]: { getList, getOne, update: vi.fn().mockResolvedValue({}) }, + [QUOTES]: { getList: vi.fn().mockResolvedValue({ items: [] }) }, + [ORDER_ITEMS]: { getFullList: vi.fn().mockResolvedValue([]) }, + }), + ); + + const res = await sweepUnmatchedOrders({ limit: 50 }); + + expect(res.processed).toBe(2); + expect(res.failed).toBe(1); + }); +}); +``` + +Add `sweepUnmatchedOrders` and `bucketAgeGaps` to the test's service import. + +- [ ] **Step 2: Run test to verify it fails** + +Run: `npx vitest run src/lib/server/order-quote-link-service.test.ts -t "sweepUnmatchedOrders"` +Expected: FAIL — `sweepUnmatchedOrders is not a function`. + +- [ ] **Step 3: Write minimal implementation** + +Append to `src/lib/server/order-quote-link-service.ts`: + +```ts +/* ============================================================================ + * Backlog sweep + * + * Auto-detection at intake only covers orders created from now on. + * This walks the orders that were never attempted — the historical + * backlog, plus any order whose lines were corrected after creation. + * + * The age-gap histogram exists so the FUZZY_WINDOW_DAYS default (90) + * can be re-tuned against what actually happens in this business + * rather than left at an estimate. + * ========================================================================== */ + +export interface AgeGapHistogram { + d0_30: number; + d31_60: number; + d61_90: number; + d91_180: number; + d181_365: number; + d365_plus: number; + total: number; +} + +export interface SweepResult { + processed: number; + suggested: number; + noMatch: number; + failed: number; + /** Total orders still awaiting a sweep after this batch. */ + remaining: number; + ageGaps: AgeGapHistogram; +} + +/** Bucket quote→order age gaps. Exported for direct unit testing. */ +export function bucketAgeGaps(gaps: ReadonlyArray): AgeGapHistogram { + const h: AgeGapHistogram = { + d0_30: 0, d31_60: 0, d61_90: 0, + d91_180: 0, d181_365: 0, d365_plus: 0, + total: 0, + }; + for (const g of gaps) { + const d = Math.abs(g); + if (d <= 30) h.d0_30++; + else if (d <= 60) h.d31_60++; + else if (d <= 90) h.d61_90++; + else if (d <= 180) h.d91_180++; + else if (d <= 365) h.d181_365++; + else h.d365_plus++; + h.total++; + } + return h; +} + +export interface SweepOptions { + /** Max orders to process in this batch. */ + limit?: number; +} + +export async function sweepUnmatchedOrders( + options: SweepOptions = {}, +): Promise { + const limit = Math.min(Math.max(options.limit ?? 100, 1), 500); + const pb = await getAdminPocketBase(); + pb.autoCancellation(false); + + const page = await pb.collection(ORDER_COLLECTION).getList<{ id: string }>(1, limit, { + filter: `quote_matching_attempted = false && quote = ""`, + sort: "-created", + fields: "id", + }); + + let suggested = 0; + let noMatch = 0; + let failed = 0; + const gaps: number[] = []; + + // Sequential on purpose: each order fans out into a candidate scan, + // and running them in parallel would hammer PB during a backlog run. + for (const row of page.items) { + try { + const res = await suggestQuoteForOrder(row.id); + if (res.best) { + suggested++; + if (res.ageGapDays !== null) gaps.push(res.ageGapDays); + } else { + noMatch++; + } + } catch (err) { + failed++; + console.error( + `[sweepUnmatchedOrders] order ${row.id} failed:`, + err instanceof Error ? err.message : err, + ); + } + } + + return { + processed: page.items.length, + suggested, + noMatch, + failed, + remaining: Math.max(0, (page.totalItems ?? 0) - page.items.length), + ageGaps: bucketAgeGaps(gaps), + }; +} +``` + +Then create `src/app/api/v1/orders/quote-match/sweep/route.ts`: + +```ts +import { NextRequest, NextResponse } from "next/server"; +import { assertAgentAuth } from "@/lib/server/agent-auth"; +import { sweepUnmatchedOrders } from "@/lib/server/order-quote-link-service"; + +/** + * POST /api/v1/orders/quote-match/sweep + * + * Run quote auto-detection over orders that have never been attempted + * — the historical backlog, and any order whose lines changed after + * creation. Orders created from now on are matched at intake by + * `createOrder`, so this is a catch-up mechanism, not the main path. + * + * Writes only soft suggestions (`potential_quote`). Never hard-links. + * + * Body: `{ limit?: number }` — orders per batch, 1..500, default 100. + * + * Returns: `{ processed, suggested, noMatch, failed, remaining, ageGaps }`. + * `ageGaps` buckets the quote→order age of every match found, so the + * 90-day search window can be retuned from real data. + * + * Re-run until `remaining` is 0. + * + * Errors: 401 if the agent bearer is missing / wrong. + */ +export const runtime = "nodejs"; +export const dynamic = "force-dynamic"; +// A 500-order batch fans out into 500 candidate scans. +export const maxDuration = 300; + +export async function POST(req: NextRequest) { + try { + assertAgentAuth(req); + const body = (await req.json().catch(() => ({}))) as { limit?: unknown }; + const limit = typeof body.limit === "number" ? body.limit : undefined; + const result = await sweepUnmatchedOrders({ limit }); + return NextResponse.json(result); + } catch (error) { + const message = + error instanceof Error ? error.message : "Quote match sweep failed"; + const status = (error as Error & { status?: number }).status || 500; + return NextResponse.json({ error: message }, { status }); + } +} +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `npx vitest run src/lib/server/order-quote-link-service.test.ts && npx tsc --noEmit` +Expected: PASS, clean typecheck. + +- [ ] **Step 5: Commit** + +```bash +git add src/lib/server/order-quote-link-service.ts src/lib/server/order-quote-link-service.test.ts src/app/api/v1/orders/quote-match/sweep/route.ts +git commit -m "feat(orders): add quote-match backlog sweep with age-gap reporting" +``` + +--- + +## Task 8: MCP tool for the sweep + +Hermes (n8n) drops the `AGENT_API_KEY` on direct REST calls, so anything it needs to run must exist as an MCP tool. + +**Files:** +- Modify: `src/app/api/mcp/route.ts` + +- [ ] **Step 1: Register the tool definition** + +In `src/app/api/mcp/route.ts`, add to the tools array immediately after the `orders.set_quote` entry (which ends around line 875): + +```ts + { + name: "orders.quote_match_sweep", + description: + "Run quote auto-detection over orders that have never been attempted. Mirrors the REST " + + "POST /api/v1/orders/quote-match/sweep endpoint.\n\n" + + "Why this exists: orders created from now on are matched automatically at intake. This is the " + + "catch-up path for the historical backlog and for orders whose line items changed after " + + "creation.\n\n" + + "Writes ONLY the soft suggestion (`potential_quote` + `potential_quote_confidence`). It never " + + "writes the hard `orders.quote` link — promoting a suggestion is a deliberate human action via " + + "`orders.set_quote`.\n\n" + + "Processes one batch per call. Re-run until the returned `remaining` is 0.\n\n" + + "Returns `{ processed, suggested, noMatch, failed, remaining, ageGaps }`. `ageGaps` buckets the " + + "quote-to-order age in days for every match found (d0_30, d31_60, d61_90, d91_180, d181_365, " + + "d365_plus, total) — use it to check whether the 90-day fuzzy search window is set correctly.", + inputSchema: { + type: "object", + properties: { + limit: { + type: "number", + description: "Orders to process in this batch. 1..500, default 100.", + }, + }, + }, + }, +``` + +- [ ] **Step 2: Add the handler** + +Immediately after the `if (toolName === "orders.set_quote") { ... }` block (ends around line 3923), add: + +```ts + if (toolName === "orders.quote_match_sweep") { + const limit = typeof args.limit === "number" ? args.limit : undefined; + // Forward to the REST route so the surface stays in lock-step, + // matching how orders.set_quote is wired. + const upstreamRes = await fetch( + `${upstreamOrigin(req)}/api/v1/orders/quote-match/sweep`, + { + method: "POST", + headers: { + "Content-Type": "application/json", + authorization: `Bearer ${process.env.AGENT_API_KEY ?? ""}`, + }, + body: JSON.stringify(limit === undefined ? {} : { limit }), + }, + ); + const data = await upstreamRes.json().catch(() => ({})); + if (!upstreamRes.ok) { + return NextResponse.json( + { + jsonrpc: "2.0", + id: body.id ?? null, + error: { + code: -32010, + message: `orders.quote_match_sweep upstream ${upstreamRes.status}: ${data?.error ?? "unknown"}`, + data, + }, + }, + { status: 200 }, + ); + } + return NextResponse.json({ + jsonrpc: "2.0", + id: body.id ?? null, + result: { content: [{ type: "text", text: JSON.stringify(data) }] }, + }); + } +``` + +- [ ] **Step 3: Verify the tool is listed** + +Start the dev server, then: + +```bash +curl -s -X POST http://localhost:4001/api/mcp -H 'content-type: application/json' -d '{"jsonrpc":"2.0","id":1,"method":"tools/list"}' | grep -c quote_match_sweep +``` + +Expected: `1` or greater. + +- [ ] **Step 4: Typecheck** + +Run: `npx tsc --noEmit && npm run lint` +Expected: clean. + +- [ ] **Step 5: Commit** + +```bash +git add src/app/api/mcp/route.ts +git commit -m "feat(mcp): expose orders.quote_match_sweep" +``` + +--- + +## ⛔ Phase 1 checkpoint + +Stop here and confirm before starting Phase 2. + +- [ ] Run the full suite: `npm run test:run` — all green. +- [ ] Start the dev server and run one small sweep batch: + +```bash +curl -s -X POST http://localhost:4001/api/v1/orders/quote-match/sweep -H "authorization: Bearer $AGENT_API_KEY" -H 'content-type: application/json' -d '{"limit":10}' +``` + +- [ ] Read the `ageGaps` histogram. If most matches land beyond `d61_90`, raise `FUZZY_WINDOW_DAYS` in `order-quote-match.ts` before sweeping the rest of the backlog. +- [ ] Open `/orders` and confirm amber suggestion pills now appear on swept orders. + +--- + +# PHASE 2 — Surfacing + +## Task 9: Extract `AttachmentList` into a shared component + +Both order pages need to render attachments identically. It currently lives inside `process/page.tsx`. + +**Files:** +- Create: `src/components/orders/attachment-list.tsx` +- Modify: `src/app/(app)/orders/[id]/process/page.tsx` + +- [ ] **Step 1: Move the component** + +Cut `AttachmentList`, its `MatchedAttachment` type, and the `inferKind` / `attachmentHref` helpers out of `process/page.tsx` into `src/components/orders/attachment-list.tsx`. Add `"use client"` at the top and `export` on `AttachmentList`, `MatchedAttachment`, `inferKind` and `attachmentHref`. Copy the bodies verbatim — this is a move, not a rewrite. + +- [ ] **Step 2: Update the importer** + +In `process/page.tsx`, replace the deleted local definitions with: + +```ts +import { + AttachmentList, + type MatchedAttachment, +} from "@/components/orders/attachment-list"; +``` + +Remove any lucide icon imports (`FileText`, `ImageIcon`, `FileQuestion`) that are now unused in `process/page.tsx` — lint will flag them. + +- [ ] **Step 3: Verify nothing changed visually** + +Run: `npx tsc --noEmit && npm run lint` +Expected: clean. + +Then open `http://localhost:4001/orders//process`, expand a "Quote source" panel, and confirm the attachment rows render exactly as before. + +- [ ] **Step 4: Commit** + +```bash +git add src/components/orders/attachment-list.tsx "src/app/(app)/orders/[id]/process/page.tsx" +git commit -m "refactor(orders): extract AttachmentList into a shared component" +``` + +--- + +## Task 10: Serve the resolved pairing to the UI + +**Files:** +- Modify: `src/app/api/v1/orders/[id]/matched-attachments/route.ts` +- Create: `src/app/api/v1/orders/[id]/quote-line/route.ts` + +- [ ] **Step 1: Add `linePairs` to the matched-attachments response** + +In `matched-attachments/route.ts`, add the import: + +```ts +import { pairOrderLines } from "@/lib/server/order-quote-link-service"; +``` + +Replace both `return NextResponse.json({...})` blocks so they include `linePairs`. For the no-quote early return (currently around line 194): + +```ts + if (!quoteId) { + // No matched quote — still return the order-level attachments + // (RFQ-email + generated PDF are quote-side so they're empty + // here; only the operator uploads on the order row exist). + return NextResponse.json({ + quoteAttachments: orderAttachementRows, + perItemAttachments: {}, + matchedQuoteItems: [], + linePairs: {}, + }); + } +``` + +And for the main return (currently around line 322): + +```ts + // Resolve the order-line → quote-line pairing server-side. This + // replaces the exact/substring guess the process page used to do + // at render time, which silently hid buyer notes whenever the PO's + // wording differed from the quote's. + // + // Confident pairs are persisted by pairOrderLines; possible ones + // come back flagged so the UI can ask the operator to confirm. + const pairing = await pairOrderLines(orderId, quoteId).catch(() => null); + const linePairs: Record< + string, + { quote_item_id: string; confidence: number; tier: "confident" | "possible" } + > = {}; + if (pairing) { + for (const c of pairing.confident) { + linePairs[c.orderItemId] = { + quote_item_id: c.quoteItemId, + confidence: c.confidence, + tier: "confident", + }; + } + for (const p of pairing.possible) { + linePairs[p.orderItemId] = { + quote_item_id: p.quoteItemId, + confidence: p.confidence, + tier: "possible", + }; + } + } + + return NextResponse.json({ + quoteAttachments, + perItemAttachments, + matchedQuoteItems, + linePairs, + }); +``` + +Also update the route's docblock `Response:` section to list `linePairs`, noting it is keyed by `order_item_id` and that `tier` distinguishes a saved pair from a guess. + +- [ ] **Step 2: Add the confirm-a-pair route** + +Create `src/app/api/v1/orders/[id]/quote-line/route.ts`: + +```ts +import { NextRequest, NextResponse } from "next/server"; +import { getAdminPocketBase } from "@/lib/server/pocketbase-admin"; + +/** + * POST /api/v1/orders/[id]/quote-line + * + * Confirm a doubtful order-line → quote-line pairing. The matcher + * surfaces 0.3–0.5 Jaccard pairs as "possible" without writing them; + * this is what the operator's confirm button calls to make one stick. + * + * Body: `{ order_item_id: string, quote_item_id: string }` + * Pass `quote_item_id: ""` to clear an incorrect pairing. + * + * Returns: `{ ok: true, order_item_id, quote_item_id }` + * + * Errors: + * - 400 if either id is missing + * - 404 if the order item doesn't exist or belongs to another order + */ +export const runtime = "nodejs"; +export const dynamic = "force-dynamic"; + +export async function POST( + req: NextRequest, + { params }: { params: Promise<{ id: string }> }, +) { + try { + const { id: orderId } = await params; + const body = (await req.json().catch(() => ({}))) as { + order_item_id?: unknown; + quote_item_id?: unknown; + }; + const orderItemId = + typeof body.order_item_id === "string" ? body.order_item_id.trim() : ""; + const quoteItemId = + typeof body.quote_item_id === "string" ? body.quote_item_id.trim() : ""; + if (!orderItemId) { + return NextResponse.json( + { error: "order_item_id is required" }, + { status: 400 }, + ); + } + + const pb = await getAdminPocketBase(); + + // Guard against patching a line that belongs to a different order. + const row = await pb + .collection("order_items") + .getOne<{ id: string; order: string }>(orderItemId, { fields: "id,order" }) + .catch(() => null); + if (!row || row.order !== orderId) { + return NextResponse.json( + { error: `Order item ${orderItemId} not found on order ${orderId}` }, + { status: 404 }, + ); + } + + await pb.collection("order_items").update(orderItemId, { + quote_item: quoteItemId || null, + }); + + return NextResponse.json({ + ok: true, + order_item_id: orderItemId, + quote_item_id: quoteItemId, + }); + } catch (error) { + const message = + error instanceof Error ? error.message : "Failed to set quote line"; + return NextResponse.json({ error: message }, { status: 500 }); + } +} +``` + +- [ ] **Step 3: Verify the endpoint** + +With the dev server running: + +```bash +curl -s http://localhost:4001/api/v1/orders//matched-attachments | head -c 400 +``` + +Expected: the JSON now contains a `linePairs` key. + +- [ ] **Step 4: Typecheck** + +Run: `npx tsc --noEmit && npm run lint` +Expected: clean. + +- [ ] **Step 5: Commit** + +```bash +git add "src/app/api/v1/orders/[id]/matched-attachments/route.ts" "src/app/api/v1/orders/[id]/quote-line/route.ts" +git commit -m "feat(orders): serve resolved quote-line pairing to the order UI" +``` + +--- + +## Task 11: Shared quote-line context component + +**Files:** +- Create: `src/components/orders/quote-line-context.tsx` + +- [ ] **Step 1: Create the component** + +```tsx +"use client"; + +import { MessageSquare, HelpCircle } from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { + AttachmentList, + type MatchedAttachment, +} from "@/components/orders/attachment-list"; + +/** + * QuoteLineContext — everything the buyer needs to know about the + * quote line behind one order line. + * + * Shared by the order detail table and the process page so the two + * never drift. Before this existed, the process page owned the only + * copy and the detail page showed nothing at all. + * + * `tier` drives the framing: a "confident" pair is stated as fact, a + * "possible" pair is explicitly presented as a guess with a confirm + * button, so a buyer is never shown a note from the wrong line without + * being told it might be wrong. + */ + +export interface QuoteLineContextProps { + /** Quote line description. Rendered only when it differs from the order's. */ + quoteDescription: string; + orderDescription: string; + /** Free-text note the estimator left on the quote line. */ + notes: string; + perItemAttachments: MatchedAttachment[]; + quoteAttachments: MatchedAttachment[]; + tier: "confident" | "possible"; + /** Called when the operator confirms a "possible" pair. */ + onConfirm?: () => void; + confirming?: boolean; +} + +export function QuoteLineContext({ + quoteDescription, + orderDescription, + notes, + perItemAttachments, + quoteAttachments, + tier, + onConfirm, + confirming = false, +}: QuoteLineContextProps) { + return ( +
+ {tier === "possible" && ( +
+
+ +
+
+ Possible match — is this the right quote line? +
+

+ The wording differs enough that we're not certain. Confirm to + link them permanently. +

+
+ {onConfirm && ( + + )} +
+
+ )} + + {quoteDescription && quoteDescription !== orderDescription && ( +
+
+ Quote line says +
+
{quoteDescription}
+
+ )} + + {notes.trim() !== "" && ( +
+
+ + Buyer note +
+
+ {notes} +
+
+ )} + + {perItemAttachments.length > 0 && ( + + )} + {quoteAttachments.length > 0 && ( + + )} +
+ ); +} + +/** Shown in place of the panel when the order line has no counterpart + * on the linked quote. Previously this case rendered nothing at all, + * which was indistinguishable from "the quote line had no note". */ +export function QuoteLineNoMatch() { + return ( +
+ No matching line on the linked quote. +
+ ); +} +``` + +- [ ] **Step 2: Typecheck** + +Run: `npx tsc --noEmit && npm run lint` +Expected: clean. + +- [ ] **Step 3: Commit** + +```bash +git add src/components/orders/quote-line-context.tsx +git commit -m "feat(orders): add shared quote-line context component" +``` + +--- + +## Task 12: Process page uses the server pairing + +**Files:** +- Modify: `src/app/(app)/orders/[id]/process/page.tsx` + +- [ ] **Step 1: Delete the render-time guesser** + +Remove the entire `findMatchedQuoteItem` function (currently `process/page.tsx:3153-3171`, including its docblock). This is the root cause of the vanishing buyer notes. + +- [ ] **Step 2: Consume `linePairs` from the fetch** + +In `fetchAll` (around line 415), extend the payload type and store the pairing: + +```ts + const payload = (await attachRes.json()) as { + quoteAttachments: MatchedAttachment[]; + perItemAttachments: Record; + linePairs: Record< + string, + { quote_item_id: string; confidence: number; tier: "confident" | "possible" } + >; + }; + setQuoteAttachments(payload.quoteAttachments ?? []); + setPerItemAttachments(payload.perItemAttachments ?? {}); + setLinePairs(payload.linePairs ?? {}); +``` + +Add the state beside `perItemAttachments` (around line 318): + +```ts + // Order-line → quote-line pairing, resolved server-side. Replaces the + // old render-time description guess, which missed any wording change. + const [linePairs, setLinePairs] = useState< + Record + >({}); +``` + +Set `setLinePairs({})` in both failure branches alongside the existing resets. + +- [ ] **Step 3: Replace every call site** + +There are three: lines 491, 1634 and 1795-1796. Replace each + +```ts +findMatchedQuoteItem(edit.description, matchedQuoteItems) +``` + +with a lookup through the server pairing: + +```ts +matchedQuoteItems.find((q) => q.id === linePairs[item.id]?.quote_item_id) ?? null +``` + +At line 1795 the item in scope is `editingItemId`, so use `linePairs[editingItemId]?.quote_item_id` there. At line 491 use the id of the item that function has in scope. + +- [ ] **Step 4: Render the shared component** + +In the `quoteSourceExpanded` block, replace the hand-rolled "Quote line says" / "Buyer note" / `AttachmentList` markup (currently lines 2447-2570, keeping the "Pricing source" grid, which is order-side data and not part of the quote context) with: + +```tsx + +``` + +Pass `pairTier`, `onConfirm` and `confirming` down from the parent as new props on the item card, where `onConfirmPair` POSTs to the route from Task 10 and then re-runs `fetchAll`: + +```ts + const confirmPair = useCallback( + async (orderItemId: string, quoteItemId: string) => { + setConfirmingPairId(orderItemId); + try { + const res = await fetch(`/api/v1/orders/${id}/quote-line`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ order_item_id: orderItemId, quote_item_id: quoteItemId }), + }); + if (!res.ok) throw new Error("Failed to confirm quote line"); + toast.success("Quote line linked"); + await fetchAll(); + } catch (err) { + toast.error(err instanceof Error ? err.message : "Failed to confirm quote line"); + } finally { + setConfirmingPairId(null); + } + }, + [id, fetchAll], + ); +``` + +with `const [confirmingPairId, setConfirmingPairId] = useState(null);` beside the other state. + +- [ ] **Step 5: Render the no-match state** + +Where the card currently renders nothing because `matchedQuoteItem` is null, render `` — but only when the order actually has a linked or suggested quote (`order.quote || order.potential_quote`). On an order with no quote at all, showing "no matching line" would be noise. + +Add the import: + +```ts +import { QuoteLineContext, QuoteLineNoMatch } from "@/components/orders/quote-line-context"; +``` + +- [ ] **Step 6: Verify in the browser** + +```bash +npx tsc --noEmit && npm run lint +``` + +Then open `http://localhost:4001/orders//process` for an order whose quote line wording differs from the order's, expand "Quote source", and confirm the buyer note now appears where it previously showed nothing. + +- [ ] **Step 7: Commit** + +```bash +git add "src/app/(app)/orders/[id]/process/page.tsx" +git commit -m "fix(orders): resolve quote line pairing server-side on the process page" +``` + +--- + +## Task 13: Order detail table + migrate off pb-helpers + +**Files:** +- Modify: `src/app/(app)/orders/[id]/page.tsx` +- Modify: `src/lib/pb-helpers.ts` + +- [ ] **Step 1: Fetch the quote context** + +In `orders/[id]/page.tsx`, add state and a fetch alongside the existing `fetchOrder`: + +```ts + const [quoteContext, setQuoteContext] = useState<{ + perItemAttachments: Record; + quoteAttachments: MatchedAttachment[]; + matchedQuoteItems: Array<{ id: string; description: string; notes: string }>; + linePairs: Record< + string, + { quote_item_id: string; confidence: number; tier: "confident" | "possible" } + >; + } | null>(null); + + const fetchQuoteContext = useCallback(async () => { + try { + const res = await fetch(`/api/v1/orders/${id}/matched-attachments`); + if (!res.ok) return setQuoteContext(null); + setQuoteContext(await res.json()); + } catch { + // Non-fatal — the order still renders without quote context. + setQuoteContext(null); + } + }, [id]); +``` + +Call `void fetchQuoteContext()` from the same effect that calls `fetchOrder`. + +- [ ] **Step 2: Add the row indicators** + +Add expansion state: + +```ts + const [expandedItemId, setExpandedItemId] = useState(null); +``` + +In the Description `TableCell` (currently line 568), append the indicators after the existing description content: + +```tsx + {(() => { + const pair = quoteContext?.linePairs?.[item.id]; + if (!pair) return null; + const qi = quoteContext?.matchedQuoteItems.find( + (q) => q.id === pair.quote_item_id, + ); + const atts = + quoteContext?.perItemAttachments?.[pair.quote_item_id] ?? []; + const hasNote = !!qi?.notes?.trim(); + if (!hasNote && atts.length === 0) return null; + return ( + + ); + })()} +``` + +- [ ] **Step 3: Add the expanded row** + +Immediately after the closing `` of each item row (line 620), add: + +```tsx + {expandedItemId === item.id && (() => { + const pair = quoteContext?.linePairs?.[item.id]; + if (!pair) return null; + const qi = quoteContext?.matchedQuoteItems.find( + (q) => q.id === pair.quote_item_id, + ); + return ( + + + + + + ); + })()} +``` + +`colSpan={9}` matches the nine `TableHead` cells at lines 520-549. **Count them again before committing** — if a column was added since, the number must match or the layout breaks. + +`quoteAttachments` is deliberately empty here: order-level attachments already have their own panel on this page (line 644), and repeating them under every line would be noise. + +- [ ] **Step 4: Add imports** + +```ts +import { QuoteLineContext } from "@/components/orders/quote-line-context"; +import type { MatchedAttachment } from "@/components/orders/attachment-list"; +``` + +Add `MessageSquare` and `Paperclip` to the existing `lucide-react` import block (line 22-43). + +- [ ] **Step 5: Migrate the quote-match calls off pb-helpers** + +`AGENTS.md` requires migrating a page's data calls when touching it. First create the single-order match route, `src/app/api/v1/orders/[id]/quote-match/route.ts`: + +```ts +import { NextRequest, NextResponse } from "next/server"; +import { suggestQuoteForOrder } from "@/lib/server/order-quote-link-service"; + +/** + * POST /api/v1/orders/[id]/quote-match + * + * Re-run quote auto-detection for one order on demand. This is what the + * operator's "Find quote" button calls. Orders are matched automatically + * at intake; this exists for the case where the operator has just typed + * a reference, or corrected the line items, and wants another look. + * + * Writes only the soft suggestion. Promoting it to a hard link is + * POST /api/v1/orders/[id]/quote. + * + * Body: `{ reference?: string }` — optional reference text to persist on + * the order (`quote_reference_text`) before matching, so the exact + * lookups have something to work with. + * + * Returns: SuggestQuoteResult — `{ orderId, best, alternatives, method, + * window, ageGapDays }`. `best` carries `quoteId`, `quoteNumber`, + * `confidence` and the `breakdown` explaining the score. + */ +export const runtime = "nodejs"; +export const dynamic = "force-dynamic"; + +export async function POST( + req: NextRequest, + { params }: { params: Promise<{ id: string }> }, +) { + try { + const { id } = await params; + const body = (await req.json().catch(() => ({}))) as { reference?: unknown }; + const reference = + typeof body.reference === "string" ? body.reference.trim() : undefined; + + if (reference !== undefined) { + const { getAdminPocketBase } = await import("@/lib/server/pocketbase-admin"); + const pb = await getAdminPocketBase(); + await pb.collection("orders").update(id, { quote_reference_text: reference }); + } + + const result = await suggestQuoteForOrder(id); + return NextResponse.json(result); + } catch (error) { + const message = + error instanceof Error ? error.message : "Quote match failed"; + return NextResponse.json({ error: message }, { status: 500 }); + } +} +``` + +Then extend `src/app/api/v1/orders/[id]/quote/route.ts` to treat an empty `quote_id` as an unlink. Replace its `quote_id is required` guard: + +```ts + const quoteId = typeof body.quote_id === "string" ? body.quote_id.trim() : ""; + if (!quoteId) { + return NextResponse.json({ error: "quote_id is required" }, { status: 400 }); + } +``` + +with: + +```ts + const quoteId = typeof body.quote_id === "string" ? body.quote_id.trim() : ""; + // An explicit empty string is the unlink path — the operator + // rejecting a suggestion or undoing a wrong link. `quote_id` being + // absent entirely is still a 400. + if (!("quote_id" in body)) { + return NextResponse.json({ error: "quote_id is required" }, { status: 400 }); + } + if (!quoteId) { + const { getAdminPocketBase } = await import("@/lib/server/pocketbase-admin"); + const pb = await getAdminPocketBase(); + const previousRow = await pb + .collection("orders") + .getOne<{ quote?: string }>(id, { fields: "quote" }) + .catch(() => null); + if (!previousRow) { + return NextResponse.json({ error: `Order ${id} not found` }, { status: 404 }); + } + await pb.collection("orders").update(id, { + quote: null, + potential_quote: null, + potential_quote_confidence: 0, + quote_match_confidence: 0, + quote_matching_attempted: false, + quote_match_method: "", + }); + const order = await pb.collection("orders").getOne(id); + return NextResponse.json({ order, previous: { quote: previousRow.quote || "" } }); + } +``` + +Also update that route's docblock to describe the unlink path. + +Now rewire the page. Replace the `findMatchingQuotesForOrder` / `applyOrderQuoteMatch` / `clearOrderQuoteMatch` / `setOrderQuoteManually` imports from `@/lib/pb-helpers` with `fetch` calls: + +- `runMatch` (line 319) → `POST /api/v1/orders/${order.id}/quote-match` with `{ reference: refInput.trim() }`. **The result shape changes**: the old `result.best.quote.quote_number` becomes `result.best.quoteNumber`, and `result.best.quote.id` becomes `result.best.quoteId`. Update the toast strings and any `matchResult` reads accordingly. The `accept` parameter disappears — this endpoint only ever suggests; accepting is now `linkQuoteManually`. +- `linkQuoteManually` (line 365) → `POST /api/v1/orders/${order.id}/quote` with `{ quote_id: quoteId }` — the existing route, which now also pairs lines via Task 5. +- `clearMatch` (line 349) → `POST /api/v1/orders/${order.id}/quote` with `{ quote_id: "" }`. + +Then delete the now-unused matcher block from `src/lib/pb-helpers.ts` (`QuoteMatchMethod` through `setOrderQuoteManually`, roughly lines 520-840) and its re-exports at lines 838-844. Confirm nothing else imports them first: + +```bash +grep -rn "findMatchingQuotesForOrder\|applyOrderQuoteMatch\|clearOrderQuoteMatch\|setOrderQuoteManually" src/ --include="*.ts" --include="*.tsx" +``` + +Expected after migration: only hits inside `pb-helpers.ts` itself (which you then delete) and its test file. + +- [ ] **Step 6: Verify in the browser** + +```bash +npx tsc --noEmit && npm run lint && npm run test:run +``` + +Then open `http://localhost:4001/orders/` for an order with a linked quote whose lines carry notes. Confirm: +- the note icon and paperclip count appear on the right rows +- clicking one expands a row showing the note and attachments +- the table layout is not broken (the `colSpan` is right) +- the quote match / link / clear buttons still work + +- [ ] **Step 7: Commit** + +```bash +git add "src/app/(app)/orders/[id]/page.tsx" src/lib/pb-helpers.ts "src/app/api/v1/orders/[id]/quote-match/route.ts" "src/app/api/v1/orders/[id]/quote/route.ts" +git commit -m "feat(orders): show quote line notes and attachments on the order detail page" +``` + +--- + +## Final verification + +- [ ] `npm run test:run` — full suite green. +- [ ] `npx tsc --noEmit && npm run lint` — clean. +- [ ] `grep -rn 'from "@/lib/pb-helpers"' src/ --include="*.ts" --include="*.tsx"` — no remaining quote-matcher callers. +- [ ] Create an order via the MCP `orders.create` tool with descriptions matching a known recent quote; confirm the amber suggestion pill appears on `/orders` without anyone clicking anything. +- [ ] Sweep the backlog to completion, re-running until `remaining` is 0. +- [ ] Review the final `ageGaps` histogram and decide whether `FUZZY_WINDOW_DAYS` should move off 90.