WFDASH-WEBAPP/docs/superpowers/plans/2026-08-29-order-quote-linking.md

85 KiB
Raw Blame History

Order ↔ Quote Auto-Linking Implementation Plan

⚠️ STOP — Tasks 1–6 are already implemented and merged

Read docs/superpowers/plans/2026-08-29-HANDOFF-outstanding-work.md FIRST.

Tasks 1–6 below are done. Only Tasks 7–13 remain, plus two follow-up fixes to Task 6 that are described only in the handoff doc.

Six review cycles changed things this document still describes the old way. The handoff lists every correction. The most dangerous ones fail silently — vitest.config.ts has no clearMocks so mock history leaks between tests and produces green-but-vacuous tests; hand-built PocketBase OR-chain filters 400 past ~3.5KB; field projections are load-bearing and untested projections silently disable safety guards.

Where this plan and the handoff disagree, the handoff wins.

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:

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:

/* ============================================================================
 * 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
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:

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:

/* ============================================================================
 * 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
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:

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<typeof vi.fn>;
  getList?: ReturnType<typeof vi.fn>;
  getFullList?: ReturnType<typeof vi.fn>;
  update?: ReturnType<typeof vi.fn>;
}

function makePb(collections: Record<string, CollectionMock>) {
  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<typeof makePb>) {
  vi.mocked(getAdminPocketBase).mockResolvedValue(
    pb as unknown as Awaited<ReturnType<typeof getAdminPocketBase>>,
  );
}

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:

/**
 * 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<QuoteMatchMethod, "no_match">;
}

export interface SuggestQuoteResult {
  orderId: string;
  best: QuoteMatchCandidate | null;
  alternatives: QuoteMatchCandidate[];
  method: QuoteMatchMethod;
  /** Which pass produced the result — for the sweep's reporting. */
  // NOTE: shipped as "initial", not "90d" — review renamed it so that
  // retuning FUZZY_WINDOW_DAYS can't make the label lie. The real shape is
  // `"exact" | "initial" | "widened" | "none"`. See the handoff doc.
  window: "exact" | "initial" | "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<LineRow>) {
  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<ReturnType<typeof getAdminPocketBase>>,
  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<QuoteRow>(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<ReturnType<typeof getAdminPocketBase>>,
  order: OrderRow,
  orderItems: LineRow[],
  windowDays: number,
): Promise<QuoteMatchCandidate[]> {
  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<QuoteRow>(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<QuoteLineRow>({
      filter: orFilter,
      fields: "id,quote,description,selling_price,quantity",
      batch: 1000,
    });

  const byQuote = new Map<string, QuoteLineRow[]>();
  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<SuggestQuoteResult> {
  const pb = await getAdminPocketBase();
  pb.autoCancellation(false);

  const order = await pb.collection(ORDER_COLLECTION).getOne<OrderRow>(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<LineRow>({
      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"] = "initial"; // shipped value; not "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<ReturnType<typeof getAdminPocketBase>>,
  orderId: string,
  best: QuoteMatchCandidate | null,
): Promise<void> {
  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
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:

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" },
          ]),
          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:

/* ============================================================================
 * 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<PairOrderLinesResult> {
  const pb = await getAdminPocketBase();
  pb.autoCancellation(false);

  const [orderItems, quoteItems] = await Promise.all([
    pb.collection(ORDER_ITEMS_COLLECTION).getFullList<LineRow & { quote_item?: string }>({
      filter: `order = "${esc(orderId)}"`,
      fields: "id,description,quote_item",
      batch: 1000,
    }),
    pb.collection(QUOTE_ITEMS_COLLECTION).getFullList<QuoteLineRow>({
      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<number>();

  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
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):

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:

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:

  await pb.collection(ORDER_COLLECTION).update(orderId, update);
  const updated = await pb.collection(ORDER_COLLECTION).getOne<Order>(orderId);

  return { order: updated, previous };
}

with:

  await pb.collection(ORDER_COLLECTION).update(orderId, update);
  const updated = await pb.collection(ORDER_COLLECTION).getOne<Order>(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:

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
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:

vi.mock("next/server", async (importOriginal) => {
  const actual = await importOriginal<typeof import("next/server")>();
  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:

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:

  return {
    order,
    order_items_collection: orderItemsCollection,
    items_created: itemsResult.created,
    items_failed: itemsResult.failed,
  };
}

with:

  // 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:

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
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:

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,
    });
    mockAdminPb(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: "",
      });
    mockAdminPb(
      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:

/* ============================================================================
 * 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<number>): 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<SweepResult> {
  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:

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
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):

  {
    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:

      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:

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
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:
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:

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/<an-order-with-a-linked-quote>/process, expand a "Quote source" panel, and confirm the attachment rows render exactly as before.

  • Step 4: Commit
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:

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):

    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):

    // 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:

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:

curl -s http://localhost:4001/api/v1/orders/<order-id>/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
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

"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 (
    <div className="space-y-2.5 text-xs">
      {tier === "possible" && (
        <div className="rounded border border-warn/40 bg-warn/5 dark:bg-warn/10 px-2.5 py-2">
          <div className="flex items-start gap-2">
            <HelpCircle className="h-3.5 w-3.5 shrink-0 mt-0.5 text-warn-foreground dark:text-warn" />
            <div className="flex-1 min-w-0">
              <div className="font-medium text-warn-foreground dark:text-warn">
                Possible match — is this the right quote line?
              </div>
              <p className="text-muted-foreground mt-0.5">
                The wording differs enough that we&apos;re not certain. Confirm to
                link them permanently.
              </p>
            </div>
            {onConfirm && (
              <Button
                size="sm"
                variant="outline"
                className="h-7 text-[11px] shrink-0"
                onClick={onConfirm}
                disabled={confirming}
              >
                {confirming ? "Linking…" : "Confirm"}
              </Button>
            )}
          </div>
        </div>
      )}

      {quoteDescription && quoteDescription !== orderDescription && (
        <div className="rounded border bg-muted/30 px-2.5 py-1.5">
          <div className="text-[10px] uppercase tracking-wide text-muted-foreground mb-0.5">
            Quote line says
          </div>
          <div className="whitespace-pre-wrap break-words">{quoteDescription}</div>
        </div>
      )}

      {notes.trim() !== "" && (
        <div className="rounded border border-warn/30 bg-warn/5 dark:bg-warn/10 px-2.5 py-1.5">
          <div className="text-[10px] uppercase tracking-wide text-warn-foreground dark:text-warn mb-0.5 inline-flex items-center gap-1">
            <MessageSquare className="h-3 w-3" />
            Buyer note
          </div>
          <div className="whitespace-pre-wrap break-words text-foreground/90">
            {notes}
          </div>
        </div>
      )}

      {perItemAttachments.length > 0 && (
        <AttachmentList label="Attached to this line" attachments={perItemAttachments} />
      )}
      {quoteAttachments.length > 0 && (
        <AttachmentList label="Quote attachments" attachments={quoteAttachments} />
      )}
    </div>
  );
}

/** 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 (
    <div className="rounded border border-dashed px-2.5 py-1.5 text-xs text-muted-foreground">
      No matching line on the linked quote.
    </div>
  );
}
  • Step 2: Typecheck

Run: npx tsc --noEmit && npm run lint Expected: clean.

  • Step 3: Commit
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:

          const payload = (await attachRes.json()) as {
            quoteAttachments: MatchedAttachment[];
            perItemAttachments: Record<string, MatchedAttachment[]>;
            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):

  // 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<string, { quote_item_id: string; confidence: number; tier: "confident" | "possible" }>
  >({});

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

findMatchedQuoteItem(edit.description, matchedQuoteItems)

with a lookup through the server pairing:

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:

                  <QuoteLineContext
                    quoteDescription={matchedQuoteItem.description || ""}
                    orderDescription={edit.description}
                    notes={matchedQuoteItem.notes || ""}
                    perItemAttachments={perItemAttachments}
                    quoteAttachments={quoteAttachments}
                    tier={pairTier}
                    onConfirm={onConfirmPair}
                    confirming={confirmingPair}
                  />

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:

  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<string | null>(null); beside the other state.

  • Step 5: Render the no-match state

Where the card currently renders nothing because matchedQuoteItem is null, render <QuoteLineNoMatch /> — 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:

import { QuoteLineContext, QuoteLineNoMatch } from "@/components/orders/quote-line-context";
  • Step 6: Verify in the browser
npx tsc --noEmit && npm run lint

Then open http://localhost:4001/orders/<id>/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
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:

  const [quoteContext, setQuoteContext] = useState<{
    perItemAttachments: Record<string, MatchedAttachment[]>;
    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:

  const [expandedItemId, setExpandedItemId] = useState<string | null>(null);

In the Description TableCell (currently line 568), append the indicators after the existing description content:

                          {(() => {
                            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 (
                              <button
                                type="button"
                                onClick={() =>
                                  setExpandedItemId(
                                    expandedItemId === item.id ? null : item.id,
                                  )
                                }
                                className="ml-2 inline-flex items-center gap-1.5 align-middle text-muted-foreground hover:text-foreground transition-colors"
                                title="Show what the quote said about this line"
                                aria-label="Show quote context for this line"
                                aria-expanded={expandedItemId === item.id}
                              >
                                {hasNote && <MessageSquare className="h-3.5 w-3.5 text-warn" />}
                                {atts.length > 0 && (
                                  <span className="inline-flex items-center gap-0.5">
                                    <Paperclip className="h-3.5 w-3.5" />
                                    <span className="text-[10px] tabular-nums">
                                      {atts.length}
                                    </span>
                                  </span>
                                )}
                              </button>
                            );
                          })()}
  • Step 3: Add the expanded row

Immediately after the closing </TableRow> of each item row (line 620), add:

                      {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 (
                          <TableRow key={`${item.id}-quote`} className="hover:bg-transparent">
                            <TableCell colSpan={9} className="py-3 bg-muted/20">
                              <QuoteLineContext
                                quoteDescription={qi?.description || ""}
                                orderDescription={item.description || ""}
                                notes={qi?.notes || ""}
                                perItemAttachments={
                                  quoteContext?.perItemAttachments?.[pair.quote_item_id] ?? []
                                }
                                quoteAttachments={[]}
                                tier={pair.tier}
                              />
                            </TableCell>
                          </TableRow>
                        );
                      })()}

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
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:

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:

    const quoteId = typeof body.quote_id === "string" ? body.quote_id.trim() : "";
    if (!quoteId) {
      return NextResponse.json({ error: "quote_id is required" }, { status: 400 });
    }

with:

    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:

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
npx tsc --noEmit && npm run lint && npm run test:run

Then open http://localhost:4001/orders/<id> 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

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.