From dd3ab2ad9ac6719f3ad998c3f056546a6a14e11f Mon Sep 17 00:00:00 2001 From: Dave Date: Fri, 16 Oct 2026 23:30:22 +0200 Subject: [PATCH] fix(quote-assistant): clone/save-as-template don't copy unreviewed AI prices A source line whose AI fill is still pending review (isAiFillPending) is copied without cost, selling price, markup or supplier, so the purple draft price can't resurface as an ordinary price on the copy. Co-Authored-By: Claude Opus 5.5 --- .../api/v1/quotes/[id]/clone/route.test.ts | 27 ++++++++++++++++++ src/app/api/v1/quotes/[id]/clone/route.ts | 8 ++++++ .../[id]/save-as-template/route.test.ts | 28 +++++++++++++++++++ .../v1/quotes/[id]/save-as-template/route.ts | 8 ++++++ 4 files changed, 71 insertions(+) diff --git a/src/app/api/v1/quotes/[id]/clone/route.test.ts b/src/app/api/v1/quotes/[id]/clone/route.test.ts index 6174e50..731fc02 100644 --- a/src/app/api/v1/quotes/[id]/clone/route.test.ts +++ b/src/app/api/v1/quotes/[id]/clone/route.test.ts @@ -150,6 +150,33 @@ describe("POST /api/v1/quotes/[id]/clone", () => { expect(quoteCreate.validity_days).toBeNull(); }); + it("copies an unreviewed AI-filled line unpriced (no cost, selling, markup or supplier)", async () => { + mockGetOne.mockResolvedValueOnce({ id: "q-real", is_template: false, customer: "c" }); + mockGetFullList.mockResolvedValueOnce([ + { + id: "qi1", description: "AI bolt", quantity: 4, notes: "zinc", supplier: "sup1", + cost_price: 10, selling_price: 15, markup: 50, pricing_source: "ai_pos", + ai_fill: { status: "filled", reviewed_at: "" }, + }, + { + id: "qi2", description: "Reviewed bolt", quantity: 1, supplier: "sup2", + cost_price: 20, selling_price: 30, markup: 50, pricing_source: "ai_pos", + ai_fill: { status: "filled", reviewed_at: "2026-10-16T10:00:00Z" }, + }, + ]); + mockCreate + .mockResolvedValueOnce({ id: "q-new", quote_number: "X" }) + .mockResolvedValueOnce({ id: "qi-new1" }) + .mockResolvedValueOnce({ id: "qi-new2" }); + + await cloneQuote(makeReq("q-real"), { params: Promise.resolve({ id: "q-real" }) }); + + const pending = mockCreate.mock.calls[1][0]; + expect(pending).toEqual({ quote: "q-new", description: "AI bolt", quantity: 4, notes: "zinc" }); + const reviewed = mockCreate.mock.calls[2][0]; + expect(reviewed).toMatchObject({ cost_price: 20, selling_price: 30, markup: 50, supplier: "sup2" }); + }); + it("returns 500 if PB throws", async () => { mockGetOne.mockRejectedValueOnce(new Error("PB down")); const res = await cloneQuote(makeReq("q-real"), { params: Promise.resolve({ id: "q-real" }) }); diff --git a/src/app/api/v1/quotes/[id]/clone/route.ts b/src/app/api/v1/quotes/[id]/clone/route.ts index f896446..57b1b0a 100644 --- a/src/app/api/v1/quotes/[id]/clone/route.ts +++ b/src/app/api/v1/quotes/[id]/clone/route.ts @@ -1,6 +1,7 @@ import { NextRequest, NextResponse } from "next/server"; import { getAdminPocketBase } from "@/lib/server/pocketbase-admin"; import { generateQuoteNumberServer } from "@/lib/server/quotes-service"; +import { isAiFillPending } from "@/lib/quote-assistant-review"; import type { Quote, QuoteItem } from "@/lib/types"; /** @@ -53,6 +54,9 @@ const CLONE_FIELDS_TO_COPY = [ "quote_reference", ] as const; +// Left off a copied line whose AI fill is still unreviewed. +const PRICE_FIELDS = new Set(["cost_price", "selling_price", "markup", "supplier"]); + export async function POST( _req: NextRequest, { params }: { params: Promise<{ id: string }> } @@ -103,6 +107,7 @@ export async function POST( const clone: Record = { quote: created.id, }; + const aiDraft = isAiFillPending(it); // Carry over descriptive + commercial fields. Skip per-item // workflow flags — the clone starts fresh. for (const f of [ @@ -114,6 +119,9 @@ export async function POST( "notes", "markup", ] as const) { + // An AI fill nobody has reviewed is a draft price: copy the line + // unpriced (like a fresh line) so the copy can't hide it. + if (aiDraft && PRICE_FIELDS.has(f)) continue; const v = (it as unknown as Record)[f]; if (v !== undefined && v !== null && v !== "") { clone[f] = v; diff --git a/src/app/api/v1/quotes/[id]/save-as-template/route.test.ts b/src/app/api/v1/quotes/[id]/save-as-template/route.test.ts index d452c78..a40e207 100644 --- a/src/app/api/v1/quotes/[id]/save-as-template/route.test.ts +++ b/src/app/api/v1/quotes/[id]/save-as-template/route.test.ts @@ -129,6 +129,34 @@ describe("POST /api/v1/quotes/[id]/save-as-template", () => { expect(clonedItem.included_in_quote).toBeUndefined(); }); + it("copies an unreviewed AI-filled line unpriced (no cost, selling, markup or supplier)", async () => { + mockGetOne.mockResolvedValueOnce({ id: "q-real", is_template: false, customer: "c" }); + mockGetFullList.mockResolvedValueOnce([ + { + id: "qi1", description: "AI bolt", quantity: 4, notes: "zinc", supplier: "sup1", + cost_price: 10, selling_price: 15, markup: 50, pricing_source: "ai_pos", + ai_fill: { status: "filled", reviewed_at: "" }, + }, + { + id: "qi2", description: "Manual bolt", quantity: 1, supplier: "sup2", + cost_price: 20, selling_price: 30, markup: 50, pricing_source: "manual", + ai_fill: { status: "filled", reviewed_at: "" }, + }, + ]); + mockCreate + .mockResolvedValueOnce({ id: "q-new", quote_number: "X" }) + .mockResolvedValueOnce({ id: "qi-new1" }) + .mockResolvedValueOnce({ id: "qi-new2" }); + + await saveAsTemplate(makeReq("q-real"), { params: Promise.resolve({ id: "q-real" }) }); + + const pending = mockCreate.mock.calls[1][0]; + expect(pending).toEqual({ quote: "q-new", description: "AI bolt", quantity: 4, notes: "zinc" }); + // A rep overrode the AI (pricing_source manual): their prices are copied. + const manual = mockCreate.mock.calls[2][0]; + expect(manual).toMatchObject({ cost_price: 20, selling_price: 30, markup: 50, supplier: "sup2" }); + }); + it("returns 500 if PB throws", async () => { mockGetOne.mockRejectedValueOnce(new Error("PB down")); const res = await saveAsTemplate(makeReq("q-real"), { params: Promise.resolve({ id: "q-real" }) }); diff --git a/src/app/api/v1/quotes/[id]/save-as-template/route.ts b/src/app/api/v1/quotes/[id]/save-as-template/route.ts index 7176fb3..c8b1e78 100644 --- a/src/app/api/v1/quotes/[id]/save-as-template/route.ts +++ b/src/app/api/v1/quotes/[id]/save-as-template/route.ts @@ -1,6 +1,7 @@ import { NextRequest, NextResponse } from "next/server"; import { getAdminPocketBase } from "@/lib/server/pocketbase-admin"; import { generateQuoteNumberServer } from "@/lib/server/quotes-service"; +import { isAiFillPending } from "@/lib/quote-assistant-review"; import type { Quote, QuoteItem } from "@/lib/types"; /** @@ -47,6 +48,9 @@ const TEMPLATE_FIELDS_TO_COPY = [ "quote_reference", ] as const; +// Left off a copied line whose AI fill is still unreviewed. +const PRICE_FIELDS = new Set(["cost_price", "selling_price", "markup", "supplier"]); + export async function POST( _req: NextRequest, { params }: { params: Promise<{ id: string }> } @@ -89,6 +93,7 @@ export async function POST( const clone: Record = { quote: created.id, }; + const aiDraft = isAiFillPending(it); // Carry over the descriptive + commercial fields. Skip per-item // flags that are workflow-state (is_processed, included_in_quote) // — the template starts fresh. @@ -101,6 +106,9 @@ export async function POST( "notes", "markup", ] as const) { + // An AI fill nobody has reviewed is a draft price: copy the line + // unpriced (like a fresh line) so the copy can't hide it. + if (aiDraft && PRICE_FIELDS.has(f)) continue; const v = (it as unknown as Record)[f]; if (v !== undefined && v !== null && v !== "") { clone[f] = v;