From 3b926570c7d95e5893d30c269cbb62b33b8719b9 Mon Sep 17 00:00:00 2001 From: Dave Date: Fri, 16 Oct 2026 00:44:45 +0200 Subject: [PATCH] fix(quotes): delivery-note quotes skip approvals; company follows customer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two quote-flow defects found while auditing Q202610315. 1. Delivery-note quotes were being dumped into the rep approval queue. A DN quote is never sent on its own — it goes out with the note, via the note's "Send the delivery note and quote to the customer" button. But `generateQuotePdf` auto-flipped every non-terminal quote to `approval_pending`, which overwrote the DN status before the skip-approval branch in `sendQuoteEmail` could ever run. Ten DN quotes were sitting in Pending Approval as a result. - `createQuoteFromDeliveryNote` now creates the quote `approved` rather than `delivery_note`. - The PDF render no longer flips DN quotes at all, as a backstop. - The quote page gets a non-sending action row pointing back at the note. No "send" button — a second way to email a customer the same invoice is a liability, not a feature. Also fixes a latent duplicate-invoice bug: the duplicate guard pinned `status = "delivery_note"`, so once the quote had been sent it fell through and a second quote could be raised for the same note. Verified by test — the old guard silently minted a duplicate. 2. `quotes.company` never followed a customer change. `quotes.company` is a denormalised copy of `customer.company` and PB does not re-cascade relations, so swapping the customer left the old company behind forever (Q202610315 read "Mre" while its customer sat at Harmony — two contradictory companies on one row). The customer swap ran as a raw client-side PB PATCH that wrote only `customer`, which also bypassed the server's placeholder guard. It now goes through `PATCH /api/v1/quotes/[id]/pdf`, which cascades both relations atomically. The customer-facing PDF already read `customer.expand.company`, which is why this never reached a customer — but every consumer of the quote's own `company` got the wrong answer. A read-only audit (`scripts/audit-quote-company-drift.mjs`) puts it at 2,591 of 9,950 quotes. That data is NOT backfilled here. Also drops the duplicate standalone "COMPANY" column on the quote detail page, and the stale-company fallback in approvals, so this class of bug can't hide in the UI again. Co-Authored-By: Claude Opus 4.8 (1M context) --- scripts/audit-quote-company-drift.mjs | 89 +++++++++++ src/app/(app)/quotes/[id]/page.tsx | 80 ++++++++-- src/app/(app)/quotes/[id]/process/page.tsx | 37 ++++- src/app/(app)/quotes/approvals/[id]/page.tsx | 11 +- src/app/api/v1/quotes/[id]/pdf/route.test.ts | 138 ++++++++++++++++++ src/app/api/v1/quotes/[id]/pdf/route.ts | 17 ++- src/lib/server/delivery-notes-service.test.ts | 24 ++- src/lib/server/delivery-notes-service.ts | 40 ++++- .../order-delivery-note-link-service.ts | 7 +- src/lib/server/quote-pdf-service.test.ts | 111 ++++++++++++++ src/lib/server/quote-pdf-service.ts | 24 ++- 11 files changed, 549 insertions(+), 29 deletions(-) create mode 100644 scripts/audit-quote-company-drift.mjs create mode 100644 src/app/api/v1/quotes/[id]/pdf/route.test.ts diff --git a/scripts/audit-quote-company-drift.mjs b/scripts/audit-quote-company-drift.mjs new file mode 100644 index 0000000..9ac55e8 --- /dev/null +++ b/scripts/audit-quote-company-drift.mjs @@ -0,0 +1,89 @@ +/** + * READ-ONLY audit: quotes whose `company` relation disagrees with + * their customer's `company`. + * + * `quotes.company` is a denormalised copy of `customer.company` that + * PocketBase does not re-cascade, so any quote whose customer was + * swapped after creation is now inconsistent. This script only READS + * and prints — it makes no writes. + * + * Run: npx tsx scripts/audit-quote-company-drift.mjs + */ +import PocketBase from "pocketbase"; + +const PB_URL = process.env.PB_URL ?? "https://pocketbase-gnqc88suy8fqk6wyryjjdpvl.169.239.180.130.sslip.io"; +const PB_ADMIN_EMAIL = process.env.PB_ADMIN_EMAIL ?? "david.els1@outlook.com"; +const PB_ADMIN_PASSWORD = process.env.PB_ADMIN_PASSWORD ?? "1234567890"; + +const pb = new PocketBase(PB_URL); +await pb.collection("_superusers").authWithPassword(PB_ADMIN_EMAIL, PB_ADMIN_PASSWORD); + +const quotes = await pb.collection("quotes").getFullList(200, { + expand: "customer,customer.company,company", + sort: "-created", +}); + +// Company records store their display name in `company`, not `name`. +const nameOf = (expand, key) => expand?.[key]?.company ?? null; +// When an expand doesn't resolve, show the raw relation id rather than a +// bare "(none)" — otherwise "unresolved" and "genuinely empty" look identical. +const label = (name, id) => (name ? name : id ? `` : "(none)"); + +let checked = 0; +let consistent = 0; +let noCustomerCompany = 0; +const drifted = []; + +for (const q of quotes) { + if (!q.customer) continue; + checked++; + const cust = q.expand?.customer; + const custCompanyId = cust?.company ?? ""; + const custCompanyName = nameOf(cust?.expand, "company"); + + if (!custCompanyId) { + noCustomerCompany++; + if (q.company) { + drifted.push({ + n: q.quote_number, + id: q.id, + status: q.status, + quoteCompany: label(nameOf(q.expand, "company"), q.company), + custCompany: "(customer has none)", + created: (q.created ?? "").slice(0, 10), + }); + } + continue; + } + + if (q.company === custCompanyId) { + consistent++; + } else { + drifted.push({ + n: q.quote_number, + id: q.id, + status: q.status, + quoteCompany: label(nameOf(q.expand, "company"), q.company), + custCompany: custCompanyName ? label(custCompanyName, custCompanyId) : label(null, custCompanyId), + created: (q.created ?? "").slice(0, 10), + }); + } +} + +console.log(`Scanned : ${quotes.length} quotes`); +console.log(`With customer: ${checked}`); +console.log(`Consistent : ${consistent}`); +console.log(`No cust co : ${noCustomerCompany} (customer has no company)`); +console.log(`DRIFTED : ${drifted.length}`); +console.log(""); + +if (drifted.length) { + console.log("quote created status quote.company -> customer.company"); + console.log("-".repeat(84)); + for (const d of drifted.slice(0, 60)) { + console.log( + `${String(d.n).padEnd(10)} ${d.created} ${String(d.status).padEnd(18)} ${d.quoteCompany} -> ${d.custCompany}`, + ); + } + if (drifted.length > 60) console.log(`… and ${drifted.length - 60} more`); +} \ No newline at end of file diff --git a/src/app/(app)/quotes/[id]/page.tsx b/src/app/(app)/quotes/[id]/page.tsx index 8e7bfb9..9811a54 100644 --- a/src/app/(app)/quotes/[id]/page.tsx +++ b/src/app/(app)/quotes/[id]/page.tsx @@ -82,6 +82,7 @@ import { Loader2, Settings, Activity, + Truck, type LucideIcon, } from "lucide-react"; import { toast } from "sonner"; @@ -2028,6 +2029,58 @@ export default function QuoteDetailPage({ params }: { params: Promise<{ id: stri const renderActions = () => { const btns: React.ReactNode[] = []; + /** + * Delivery-note quotes get a small, deliberately non-sending action + * row (2026-10-16). + * + * A DN quote shares its statuses with the regular pipeline, so it + * can't own a clean `switch` arm without duplicating three existing + * ones. Branch on the `delivery_note` relation before the switch. + * + * NOTE: there is deliberately NO "send" button here. A + * delivery-note quote is never sent on its own — it goes out with + * the note, via the delivery note's own "Send the delivery note and + * quote to the customer" button. The quote is created pre-approved + * (`createQuoteFromDeliveryNote`), so the rep approval queue never + * sees it. This row only points back at the note where Send lives, + * plus preview/re-price. An earlier draft of this fix added a + * "Send to customer" button that POSTed the quote directly — that + * would have created a second, competing way to email a customer + * the same invoice. + */ + if ( + quote.delivery_note && + (quote.status === "delivery_note" || + quote.status === "claimed" || + quote.status === "processed" || + quote.status === "approved") + ) { + return ( +
+ + + + + Sent together with the delivery note — not on its own. + +
+ ); + } + switch (quote.status) { case "new": btns.push( @@ -2072,6 +2125,8 @@ export default function QuoteDetailPage({ params }: { params: Promise<{ id: stri // audit trail. // - Submit for Approval: PDF generation IS the submit. // The PDF route auto-flips to approval_pending on render. + // (Regular quotes only — delivery-note quotes are exempt + // from that flip and return early above.) btns.push(