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) <noreply@anthropic.com>
89 lines
No EOL
3 KiB
JavaScript
89 lines
No EOL
3 KiB
JavaScript
/**
|
|
* 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 ? `<id ${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`);
|
|
} |