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.
This commit is contained in:
Dave 2026-08-26 23:31:41 +02:00
parent e74ef505e7
commit 1d6f7538d8
2 changed files with 167 additions and 3 deletions

View file

@ -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" });

View file

@ -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),