MEGAINFRA-WEBAPP/scripts/pb-add-management.py
DEls a96f35366d feat(management): store planning data in PocketBase with editable income, add job and forecast vs actual chart
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>
2026-10-20 03:04:01 +02:00

181 lines
8.6 KiB
Python

#!/usr/bin/env python3
"""Create the management planning collections in PocketBase.
The Management pages read the planning workbook out of PocketBase so admins
can enter actual income, change a project's status and keep an audit trail
(see docs/superpowers/plans/2026-10-19-management-pocketbase.md).
Run: python3 scripts/pb-add-management.py [--dry-run]
--dry-run only reads and prints what would be created; it writes nothing.
The script is idempotent: a missing collection is created, an existing one is
verified (fields, indexes, server-only rules) and never modified. No record is
touched and nothing is ever deleted.
"""
import sys
from pb_env import http, superuser_token
# The planning workbook arrays and the history blob are far bigger than
# PocketBase's 1MB json default.
JSON_MAX_SIZE = 2000000
def main():
dry_run = "--dry-run" in sys.argv[1:]
if any(arg not in ("--dry-run", "--help") for arg in sys.argv[1:]):
sys.exit("Unknown option. Usage: python3 scripts/pb-add-management.py [--dry-run]")
if "--help" in sys.argv[1:]:
print(__doc__.strip())
return
token = superuser_token()
created = []
def read(name):
code, result = http("GET", f"/api/collections/{name}", token=token)
if code != 200:
raise RuntimeError(f"Cannot read {name}: HTTP {code}")
return result
def relation(name, collection, required=True, cascade_delete=False):
return {"name": name, "type": "relation", "required": required,
"collectionId": collection["id"], "maxSelect": 1, "cascadeDelete": cascade_delete}
def json_field(name):
return {"name": name, "type": "json", "required": False, "maxSize": JSON_MAX_SIZE}
def json_number_field(name):
# A json field keeps a missing number null; a number field hands back
# 0 for an empty value, which is not the same as "not supplied".
return {"name": name, "type": "json", "required": False, "maxSize": 1000}
def ensure(name, fields, indexes):
wanted = {"name": name, "type": "base", "fields": fields + [
{"name": "created", "type": "autodate", "onCreate": True, "onUpdate": False},
{"name": "updated", "type": "autodate", "onCreate": True, "onUpdate": True},
], "indexes": indexes, **{rule: None for rule in (
"listRule", "viewRule", "createRule", "updateRule", "deleteRule")}}
code, actual = http("GET", f"/api/collections/{name}", token=token)
if code == 404:
if dry_run:
print(f"Would create {name}")
created.append(name)
# The relations below need a collection id to point at; the
# placeholder is never written, it only keeps the dry run going.
return {"id": "000000000000000", "name": name}
code, actual = http("POST", "/api/collections", wanted, token)
if code != 200:
raise RuntimeError(f"Cannot create {name}: HTTP {code}: {actual}")
print(f"Created {name}")
created.append(name)
elif code != 200:
raise RuntimeError(f"Cannot inspect {name}: HTTP {code}")
actual = read(name)
by_name = {f["name"]: f for f in actual["fields"]}
for field in wanted["fields"]:
if any(by_name.get(field["name"], {}).get(k) != v for k, v in field.items()):
raise RuntimeError(f"Incompatible {name}.{field['name']}")
for index in indexes:
if index not in actual.get("indexes", []):
raise RuntimeError(f"Missing index on {name}")
for rule in ("listRule", "viewRule", "createRule", "updateRule", "deleteRule"):
if actual.get(rule) is not None:
raise RuntimeError(f"Expected server-only access on {name}")
if not dry_run:
print(f"Verified {name}")
return actual
sites = read("sites")
plans = ensure("management_plans", [
{"name": "plan_year", "type": "number", "required": True, "onlyInt": True},
{"name": "source_file", "type": "text", "required": False},
{"name": "source_sheet", "type": "text", "required": False},
{"name": "source_sha256", "type": "text", "required": False},
{"name": "period_label", "type": "text", "required": False},
{"name": "cost_detail_available", "type": "bool", "required": False},
{"name": "opening_retention", "type": "number", "required": False},
{"name": "reported_cost_annual", "type": "number", "required": False},
json_field("months"),
json_field("reported_income"),
json_field("cashflow_income_factors"),
json_field("forecast_cost_rates"),
json_field("reported_costs"),
json_field("reported_retention"),
json_field("reported_retention_closing"),
json_field("reported_cumulative_balance"),
json_field("retention_payouts"),
json_field("head_office_costs"),
# The retention periods of the source schedule, including the periods
# that carry no release.
json_field("retention_months"),
json_field("history"),
{"name": "revision", "type": "number", "required": False, "min": 1, "onlyInt": True},
], ["CREATE UNIQUE INDEX `idx_management_plans_plan_year` ON `management_plans` (`plan_year`)"])
projects = ensure("management_projects", [
{"name": "key", "type": "text", "required": True},
# The canonical display name, the one seen first across the years. The
# per-year client and engineer live on the project plan.
{"name": "name", "type": "text", "required": False},
relation("site", sites, False),
], ["CREATE UNIQUE INDEX `idx_management_projects_key` ON `management_projects` (`key`)"])
project_plans = ensure("management_project_plans", [
relation("plan", plans, True, True),
relation("project", projects, True),
{"name": "status", "type": "select", "required": False, "maxSelect": 1,
"values": ["active", "awarded"]},
{"name": "sort_order", "type": "number", "required": False},
# The project details of this year: they differ from year to year.
{"name": "name", "type": "text", "required": True},
{"name": "client", "type": "text", "required": False},
{"name": "engineer", "type": "text", "required": False},
{"name": "manager", "type": "text", "required": False},
json_number_field("stated_value"),
json_number_field("remaining_value"),
{"name": "retention_eligible", "type": "bool", "required": False},
json_field("income"),
json_field("forecast_income"),
json_field("actual_income"),
json_field("future_income"),
json_field("forecast_baseline"),
json_field("retention_rates"),
json_field("costs"),
{"name": "revision", "type": "number", "required": False, "min": 1, "onlyInt": True},
], ["CREATE UNIQUE INDEX `idx_management_project_plans_plan_project` ON `management_project_plans` (`plan`, `project`)"])
ensure("management_retention", [
relation("plan", plans, True, True),
{"name": "project_name", "type": "text", "required": False},
relation("project", projects, False),
{"name": "debtor", "type": "text", "required": False},
{"name": "amount", "type": "number", "required": False},
{"name": "release_date", "type": "date", "required": False},
{"name": "source_id", "type": "text", "required": False},
], ["CREATE INDEX `idx_management_retention_plan_source` ON `management_retention` (`plan`, `source_id`)"])
ensure("management_edits", [
{"name": "plan_year", "type": "number", "required": False, "onlyInt": True},
# The audit trail outlives the project plan it points at.
relation("project_plan", project_plans, False),
{"name": "project_name", "type": "text", "required": False},
{"name": "field", "type": "text", "required": False},
{"name": "month_index", "type": "number", "required": False},
json_field("old_value"),
json_field("new_value"),
{"name": "user_label", "type": "text", "required": False},
], ["CREATE INDEX `idx_management_edits_project_plan` ON `management_edits` (`project_plan`, `plan_year`)"])
if dry_run:
print(f"Dry run: {len(created)} collection(s) would be created: {', '.join(created) or 'none'}")
print("Nothing was written.")
return
print("Verified the management planning schema. No plan, project or audit record changed.")
if __name__ == "__main__":
try:
main()
except Exception as exc:
sys.exit(str(exc))