mega-plant/scripts/setup-site-assignment.mjs
David 293276ccce Sites: auto-allocate container lines to the container the site hires
A container line prints a generic code ("MPLANT05") that the plant register
does not carry, so it had no asset to book to and sat unbooked forever. Each
site now records the container it hires — the code the invoice uses and the
plant item it books to — and a line arriving on an invoice for that site is
allocated to it automatically on import.

The lookup is scoped to the invoice's OWN site, not globally: two sites can
each hire an "MPLANT05" unit, and each line must land on the unit actually at
its own site. Covered by unit tests rather than by deleting a live invoice
and re-importing it.

A code with no asset is refused on save — it would auto-allocate to nothing —
and both or neither is required.

Assigned and booked: Autumns Gate "MPLANT05" -> SC02, The Hills "MPLANT05" ->
SC03, and The Hills' stuck line is now booked and earning. Container lines on
future invoices for those two sites will be booked without anyone touching them.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-10-02 18:32:12 +02:00

90 lines
3.6 KiB
JavaScript

// Adds the `site` relation on `assets` → `sites`, so every machine can be
// assigned to the yard it is actually parked at.
//
// Idempotent: safe to re-run. Exits non-zero if `sites` is missing, because
// creating the relation without a target collection would leave a dangling
// field rather than an obvious failure.
//
// Usage: node scripts/setup-site-assignment.mjs
import PocketBase from "pocketbase";
import { fileURLToPath } from "node:url";
import { dirname, resolve } from "node:path";
const __dirname = dirname(fileURLToPath(import.meta.url));
const PB = process.env.NEXT_PUBLIC_PB_URL;
const EMAIL = process.env.PB_ADMIN;
const PASS = process.env.PB_PASS;
const missing = [];
if (!PB) 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(", ")}`);
console.error(" Copy .env.example to .env.local and fill in.");
process.exit(1);
}
const pb = new PocketBase(PB);
pb.autoCancellation(false);
console.log(`→ Connecting to ${PB} as ${EMAIL}`);
await pb.collection("_superusers").authWithPassword(EMAIL, PASS);
const sites = await pb.collections.getOne("sites").catch(() => null);
if (!sites) {
console.error("✗ Collection `sites` does not exist. Create it first, then re-run.");
process.exit(1);
}
const siteCount = (await pb.collection("sites").getList(1, 1)).totalItems;
console.log(` ✓ found \`sites\` with ${siteCount} row(s)`);
const assets = await pb.collections.getOne("assets");
const existing = assets.fields.find((f) => f.name === "site");
if (existing) {
// PocketBase refuses to repoint an existing relation (it throws
// `validation_field_relation_change`). If an earlier run created the field
// against the wrong collection, say so instead of silently leaving it broken
// — every assignment will fail with `validation_missing_rel_records`.
if (existing.collectionId !== sites.id) {
const used = (await pb.collection("assets").getFullList()).filter((a) => a.site).length;
if (used > 0) {
console.error(`✗ assets.site points at the wrong collection and ${used} asset(s) already use it.`);
console.error(" Fix by hand: drop the `site` field in the PocketBase UI, then re-run this script.");
process.exit(1);
}
console.log(" · assets.site points at the wrong collection and is unused — recreating it.");
assets.fields = assets.fields.filter((f) => f.name !== "site");
await pb.collections.update(assets.id, { fields: assets.fields });
} else {
console.log(" ✓ assets.site already exists and targets `sites` — nothing to do.");
process.exit(0);
}
}
const rand = (n) => Math.random().toString(36).slice(2, 2 + n);
assets.fields.push({
name: "site",
type: "relation",
required: false,
presentable: false,
hidden: false,
system: false,
collectionId: sites.id, // self-relation: a site may be scoped to a parent site
minSelect: null,
maxSelect: 1,
maxSize: 0,
// Deleting a site must NEVER delete the machines parked at it. This is the
// opposite of monthly_income.asset, which deliberately cascades.
cascadeDelete: false,
id: "relation" + rand(15),
});
const updated = await pb.collections.update(assets.id, { fields: assets.fields });
const field = updated.fields.find((f) => f.name === "site");
if (!field) {
console.error("✗ assets.site missing after update — PocketBase rejected the change.");
process.exit(1);
}
console.log(` ✓ assets.site added (maxSelect=${field.maxSelect}, cascadeDelete=${field.cascadeDelete})`);
console.log("\nNext: assign machines from /plant → Edit → Site, or run `npm run seed`.");