mega-plant/scripts/setup-oil-collections.mjs
David 29a035a4f2 Clock app: vehicle-QR diesel book-out with meter photo; user access levels
- Scanning a vehicle QR (its register code, e.g. TLB04) books diesel from the main
  tank; litres, optional odometer and a required meter photo (stored protected,
  viewable from the Booked out table)
- Settings: users are Full or Clock-app-only; clock-only users are confined to /clock
- Clock app: tapped tab highlights at once plus a loading screen; remove the
  "scanned on this device" list

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-22 12:25:15 +02:00

152 lines
No EOL
6.6 KiB
JavaScript

// Create the `oil_items` + `oil_stock_movements` PocketBase collections.
//
// Two tracked oils, and the stock history behind them. Why a movements table rather
// than a running number on the item: stock is the SUM of what came in MINUS what went
// out, and "what went out" arrives later when a drum is scanned. Storing only a
// balance would make the first usage a guess about what it removed.
//
// `oil_items` is NOT wiped by the seed — a person can add an oil in the app, the same
// rule as operators and mechanics. `oil_stock_movements` is derived from uploaded
// purchase orders and IS rebuilt by the seed.
//
// Idempotent: safe to re-run.
//
// Usage: node scripts/setup-oil-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 purchasesId = (await pb.collections.getOne("purchases")).id;
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;
}
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");
const oilItemsId = await ensureCollection("oil_items", [
{ name: "code", type: "text", required: true },
{ name: "name", type: "text" },
{ name: "detail", type: "text" },
{ name: "supplier", type: "text" },
{ name: "material_code", type: "text" },
// The cost code the oil PO prints for this store — "MPLANT, ENGOIL". This is how a
// uploaded PO line is recognised as OIL rather than as spend against a machine:
// a machine cost code resolves in `assets`, a store code resolves here, and the two
// namespaces cannot collide because a store name is never a machine code.
{ name: "store_cost_code", type: "text" },
// One drum. A drum is FULL at this figure and fills proportionally below it.
{ name: "drum_litres", type: "number" },
{ name: "colour", type: "text" },
// Exactly what the drum's QR encodes: name + item code.
{ name: "qr_payload", type: "text" },
{ name: "source", type: "text" },
{ name: "created", type: "date", onCreate: true, presentable: false },
{ name: "updated", type: "date", onUpdate: true, presentable: false },
]);
await ensureCollection("oil_stock_movements", [
{ name: "item", type: "relation", required: true, collectionId: oilItemsId, minSelect: 1, maxSelect: 1, cascadeDelete: true },
{ name: "purchase", type: "relation", collectionId: purchasesId, minSelect: 1, maxSelect: 1, cascadeDelete: true },
// "received" when a PO delivers oil, "used" when a drum is drawn down. Usage is not
// wired up yet, so every movement today is a receipt.
{ name: "kind", type: "text", required: true },
{ name: "litres", type: "number", required: true },
{ name: "occurred_on", type: "text" },
{ name: "note", type: "text" },
// Book-out ("used") movements: which vehicle the litres went into, and who booked them.
// Not cascadeDelete — removing a machine must not erase the record of oil that was drawn.
{ name: "asset", type: "relation", collectionId: assetsId, minSelect: 0, maxSelect: 1, cascadeDelete: false },
{ name: "booked_by", type: "text" },
// Odometer (km) or hour-meter reading the driver typed in. Optional: not every machine has one.
{ name: "odometer", type: "number" },
// Photo of the vehicle's meter at the time of fuelling, for verification.
{ name: "meter_photo", type: "file", maxSelect: 1, maxSize: 10485760, mimeTypes: ["image/jpeg", "image/png", "image/webp", "image/heic", "image/heif"], protected: true },
{ name: "created", type: "date", onCreate: true, presentable: false },
{ name: "updated", type: "date", onUpdate: true, presentable: false },
]);
console.log("\n✓ oils ready. Usage: /running/oils");
process.exit(0);