feat(orders): serve resolved quote-line pairing to the order UI

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 <noreply@anthropic.com>
This commit is contained in:
Dave 2026-08-30 12:20:16 +02:00
parent 63e4a654bf
commit 23b50a32a9
2 changed files with 135 additions and 0 deletions

View file

@ -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<quote_item_id, [...]>,
* matchedQuoteItems: [{ id, description, cost_price, selling_price, supplier, quote_item_id }]
* linePairs: Record<order_item_id, { quote_item_id, confidence, tier: "confident" | "possible" }>
* }
* `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" | <message> }
*
@ -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(

View file

@ -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 });
}
}