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:
parent
63e4a654bf
commit
23b50a32a9
2 changed files with 135 additions and 0 deletions
|
|
@ -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(
|
||||
|
|
|
|||
89
src/app/api/v1/orders/[id]/quote-line/route.ts
Normal file
89
src/app/api/v1/orders/[id]/quote-line/route.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue