- Standardize quote number format from Q-YYYY-MM-NNN to QYYYYMMNNN across all client, server, test and API code - Add atomic backfill logic for quote counters to avoid sequence collisions with existing records - Rearrange Delivery Notes and Repairs to the Operations sidebar section - Add supplier count badge to pricelist catalogue and improve filter tab overflow styling - Optimize font loading in root layout to skip preloading unused font weights - Improve table styling for customer and company pages with better text wrapping and empty states - Fix rep display logic in quotes page to show "Unassigned" when no rep is assigned - Add supporting documentation, PocketBase counter schemas and setup migration scripts - Remove embedded SupplierMap from suppliers page for cleaner layout - Improve invoice card layout for company and customer info spacing - Improve filter label styling and accessibility in collection map page
230 lines
7.8 KiB
TypeScript
230 lines
7.8 KiB
TypeScript
/* eslint-disable */
|
|
/**
|
|
* Migration — restore the `quote_counters` collection and add the new
|
|
* `order_counters` collection.
|
|
*
|
|
* WHY THIS EXISTS
|
|
* ───────────────
|
|
* Both `quotes-service.ts` and `orders-service.ts` rely on dedicated
|
|
* PocketBase collections to atomically generate per-(year, month)
|
|
* sequence numbers (the `QYYYYMMNNN` quote number and the
|
|
* `ORD-YYYY-MM-NNN` order number). The atomic guarantee comes from
|
|
* PocketBase's `last_seq+` increment operator — a single read-then-write
|
|
* that the server evaluates against the current row state, so two
|
|
* concurrent callers can never observe the same value and collide.
|
|
*
|
|
* If either counter collection is missing, the next number generation
|
|
* crashes with a 404 ("Missing collection context"). The destructive
|
|
* `scripts/setup-pocketbase.ts` (used to wipe the dev PB before a
|
|
* rebuild) was the most common cause — it deleted the counter tables
|
|
* along with everything else.
|
|
*
|
|
* This migration:
|
|
* 1. Creates `quote_counters` if it doesn't exist (re-creates after a
|
|
* wipe). Schema mirrors `docs/pocketbase/quote_counters.collection.json`.
|
|
* 2. Creates `order_counters` if it doesn't exist. Schema mirrors
|
|
* `docs/pocketbase/order_counters.collection.json`. This one was
|
|
* new as of 2026-07-10 — before then, `orders-service.ts` had no
|
|
* atomic counter and used a read-then-write fallback that was
|
|
* racy under concurrent order creation.
|
|
* 3. For each new counter row created in this run, backfills
|
|
* `last_seq` from any pre-existing records (quotes / orders) for
|
|
* that (year, month) so the first generated number doesn't
|
|
* collide with existing data.
|
|
*
|
|
* IDEMPOTENT — running twice is a no-op. The script logs what it did
|
|
* (or skipped) and exits 0 either way.
|
|
*
|
|
* Required env (in .env or .env.local):
|
|
* NEXT_PUBLIC_POCKETBASE_URL
|
|
* PB_ADMIN_EMAIL
|
|
* PB_ADMIN_PASSWORD
|
|
*
|
|
* Run with:
|
|
* npx tsx scripts/migrations/2026-07-10-restore-counters.ts
|
|
*/
|
|
import PocketBase from "pocketbase";
|
|
import { readFileSync } from "node:fs";
|
|
import { resolve } from "node:path";
|
|
|
|
function loadEnvFile(name: string) {
|
|
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*=\s*(.+)$/i);
|
|
if (m && !process.env[m[1]]) {
|
|
process.env[m[1]] = m[2].replace(/^["']|["']$/g, "").trim();
|
|
}
|
|
}
|
|
} catch {
|
|
/* file not present — fine, rely on shell env */
|
|
}
|
|
}
|
|
loadEnvFile(".env");
|
|
loadEnvFile(".env.local");
|
|
|
|
const AUTH_RULE = "@request.auth.id != ''";
|
|
|
|
type CounterDef = {
|
|
name: "quote_counters" | "order_counters";
|
|
// The collection whose pre-existing rows we backfill from.
|
|
backfillSource: "quotes" | "orders";
|
|
// The numeric field on the source that we extract the sequence from.
|
|
sourceField: "quote_number" | "order_number";
|
|
// Regex group 1 captures the trailing integer sequence.
|
|
sequenceRegex: RegExp;
|
|
};
|
|
|
|
const COUNTERS: CounterDef[] = [
|
|
{
|
|
name: "quote_counters",
|
|
backfillSource: "quotes",
|
|
sourceField: "quote_number",
|
|
// Match the current no-dash format `QYYYYMMNNN`. Also accept the
|
|
// legacy dashed `Q-YYYY-MM-NNN` so an in-flight migration doesn't
|
|
// strand the count. The year/month extraction below does the same.
|
|
sequenceRegex: /^Q-?(\d{4})-?(\d{2})-?(\d+)$/,
|
|
},
|
|
{
|
|
name: "order_counters",
|
|
backfillSource: "orders",
|
|
sourceField: "order_number",
|
|
sequenceRegex: /^ORD-(\d{4})-(\d{2})-(\d+)$/,
|
|
},
|
|
];
|
|
|
|
async function main() {
|
|
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 (e.g. sourced from .env.local).",
|
|
);
|
|
}
|
|
|
|
const pb = new PocketBase(url);
|
|
await pb.collection("_superusers").authWithPassword(email, password);
|
|
console.log(`[counters] Authenticated against ${url}`);
|
|
|
|
// 1. Find which counter collections are missing.
|
|
const allCols = await pb.collections.getFullList();
|
|
const existing = new Set(allCols.map((c) => c.name));
|
|
|
|
let createdCollections = 0;
|
|
let createdRows = 0;
|
|
let backfilledRows = 0;
|
|
|
|
for (const def of COUNTERS) {
|
|
if (existing.has(def.name)) {
|
|
console.log(`[counters] ${def.name} already exists — skip creation.`);
|
|
} else {
|
|
await pb.collections.create({
|
|
name: def.name,
|
|
type: "base",
|
|
system: false,
|
|
listRule: AUTH_RULE,
|
|
viewRule: AUTH_RULE,
|
|
createRule: AUTH_RULE,
|
|
updateRule: AUTH_RULE,
|
|
deleteRule: AUTH_RULE,
|
|
fields: [
|
|
{
|
|
name: "year",
|
|
type: "number",
|
|
required: true,
|
|
min: 2000,
|
|
max: 2999,
|
|
noDecimal: true,
|
|
},
|
|
{
|
|
name: "month",
|
|
type: "number",
|
|
required: true,
|
|
min: 1,
|
|
max: 12,
|
|
noDecimal: true,
|
|
},
|
|
{
|
|
name: "last_seq",
|
|
type: "number",
|
|
required: false,
|
|
min: 0,
|
|
noDecimal: true,
|
|
},
|
|
],
|
|
indexes: [
|
|
`CREATE UNIQUE INDEX idx_${def.name}_year_month ON ${def.name} (year, month)`,
|
|
],
|
|
});
|
|
createdCollections++;
|
|
console.log(`[counters] Created ${def.name}.`);
|
|
}
|
|
|
|
// 2. Backfill any (year, month) where the source collection has rows
|
|
// but the counter does not. Without this, the first generate
|
|
// after a wipe would start at 001 and collide with the
|
|
// existing NNN-th record.
|
|
const countersRows = await pb.collection(def.name).getFullList<{
|
|
year: number;
|
|
month: number;
|
|
}>();
|
|
const counterKeys = new Set(
|
|
countersRows.map((r) => `${r.year}-${r.month}`),
|
|
);
|
|
|
|
const sourceRows = await pb
|
|
.collection(def.backfillSource)
|
|
.getFullList<Record<string, unknown>>({ fields: def.sourceField });
|
|
const byMonth = new Map<string, number>();
|
|
for (const row of sourceRows) {
|
|
const num = row[def.sourceField];
|
|
if (typeof num !== "string") continue;
|
|
const m = num.match(def.sequenceRegex);
|
|
if (!m) continue;
|
|
// Groups depend on the regex per def above. For the quote regex
|
|
// it's `[full, year, month, seq]`; for orders it's `[full, year,
|
|
// month, seq]` too. Always the last group is the sequence, year
|
|
// and month are the prior two.
|
|
const year = parseInt(m[1], 10);
|
|
const month = parseInt(m[2], 10);
|
|
const seq = parseInt(m[3], 10);
|
|
if (!Number.isFinite(year) || !Number.isFinite(month) || !Number.isFinite(seq)) {
|
|
continue;
|
|
}
|
|
if (month < 1 || month > 12 || year < 2000 || year > 2999) continue;
|
|
const key = `${year}-${month}`;
|
|
const cur = byMonth.get(key) ?? 0;
|
|
if (seq > cur) byMonth.set(key, seq);
|
|
}
|
|
|
|
for (const [key, maxSeq] of byMonth.entries()) {
|
|
if (counterKeys.has(key)) continue;
|
|
const [yearStr, monthStr] = key.split("-");
|
|
const year = parseInt(yearStr, 10);
|
|
const month = parseInt(monthStr, 10);
|
|
await pb.collection(def.name).create({
|
|
year,
|
|
month,
|
|
last_seq: maxSeq,
|
|
});
|
|
createdRows++;
|
|
backfilledRows++;
|
|
console.log(
|
|
`[counters] Backfilled ${def.name} (${key}) with last_seq=${maxSeq} (from existing ${def.backfillSource} rows).`,
|
|
);
|
|
}
|
|
}
|
|
|
|
console.log(
|
|
`[counters] Done. Collections created: ${createdCollections}, counter rows created: ${createdRows}, backfilled: ${backfilledRows}.`,
|
|
);
|
|
console.log("[counters] Migration complete.");
|
|
}
|
|
|
|
main().catch((err) => {
|
|
console.error("[counters] Migration failed:", err);
|
|
process.exit(1);
|
|
});
|