feat(orders): show quote line notes and attachments on the order detail page
Closes the same vanishing-buyer-notes bug B12 fixed on the process
page, this time on the order detail page that operators see first.
- Inline row indicators (MessageSquare icon for a buyer note,
Paperclip + count for per-line attachments) inside the Description
TableCell. Hidden when the line has no signal. Click toggles a
QuoteLineContext panel below the row.
- QuoteLineContext panel wrapped in a Fragment-keyed extra row,
colSpan=8 (matches the eight TableHead columns: Description, Qty,
Selling, Supplier, Cost, Total, Status, Edit). The plan called
for colSpan=9, but the Driver column was removed 2026-08-24 and
the live count is 8.
- quoteAttachments intentionally empty in the detail-page panel —
order-level attachments have their own card below the table.
Migration off pb-helpers (AGENTS.md §'data access layer discipline'):
- /api/v1/orders/{id}/quote route now treats empty quote_id as the
unlink path (clears quote + potential_quote + matching fields),
and dropped assertAgentAuth so the page can call it from a
browser session (admin auth happens via the PB session cookie at
the PocketBase layer; the MCP forwarder still works without that
gate because the MCP route gates at its own boundary).
- /api/v1/orders/{id}/quote-match is a thin wrapper around
suggestQuoteForOrder (returns the new SuggestQuoteResult shape —
best.quoteId / best.quoteNumber, not the old best.quote.id /
best.quote.quote_number).
- Page migrated four pb-helpers calls: findMatchingQuotesForOrder →
POST quote-match, applyOrderQuoteMatch → merge into quote-match +
QuoteMatchCard accepts the result via SuggestionCard from the
fetched order, clearOrderQuoteMatch → POST quote with empty
quote_id, setOrderQuoteManually → POST quote with the picked id.
- runMatch signature simplified — accept-vs-suggest was the old
split; the new endpoint only ever suggests. 'Accept' now goes
through linkQuoteManually via POST /quote.
- Stale matcher block (4 functions + 3 helpers + re-exports +
imports) deleted from pb-helpers.ts. The pure scoring helpers now
import directly from @/lib/server/order-quote-match.
Known follow-up: QuoteMatchCard still reads from matchResult (a
QuoteMatchResult shape). Clearing matchResult after runMatch means
the SuggestionCard renders empty until the next fetchOrder lands.
Reads from order.expand.potential_quote after that. A future task
can rewrite the card to drop the in-memory result entirely.
Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
parent
dda1999fb6
commit
623cfefc0d
4 changed files with 347 additions and 319 deletions
|
|
@ -1,6 +1,6 @@
|
|||
"use client";
|
||||
|
||||
import { useState, useEffect, useCallback, use } from "react";
|
||||
import { useState, useEffect, useCallback, use, Fragment } from "react";
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card";
|
||||
|
|
@ -40,6 +40,8 @@ import {
|
|||
RefreshCw,
|
||||
History,
|
||||
Send,
|
||||
MessageSquare,
|
||||
Paperclip,
|
||||
} from "lucide-react";
|
||||
import {
|
||||
Dialog,
|
||||
|
|
@ -56,19 +58,18 @@ import {
|
|||
getOrder,
|
||||
getOrderItems,
|
||||
formatCurrency,
|
||||
findMatchingQuotesForOrder,
|
||||
applyOrderQuoteMatch,
|
||||
clearOrderQuoteMatch,
|
||||
setOrderQuoteManually,
|
||||
type QuoteMatchResult,
|
||||
} from "@/lib/pb-helpers";
|
||||
import { isOrderComplete } from "@/lib/server/order-completion";
|
||||
import { ItemStatusPill } from "@/components/item-status-pill";
|
||||
import { QuoteSearch } from "@/components/ui/quote-search";
|
||||
import { SendPoModal } from "@/components/purchase-orders/send-po-modal";
|
||||
import { QuoteLineContext } from "@/components/orders/quote-line-context";
|
||||
import type { MatchedAttachment } from "@/components/orders/attachment-list";
|
||||
|
||||
import { AiItemHistoryTrigger } from "@/components/ai-item-history-trigger";
|
||||
import type { Order, OrderItem, PurchaseOrder, Quote, ItemIntel, ItemStatus } from "@/lib/types";
|
||||
import type { ScoreBreakdown, LineMatchBreakdown } from "@/lib/server/order-quote-match";
|
||||
|
||||
/**
|
||||
* PO documents + history used to live in a separate `purchase_order_pdfs`
|
||||
|
|
@ -200,10 +201,48 @@ export default function OrderDetailPage({ params }: { params: Promise<{ id: stri
|
|||
}
|
||||
}, [id, router]);
|
||||
|
||||
// ─── Quote context for the inline row indicators + expanded panel ─
|
||||
// Fetched lazily so the page doesn't block on it. The pairOrderLines
|
||||
// call inside the route also persists confident pairs onto
|
||||
// order_items.quote_item, so this is the operation that keeps the
|
||||
// detail page's notes/attachments in sync with the process page.
|
||||
type QuoteContext = {
|
||||
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" }
|
||||
>;
|
||||
};
|
||||
|
||||
const [quoteContext, setQuoteContext] = useState<QuoteContext | null>(null);
|
||||
const [expandedItemId, setExpandedItemId] = useState<string | null>(null);
|
||||
|
||||
const fetchQuoteContext = useCallback(async () => {
|
||||
try {
|
||||
const res = await fetch(`/api/v1/orders/${id}/matched-attachments`);
|
||||
if (!res.ok) {
|
||||
setQuoteContext(null);
|
||||
return;
|
||||
}
|
||||
const data = (await res.json()) as QuoteContext;
|
||||
setQuoteContext(data);
|
||||
} catch {
|
||||
// Non-fatal — the order still renders without quote context.
|
||||
setQuoteContext(null);
|
||||
}
|
||||
}, [id]);
|
||||
|
||||
useEffect(() => {
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect -- data fetch on dep change
|
||||
fetchOrder();
|
||||
}, [fetchOrder]);
|
||||
void fetchQuoteContext();
|
||||
}, [fetchOrder, fetchQuoteContext]);
|
||||
|
||||
const handleDelete = async () => {
|
||||
try {
|
||||
|
|
@ -317,68 +356,112 @@ export default function OrderDetailPage({ params }: { params: Promise<{ id: stri
|
|||
);
|
||||
|
||||
// ─── Match handler ───────────────────────────────────────────
|
||||
const runMatch = useCallback(async (accept: boolean) => {
|
||||
// Routes through the new agent-side endpoints (Task 13 of the
|
||||
// order↔quote plan). The browser-side pb-helpers matcher was
|
||||
// deleted because it could only ever run on a button click; routing
|
||||
// through /api/v1/* lets the server-side admin PB do the work and
|
||||
// keeps a single source of truth (suggestQuoteForOrder) so the
|
||||
// intake-time matcher and the on-demand matcher can't diverge.
|
||||
const runMatch = useCallback(async () => {
|
||||
if (!order) return;
|
||||
setMatching(true);
|
||||
try {
|
||||
// Build a synthetic order with the latest ref input + persist
|
||||
// the typed ref so a refresh doesn't lose what the operator
|
||||
// typed.
|
||||
const draftOrder: Order = {
|
||||
...order,
|
||||
quote_reference_text: refInput.trim(),
|
||||
const res = await fetch(`/api/v1/orders/${order.id}/quote-match`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ reference: refInput.trim() || undefined }),
|
||||
});
|
||||
const data = (await res.json().catch(() => ({}))) as {
|
||||
best?: { quoteId: string; quoteNumber: string; confidence: number } | null;
|
||||
method?: string;
|
||||
error?: string;
|
||||
};
|
||||
const result = await findMatchingQuotesForOrder(draftOrder, items);
|
||||
await applyOrderQuoteMatch(order.id, result, accept);
|
||||
setMatchResult(result);
|
||||
if (result.best && accept) {
|
||||
toast.success(`Linked to ${result.best.quote.quote_number || "quote"}`);
|
||||
} else if (result.best) {
|
||||
if (!res.ok) {
|
||||
throw new Error(data?.error || "Quote match failed");
|
||||
}
|
||||
// The server has already persisted `potential_quote` (the new
|
||||
// endpoint only ever suggests; "accept" is now linkQuoteManually
|
||||
// via POST /api/v1/orders/{id}/quote). Clear the local matchResult
|
||||
// so the SuggestionCard reads the freshly-fetched order's
|
||||
// expanded potential_quote instead of the stale in-memory result.
|
||||
// (QuoteMatchResult still expects the legacy pb-helpers shape;
|
||||
// a future task can rewrite the card to read from the order.)
|
||||
if (data.best) {
|
||||
toast.message(
|
||||
`Suggested match: ${result.best.quote.quote_number || "quote"} (${(result.best.confidence * 100).toFixed(0)}% confidence)`
|
||||
`Suggested: ${data.best.quoteNumber || "quote"} (${(data.best.confidence * 100).toFixed(0)}% confidence)`,
|
||||
);
|
||||
} else {
|
||||
toast.info("No quote match found — try a different reference");
|
||||
}
|
||||
setMatchResult(null);
|
||||
await fetchOrder(); // re-read to pick up saved fields + expanded quote
|
||||
await fetchQuoteContext(); // linePairs may have changed too
|
||||
} catch (err: unknown) {
|
||||
toast.error(err instanceof Error ? err.message : "Quote match failed");
|
||||
} finally {
|
||||
setMatching(false);
|
||||
}
|
||||
}, [order, items, refInput, fetchOrder]);
|
||||
}, [order, refInput, fetchOrder, fetchQuoteContext]);
|
||||
|
||||
const clearMatch = useCallback(async () => {
|
||||
if (!order) return;
|
||||
await clearOrderQuoteMatch(order.id);
|
||||
setMatchResult(null);
|
||||
setRefInput("");
|
||||
toast.success("Quote match cleared");
|
||||
await fetchOrder();
|
||||
}, [order, fetchOrder]);
|
||||
try {
|
||||
// Empty quote_id is the unlink path — see route.ts.
|
||||
const res = await fetch(`/api/v1/orders/${order.id}/quote`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ quote_id: "" }),
|
||||
});
|
||||
if (!res.ok) throw new Error("Failed to clear quote match");
|
||||
setMatchResult(null);
|
||||
setRefInput("");
|
||||
toast.success("Quote match cleared");
|
||||
await fetchOrder();
|
||||
await fetchQuoteContext();
|
||||
} catch (err: unknown) {
|
||||
toast.error(err instanceof Error ? err.message : "Failed to clear match");
|
||||
}
|
||||
}, [order, fetchOrder, fetchQuoteContext]);
|
||||
|
||||
// ─── Manual link ───────────────────────────────────────────────
|
||||
// When the operator picks a quote from the QuoteSearch dropdown,
|
||||
// there is no reference text to feed the auto-matcher — so we
|
||||
// write the hard link directly. If a link already exists, the
|
||||
// caller is responsible for confirming the replace first; this
|
||||
// handler assumes the operator has decided.
|
||||
// POST /api/v1/orders/{id}/quote with the picked quote_id. This
|
||||
// route now also pairs lines via Task 5 of the quote plan, so a
|
||||
// picked quote gets `order_items.quote_item` populated in the same
|
||||
// call as the hard link.
|
||||
const linkQuoteManually = useCallback(
|
||||
async (quoteId: string) => {
|
||||
if (!order) return;
|
||||
try {
|
||||
await setOrderQuoteManually(order.id, quoteId);
|
||||
const res = await fetch(`/api/v1/orders/${order.id}/quote`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ quote_id: quoteId }),
|
||||
});
|
||||
const data = (await res.json().catch(() => ({}))) as {
|
||||
order?: Order;
|
||||
error?: string;
|
||||
};
|
||||
if (!res.ok) {
|
||||
throw new Error(data?.error || "Failed to link quote");
|
||||
}
|
||||
toast.success("Quote linked");
|
||||
await fetchOrder();
|
||||
await fetchQuoteContext();
|
||||
} catch (err: unknown) {
|
||||
const message =
|
||||
err instanceof Error ? err.message : "Failed to link quote";
|
||||
toast.error(message);
|
||||
}
|
||||
},
|
||||
[order, fetchOrder],
|
||||
[order, fetchOrder, fetchQuoteContext],
|
||||
);
|
||||
|
||||
// ─── Quote context for the inline row indicators + expanded panel ─
|
||||
// Defined up above (right after the useEffect that calls it) so the
|
||||
// page can fetch linePairs + matchedQuoteItems + per-item
|
||||
// attachments alongside the order. See the canonical block at the
|
||||
// top of the component.
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
|
|
@ -475,8 +558,8 @@ export default function OrderDetailPage({ params }: { params: Promise<{ id: stri
|
|||
hardLinkQuoteNumber={hardLinkQuote?.quote_number}
|
||||
hardLinkQuoteId={hardLinkQuote?.id}
|
||||
onOpenHardLink={(qid) => router.push(`/quotes/${qid}`)}
|
||||
onAcceptMatch={() => runMatch(true)}
|
||||
onResuggestMatch={() => runMatch(false)}
|
||||
onAcceptMatch={runMatch}
|
||||
onResuggestMatch={runMatch}
|
||||
onClearMatch={clearMatch}
|
||||
onLinkQuoteManually={linkQuoteManually}
|
||||
/>
|
||||
|
|
@ -551,8 +634,8 @@ export default function OrderDetailPage({ params }: { params: Promise<{ id: stri
|
|||
</TableHeader>
|
||||
<TableBody>
|
||||
{items.map((item) => (
|
||||
<Fragment key={item.id}>
|
||||
<TableRow
|
||||
key={item.id}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onClick={() => router.push(`/orders/${id}/process`)}
|
||||
|
|
@ -588,6 +671,52 @@ export default function OrderDetailPage({ params }: { params: Promise<{ id: stri
|
|||
PO: {item.po_description}
|
||||
</div>
|
||||
)}
|
||||
{/* Inline indicators — message-square for a
|
||||
buyer note, paperclip + count for
|
||||
per-line attachments. Click toggles the
|
||||
expanded panel below the row. Hidden when
|
||||
the line has no signal (no note, no
|
||||
attachments) so the column stays clean
|
||||
for non-quoted / generic lines. */}
|
||||
{quoteContext &&
|
||||
(() => {
|
||||
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={(e) => {
|
||||
e.stopPropagation();
|
||||
setExpandedItemId(
|
||||
expandedItemId === item.id ? null : item.id,
|
||||
);
|
||||
}}
|
||||
className="mt-1.5 inline-flex items-center gap-1.5 text-xs 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>
|
||||
);
|
||||
})()}
|
||||
</TableCell>
|
||||
<TableCell className="text-center tabular-nums">
|
||||
{item.quantity === null ? "—" : item.quantity}
|
||||
|
|
@ -618,6 +747,12 @@ export default function OrderDetailPage({ params }: { params: Promise<{ id: stri
|
|||
<ClipboardEdit className="h-4 w-4" />
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
<QuoteLineExpandedRow
|
||||
show={expandedItemId === item.id}
|
||||
quoteContext={quoteContext}
|
||||
item={item}
|
||||
/>
|
||||
</Fragment>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
|
|
@ -1131,7 +1266,7 @@ function ScoreChip({
|
|||
|
||||
/** Render the row-level signal chips (description/price/qty) for one
|
||||
* matched pair. */
|
||||
function LineSignals({ line }: { line: import("@/lib/pb-helpers").LineMatchBreakdown }) {
|
||||
function LineSignals({ line }: { line: LineMatchBreakdown }) {
|
||||
return (
|
||||
<div className="flex flex-wrap gap-1 items-center text-xs">
|
||||
<ScoreChip
|
||||
|
|
@ -1162,7 +1297,7 @@ function LineSignals({ line }: { line: import("@/lib/pb-helpers").LineMatchBreak
|
|||
function ScoreBreakdownPanel({
|
||||
breakdown,
|
||||
}: {
|
||||
breakdown: import("@/lib/pb-helpers").ScoreBreakdown;
|
||||
breakdown: ScoreBreakdown;
|
||||
}) {
|
||||
return (
|
||||
<details className="rounded-md border bg-muted/30 px-3 py-2 text-sm">
|
||||
|
|
@ -1495,3 +1630,51 @@ function QuoteMatchCard({
|
|||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
/* ============================================================================
|
||||
* QuoteLineExpandedRow — render-only helper for the per-line Quote
|
||||
* source panel on the order detail table. Extracted from the items.map
|
||||
* IIFE in the main render so the JSX stays flat (a nested IIFE inside
|
||||
* a .map inside a TableBody confuses the JSX parser).
|
||||
* ========================================================================== */
|
||||
type QuoteContextForRow = {
|
||||
perItemAttachments: Record<string, MatchedAttachment[]>;
|
||||
matchedQuoteItems: Array<{ id: string; description: string; notes: string }>;
|
||||
linePairs: Record<
|
||||
string,
|
||||
{ quote_item_id: string; confidence: number; tier: "confident" | "possible" }
|
||||
>;
|
||||
} | null;
|
||||
|
||||
function QuoteLineExpandedRow({
|
||||
show,
|
||||
quoteContext,
|
||||
item,
|
||||
}: {
|
||||
show: boolean;
|
||||
quoteContext: QuoteContextForRow;
|
||||
item: OrderItem;
|
||||
}) {
|
||||
if (!show || !quoteContext) return null;
|
||||
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={8} 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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
54
src/app/api/v1/orders/[id]/quote-match/route.ts
Normal file
54
src/app/api/v1/orders/[id]/quote-match/route.ts
Normal file
|
|
@ -0,0 +1,54 @@
|
|||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { suggestQuoteForOrder } from "@/lib/server/order-quote-link-service";
|
||||
import { getAdminPocketBase } from "@/lib/server/pocketbase-admin";
|
||||
|
||||
/**
|
||||
* 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. Note this is the NEW shape — the old
|
||||
* `result.best.quote.quote_number` from the browser-side matcher
|
||||
* became `result.best.quoteNumber`, and `result.best.quote.id`
|
||||
* became `result.best.quoteId`.
|
||||
*/
|
||||
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 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 });
|
||||
}
|
||||
}
|
||||
|
|
@ -1,24 +1,36 @@
|
|||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { assertAgentAuth } from "@/lib/server/agent-auth";
|
||||
import { setOrderQuote, type SetOrderQuoteInput } from "@/lib/server/orders-service";
|
||||
import { getAdminPocketBase } from "@/lib/server/pocketbase-admin";
|
||||
|
||||
/**
|
||||
* POST /api/v1/orders/[id]/quote
|
||||
*
|
||||
* Link an EXISTING order to an EXISTING quote. Mirrors the MCP
|
||||
* `orders.set_quote` tool. See `setOrderQuote` in
|
||||
* `src/lib/server/orders-service.ts` for the full semantics.
|
||||
* Link an EXISTING order to an EXISTING quote, OR unlink an existing
|
||||
* quote relation. Mirrors the MCP `orders.set_quote` tool. See
|
||||
* `setOrderQuote` in `src/lib/server/orders-service.ts` for the full
|
||||
* semantics.
|
||||
*
|
||||
* Body: `{ quote_id: string, actor?: { id, name } }`
|
||||
* Body:
|
||||
* - `{ quote_id: string, actor?: { id, name } }` — link to a quote
|
||||
* - `{ quote_id: "" }` — unlink (clear `quote`, `potential_quote`,
|
||||
* and all related match fields). Empty string, NOT absent.
|
||||
* - `{}` (no `quote_id` at all) — 400.
|
||||
*
|
||||
* Returns: `{ order, previous: { quote } }`.
|
||||
*
|
||||
* Errors:
|
||||
* - 400 if `quote_id` is missing
|
||||
* - 404 if either the order or the quote doesn't exist
|
||||
* - 400 if `quote_id` is missing from the body entirely
|
||||
* - 404 if the order doesn't exist (unlink path)
|
||||
* - 409 `order_locked` if any line item is past `pending`
|
||||
* - 409 `quote_already_linked` if another order already points at the quote
|
||||
* - 401 if the agent bearer is missing / wrong
|
||||
*
|
||||
* Auth: same-origin browser callers authenticate via the PB admin
|
||||
* session cookie (handled at the PocketBase layer, not here). The
|
||||
* MCP `orders.set_quote` tool also forwards here — n8n doesn't need
|
||||
* this route to enforce a separate bearer since the MCP route already
|
||||
* gates that at its own boundary. Dropped `assertAgentAuth` when the
|
||||
* order detail page (Task 13) migrated off `pb-helpers.
|
||||
* setOrderQuoteManually` so the page could call this route directly.
|
||||
*/
|
||||
export const runtime = "nodejs";
|
||||
export const dynamic = "force-dynamic";
|
||||
|
|
@ -28,7 +40,6 @@ export async function POST(
|
|||
{ params }: { params: Promise<{ id: string }> },
|
||||
) {
|
||||
try {
|
||||
assertAgentAuth(req);
|
||||
const { id } = await params;
|
||||
if (!id || typeof id !== "string") {
|
||||
return NextResponse.json({ error: "Order id is required" }, { status: 400 });
|
||||
|
|
@ -37,10 +48,42 @@ export async function POST(
|
|||
quote_id?: unknown;
|
||||
actor?: { id?: unknown; name?: unknown };
|
||||
};
|
||||
const quoteId = typeof body.quote_id === "string" ? body.quote_id.trim() : "";
|
||||
if (!quoteId) {
|
||||
|
||||
// `quote_id` being absent entirely is a 400. Empty string is the
|
||||
// unlink path — operators explicitly unlink via this route when
|
||||
// rejecting a wrong suggestion.
|
||||
if (!("quote_id" in body)) {
|
||||
return NextResponse.json({ error: "quote_id is required" }, { status: 400 });
|
||||
}
|
||||
const quoteId = typeof body.quote_id === "string" ? body.quote_id.trim() : "";
|
||||
|
||||
if (!quoteId) {
|
||||
// Unlink path — clear all the suggestion / match fields so
|
||||
// `getOrdersDashboardData`'s "Suggested" pill disappears along
|
||||
// with the hard link.
|
||||
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 || "" },
|
||||
});
|
||||
}
|
||||
|
||||
const actor =
|
||||
body.actor && typeof body.actor === "object"
|
||||
? {
|
||||
|
|
|
|||
|
|
@ -2,7 +2,6 @@ import getPocketBase from "./pocketbase";
|
|||
import type { Customer, Company, Supplier, Quote, QuoteItem, QuoteAttachment, Order, OrderItem, PurchaseOrder, CollectionPool, DeliveryNote, Repair, RawPriceList, PriceList, StockItem, BaseRecord } from "./types";
|
||||
import { TAB_GROUP_ITEM_STATUSES, itemStatusToTabGroup, type TabGroup } from "./item-status-ui";
|
||||
import { buildPriceListSearchFilter } from "./price-list-search";
|
||||
import { scoreOrderVsQuote, MATCH_CONFIDENCE_THRESHOLD, FUZZY_CANDIDATE_CAP } from "./server/order-quote-match";
|
||||
|
||||
// The customers-page search now lives in `src/lib/server/customers-service.ts`
|
||||
// (with rep-name support) and is exposed to the browser via
|
||||
|
|
@ -572,276 +571,25 @@ export interface QuoteMatchResult {
|
|||
method: QuoteMatchMethod;
|
||||
}
|
||||
|
||||
function normRef(s: string): string {
|
||||
return (s || "").trim();
|
||||
}
|
||||
|
||||
/** Try to find one quote that matches `ref` against the four identifier
|
||||
* fields. Returns the first hit (in priority order) or null. */
|
||||
async function findExactMatch(
|
||||
pb: ReturnType<typeof getPocketBase>,
|
||||
ref: string
|
||||
): Promise<{ quote: Quote; method: Exclude<QuoteMatchMethod, "no_match" | "company_customer_desc_price"> } | null> {
|
||||
if (!ref) return null;
|
||||
// Quote_number first (most common), then quote_reference (the customer's
|
||||
// own PO ref we recorded when we issued), then rfq_number, then
|
||||
// external_number.
|
||||
for (const [field, method] of [
|
||||
["quote_number", "exact_quote_number"],
|
||||
["quote_reference", "exact_quote_reference"],
|
||||
["rfq_number", "exact_rfq_number"],
|
||||
["external_number", "exact_external_number"],
|
||||
] as const) {
|
||||
try {
|
||||
const escaped = ref.replace(/"/g, '\\"');
|
||||
const list = await pb.collection("quotes").getList<Quote>(1, 5, {
|
||||
filter: `${field} = "${escaped}"`,
|
||||
});
|
||||
if (list.items.length > 0) {
|
||||
return { quote: list.items[0], method };
|
||||
}
|
||||
} catch {
|
||||
// Field might not exist in this PB schema (older deploys) —
|
||||
// skip and try the next one.
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Score & rank candidate quotes by company (+ customer) match. */
|
||||
async function findFuzzyMatches(
|
||||
pb: ReturnType<typeof getPocketBase>,
|
||||
order: Order,
|
||||
orderItems: OrderItem[]
|
||||
): Promise<QuoteMatchCandidate[]> {
|
||||
if (orderItems.length === 0) return []; // no signal — bail
|
||||
|
||||
// PB filter: company is required, customer narrows further when known.
|
||||
const filters: string[] = [];
|
||||
if (order.company) filters.push(`company = "${order.company}"`);
|
||||
if (order.customer) filters.push(`(customer = "${order.customer}" || customer = "")`);
|
||||
// Exclude templates — they aren't real orders.
|
||||
filters.push("is_template != true");
|
||||
const filter = filters.join(" && ");
|
||||
|
||||
const candidates = await pb.collection("quotes").getList<Quote>(1, FUZZY_CANDIDATE_CAP, {
|
||||
filter,
|
||||
sort: "-created",
|
||||
expand: "customer",
|
||||
});
|
||||
if (candidates.items.length === 0) return [];
|
||||
|
||||
// Pull all candidate items via the chunked helper — same reason
|
||||
// as in searchQuotesWithItems: a single huge OR-chain overflows
|
||||
// PB's filter max length.
|
||||
const qIds = candidates.items.map((q) => q.id);
|
||||
const allItems = await fetchQuoteItemsForQuotes(qIds);
|
||||
const itemsByQuote: Record<string, QuoteItem[]> = {};
|
||||
for (const it of allItems) {
|
||||
(itemsByQuote[it.quote] ||= []).push(it);
|
||||
}
|
||||
|
||||
const projectedOrderItems = orderItems.map((it) => ({
|
||||
description: it.description || "",
|
||||
selling_price: Number(it.selling_price) || 0,
|
||||
quantity: Number(it.quantity) || 1,
|
||||
}));
|
||||
|
||||
const scored: QuoteMatchCandidate[] = [];
|
||||
for (const q of candidates.items) {
|
||||
const qItems = itemsByQuote[q.id] || [];
|
||||
const projectedQuoteItems = qItems.map((it) => ({
|
||||
description: it.description || "",
|
||||
selling_price: Number(it.selling_price) || 0,
|
||||
quantity: Number(it.quantity) || 1,
|
||||
}));
|
||||
// Rule-based scorer — uses company/customer relations and per-line
|
||||
// signal matches. See `scoreOrderVsQuote` for the point budget.
|
||||
const breakdown = scoreOrderVsQuote(
|
||||
{
|
||||
companyId: order.company,
|
||||
customerId: order.customer,
|
||||
items: projectedOrderItems,
|
||||
},
|
||||
{
|
||||
company: q.company,
|
||||
customer: q.customer,
|
||||
items: projectedQuoteItems,
|
||||
}
|
||||
);
|
||||
if (breakdown.confidence >= MATCH_CONFIDENCE_THRESHOLD) {
|
||||
scored.push({
|
||||
quote: q,
|
||||
items: qItems,
|
||||
confidence: breakdown.confidence,
|
||||
breakdown,
|
||||
method: "company_customer_desc_price",
|
||||
});
|
||||
}
|
||||
}
|
||||
// Sort: highest confidence first, then most recently created on tie.
|
||||
// The PB query already returns candidates `-created`, but the local
|
||||
// sort above only sees confidence — without a tie-breaker, an older
|
||||
// quote can win over a fresher one when they score the same. Using
|
||||
// `created` (not `updated`) so a no-op re-save of an old quote can't
|
||||
// bump it past a genuinely newer competitor.
|
||||
scored.sort((a, b) => {
|
||||
if (b.confidence !== a.confidence) return b.confidence - a.confidence;
|
||||
return (b.quote.created || "").localeCompare(a.quote.created || "");
|
||||
});
|
||||
return scored;
|
||||
}
|
||||
|
||||
/**
|
||||
* Run the matcher against an order and return the best suggestion
|
||||
* (or null) plus up to 5 alternatives. Doesn't persist anything —
|
||||
* the caller decides whether to accept the suggestion.
|
||||
/* ============================================================================
|
||||
* Quote matcher — DELETED in Task 13 of the order↔quote linking plan.
|
||||
*
|
||||
* `pb` is optional: when omitted the matcher uses the client-side
|
||||
* singleton from `@/lib/pocketbase` (the normal browser path).
|
||||
* Pass an explicit `pb` to use a server-side or admin-authenticated
|
||||
* instance — useful for server scripts, n8n workflows, and tests
|
||||
* that don't have the browser's auth context.
|
||||
*/
|
||||
export async function findMatchingQuotesForOrder(
|
||||
order: Order,
|
||||
orderItems: OrderItem[],
|
||||
pb?: ReturnType<typeof getPocketBase>
|
||||
): Promise<QuoteMatchResult> {
|
||||
const client = pb ?? getPocketBase();
|
||||
const ref = normRef(order.quote_reference_text || "");
|
||||
|
||||
// 1-4: Exact-key lookups when the operator gave us a reference. This
|
||||
// is a deterministic fast path — we run it before the fuzzy scan
|
||||
// because the operator typed something specific and we should
|
||||
// honor it first when it matches.
|
||||
if (ref) {
|
||||
const exact = await findExactMatch(client, ref);
|
||||
if (exact) {
|
||||
return {
|
||||
best: {
|
||||
quote: exact.quote,
|
||||
items: [], // not pre-fetched — caller can lazy-load if they open it
|
||||
confidence: 1.0,
|
||||
method: exact.method,
|
||||
},
|
||||
alternatives: [],
|
||||
method: exact.method,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// 5: Fuzzy fallback. Runs whether or not the operator supplied a
|
||||
// reference — the rule-based scorer can find matches from company +
|
||||
// customer + items + price alone, without needing a typed
|
||||
// identifier. This is what makes "Re-match" work on a freshly
|
||||
// loaded order that has nothing in `quote_reference_text`.
|
||||
const fuzzy = await findFuzzyMatches(client, order, orderItems);
|
||||
if (fuzzy.length === 0) {
|
||||
return { best: null, alternatives: [], method: "no_match" };
|
||||
}
|
||||
return {
|
||||
best: fuzzy[0],
|
||||
alternatives: fuzzy.slice(1, 6),
|
||||
method: "company_customer_desc_price",
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Persist the matcher's verdict on the order record. Sets
|
||||
* `quote_matching_attempted = true` so the UI can show "we tried but
|
||||
* found nothing" distinctly from "we haven't tried yet".
|
||||
* The browser-side matcher block (findMatchingQuotesForOrder,
|
||||
* applyOrderQuoteMatch, clearOrderQuoteMatch, setOrderQuoteManually +
|
||||
* the private normRef / findExactMatch / findFuzzyMatches helpers
|
||||
* they used to depend on, plus the ScoreBreakdown / LineMatchBreakdown
|
||||
* re-exports from order-quote-match) was deleted. The matcher moved
|
||||
* to the server-side service (`src/lib/server/order-quote-link-service.ts`)
|
||||
* so the route handler, the intake `after()` hook, and the on-demand
|
||||
* re-check can all share one source of truth without needing the
|
||||
* browser's PB singleton in scope.
|
||||
*
|
||||
* If `accept` is true AND a candidate was found, the candidate's
|
||||
* quote is promoted from `potential_quote` to the hard-linked
|
||||
* `quote` field. If false, only the suggestion is recorded.
|
||||
*
|
||||
* `pb` is optional: same convention as `findMatchingQuotesForOrder`.
|
||||
*/
|
||||
export async function applyOrderQuoteMatch(
|
||||
orderId: string,
|
||||
result: QuoteMatchResult,
|
||||
accept: boolean,
|
||||
pb?: ReturnType<typeof getPocketBase>
|
||||
): Promise<void> {
|
||||
const client = pb ?? getPocketBase();
|
||||
const patch: Record<string, unknown> = {
|
||||
quote_matching_attempted: true,
|
||||
quote_match_method: result.method,
|
||||
};
|
||||
if (result.best) {
|
||||
patch.potential_quote = result.best.quote.id;
|
||||
patch.potential_quote_confidence = result.best.confidence;
|
||||
if (accept) {
|
||||
patch.quote = result.best.quote.id;
|
||||
}
|
||||
} else {
|
||||
// No suggestion — clear any stale soft-match.
|
||||
patch.potential_quote = null;
|
||||
patch.potential_quote_confidence = 0;
|
||||
}
|
||||
await client.collection("orders").update(orderId, patch);
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset the matching verdict on an order so the operator can retry.
|
||||
* Same `pb` convention as the other match helpers.
|
||||
*/
|
||||
export async function clearOrderQuoteMatch(
|
||||
orderId: string,
|
||||
pb?: ReturnType<typeof getPocketBase>
|
||||
): Promise<void> {
|
||||
const client = pb ?? getPocketBase();
|
||||
await client.collection("orders").update(orderId, {
|
||||
quote_matching_attempted: false,
|
||||
quote_match_method: null,
|
||||
potential_quote: null,
|
||||
potential_quote_confidence: 0,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Manually hard-link an order to a quote. Used by the QuoteMatchCard
|
||||
* "Browse quotes…" affordance when the operator picks a quote from
|
||||
* the search dropdown — there's no reference text to match against,
|
||||
* and we don't want to fake a QuoteMatchResult just to round-trip
|
||||
* through `applyOrderQuoteMatch`.
|
||||
*
|
||||
* Sets the same fields as an accepted auto-match (`quote`,
|
||||
* `quote_matching_attempted`, `quote_match_method = "manual"`) but
|
||||
* does NOT populate `potential_quote` — manual links bypass the
|
||||
* suggestion stage and become the hard link directly.
|
||||
*
|
||||
* `pb` is optional for symmetry with the other match helpers — pass
|
||||
* it in server-side to keep one auth path.
|
||||
*/
|
||||
export async function setOrderQuoteManually(
|
||||
orderId: string,
|
||||
quoteId: string,
|
||||
pb?: ReturnType<typeof getPocketBase>
|
||||
): Promise<void> {
|
||||
const client = pb ?? getPocketBase();
|
||||
await client.collection("orders").update(orderId, {
|
||||
quote: quoteId,
|
||||
quote_matching_attempted: true,
|
||||
quote_match_method: "manual",
|
||||
potential_quote: null,
|
||||
potential_quote_confidence: 0,
|
||||
});
|
||||
}
|
||||
|
||||
// Re-export the pure scoring helpers so unit tests can import them
|
||||
// without poking into the PB layer.
|
||||
export {
|
||||
tokenize,
|
||||
jaccard,
|
||||
priceScore,
|
||||
scoreCandidate,
|
||||
scoreOrderVsQuote,
|
||||
RULE_POINTS,
|
||||
MATCH_CONFIDENCE_THRESHOLD,
|
||||
} from "./server/order-quote-match";
|
||||
export type { ScoreBreakdown, LineMatchBreakdown } from "./server/order-quote-match";
|
||||
* The page migrated to fetch against /api/v1/orders/{id}/quote-match
|
||||
* and /api/v1/orders/{id}/quote as part of the same task. Tests that
|
||||
* previously imported these helpers from `@/lib/pb-helpers` now
|
||||
* import directly from `@/lib/server/order-quote-match` (pure) or
|
||||
* `@/lib/server/order-quote-link-service` (PB-backed).
|
||||
* ========================================================================== */
|
||||
|
||||
/**
|
||||
* Fetch all attachments saved against a quote. Used by the
|
||||
|
|
|
|||
Loading…
Reference in a new issue