// Seed PocketBase with data extracted from data/source/*.json // Regenerate those files with `node scripts/extract.mjs` // // Usage: PB_ADMIN=... PB_PASS=... node scripts/seed.mjs import PocketBase from "pocketbase"; import { readFileSync } from "node:fs"; import { fileURLToPath } from "node:url"; import { dirname, resolve } from "node:path"; const __dirname = dirname(fileURLToPath(import.meta.url)); const DATA_DIR = resolve(__dirname, "..", "data", "source"); // Read required env — fail loudly if missing (per Part 5.2) const PB = process.env.NEXT_PUBLIC_PB_URL; const EMAIL = process.env.PB_ADMIN; const PASS = process.env.PB_PASS; const errors = []; if (!PB) errors.push("NEXT_PUBLIC_PB_URL"); if (!EMAIL) errors.push("PB_ADMIN"); if (!PASS) errors.push("PB_PASS"); if (errors.length) { console.error(`✗ Missing required env: ${errors.join(", ")}`); console.error(" Copy .env.example to .env.local and fill in."); process.exit(1); } const ASSETS_COLL = "assets"; const INCOME_COLL = "monthly_income"; const RUN_COLL = "monthly_running_costs"; const OVERHEADS_COLL = "overheads"; const assets = JSON.parse(readFileSync(resolve(DATA_DIR, "assets.json"), "utf8")); const machines = JSON.parse(readFileSync(resolve(DATA_DIR, "machines.json"), "utf8")); const mrc = JSON.parse(readFileSync(resolve(DATA_DIR, "mrc.json"), "utf8")); const overheads = JSON.parse(readFileSync(resolve(DATA_DIR, "overheads.json"), "utf8")); const pb = new PocketBase(PB); pb.autoCancellation(false); console.log(`→ Connecting to ${PB} as ${EMAIL}`); await pb.collection("_superusers").authWithPassword(EMAIL, PASS); // Part 5.3: _superusers, not admins console.log(" ✓ authenticated"); // Wipe existing rows so we can re-seed cleanly for (const c of [ASSETS_COLL, INCOME_COLL, RUN_COLL, OVERHEADS_COLL]) { const items = await pb.collection(c).getFullList(); for (const r of items) await pb.collection(c).delete(r.id); } console.log(" ✓ wiped existing rows"); // === Build alias map for code mismatches between graph sheet and asset register === // Per Part 1.4: derived from the workbook, not invented. const codeAliases = { "428E": "TLB02", // CAT 425F (428E) → TLB02 "430": "SS01", // CASE420 (430) → first SS01 (handled below) }; function resolveCode(graphCode, graphName) { if (!graphCode) return null; if (codeAliases[graphCode]) { // Special-case 430 — first match wins unless name indicates second if (graphCode === "430" && /SS02/i.test(graphName)) return "SS02"; return codeAliases[graphCode]; } return graphCode; } // === Synthesize stable codes for uncoded items (containers, generators, camps) === // Per Part 1.4: deterministic by normalised name. Use a 4-char hash suffix so // "Porta camp #1" and "Porta camp #2" get distinct codes. function normalizeName(n) { return String(n).toLowerCase().replace(/[^a-z0-9]+/g, "").slice(0, 12); } function hash4(s) { let h = 0; for (let i = 0; i < s.length; i++) h = (h * 31 + s.charCodeAt(i)) | 0; return (h >>> 0).toString(36).toUpperCase().padStart(4, "0").slice(-4); } function syntheticCode(name) { return "Z" + normalizeName(name).toUpperCase().slice(0, 4) + hash4(normalizeName(name)); } // === Insert assets === console.log(`\n→ Seeding ${assets.length} assets`); const codeToId = {}; const nameToId = {}; let ok = 0; const seedErrors = []; for (const a of assets) { try { const rec = await pb.collection(ASSETS_COLL).create({ code: a.code, name: a.name, category: a.category || "Plant", gl_account: a.gl_account || "", supplier: a.supplier || "", monthly_excl: a.monthly_excl || 0, monthly_incl: a.monthly_incl || 0, purchase_value: a.purchase_value || 0, // Part 1.6: site_rate dropped — it was always a copy of monthly_incl }); if (a.code) codeToId[a.code] = rec.id; nameToId[normalizeName(a.name)] = rec.id; ok++; } catch (e) { seedErrors.push(`asset "${a.name}": ${e.response?.data?.message || e.message}`); } } console.log(` ✓ ${ok}/${assets.length} assets inserted`); // === Insert monthly_income from Machine Graph Data === // Part 1.5: monthly_income no longer carries running_cost — we derive it from monthly_running_costs on read. // Part 1.4: alias map + synthetic codes for uncoded items, fail loudly on unmatched rows. console.log("\n→ Seeding monthly_income from Machine Graph Data"); let incomeRows = 0; const incomeErrors = []; const lastActualMonth = (() => { // Last month with any recorded income across the whole workbook let m = 0; for (const x of machines) for (const d of x.data) if (d.income > 0 && d.month > m) m = d.month; return m; })(); for (const m of machines) { const resolvedCode = resolveCode(m.code, m.machine); let assetId = resolvedCode ? codeToId[resolvedCode] : null; if (!assetId && m.machine) assetId = nameToId[normalizeName(m.machine)]; if (!assetId) { // Last-resort: auto-create a synthetic asset for this machine so the income can land. // Per Part 1.4: deterministic by normalised name so repeat seeds are idempotent. const syn = syntheticCode(m.machine); let synId = codeToId[syn]; if (!synId) { try { const rec = await pb.collection(ASSETS_COLL).create({ code: syn, name: m.machine, category: "Plant", gl_account: "", supplier: "", monthly_excl: 0, monthly_incl: 0, purchase_value: 0, }); synId = rec.id; codeToId[syn] = synId; nameToId[normalizeName(m.machine)] = synId; ok++; console.log(` + auto-created synthetic asset ${syn} for "${m.machine}"`); } catch (e) { incomeErrors.push(`machine "${m.machine}" (code=${m.code || "—"}) — could not create synthetic asset: ${e.response?.data?.message || e.message}`); continue; } } assetId = synId; } for (const d of m.data) { const isForecast = d.month > lastActualMonth; try { await pb.collection(INCOME_COLL).create({ asset: assetId, year: 2026, month: d.month, income: d.income, fixed_expenses: d.fixed_expenses, is_forecast: isForecast, // Part 1.2 // running_cost removed (Part 1.5) — derived from monthly_running_costs at read time }); incomeRows++; } catch (e) { incomeErrors.push(`income ${m.code || m.machine} m=${d.month}: ${e.response?.data?.message || e.message}`); } } } console.log(` ✓ ${incomeRows} income rows inserted`); // === Insert monthly_running_costs === console.log("\n→ Seeding monthly_running_costs"); let runRows = 0; const runErrors = []; for (const r of mrc) { let assetId = codeToId[r.code]; if (!assetId) assetId = nameToId[normalizeName(r.name)]; if (!assetId) { runErrors.push(`running ${r.code || r.name} m=${r.month}: no asset matched`); continue; } if (!r.amount) continue; try { await pb.collection(RUN_COLL).create({ asset: assetId, year: 2026, month: r.month, amount: r.amount, }); runRows++; } catch (e) { runErrors.push(`running ${r.code} m=${r.month}: ${e.response?.data?.message || e.message}`); } } console.log(` ✓ ${runRows} running-cost rows inserted`); // === Insert overheads === // Part 1.3: skip "Plant Overheads" — those rows are machines already in assets. console.log("\n→ Seeding overheads (excluding Plant Overheads per Part 1.3)"); let ohRows = 0; const ohErrors = []; for (const o of overheads) { if (o.section === "Plant Overheads") continue; // Part 1.3 try { await pb.collection(OVERHEADS_COLL).create({ section: o.section, description: o.description, gl_account: o.gl_account || "", supplier: o.supplier || "", qty: o.qty || 0, unit_price: o.unit_price || 0, amount_excl: o.amount_excl || 0, vat: o.vat || 0, amount_incl: o.amount_incl || 0, }); ohRows++; } catch (e) { ohErrors.push(`overhead "${o.description}": ${e.response?.data?.message || e.message}`); } } console.log(` ✓ ${ohRows} overhead rows inserted`); console.log("\n=== Summary ==="); console.log(`Assets: ${ok}/${assets.length}`); console.log(`Income: ${incomeRows}`); console.log(`Running: ${runRows}`); console.log(`Overheads: ${ohRows}`); const totalErrors = seedErrors.length + incomeErrors.length + runErrors.length + ohErrors.length; if (totalErrors > 0) { console.error(`\n✗ ${totalErrors} errors — seed FAILED loudly:`); for (const e of [...seedErrors, ...incomeErrors, ...runErrors, ...ohErrors]) console.error(` - ${e}`); process.exit(2); } console.log("\n✅ Seed complete");