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 <noreply@anthropic.com>
This commit is contained in:
parent
4e0f7a5c5c
commit
dd3ab2ad9a
4 changed files with 71 additions and 0 deletions
|
|
@ -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" }) });
|
||||
|
|
|
|||
|
|
@ -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<string>(["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<string, unknown> = {
|
||||
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<string, unknown>)[f];
|
||||
if (v !== undefined && v !== null && v !== "") {
|
||||
clone[f] = v;
|
||||
|
|
|
|||
|
|
@ -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" }) });
|
||||
|
|
|
|||
|
|
@ -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<string>(["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<string, unknown> = {
|
||||
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<string, unknown>)[f];
|
||||
if (v !== undefined && v !== null && v !== "") {
|
||||
clone[f] = v;
|
||||
|
|
|
|||
Loading…
Reference in a new issue