feat(quote-assistant): tick/run/counts routes, MCP tool, re-price guard

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Dave 2026-10-16 19:38:38 +02:00
parent bb06165318
commit d34be52a66
11 changed files with 465 additions and 0 deletions

View file

@ -278,6 +278,7 @@ const toolNames = body.result.tools.map((t: { name: string }) => t.name);
"quotes.submit",
"quotes.re_price",
"quotes.next_number",
"quotes.assistant_fill",
"quotes.lookup",
"attachments.upload_for_quote",
// Quote attachments — list + PDF retrieval (added 2026-08-05)

View file

@ -391,6 +391,14 @@ const TOOL_HINTS: Record<string, { hint: string; nextActions: string[] }> = {
"If 500, check server logs for the PDFKit stack trace",
],
},
"quotes.assistant_fill": {
hint: "The run reports problems in `error` instead of throwing: \"quote is not open…\" (only draft/pending quotes are filled), \"AI is disabled in settings\", or \"AI call failed…\" (the affected lines are marked `error` and retry automatically after 1 hour). Deterministic ones should not be retried.",
nextActions: [
"Check the quote status with quotes.get before retrying",
"If the AI is disabled, ask the operator to enable it in /settings/ai",
"Filled lines stay purple until a rep reviews them — do not mark them done or send the quote",
],
},
"quotes.re_price": {
hint: "Re-price can fail with 422 (no items left to price) or 500 (PB write).",
nextActions: [
@ -1576,6 +1584,18 @@ const tools = [
"Reserve the next quote number (`Q{yy}{mm}{NNN}`). Atomic counter; gaps are possible if the caller never persists the resulting draft.",
inputSchema: { type: "object", properties: {} },
},
{
name: "quotes.assistant_fill",
description:
"Run the AI quote assistant on one quote now: fills unpriced lines from matching POS items (purple, unreviewed) and notes the closest items for unmatched lines. Never marks lines done or sends anything.",
inputSchema: {
type: "object",
required: ["quote_id"],
properties: {
quote_id: { type: "string", description: "quote id" },
},
},
},
{
name: "quotes.lookup",
description:
@ -5327,6 +5347,22 @@ async function handleMcpPost(req: NextRequest) {
return jsonRpcOk(body.id, { quote_number });
}
if (toolName === "quotes.assistant_fill") {
const quoteId = typeof args.quote_id === "string" ? args.quote_id.trim() : "";
if (!quoteId) throw new Error("quote_id is required.");
const { fillQuote } = await import("@/lib/server/quote-assistant-service");
const result = await fillQuote(quoteId, { force: true, actorName: "MCP" });
if (result.error) {
const hintEntry = TOOL_HINTS["quotes.assistant_fill"];
return jsonRpcOk(body.id, {
...result,
hint: hintEntry?.hint,
nextActions: hintEntry?.nextActions,
});
}
return jsonRpcOk(body.id, result);
}
if (toolName === "quotes.lookup") {
const params = new URLSearchParams();
if (args.q) params.set("q", String(args.q));

View file

@ -0,0 +1,89 @@
import { describe, it, expect, beforeEach, vi } from "vitest";
import { NextRequest } from "next/server";
const fillQuote = vi.fn();
vi.mock("@/lib/server/quote-assistant-service", () => ({
fillQuote: (...a: unknown[]) => fillQuote(...a),
}));
const getBrowserSessionUser = vi.fn();
vi.mock("@/lib/server/browser-session", () => ({
getBrowserSessionUser: (...a: unknown[]) => getBrowserSessionUser(...a),
}));
import { POST } from "./route";
function makeReq() {
return new NextRequest("http://localhost/api/v1/quotes/q1/assistant/run", {
method: "POST",
headers: { authorization: "Bearer tok" },
});
}
const ctx = { params: Promise.resolve({ id: "q1" }) };
beforeEach(() => {
vi.clearAllMocks();
getBrowserSessionUser.mockResolvedValue({ id: "u1", user_type: null, name: "Sam" });
});
describe("POST /api/v1/quotes/[id]/assistant/run", () => {
it("401s without a valid session", async () => {
getBrowserSessionUser.mockRejectedValue(
Object.assign(new Error("Not authenticated."), { status: 401 }),
);
const res = await POST(makeReq(), ctx);
expect(res.status).toBe(401);
expect(fillQuote).not.toHaveBeenCalled();
});
it("runs fillQuote forced, attributed to the user", async () => {
const result = { quoteId: "q1", filled: 2, unmatched: 1, skipped: 0, error: "" };
fillQuote.mockResolvedValue(result);
const res = await POST(makeReq(), ctx);
expect(fillQuote).toHaveBeenCalledWith("q1", { force: true, actorName: "Sam" });
expect(res.status).toBe(200);
expect(await res.json()).toEqual(result);
});
it("falls back to 'a user' when the user has no name", async () => {
getBrowserSessionUser.mockResolvedValue({ id: "u1", user_type: null, name: "" });
fillQuote.mockResolvedValue({ quoteId: "q1", filled: 0, unmatched: 0, skipped: 0, error: "" });
await POST(makeReq(), ctx);
expect(fillQuote).toHaveBeenCalledWith("q1", { force: true, actorName: "a user" });
});
it("409s when the quote is not open", async () => {
fillQuote.mockResolvedValue({
quoteId: "q1", filled: 0, unmatched: 0, skipped: 0,
error: "quote is not open (status: sent)",
});
const res = await POST(makeReq(), ctx);
expect(res.status).toBe(409);
expect(await res.json()).toEqual({ error: "quote is not open (status: sent)" });
});
it("409s when the AI is disabled", async () => {
fillQuote.mockResolvedValue({
quoteId: "q1", filled: 0, unmatched: 0, skipped: 0,
error: "AI is disabled in settings",
});
const res = await POST(makeReq(), ctx);
expect(res.status).toBe(409);
expect(await res.json()).toEqual({ error: "AI is disabled in settings" });
});
it("returns 200 with the result when the AI call failed", async () => {
const result = { quoteId: "q1", filled: 0, unmatched: 0, skipped: 0, error: "AI call failed: timeout" };
fillQuote.mockResolvedValue(result);
const res = await POST(makeReq(), ctx);
expect(res.status).toBe(200);
expect(await res.json()).toEqual(result);
});
it("500s when fillQuote throws unexpectedly", async () => {
fillQuote.mockRejectedValue(new Error("pb down"));
const res = await POST(makeReq(), ctx);
expect(res.status).toBe(500);
expect(await res.json()).toEqual({ error: "pb down" });
});
});

View file

@ -0,0 +1,43 @@
import { NextRequest, NextResponse } from "next/server";
import { getBrowserSessionUser } from "@/lib/server/browser-session";
import { fillQuote } from "@/lib/server/quote-assistant-service";
/**
* POST /api/v1/quotes/[id]/assistant/run
*
* Manual "run the AI quote assistant now" for one quote (the button on the
* Process page). Forces a run past the retry/backoff rules, attributed to
* the signed-in user in the audit trail.
*
* Auth: browser session (PB token forwarded in `Authorization`).
*
* Response: the `FillQuoteResult`. 409 when the quote isn't open or the AI
* is switched off. An AI-call failure is not an HTTP error: it comes back
* as 200 with `error` set and the failed lines marked on the quote.
*/
export const runtime = "nodejs";
export const dynamic = "force-dynamic";
export async function POST(
req: NextRequest,
{ params }: { params: Promise<{ id: string }> },
) {
try {
const user = await getBrowserSessionUser(req);
const { id } = await params;
const result = await fillQuote(id, {
force: true,
actorName: user.name || "a user",
});
if (result.error.startsWith("quote is not open") || result.error === "AI is disabled in settings") {
return NextResponse.json({ error: result.error }, { status: 409 });
}
return NextResponse.json(result);
} catch (err) {
const message = err instanceof Error ? err.message : "Assistant run failed";
const status = (err as Error & { status?: number }).status || 500;
return NextResponse.json({ error: message }, { status });
}
}

View file

@ -0,0 +1,86 @@
import { describe, it, expect, beforeEach, vi } from "vitest";
import { NextRequest } from "next/server";
const findFirstPriceListBySku = vi.fn();
vi.mock("@/lib/server/price-list-match", () => ({
findFirstPriceListBySku: (...a: unknown[]) => findFirstPriceListBySku(...a),
}));
const update = vi.fn();
const getFullList = vi.fn();
const suppliersGetList = vi.fn();
vi.mock("@/lib/server/pocketbase-admin", () => ({
getAdminPocketBase: vi.fn().mockResolvedValue({
collection: (name: string) => {
if (name === "quotes") return { getOne: vi.fn().mockResolvedValue({ id: "q1" }) };
if (name === "quote_items") return { getFullList, update };
if (name === "suppliers") return { getList: suppliersGetList };
throw new Error(`unexpected collection ${name}`);
},
}),
}));
import { POST } from "./route";
const ctx = { params: Promise.resolve({ id: "q1" }) };
function makeReq() {
return new NextRequest("http://localhost/api/v1/quotes/q1/re-price", {
method: "POST",
headers: { authorization: "Bearer tok" },
});
}
const aiPending = {
id: "i1",
sku: "X1",
description: "Widget",
pricing_source: "ai_pos",
ai_fill: { status: "filled", stock_item_id: "s1" },
};
beforeEach(() => {
vi.clearAllMocks();
// The route's session check validates the token against PB.
vi.stubGlobal("fetch", vi.fn().mockResolvedValue({ ok: true, json: async () => ({}) }));
suppliersGetList.mockResolvedValue({ items: [] });
findFirstPriceListBySku.mockResolvedValue({
id: "pl1",
sku: "X1",
description: "Catalogue widget",
cost_price: 10,
selling_price: 15,
supplier: "Acme",
});
});
describe("POST /api/v1/quotes/[id]/re-price — AI line guard", () => {
it("skips an unreviewed AI line and never looks it up", async () => {
getFullList.mockResolvedValue([aiPending]);
const res = await POST(makeReq(), ctx);
const body = await res.json();
expect(res.status).toBe(200);
expect(body.perItem).toEqual([{ itemId: "i1", sku: "X1", outcome: "skipped" }]);
expect(body.skipped).toBe(1);
expect(body.matched).toBe(0);
expect(findFirstPriceListBySku).not.toHaveBeenCalled();
expect(update).not.toHaveBeenCalled();
});
it("still re-prices an AI line once it has been reviewed", async () => {
getFullList.mockResolvedValue([
{ ...aiPending, ai_fill: { ...aiPending.ai_fill, reviewed_at: "2026-10-16T10:00:00Z" } },
]);
const res = await POST(makeReq(), ctx);
const body = await res.json();
expect(findFirstPriceListBySku).toHaveBeenCalledWith("X1");
expect(body.matched).toBe(1);
expect(update).toHaveBeenCalledTimes(1);
});
it("re-prices a normal SKU line", async () => {
getFullList.mockResolvedValue([{ id: "i2", sku: "X1", description: "Widget" }]);
const res = await POST(makeReq(), ctx);
expect((await res.json()).matched).toBe(1);
expect(findFirstPriceListBySku).toHaveBeenCalledWith("X1");
});
});

View file

@ -4,6 +4,8 @@ import {
type PriceListMatch,
} from "@/lib/server/price-list-match";
import { getAdminPocketBase } from "@/lib/server/pocketbase-admin";
import { isAiFillPending } from "@/lib/quote-assistant-review";
import type { QuoteItemAiFill } from "@/lib/types";
/**
* POST /api/v1/quotes/[id]/re-price
@ -161,6 +163,8 @@ export async function POST(
selling_price?: number;
markup?: number;
supplier?: string;
pricing_source: string;
ai_fill?: QuoteItemAiFill | null;
}>({
filter: `quote = "${quoteId.replace(/"/g, '\\"')}"`,
sort: "+created",
@ -173,6 +177,13 @@ export async function POST(
for (const item of items) {
const sku = (item.sku ?? "").trim();
// Never overwrite an AI-filled line nobody has reviewed yet:
// the rep's review (approve / edit) comes first.
if (isAiFillPending(item)) {
skipped++;
perItem.push({ itemId: item.id, sku, outcome: "skipped" });
continue;
}
if (!sku) {
skipped++;
perItem.push({ itemId: item.id, sku: "", outcome: "skipped" });

View file

@ -0,0 +1,65 @@
import { describe, it, expect, beforeEach, vi } from "vitest";
import { NextRequest } from "next/server";
const getAssistantCounts = vi.fn();
vi.mock("@/lib/server/quote-assistant-service", () => ({
getAssistantCounts: (...a: unknown[]) => getAssistantCounts(...a),
}));
const getBrowserSessionUser = vi.fn();
vi.mock("@/lib/server/browser-session", () => ({
getBrowserSessionUser: (...a: unknown[]) => getBrowserSessionUser(...a),
}));
import { GET } from "./route";
function makeReq(qs = "") {
return new NextRequest(`http://localhost/api/v1/quotes/assistant/counts${qs}`, {
headers: { authorization: "Bearer tok" },
});
}
beforeEach(() => {
vi.clearAllMocks();
getBrowserSessionUser.mockResolvedValue({ id: "u1", user_type: null, name: "Sam" });
});
describe("GET /api/v1/quotes/assistant/counts", () => {
it("401s without a valid session", async () => {
getBrowserSessionUser.mockRejectedValue(
Object.assign(new Error("Not authenticated."), { status: 401 }),
);
const res = await GET(makeReq("?ids=a"));
expect(res.status).toBe(401);
expect(getAssistantCounts).not.toHaveBeenCalled();
});
it("400s when ids is missing or empty", async () => {
expect((await GET(makeReq())).status).toBe(400);
expect((await GET(makeReq("?ids="))).status).toBe(400);
expect((await GET(makeReq("?ids=,"))).status).toBe(400);
expect(getAssistantCounts).not.toHaveBeenCalled();
});
it("400s when more than 500 ids are passed", async () => {
const ids = Array.from({ length: 501 }, (_, i) => `id${i}`).join(",");
const res = await GET(makeReq(`?ids=${ids}`));
expect(res.status).toBe(400);
expect(getAssistantCounts).not.toHaveBeenCalled();
});
it("passes the split, trimmed ids to the service", async () => {
getAssistantCounts.mockResolvedValue({ a: { pending: 1, total: 3 } });
const res = await GET(makeReq("?ids=a,%20b,c"));
expect(getAssistantCounts).toHaveBeenCalledWith(["a", "b", "c"]);
expect(res.status).toBe(200);
expect(await res.json()).toEqual({ a: { pending: 1, total: 3 } });
});
it("accepts exactly 500 ids", async () => {
getAssistantCounts.mockResolvedValue({});
const ids = Array.from({ length: 500 }, (_, i) => `id${i}`).join(",");
const res = await GET(makeReq(`?ids=${ids}`));
expect(res.status).toBe(200);
});
});

View file

@ -0,0 +1,45 @@
import { NextRequest, NextResponse } from "next/server";
import { getBrowserSessionUser } from "@/lib/server/browser-session";
import { getAssistantCounts } from "@/lib/server/quote-assistant-service";
/**
* GET /api/v1/quotes/assistant/counts?ids=a,b,c
*
* Per-quote AI-fill counts for the Quotes board badge:
* { [quoteId]: { pending, total } }
* Quotes with no lines are absent from the result.
*
* Auth: browser session (PB token forwarded in `Authorization`).
* 400 when `ids` is missing/empty or has more than 500 entries.
*/
export const runtime = "nodejs";
export const dynamic = "force-dynamic";
const MAX_IDS = 500;
export async function GET(req: NextRequest) {
try {
await getBrowserSessionUser(req);
const raw = new URL(req.url).searchParams.get("ids") ?? "";
const ids = raw
.split(",")
.map((s) => s.trim())
.filter(Boolean);
if (ids.length === 0) {
return NextResponse.json({ error: "ids is required" }, { status: 400 });
}
if (ids.length > MAX_IDS) {
return NextResponse.json(
{ error: `ids may contain at most ${MAX_IDS} entries` },
{ status: 400 },
);
}
return NextResponse.json(await getAssistantCounts(ids));
} catch (err) {
const message = err instanceof Error ? err.message : "Counts failed";
const status = (err as Error & { status?: number }).status || 500;
return NextResponse.json({ error: message }, { status });
}
}

View file

@ -0,0 +1,55 @@
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
import { NextRequest } from "next/server";
const runAssistantTick = vi.fn();
vi.mock("@/lib/server/quote-assistant-service", () => ({
runAssistantTick: (...a: unknown[]) => runAssistantTick(...a),
}));
import { POST } from "./route";
function makeReq(token?: string) {
return new NextRequest("http://localhost/api/v1/quotes/assistant/tick", {
method: "POST",
headers: token ? { authorization: `Bearer ${token}` } : {},
});
}
let prevKey: string | undefined;
beforeEach(() => {
vi.clearAllMocks();
prevKey = process.env.AGENT_API_KEY;
process.env.AGENT_API_KEY = "good-key";
});
afterEach(() => {
if (prevKey === undefined) delete process.env.AGENT_API_KEY;
else process.env.AGENT_API_KEY = prevKey;
});
describe("POST /api/v1/quotes/assistant/tick", () => {
it("401s without a key", async () => {
const res = await POST(makeReq());
expect(res.status).toBe(401);
expect(runAssistantTick).not.toHaveBeenCalled();
});
it("401s with a wrong key", async () => {
const res = await POST(makeReq("bad-key"));
expect(res.status).toBe(401);
expect(runAssistantTick).not.toHaveBeenCalled();
});
it("returns the tick result for a good key", async () => {
runAssistantTick.mockResolvedValue({ ran: false, reason: "not_due" });
const res = await POST(makeReq("good-key"));
expect(res.status).toBe(200);
expect(await res.json()).toEqual({ ran: false, reason: "not_due" });
});
it("500s when the tick throws", async () => {
runAssistantTick.mockRejectedValue(new Error("boom"));
const res = await POST(makeReq("good-key"));
expect(res.status).toBe(500);
expect(await res.json()).toEqual({ error: "boom" });
});
});

View file

@ -0,0 +1,30 @@
import { NextRequest, NextResponse } from "next/server";
import { assertAgentAuth } from "@/lib/server/agent-auth";
import { runAssistantTick } from "@/lib/server/quote-assistant-service";
/**
* POST /api/v1/quotes/assistant/tick
*
* Scheduled entry point for the AI quote assistant. n8n calls this every
* 5 minutes; the service itself decides whether anything is due (AI and
* the assistant switched on, configured interval elapsed).
*
* Auth: `assertAgentAuth` (Bearer `AGENT_API_KEY`).
*
* Response: the `runAssistantTick` result, e.g.
* { ran: false, reason: "disabled" | "not_due" } or { ran: true, summary }
*/
export const runtime = "nodejs";
export const dynamic = "force-dynamic";
export async function POST(req: NextRequest) {
try {
assertAgentAuth(req);
const result = await runAssistantTick();
return NextResponse.json(result);
} catch (err) {
const message = err instanceof Error ? err.message : "Assistant tick failed";
const status = (err as Error & { status?: number }).status || 500;
return NextResponse.json({ error: message }, { status });
}
}

View file

@ -94,6 +94,10 @@ describe("classifyTool", () => {
expect(classifyTool("stock_purchases.next_number")).toBe("write");
});
it("treats quotes.assistant_fill as write (AI-spending, writes lines)", () => {
expect(classifyTool("quotes.assistant_fill")).toBe("write");
});
it("default-denies (write) unknown tools", () => {
expect(classifyTool("brand_new.tool")).toBe("write");
});