297 lines
12 KiB
TypeScript
297 lines
12 KiB
TypeScript
|
|
#!/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;
|
||
|
|
});
|