"""Backfill the ``hardware`` cost_type into ``finance_spend_facts`` over a historical period range. Why this exists =============== The unified spend ledger gained a ``hardware`` cost_type (monthly depreciation on the ``consumption`` basis + purchase cash on the ``cash`` basis) — see ``finance_spend_coordinator.collect_hardware_rows``. The daily ``finance.spend_rollup`` cron only (re)materialises the current + previous month, so: * **Open** historical months pick hardware up automatically on the next cron tick (the ROLLUP_SEMANTICS_VERSION bump forces a recompute). * **Closed** historical months are NOT auto-republished (drift detection only), so a fiscal year that was already closed month-by-month would show $0 hardware until explicitly recomputed. This one-shot tool recomputes a period range so the BU fiscal-year cost view shows hardware across the whole elapsed year. For a CLOSED month it recomputes WITH ``allow_closed=True`` (republish) — this is a legitimate restatement: the books were genuinely incomplete (hardware was missing), and ``materialize_period`` records a ``FinanceRestatement`` row for the audit trail when a closed total moves. Idempotent: same inputs → same ``fact_key`` set → re-running is a no-op. Usage ===== # Dry-run (default): list the periods that would be recomputed. python -m scripts.backfill_hardware_spend --start 2026-04 --end 2026-09 # Execute against the LOCAL dev DB. python -m scripts.backfill_hardware_spend --start 2026-04 --end 2026-09 --execute # Recompute the current fiscal year (April start) up to this month. python -m scripts.backfill_hardware_spend --current-fy --execute ``--include-closed`` republishes closed months too (restatement); omit it to skip closed months and only refresh open ones. """ from __future__ import annotations import argparse import asyncio import logging import os import sys from datetime import UTC, datetime # Ensure the app package is importable when run as a file. sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) # Load the full SQLAlchemy mapper registry BEFORE any ORM query. The # ``--execute`` path drives ``materialize_period`` → ``collect_all_rows``, # whose queries configure the global mapper registry; importing only the # finance models leaves cross-domain relationships (e.g. # ``IdentityPerson.roles`` → ``AuthUserRole``) unresolved and the first # query raises ``InvalidRequestError: ... 'AuthUserRole' is not defined``. # Importing ``app.main`` registers every model (it transitively imports all # routers/domains) and has no import-time side effects (the server lifespan # does not run on a bare import). Mirrors why ``alembic/env.py`` imports all # model modules up front. import app.main # noqa: E402,F401 from app.core import fiscal_year as fy # noqa: E402 from app.core.coordinators import finance_spend_coordinator as coord # noqa: E402 from app.core.database import async_session_factory # noqa: E402 from app.domains.finance.models import CLOSE_CLOSED # noqa: E402 logging.basicConfig(level=logging.INFO, format="%(levelname)s %(message)s") logger = logging.getLogger("backfill_hardware_spend") def _iter_periods(start: str, end: str) -> list[str]: sy, sm = int(start[:4]), int(start[5:7]) ey, em = int(end[:4]), int(end[5:7]) out: list[str] = [] y, m = sy, sm while (y, m) <= (ey, em): out.append(f"{y:04d}-{m:02d}") m += 1 if m > 12: m, y = 1, y + 1 return out async def _resolve_range(args: argparse.Namespace) -> tuple[str, str]: if args.current_fy: async with async_session_factory() as db: start_month = await fy.get_fiscal_year_start_month(db) cur_fy = fy.current_fiscal_year(start_month) lo, _ = fy.fy_bounds(cur_fy, start_month) now = datetime.now(UTC) end = min(f"{now.year:04d}-{now.month:02d}", fy.fy_bounds(cur_fy, start_month)[1]) return lo, end if not (args.start and args.end): raise SystemExit("Provide --start and --end (YYYY-MM), or --current-fy.") return args.start, args.end async def main_async(args: argparse.Namespace) -> int: start, end = await _resolve_range(args) periods = _iter_periods(start, end) logger.info("Hardware backfill window: %s .. %s (%d periods)", start, end, len(periods)) if not args.execute: logger.info("DRY-RUN — pass --execute to write. Periods: %s", ", ".join(periods)) return 0 recomputed = 0 skipped_closed = 0 async with async_session_factory() as db: for period in periods: close = await coord._is_closed(db, period) is_closed = close is not None and close.status == CLOSE_CLOSED if is_closed and not args.include_closed: logger.info("skip %s (closed; pass --include-closed to restate)", period) skipped_closed += 1 continue result = await coord.materialize_period( db, period, allow_closed=args.include_closed, ) await db.commit() recomputed += 1 logger.info( "%s: rows=%s consumption_usd=%s restatement=%s", period, result.get("rows"), result.get("consumption_total_usd"), bool(result.get("restatement")), ) logger.info("Done. recomputed=%d skipped_closed=%d", recomputed, skipped_closed) return 0 def main() -> int: p = argparse.ArgumentParser(description="Backfill hardware cost_type into finance_spend_facts.") p.add_argument("--start", help="First period YYYY-MM") p.add_argument("--end", help="Last period YYYY-MM (inclusive)") p.add_argument("--current-fy", action="store_true", help="Use the current fiscal year (start month from platform setting).") p.add_argument("--include-closed", action="store_true", help="Also recompute CLOSED periods (republish + restatement).") p.add_argument("--execute", action="store_true", help="Write (default is dry-run).") return asyncio.run(main_async(p.parse_args())) if __name__ == "__main__": raise SystemExit(main())