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;