Splits the Running Costs group into six purpose-built pages, each with its own
collection and its own source data, plus a coordinate parser for purchase orders.
Insurance (R503 330/yr, 20 vehicles)
A premium can move every month or three, so it is stored as a RATE PERIOD in
insurance_rates and each month's cost is resolved against the rate in force
that month. cost_lines insurance rows are derived from those periods. An absent
rate is null, never 0 — nobody has entered the date, which is not the same as
being insured for free.
Financing (20 agreements, R428 585/mo)
Instalment from the sheet; start date, paid-off date and the settled toggle are
human-entered and preserved across re-seeds. Months-left and outstanding are
null until a date is entered, never R0 on a live debt. The two paid-off
agreements (WB02, DT01) show as settled and are excluded from the run-rate.
Purchases + Oil Stock
Uploads PO PDFs the way /income/invoices does. The parser reads columns by
x-position, because plain text extraction reorders them, and refuses any PO
whose lines do not sum to the sub-total. A cost code either names a machine
("SS-02" -> SS02) or a stock store ("MPLANT, ENGOIL") — never both, never
guessed. Oil POs move litres into oil_stock_movements instead of onto a machine;
stock is the sum of movements so the first usage is a subtraction from history.
Operators (19) and Mechanics (2)
Both are people with an employee number, separately reconcilable by the seed so
someone added in the app survives. Mechanics get a badge QR; a mechanic with no
employee number cannot be printed, because the code is what the QR encodes.
Cross-cutting
- The seed no longer deletes operators, mechanics or oil items: people are added
in the app, and those rows exist in no file.
- Re-pointing allocations that PocketBase clears because `assets` is rebuilt
with new ids each seed (operators, purchase lines).
- One QR encoder for screen and PDF, so a printed code cannot drift from the one
on screen. pdf-lib writes real vector PDFs; pdfjs-dist only reads them.
- Every figure is null-safe: unknown is never rendered as zero.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
141 lines
No EOL
5.8 KiB
JavaScript
141 lines
No EOL
5.8 KiB
JavaScript
// Create the `insurance_rates` PocketBase collection.
|
|
//
|
|
// WHY A NEW COLLECTION, and not more rows in `cost_lines`:
|
|
// `cost_lines` answers "what did this machine cost in month M" — one row per month.
|
|
// `insurance_rates` answers "what was this machine's PREMIUM during that stretch"
|
|
// — one row per rate PERIOD. They are different grains on purpose, because a
|
|
// premium moves every month or three (client ruling, 2026-10-08) and a monthly
|
|
// table forces you to re-key the same rate twelve times with no way to see when
|
|
// it changed. The monthly `cost_lines` figures are then DERIVED from the rate
|
|
// history, so the premium has exactly one home.
|
|
//
|
|
// Idempotent: safe to re-run. Mirrors the rules of `monthly_income` so behaviour
|
|
// stays uniform, and grows the schema rather than leaving it half-shaped.
|
|
//
|
|
// Usage: node scripts/setup-insurance-collections.mjs
|
|
import PocketBase from "pocketbase";
|
|
import { readFileSync, existsSync } from "node:fs";
|
|
import { fileURLToPath } from "node:url";
|
|
import { dirname, resolve } from "node:path";
|
|
|
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
const ROOT = resolve(__dirname, "..");
|
|
|
|
// Load .env.local the same way seed.mjs does — a script whose job is to shape the
|
|
// schema cannot fail on a missing URL and report success.
|
|
const envPath = resolve(ROOT, ".env.local");
|
|
if (!existsSync(envPath)) {
|
|
console.error("✗ .env.local not found. Copy .env.example to .env.local and fill it in.");
|
|
process.exit(1);
|
|
}
|
|
for (const line of readFileSync(envPath, "utf8").split("\n")) {
|
|
const t = line.trim();
|
|
if (!t || t.startsWith("#")) continue;
|
|
const i = t.indexOf("=");
|
|
if (i > 0) process.env[t.slice(0, i).trim()] = t.slice(i + 1).trim();
|
|
}
|
|
|
|
const PB_URL = process.env.NEXT_PUBLIC_PB_URL;
|
|
const EMAIL = process.env.PB_ADMIN;
|
|
const PASS = process.env.PB_PASS;
|
|
const missing = [];
|
|
if (!PB_URL) missing.push("NEXT_PUBLIC_PB_URL");
|
|
if (!EMAIL) missing.push("PB_ADMIN");
|
|
if (!PASS) missing.push("PB_PASS");
|
|
if (missing.length) {
|
|
console.error(`✗ Missing required env: ${missing.join(", ")}`);
|
|
process.exit(1);
|
|
}
|
|
|
|
const pb = new PocketBase(PB_URL);
|
|
pb.autoCancellation(false);
|
|
|
|
console.log(`→ Connecting to ${PB_URL} as ${EMAIL}`);
|
|
await pb.collection("_superusers").authWithPassword(EMAIL, PASS);
|
|
console.log(" ✓ authenticated");
|
|
|
|
const reference = await pb.collections.getOne("monthly_income");
|
|
const RULES = {
|
|
listRule: reference.listRule,
|
|
viewRule: reference.viewRule,
|
|
createRule: reference.createRule ?? null,
|
|
updateRule: reference.updateRule ?? null,
|
|
deleteRule: reference.deleteRule ?? null,
|
|
};
|
|
console.log(` · rules mirrored from monthly_income (list="${RULES.listRule}")`);
|
|
|
|
const assetsCollection = await pb.collections.getOne("assets");
|
|
const assetsId = assetsCollection.id;
|
|
|
|
async function ensureCollection(name, fields) {
|
|
const existing = await pb.collections.getFullList({ filter: `name = "${name}"`, perPage: 1 });
|
|
if (existing.length) {
|
|
const have = new Set(existing[0].fields.map((f) => f.name));
|
|
const missing = fields.filter((f) => !have.has(f.name));
|
|
if (!missing.length) {
|
|
console.log(` · ${name} already present (${existing[0].fields.length} fields)`);
|
|
} else {
|
|
await updateSchema(name, (cur) => [...cur, ...missing], `added to ${name}: ${missing.map((f) => f.name).join(", ")}`);
|
|
}
|
|
return existing[0].id;
|
|
}
|
|
// pb.collections.create() serialises an array schema into repeated `fields=`
|
|
// params, which PocketBase rejects. Post the JSON body directly instead.
|
|
const created = await pb.send("/api/collections", {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({
|
|
name,
|
|
type: "base",
|
|
fields,
|
|
listRule: RULES.listRule ?? "",
|
|
viewRule: RULES.viewRule ?? "",
|
|
createRule: RULES.createRule ?? "",
|
|
updateRule: RULES.updateRule ?? "",
|
|
deleteRule: RULES.deleteRule ?? "",
|
|
}),
|
|
});
|
|
console.log(` ✓ created ${name} (${created.id}) with ${created.fields.length} fields`);
|
|
return created.id;
|
|
}
|
|
|
|
async function updateSchema(collectionName, transform, label) {
|
|
const coll = await pb.collections.getOne(collectionName);
|
|
const next = transform([...coll.fields]);
|
|
if (JSON.stringify(next) === JSON.stringify(coll.fields)) {
|
|
console.log(` · ${collectionName} schema already current`);
|
|
return;
|
|
}
|
|
await pb.send(`/api/collections/${coll.name}`, {
|
|
method: "PATCH",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({ fields: next }),
|
|
});
|
|
console.log(` ✓ ${label}`);
|
|
}
|
|
|
|
console.log("\n→ Ensuring collections");
|
|
|
|
await ensureCollection("insurance_rates", [
|
|
// The plant vehicle this premium belongs to. cascadeDelete=true matches
|
|
// `cost_lines` — deleting a machine must take its rate history with it, or the
|
|
// history would outlive the asset it describes.
|
|
{ name: "asset", type: "relation", required: true, collectionId: assetsId, minSelect: 1, maxSelect: 1, cascadeDelete: true },
|
|
{ name: "year", type: "number", required: true },
|
|
// Inclusive first month of the period. Required — a premium with no start date
|
|
// cannot be resolved against a month, which is the entire point of this table.
|
|
{ name: "month_from", type: "number", required: true },
|
|
// Inclusive last month. 0 means OPEN — still the current premium. Never null:
|
|
// a null here would silently read as "unknown", and an unknown end date must not
|
|
// be mistaken for a period that never ends.
|
|
{ name: "month_to", type: "number" },
|
|
{ name: "rate", type: "number", required: true },
|
|
// The description printed on the rate sheet, kept for provenance.
|
|
{ name: "plant", type: "text" },
|
|
{ name: "source", type: "text" },
|
|
{ name: "created", type: "date", onCreate: true, presentable: false },
|
|
{ name: "updated", type: "date", onUpdate: true, presentable: false },
|
|
]);
|
|
|
|
console.log("\n✓ insurance_rates ready. Next: npm run seed");
|
|
process.exit(0); |