From 23b50a32a9356a0525bb2a8d4770e1c7fb20e4ce Mon Sep 17 00:00:00 2001 From: Dave Date: Sun, 30 Aug 2026 12:20:16 +0200 Subject: [PATCH] feat(orders): serve resolved quote-line pairing to the order UI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The order detail page and process page need the pairing, not the matcher — until now the process page was doing an exact/substring guess at render time and silently hiding buyer notes when the PO's wording differed from the quote's. - matched-attachments response gains linePairs (keyed by order_item_id, carrying quote_item_id, confidence, tier). pairOrderLines is called defensively — a pairing failure cannot kill the attachments response (catch returns null → empty linePairs object, the rest of the payload still serves). - POST /api/v1/orders/[id]/quote-line writes the operator's confirm decision (or unlink with empty quote_item_id) onto order_items.quote_item. Guards against patching a line that belongs to a different order (404 otherwise). Both routes are additive — the existing matched-attachments fields are unchanged, the MCP orders.list_attachments consumer keeps working. Co-Authored-By: Claude --- .../orders/[id]/matched-attachments/route.ts | 46 ++++++++++ .../api/v1/orders/[id]/quote-line/route.ts | 89 +++++++++++++++++++ 2 files changed, 135 insertions(+) create mode 100644 src/app/api/v1/orders/[id]/quote-line/route.ts diff --git a/src/app/api/v1/orders/[id]/matched-attachments/route.ts b/src/app/api/v1/orders/[id]/matched-attachments/route.ts index 245c71c..3c7bea4 100644 --- a/src/app/api/v1/orders/[id]/matched-attachments/route.ts +++ b/src/app/api/v1/orders/[id]/matched-attachments/route.ts @@ -1,5 +1,6 @@ import { NextRequest, NextResponse } from "next/server"; import { getAdminPocketBase } from "@/lib/server/pocketbase-admin"; +import { pairOrderLines } from "@/lib/server/order-quote-link-service"; /** * GET /api/v1/orders/[id]/matched-attachments @@ -46,11 +47,21 @@ import { getAdminPocketBase } from "@/lib/server/pocketbase-admin"; * quoteAttachments: [{ id, filename, file, size_bytes, content_type, kind, direction, uploaded_at, source }], * perItemAttachments: Record, * matchedQuoteItems: [{ id, description, cost_price, selling_price, supplier, quote_item_id }] + * linePairs: Record * } * `source` on each `quoteAttachments` entry is "quote_attachments" * or "order_attachements" so callers can tell where the file * physically lives. (Per-line entries don't need it — they all * come from `quote_attachments`.) + * `linePairs` is the server-resolved order-line → quote-line pairing + * (keyed by `order_item_id`). Confident pairs are persisted onto + * `order_items.quote_item` by `pairOrderLines`; possible pairs are + * surfaced here so the UI can prompt the operator to confirm. + * `tier` distinguishes the two — a buyer is never shown a note from + * the wrong line without being told it might be wrong. Computed + * defensively (`.catch(() => null)`): a pairing failure must not + * kill the attachments response, since the rest of the payload + * remains useful. * 404 — { error: "Order not found" } * 500 — { error: "PB admin unavailable" | } * @@ -195,6 +206,7 @@ export async function GET( quoteAttachments: orderAttachementRows, perItemAttachments: {}, matchedQuoteItems: [], + linePairs: {}, }); } @@ -319,10 +331,44 @@ export async function GET( })); } + // Resolve the order-line → quote-line pairing server-side. 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 onto `order_items.quote_item`; possible pairs come + // back here so the UI can prompt the operator to confirm. + // + // Defensive: a pairing failure must not kill the attachments + // response — the rest of the payload (quote attachments, per-line + // attachments, matched quote items) is still useful without it. + // An empty `linePairs` is the right fallback shape. + 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, }); } catch (err) { return NextResponse.json( diff --git a/src/app/api/v1/orders/[id]/quote-line/route.ts b/src/app/api/v1/orders/[id]/quote-line/route.ts new file mode 100644 index 0000000..526d476 --- /dev/null +++ b/src/app/api/v1/orders/[id]/quote-line/route.ts @@ -0,0 +1,89 @@ +import { NextRequest, NextResponse } from "next/server"; +import { getAdminPocketBase } from "@/lib/server/pocketbase-admin"; + +/** + * POST /api/v1/orders/[id]/quote-line + * + * Confirm or clear 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. Pass `quote_item_id: ""` to clear an existing or incorrect + * pairing. + * + * Body: `{ order_item_id: string, quote_item_id?: string }` + * - `order_item_id` (required) — the order line being confirmed. + * - `quote_item_id` (optional) — the quote line to pair it to. Empty + * string or omission clears the link. + * + * Returns: `{ ok: true, order_item_id, quote_item_id }` + * + * Errors: + * 400 — `order_item_id` missing or empty. + * 404 — order item doesn't exist or belongs to a different order. + * + * Why a separate route from the matcher: + * `POST /api/v1/orders/[id]/matched-attachments` already calls + * `pairOrderLines` to compute the pairing on every fetch. We don't + * want the operator's confirm click to trigger a re-scan — it's a + * single targeted write, not a re-match. This endpoint writes the + * operator's decision directly onto `order_items.quote_item`. The + * matcher service has its own no-overwrite guard for confident + * pairs; this route is the deliberate-overwrite path for human + * decisions. + */ +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. + // Without this, the operator could write quote_item onto any + // order's line just by knowing its id. + 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 }); + } +}