From 1d6f7538d8a6296af9b1495ad2d3d0b0d1929279 Mon Sep 17 00:00:00 2001 From: Dave Date: Wed, 26 Aug 2026 23:31:41 +0200 Subject: [PATCH] feat(quotes): skip pricing checks in diagnosePdfReadiness when is_no_quote Keeps the 'no supplier' and 'not processed' checks active so the function still flags legitimate data issues, but lets zero-priced items through when the operator has marked the quote as a decline. --- src/lib/server/quotes-service.test.ts | 156 ++++++++++++++++++++++++++ src/lib/server/quotes-service.ts | 14 ++- 2 files changed, 167 insertions(+), 3 deletions(-) diff --git a/src/lib/server/quotes-service.test.ts b/src/lib/server/quotes-service.test.ts index a89a97c..7dd442e 100644 --- a/src/lib/server/quotes-service.test.ts +++ b/src/lib/server/quotes-service.test.ts @@ -1136,6 +1136,162 @@ describe("sendQuoteEmail", () => { }); }); +/** + * diagnosePdfReadiness is private to quotes-service.ts, but it's + * reachable through sendQuoteEmail when the quote has no PDF. We use + * that path to test the per-line readiness logic — the goal of these + * cases is to verify that an `is_no_quote` quote can be sent even + * though its items have zero prices / aren't processed, while the + * "no supplier" gate stays active (a real data-quality issue even + * on a decline-quote). + */ +describe("diagnosePdfReadiness with is_no_quote", () => { + async function seedForReadinessCheck(opts: { + isNoQuote: boolean; + items: Array<{ + description: string; + cost_price?: number; + selling_price?: number; + is_processed?: boolean; + supplier?: string; + }>; + }) { + const customer = await pbMock.collection("customers").create({ + customer: "Acme Mining", + email: "buyer@acme.co.za", + alternative_email: "", + buyer_email: "", + number: "", + company: "Acme", + dateadded: "", + lastvisiteddate: "", + rep: "", + coordinates: "", + description: "", + }); + const q = await pbMock.collection("quotes").create({ + quote_number: "Q-2026-06-NOQ", + customer: customer.id, + status: "approval_pending", + is_no_quote: opts.isNoQuote, + notes: "", + email_sender: "", + email_subject: "", + email_body: "", + has_attachments: false, + company_letterhead: "CNL", + }); + // No PDF — force the diagnosePdfReadiness path inside sendQuoteEmail. + for (const it of opts.items) { + await pbMock.collection("quote_items").create({ + quote: q.id, + quote_number: "Q-2026-06-NOQ", + description: it.description, + quantity: 1, + cost_price: it.cost_price ?? 0, + selling_price: it.selling_price ?? 0, + markup: 0, + supplier: it.supplier ?? "", + is_processed: it.is_processed ?? false, + sku: "", + unit: "EA", + notes: "", + pricing_source: "manual", + }); + } + return { quote: q, customer }; + } + + it("returns no items_not_ready issue when is_no_quote is true and zero prices", async () => { + const { quote } = await seedForReadinessCheck({ + isNoQuote: true, + items: [ + { + description: "M16 bolt", + cost_price: 0, + selling_price: 0, + is_processed: false, + supplier: "sup-acme", + }, + ], + }); + const result = await sendQuoteEmail(quote.id, { + cc: "", + bcc: "", + subject: "", + message: "", + attachAuthLetters: false, + selectedAttachmentIds: [], + }); + // With the relaxation, the per-line pricing/processed checks are + // skipped — and the supplier field is set so no other issue fires. + // The diagnosePdfReadiness function falls through to "no_pdf". + expect(result.failure?.code).not.toBe("items_not_ready"); + expect(result.failure?.code).toBe("no_pdf"); + }); + + it("still flags no supplier when is_no_quote is true", async () => { + const { quote } = await seedForReadinessCheck({ + isNoQuote: true, + items: [ + { + description: "M16 bolt", + cost_price: 0, + selling_price: 0, + is_processed: false, + // supplier intentionally blank — must still flag. + }, + ], + }); + const result = await sendQuoteEmail(quote.id, { + cc: "", + bcc: "", + subject: "", + message: "", + attachAuthLetters: false, + selectedAttachmentIds: [], + }); + expect(result.failure?.code).toBe("items_not_ready"); + expect(result.failure?.details?.issues?.[0]?.missing).toContain( + "no supplier" + ); + // And the pricing/processed checks must NOT appear in the missing list + // (since the operator marked this as a decline). + const missing = result.failure?.details?.issues?.[0]?.missing ?? []; + expect(missing).not.toContain("cost price"); + expect(missing).not.toContain("selling price"); + expect(missing).not.toContain("not processed"); + }); + + it("still flags items_not_ready when is_no_quote is false and zero prices (regression)", async () => { + const { quote } = await seedForReadinessCheck({ + isNoQuote: false, + items: [ + { + description: "M16 bolt", + cost_price: 0, + selling_price: 0, + is_processed: false, + supplier: "sup-acme", + }, + ], + }); + const result = await sendQuoteEmail(quote.id, { + cc: "", + bcc: "", + subject: "", + message: "", + attachAuthLetters: false, + selectedAttachmentIds: [], + }); + expect(result.failure?.code).toBe("items_not_ready"); + const missing = result.failure?.details?.issues?.[0]?.missing ?? []; + expect(missing).toContain("not processed"); + expect(missing).toContain("cost price"); + expect(missing).toContain("selling price"); + }); +}); + describe("retryQuoteEmail", () => { it("flips a mail_failed quote back to sending and clears error", async () => { const { quote } = await seedReadyQuote({ status: "mail_failed" }); diff --git a/src/lib/server/quotes-service.ts b/src/lib/server/quotes-service.ts index 51b9e29..a14d65b 100644 --- a/src/lib/server/quotes-service.ts +++ b/src/lib/server/quotes-service.ts @@ -1832,10 +1832,18 @@ async function diagnosePdfReadiness( }> = []; for (const it of allItems) { const missing: string[] = []; - if (!it.is_processed) missing.push("not processed"); + // Pricing / processed checks are skipped when the operator has + // marked this quote as a decline (`is_no_quote`) — they explicitly + // don't want a priced PDF. The supplier check stays active: + // "no supplier" is a data-quality issue regardless of pricing + // intent, and an unpriced item with no supplier means the line + // was never even looked at. + if (!quote.is_no_quote) { + if (!it.is_processed) missing.push("not processed"); + if (!(Number(it.cost_price) > 0)) missing.push("cost price"); + if (!(Number(it.selling_price) > 0)) missing.push("selling price"); + } if (!it.supplier) missing.push("no supplier"); - if (!(Number(it.cost_price) > 0)) missing.push("cost price"); - if (!(Number(it.selling_price) > 0)) missing.push("selling price"); if (missing.length > 0) { issues.push({ itemId: String(it.id),