mega-plant/scripts/setup-operators-collections.mjs

175 lines
7.2 KiB
JavaScript
Raw Normal View History

Add Running Costs sub-pages: insurance, financing, purchases, oil, operators, mechanics 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>
2026-10-08 23:06:27 +00:00
// Create the `operators` PocketBase collection.
//
// This is TERM 3 of the fixed-expense formula — the operator wage that varies per
// machine per month. It was the last unknown in the workbook's cost model; this sheet
// supplies it.
//
// WHY NOT `cost_lines`: a cost line is "what did this machine cost in month M". An
// operator is a PERSON with a rate who is allocated to a machine, and that allocation
// is what a human changes when someone moves off a machine onto another. Putting the
// link in a per-month cost table would re-key the same person twelve times and lose
// the one field people actually come to update.
//
// cascadeDelete is FALSE on `asset` on purpose: deleting a machine must leave the
// operator on the register as UNASSIGNED, not delete the person.
//
// Idempotent: safe to re-run.
//
// Usage: node scripts/setup-operators-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, "..");
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 assetsId = (await pb.collections.getOne("assets")).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 absent = fields.filter((f) => !have.has(f.name));
if (!absent.length) {
console.log(` · ${name} already present (${existing[0].fields.length} fields)`);
} else {
await updateSchema(name, (cur) => [...cur, ...absent], `added to ${name}: ${absent.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");
async function ensureField(collectionName, field) {
const coll = await pb.collections.getOne(collectionName);
if (coll.fields.some((f) => f.name === field.name)) {
console.log(` · ${collectionName}.${field.name} already present`);
return;
}
await pb.send(`/api/collections/${coll.name}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ fields: [...coll.fields, field] }),
});
console.log(` ✓ added ${collectionName}.${field.name}`);
}
await ensureCollection("operators", [
{ name: "name", type: "text", required: true },
{ name: "employee_code", type: "text" },
// Hourly rate as printed on the operator sheet. NOT marked required, for the same
// reason `financing_terms.installment` is not: PocketBase rejects 0 on a required
// number, and an unassigned operator still has a rate while an unallocated MACHINE
// does not. `scripts/seed.mjs` is where the "an operator must have a rate" rule lives.
{ name: "rate", type: "number" },
// Which plant vehicle this operator is allocated to. Empty = unassigned, which is a
// normal state on this register (two operators had no machine on the sheet).
{ name: "asset", type: "relation", collectionId: assetsId, minSelect: 1, maxSelect: 1, cascadeDelete: false },
{ name: "source", type: "text" },
{ name: "created", type: "date", onCreate: true, presentable: false },
{ name: "updated", type: "date", onUpdate: true, presentable: false },
]);
// MIGRATION: add `on_operator_sheet` to `assets`.
//
// The operator sheet lists TWENTY vehicles. The plant register holds forty-one coded
// machines, the difference being containers, generators and truck trailers that no
// person is ever allocated to. The sheet decides which machines belong on the
// operator register, and the register still supplies their name and code — so the
// scope has to be STORED, not guessed from "has an operator". Three of the twenty
// (TLB01, TLB02, RV01) have nobody on them and must still be listed; inferring from
// the operators collection would drop exactly those three.
await ensureField("assets", {
name: "on_operator_sheet",
type: "bool",
// Not presentable: this is a scope flag set by the seed from data/source/operators.json,
// never something a person edits.
presentable: false,
});
// MIGRATION: `rate_set_in_app` on `operators`.
//
// The rate can now be changed from the employee's dashboard (/employees/<person>). The
// seed refreshes `rate` from data/source/operators.json on every run, which would quietly
// put an app-edited rate back to the old sheet value. This flag tells the seed to leave
// that one rate alone.
await ensureField("operators", { name: "rate_set_in_app", type: "bool", presentable: false });
Add Running Costs sub-pages: insurance, financing, purchases, oil, operators, mechanics 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>
2026-10-08 23:06:27 +00:00
console.log("\n✓ operators ready. Next: npm run seed && node scripts/book-invoice.mjs");
process.exit(0);