WFDASH-WEBAPP/scripts/migrations/2026-08-07-renumber-duplicate-quotes.js
SJCoetz b52de77f8c fix(sprint): filter stale quote items, add cleanup migrations
- Update sprint page query to only show unprocessed items from active (new/claimed) quotes, plus orphans
- Add one-shot migration to mark existing stale unprocessed quote items as processed
- Add one-shot migration to fix duplicate Q202608235 quote number entries
- Refine comments in the sprint page component for better maintainability
2026-08-07 20:15:46 +02:00

288 lines
No EOL
9.8 KiB
JavaScript

/* eslint-disable */
/**
* One-shot migration — 2026-08-07-renumber-duplicate-quotes.js
*
* Background
* ----------
* As of 2026-08-07, 39 quote records share `quote_number = "Q202608235"`.
* The atomic `quote_counters` row for (2026, 8) had been reset to a low
* value (likely by `scripts/setup-pocketbase.ts` wiping the collection),
* so subsequent creations kept getting fresh numbers — but the 39
* pre-existing duplicates were never re-stamped.
*
* What this script does
* ---------------------
* Default mode (no args, or `--dry-run`) → reads counter state, projects
* the next 38 reserved numbers, prints the full before/after plan, and
* exits without writing. Dry-run does NOT consume counter values, so
* the plan you see IS the plan that apply will execute.
*
* Pass `--apply` to write.
*
* 1. Find every quote with `quote_number = "Q202608235"` AND
* `status != "new"`. The single `status = "new"` row is the
* legitimate Q202608235 (the actual Hennie Muller / Gary Isherwood
* RFQ that arrived at 2026-08-07). We keep that one untouched and
* renumber the other 38.
*
* 2. Project the next 38 quote numbers from `quote_counters.last_seq`
* WITHOUT incrementing (dry-run) or atomically reserve them (apply,
* via `last_seq+` operator — same pattern
* `src/lib/server/quotes-service.ts:generateQuoteNumberServer` uses).
*
* 3. Update the quote row + every `quote_items` row whose `quote` FK
* points at it. `quote_items.quote_number` is a denormalised text
* field that the sprint page reads for display; if we don't update
* it, the sprint view keeps showing "Q202608235" on rows that now
* belong to a different parent.
*
* 4. Leaves the `quote_counters.last_seq` advanced by exactly 38.
*
* Idempotent: if run a second time, the 38 quotes no longer have
* quote_number "Q202608235" so the script finds zero matches and exits.
*/
const PocketBase = require("pocketbase/cjs");
const { readFileSync } = require("node:fs");
const { resolve } = require("node:path");
function loadEnvFile(name) {
try {
const raw = readFileSync(resolve(process.cwd(), name), "utf8");
for (const line of raw.split("\n")) {
const trimmed = line.trim();
if (!trimmed || trimmed.startsWith("#")) continue;
const m = trimmed.match(/^([A-Z0-9_]+)\s*=(.+)$/i);
if (m && !process.env[m[1]]) {
process.env[m[1]] = m[2].replace(/^["']|["']$/g, "").trim();
}
}
} catch {
/* fine — rely on shell env */
}
}
loadEnvFile(".env");
loadEnvFile(".env.local");
const URL = process.env.NEXT_PUBLIC_POCKETBASE_URL;
const EMAIL = process.env.PB_ADMIN_EMAIL;
const PASSWORD = process.env.PB_ADMIN_PASSWORD;
if (!URL || !EMAIL || !PASSWORD) {
throw new Error(
"Set NEXT_PUBLIC_POCKETBASE_URL, PB_ADMIN_EMAIL, PB_ADMIN_PASSWORD (.env.local)."
);
}
const DUP_QUOTE_NUMBER = "Q202608235";
function formatQuoteNumber(year, month, seq) {
return `Q${year}${String(month).padStart(2, "0")}${String(seq).padStart(3, "0")}`;
}
async function authedPb() {
const pb = new PocketBase(URL);
await pb.collection("_superusers").authWithPassword(EMAIL, PASSWORD);
return pb;
}
/**
* Get the (year, month) counter row, creating it on first use.
* Same create-or-fetch + backfill dance as
* `getOrCreateCounter` in src/lib/server/quotes-service.ts,
* inlined here so this migration script has no @/lib dependencies.
*/
async function getOrCreateCounter(pb, year, month) {
let counter;
try {
counter = await pb
.collection("quote_counters")
.getFirstListItem(`year=${year} && month=${month}`);
return counter;
} catch {
/* fall through to create */
}
let maxSeq = 0;
try {
const all = await pb.collection("quotes").getFullList({
fields: "quote_number",
batch: 5000,
});
const reNoDash = new RegExp(`^Q${year}${String(month).padStart(2, "0")}(\\d+)$`);
const reDashed = new RegExp(`^Q-${year}-${String(month).padStart(2, "0")}-(\\d+)$`);
for (const q of all) {
const num = q.quote_number || "";
const m = num.match(reNoDash) || num.match(reDashed);
if (m) {
const n = parseInt(m[1], 10);
if (Number.isFinite(n) && n > maxSeq) maxSeq = n;
}
}
} catch {
/* ignore — start from 0 */
}
try {
counter = await pb.collection("quote_counters").create({
year,
month,
last_seq: maxSeq,
});
} catch {
// Lost a race — another process created the row first. Fetch theirs.
counter = await pb
.collection("quote_counters")
.getFirstListItem(`year=${year} && month=${month}`);
}
return counter;
}
/**
* Atomically reserve the next quote number for (year, month).
* Same pattern as `generateQuoteNumberServer` in
* src/lib/server/quotes-service.ts.
*/
async function reserveNext(pb, year, month) {
const counter = await getOrCreateCounter(pb, year, month);
const updated = await pb
.collection("quote_counters")
.update(counter.id, { "last_seq+": 1 });
return formatQuoteNumber(year, month, updated.last_seq);
}
async function main() {
const apply = process.argv.includes("--apply");
const dryRun = !apply;
console.log(
dryRun
? "=== DRY RUN — no writes, counter NOT consumed ===\n"
: "=== APPLY MODE — live writes, counter will advance ===\n"
);
const pb = await authedPb();
console.log(`Authenticated against ${URL}\n`);
// 1. Find the duplicates. We expect 39 rows; the single `status = "new"`
// one is the legitimate Q202608235 and stays as-is.
const dups = await pb.collection("quotes").getFullList({
filter: `quote_number = "${DUP_QUOTE_NUMBER}"`,
sort: "created",
fields: "id,status,customer,quote_number,created,email_subject",
});
console.log(`Quotes stamped ${DUP_QUOTE_NUMBER}: ${dups.length}`);
const toRenumber = dups.filter((q) => q.status !== "new");
const keep = dups.filter((q) => q.status === "new");
console.log(` → ${keep.length} legitimate (status=new) — keep as-is`);
console.log(` → ${toRenumber.length} duplicates — ${apply ? "renumber" : "would renumber"}\n`);
if (toRenumber.length === 0) {
console.log("Nothing to do. (Already migrated?)");
return;
}
// 2. Read the current counter state WITHOUT consuming it. The dry-run
// plan below is a pure projection of what apply would reserve. The
// actual increments happen in apply via `last_seq+`.
const now = new Date();
const year = now.getFullYear();
const month = now.getMonth() + 1;
const counter = await getOrCreateCounter(pb, year, month);
const startSeq = counter.last_seq + 1;
const endSeq = counter.last_seq + toRenumber.length;
console.log(
`Counter state (year=${year}, month=${month}): last_seq=${counter.last_seq}`
);
console.log(
`Projected reservation: ${formatQuoteNumber(year, month, startSeq)} through ${formatQuoteNumber(year, month, endSeq)}\n`
);
const plan = toRenumber.map((q, i) => ({
id: q.id,
from: DUP_QUOTE_NUMBER,
to: formatQuoteNumber(year, month, startSeq + i),
status: q.status,
created: q.created,
subject: (q.email_subject || "").slice(0, 60),
}));
// 3. Cross-check: no plan row's `to` collides with any existing quote
// number other than the dup we're fixing.
const existing = new Set();
const allQuotes = await pb.collection("quotes").getFullList({
fields: "quote_number",
batch: 5000,
});
for (const q of allQuotes) existing.add(q.quote_number || "");
const clashes = plan.filter(
(p) => existing.has(p.to) && p.to !== DUP_QUOTE_NUMBER,
);
if (clashes.length) {
console.error(
`ERROR: ${clashes.length} projected numbers already exist in DB — aborting.`,
);
for (const c of clashes) console.error(` ${c.to} ← ${c.id} (${c.subject})`);
process.exit(1);
}
console.log("Full plan (in order):");
plan.forEach((p) =>
console.log(
` ${p.id} ${p.created} ${p.status.padEnd(18)} ${p.from} → ${p.to} ${JSON.stringify(p.subject)}`,
),
);
if (dryRun) {
console.log("\nDry run complete. Counter NOT advanced. Re-run with --apply to execute.");
return;
}
// 4. APPLY — atomically reserve each number (via `last_seq+`) and write.
// We re-reserve here rather than trusting the dry-run projection,
// so concurrent callers between dry-run and apply can't sneak a
// number in and steal our slot.
console.log("\nApplying updates…");
let updatedQuotes = 0;
let updatedItems = 0;
for (let i = 0; i < plan.length; i++) {
const p = plan[i];
const reserved = await reserveNext(pb, year, month);
if (reserved !== p.to) {
// Concurrent reservation between dry-run and apply shifted us.
// Use what we actually got — log the divergence so the operator
// can see what happened.
console.warn(
` drift: expected ${p.to}, got ${reserved} (id=${p.id}) — using ${reserved}`,
);
p.to = reserved;
}
try {
await pb.collection("quotes").update(p.id, { quote_number: p.to });
updatedQuotes++;
} catch (e) {
console.error(` FAILED quote ${p.id}: ${e.message}`);
continue;
}
try {
const items = await pb.collection("quote_items").getFullList({
filter: `quote = "${p.id}"`,
fields: "id",
});
for (const it of items) {
await pb.collection("quote_items").update(it.id, { quote_number: p.to });
updatedItems++;
}
} catch (e) {
console.error(` FAILED items cascade for ${p.id}: ${e.message}`);
}
if (updatedQuotes % 10 === 0) console.log(` …${updatedQuotes}/${plan.length} quotes`);
}
console.log(
`\nDone. Updated ${updatedQuotes} quote rows and ${updatedItems} quote_items rows.`,
);
}
main().catch((e) => {
console.error("Migration failed:", e.message);
console.error(e.stack);
process.exit(1);
});