Move the Management planning snapshots into PocketBase collections (plans, projects, per-year project plans, retention, edit log), loaded with a bundled-JSON fallback. Admins can double-click an income cell to enter forecast and actual, edit awarded jobs' planned amounts, change status and costs, and add jobs, with revision checks and an audit trail. Income chart now shows forecast and invoiced bars with not-yet-started money stacked in red; Income is in the sidebar. Adds schema and import scripts, and jsdom/Testing Library dev dependencies for UI tests. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
296 lines
12 KiB
TypeScript
296 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;
|
|
});
|