#!/usr/bin/env tsx /** * Import the management planning workbook into PocketBase. * * Run: npx tsx --env-file=.env.local scripts/import-management-plans.ts [--dry-run] [--year 2026] * * The script reads the three JSON snapshots in src/lib/management, splits them * into the management_plans / management_projects / management_project_plans / * management_retention records and upserts them: * * plan by plan_year * project by key (shared across the years, created once) * project plan by (plan, project) * retention by (plan, source_id) * * It is idempotent: a second run finds every record unchanged and writes * nothing. A project plan that has been edited in the app (revision above 1) * is skipped and reported — the import never overwrites an edit. Nothing is * ever deleted. */ import fs from "node:fs/promises"; import path from "node:path"; import type PocketBase from "pocketbase"; import { pbAdmin } from "../src/lib/pb"; import type { WorkbookSnapshot } from "../src/lib/management/model"; import { snapshotToRows, type ManagementPlanRow, type ManagementProjectPlanRow, type ManagementProjectRow, type ManagementRetentionRow, } from "../src/lib/management/store"; import { planImport, type ExistingRecords, type ImportYear } from "../src/lib/management/import-plan"; const REPO_ROOT = path.resolve(__dirname, ".."); const SNAPSHOT_DIR = path.join(REPO_ROOT, "src/lib/management"); const SNAPSHOTS = [ { year: 2025, file: "workbook-snapshot-2025.json" }, { year: 2026, file: "workbook-snapshot.json" }, { year: 2027, file: "workbook-snapshot-2027.json" }, ]; type Options = { dryRun: boolean; year: number | null }; function parseArgs(argv: string[]): Options { const options: Options = { dryRun: false, year: null }; for (let index = 0; index < argv.length; index += 1) { const arg = argv[index]; if (arg === "--dry-run") { options.dryRun = true; continue; } if (arg === "--year") { const value = Number(argv[index + 1]); if (!Number.isInteger(value)) throw new Error("--year needs a year, e.g. --year 2026"); options.year = value; index += 1; continue; } throw new Error(`Unknown option: ${arg}`); } return options; } async function readSnapshot(file: string): Promise { const raw = await fs.readFile(path.join(SNAPSHOT_DIR, file), "utf8"); return JSON.parse(raw) as WorkbookSnapshot; } function planPayload(row: ManagementPlanRow) { return { plan_year: row.plan_year, source_file: row.source_file, source_sheet: row.source_sheet, source_sha256: row.source_sha256, period_label: row.period_label, cost_detail_available: row.cost_detail_available, opening_retention: row.opening_retention, reported_cost_annual: row.reported_cost_annual, months: row.months, reported_income: row.reported_income, cashflow_income_factors: row.cashflow_income_factors, forecast_cost_rates: row.forecast_cost_rates, reported_costs: row.reported_costs, reported_retention: row.reported_retention, reported_retention_closing: row.reported_retention_closing, reported_cumulative_balance: row.reported_cumulative_balance, retention_payouts: row.retention_payouts, head_office_costs: row.head_office_costs, retention_months: row.retention_months ?? [], history: row.history, }; } function projectPayload(row: ManagementProjectRow) { // The site link is deliberately left alone: it points at live site data. // The name is the canonical one, taken from the first year that is imported. return { key: row.key, name: row.name }; } function projectPlanPayload(row: ManagementProjectPlanRow, planId: string, projectId: string) { return { plan: planId, project: projectId, status: row.status, sort_order: row.sort_order, // The project details belong to the year, so they sit on this row. name: row.name, client: row.client, engineer: row.engineer, manager: row.manager, stated_value: row.stated_value, remaining_value: row.remaining_value, retention_eligible: row.retention_eligible, income: row.income, forecast_income: row.forecast_income, actual_income: row.actual_income, future_income: row.future_income, forecast_baseline: row.forecast_baseline, retention_rates: row.retention_rates, costs: row.costs, }; } function retentionPayload(row: ManagementRetentionRow, planId: string, projectId: string | null) { return { plan: planId, project_name: row.project_name, project: projectId, debtor: row.debtor, amount: row.amount, release_date: row.release_date, source_id: row.source_id, }; } /** * The stored records of one collection. A collection that does not exist yet * simply means nothing has been imported, which is what the dry run reports. */ async function listRecords(pb: PocketBase, collection: string): Promise[]> { try { return (await pb.collection(collection).getFullList()) as unknown as Record[]; } catch (error) { if ((error as { status?: number })?.status === 404) return []; throw error; } } const yearOf = (planKey: string) => { const match = /plan-(\d{4})/.exec(planKey); return match ? Number(match[1]) : 0; }; type Counters = { create: number; update: number; "skip-edited": number; unchanged: number }; const counters = (): Counters => ({ create: 0, update: 0, "skip-edited": 0, unchanged: 0 }); function mergeCounts(target: Counters, action: keyof Counters) { target[action] += 1; } async function main() { const options = parseArgs(process.argv.slice(2)); const wanted = SNAPSHOTS.filter((entry) => options.year === null || entry.year === options.year); if (!wanted.length) throw new Error(`No workbook for year ${options.year}`); const years: ImportYear[] = []; for (const entry of wanted) { years.push({ year: entry.year, rows: snapshotToRows(await readSnapshot(entry.file)) }); } const pb = await pbAdmin(); const existing: ExistingRecords = { plans: await listRecords(pb, "management_plans"), projects: await listRecords(pb, "management_projects"), projectPlans: await listRecords(pb, "management_project_plans"), retentions: await listRecords(pb, "management_retention"), }; const plan = planImport(years, existing); if (options.dryRun) { const perYear = new Map(); const yearState = (year: number) => { let state = perYear.get(year); if (!state) { state = { plans: counters(), projectPlans: counters(), retentions: counters(), newProjects: 0, sharedProjects: 0 }; perYear.set(year, state); } return state; }; for (const action of plan.plans) mergeCounts(yearState(action.row.plan_year).plans, action.action); for (const action of plan.projectPlans) mergeCounts(yearState(yearOf(action.row.plan)).projectPlans, action.action); for (const action of plan.retentions) mergeCounts(yearState(yearOf(action.row.plan)).retentions, action.action); const seenKeys = new Set(); for (const { year, rows } of years) { for (const project of rows.projects) { if (seenKeys.has(project.key)) yearState(year).sharedProjects += 1; else { yearState(year).newProjects += 1; seenKeys.add(project.key); } } } console.log("Dry run — nothing was written.\n"); for (const year of [...perYear.keys()].sort((a, b) => a - b)) { const state = perYear.get(year)!; console.log(`${year}`); console.log(` plans create ${state.plans.create} | update ${state.plans.update} | skip-edited ${state.plans["skip-edited"]} | unchanged ${state.plans.unchanged}`); console.log(` project plans create ${state.projectPlans.create} | update ${state.projectPlans.update} | skip-edited ${state.projectPlans["skip-edited"]} | unchanged ${state.projectPlans.unchanged}`); console.log(` retention create ${state.retentions.create} | update ${state.retentions.update} | skip-edited ${state.retentions["skip-edited"]} | unchanged ${state.retentions.unchanged}`); console.log(` projects ${state.newProjects} new | ${state.sharedProjects} shared with an earlier year`); } console.log(`\nTotals: create ${plan.counts.create} | update ${plan.counts.update} | skip-edited ${plan.counts["skip-edited"]} | unchanged ${plan.counts.unchanged}`); console.log(`\nProject keys that appear in only one of the imported years (${plan.projectKeysOnlyInOneYear.length}):`); for (const key of plan.projectKeysOnlyInOneYear) console.log(` - ${key}`); if (years.length < SNAPSHOTS.length) { console.log(`\nNote: only ${years.length} of the ${SNAPSHOTS.length} workbook years were compared.`); } return; } const planIds = new Map(); const projectIds = new Map(); const skipped: string[] = []; for (const action of plan.plans) { if (action.action === "unchanged") { planIds.set(action.row.id, action.recordId!); continue; } if (action.action === "create") { const created = await pb.collection("management_plans").create({ ...planPayload(action.row), revision: 1 }); planIds.set(action.row.id, created.id); } else { await pb.collection("management_plans").update(action.recordId!, planPayload(action.row)); planIds.set(action.row.id, action.recordId!); } } for (const action of plan.projects) { if (action.action === "unchanged") { projectIds.set(action.row.id, action.recordId!); continue; } if (action.action === "create") { const created = await pb.collection("management_projects").create(projectPayload(action.row)); projectIds.set(action.row.id, created.id); } else { await pb.collection("management_projects").update(action.recordId!, projectPayload(action.row)); projectIds.set(action.row.id, action.recordId!); } } for (const action of plan.projectPlans) { if (action.action === "unchanged") continue; if (action.action === "skip-edited") { skipped.push(`${action.row.id} (${action.row.project})`); continue; } const planId = planIds.get(action.row.plan); const projectId = projectIds.get(action.row.project); if (!planId || !projectId) throw new Error(`Cannot resolve the relations of ${action.row.id}`); const payload = projectPlanPayload(action.row, planId, projectId); if (action.action === "create") { await pb.collection("management_project_plans").create({ ...payload, revision: 1 }); } else { await pb.collection("management_project_plans").update(action.recordId!, payload); } } for (const action of plan.retentions) { if (action.action === "unchanged") continue; const planId = planIds.get(action.row.plan); if (!planId) throw new Error(`Cannot resolve the plan of ${action.row.id}`); const projectId = action.row.project ? projectIds.get(action.row.project) ?? null : null; const payload = retentionPayload(action.row, planId, projectId); if (action.action === "create") { await pb.collection("management_retention").create(payload); } else { await pb.collection("management_retention").update(action.recordId!, payload); } } console.log(`Imported ${wanted.map((entry) => entry.year).join(", ")}: create ${plan.counts.create} | update ${plan.counts.update} | skip-edited ${plan.counts["skip-edited"]} | unchanged ${plan.counts.unchanged}`); if (skipped.length) { console.log("Skipped because they were edited in the app:"); for (const entry of skipped) console.log(` - ${entry}`); } } main().catch((error) => { console.error(error instanceof Error ? error.message : "The management import failed"); process.exitCode = 1; });