MEGAINFRA-WEBAPP/scripts/import-management-plans.ts

297 lines
12 KiB
TypeScript
Raw Normal View History

#!/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<WorkbookSnapshot> {
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<Record<string, unknown>[]> {
try {
return (await pb.collection(collection).getFullList()) as unknown as Record<string, unknown>[];
} 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<number, { plans: Counters; projectPlans: Counters; retentions: Counters; newProjects: number; sharedProjects: number }>();
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<string>();
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<string, string>();
const projectIds = new Map<string, string>();
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;
});