feat: Implement initial capex calculation feature

- Added CapexComponentInput, CapexParameters, CapexCalculationRequest, CapexCalculationResult, and related schemas for capex calculations.
- Introduced calculate_initial_capex function to aggregate capex components and compute totals and timelines.
- Created ProjectCapexRepository and ScenarioCapexRepository for managing capex snapshots in the database.
- Developed capex.html template for capturing and displaying initial capex data.
- Registered common Jinja2 filters for formatting currency and percentages.
- Implemented unit and integration tests for capex calculation functionality.
- Updated unit of work to include new repositories for capex management.
This commit is contained in:
2025-11-12 23:51:52 +01:00
parent 6c1570a254
commit d9fd82b2e3
16 changed files with 1566 additions and 93 deletions

View File

@@ -3,7 +3,7 @@
from __future__ import annotations
from decimal import Decimal
from typing import Any
from typing import Any, Sequence
from fastapi import APIRouter, Depends, Query, Request, status
from fastapi.responses import HTMLResponse, JSONResponse, Response
@@ -14,22 +14,31 @@ from starlette.datastructures import FormData
from dependencies import get_pricing_metadata, get_unit_of_work, require_authenticated_user
from models import (
Project,
ProjectCapexSnapshot,
ProjectProfitability,
Scenario,
ScenarioCapexSnapshot,
ScenarioProfitability,
User,
)
from schemas.calculations import (
CapexCalculationOptions,
CapexCalculationRequest,
CapexCalculationResult,
CapexComponentInput,
CapexParameters,
ProfitabilityCalculationRequest,
ProfitabilityCalculationResult,
)
from services.calculations import calculate_profitability
from services.exceptions import EntityNotFoundError, ProfitabilityValidationError
from services.calculations import calculate_initial_capex, calculate_profitability
from services.exceptions import CapexValidationError, EntityNotFoundError, ProfitabilityValidationError
from services.pricing import PricingMetadata
from services.unit_of_work import UnitOfWork
from routes.template_filters import register_common_filters
router = APIRouter(prefix="/calculations", tags=["Calculations"])
templates = Jinja2Templates(directory="templates")
register_common_filters(templates)
_SUPPORTED_METALS: tuple[dict[str, str], ...] = (
{"value": "copper", "label": "Copper"},
@@ -39,6 +48,14 @@ _SUPPORTED_METALS: tuple[dict[str, str], ...] = (
_SUPPORTED_METAL_VALUES = {entry["value"] for entry in _SUPPORTED_METALS}
_DEFAULT_EVALUATION_PERIODS = 10
_CAPEX_CATEGORY_OPTIONS: tuple[dict[str, str], ...] = (
{"value": "equipment", "label": "Equipment"},
{"value": "infrastructure", "label": "Infrastructure"},
{"value": "land", "label": "Land & Property"},
{"value": "miscellaneous", "label": "Miscellaneous"},
)
_DEFAULT_CAPEX_HORIZON_YEARS = 5
def _combine_impurity_metadata(metadata: PricingMetadata) -> list[dict[str, object]]:
"""Build impurity rows combining thresholds and penalties."""
@@ -190,6 +207,266 @@ def _prepare_form_data_for_display(
return data
def _coerce_bool(value: Any) -> bool:
if isinstance(value, bool):
return value
if isinstance(value, str):
lowered = value.strip().lower()
return lowered in {"1", "true", "yes", "on"}
return bool(value)
def _serialise_capex_component_entry(component: Any) -> dict[str, Any]:
if isinstance(component, CapexComponentInput):
raw = component.model_dump()
elif isinstance(component, dict):
raw = dict(component)
else:
raw = {
"id": getattr(component, "id", None),
"name": getattr(component, "name", None),
"category": getattr(component, "category", None),
"amount": getattr(component, "amount", None),
"currency": getattr(component, "currency", None),
"spend_year": getattr(component, "spend_year", None),
"notes": getattr(component, "notes", None),
}
return {
"id": raw.get("id"),
"name": _value_or_blank(raw.get("name")),
"category": raw.get("category") or "equipment",
"amount": _value_or_blank(raw.get("amount")),
"currency": _value_or_blank(raw.get("currency")),
"spend_year": _value_or_blank(raw.get("spend_year")),
"notes": _value_or_blank(raw.get("notes")),
}
def _serialise_capex_parameters(parameters: Any) -> dict[str, Any]:
if isinstance(parameters, CapexParameters):
raw = parameters.model_dump()
elif isinstance(parameters, dict):
raw = dict(parameters)
else:
raw = {}
return {
"currency_code": _value_or_blank(raw.get("currency_code")),
"contingency_pct": _value_or_blank(raw.get("contingency_pct")),
"discount_rate_pct": _value_or_blank(raw.get("discount_rate_pct")),
"evaluation_horizon_years": _value_or_blank(
raw.get("evaluation_horizon_years")
),
}
def _serialise_capex_options(options: Any) -> dict[str, Any]:
if isinstance(options, CapexCalculationOptions):
raw = options.model_dump()
elif isinstance(options, dict):
raw = dict(options)
else:
raw = {}
return {"persist": _coerce_bool(raw.get("persist", False))}
def _build_capex_defaults(
*,
project: Project | None,
scenario: Scenario | None,
) -> dict[str, Any]:
currency = ""
if scenario and getattr(scenario, "currency", None):
currency = str(scenario.currency).upper()
elif project and getattr(project, "currency", None):
currency = str(project.currency).upper()
discount_rate = ""
scenario_discount = getattr(scenario, "discount_rate", None)
if scenario_discount is not None:
discount_rate = float(scenario_discount)
return {
"components": [],
"parameters": {
"currency_code": currency or None,
"contingency_pct": None,
"discount_rate_pct": discount_rate,
"evaluation_horizon_years": _DEFAULT_CAPEX_HORIZON_YEARS,
},
"options": {
"persist": bool(scenario or project),
},
"currency_code": currency or None,
"default_horizon": _DEFAULT_CAPEX_HORIZON_YEARS,
"last_updated_at": getattr(scenario, "capex_updated_at", None),
}
def _prepare_capex_context(
request: Request,
*,
project: Project | None,
scenario: Scenario | None,
form_data: dict[str, Any] | None = None,
result: CapexCalculationResult | None = None,
errors: list[str] | None = None,
notices: list[str] | None = None,
component_errors: list[str] | None = None,
component_notices: list[str] | None = None,
) -> dict[str, Any]:
if form_data is not None and hasattr(form_data, "model_dump"):
form_data = form_data.model_dump() # type: ignore[assignment]
defaults = _build_capex_defaults(project=project, scenario=scenario)
raw_components: list[Any] = []
if form_data and "components" in form_data:
raw_components = list(form_data.get("components") or [])
components = [
_serialise_capex_component_entry(component) for component in raw_components
]
raw_parameters = defaults["parameters"].copy()
if form_data and form_data.get("parameters"):
raw_parameters.update(
_serialise_capex_parameters(form_data.get("parameters"))
)
parameters = _serialise_capex_parameters(raw_parameters)
raw_options = defaults["options"].copy()
if form_data and form_data.get("options"):
raw_options.update(_serialise_capex_options(form_data.get("options")))
options = _serialise_capex_options(raw_options)
currency_code = parameters.get(
"currency_code") or defaults["currency_code"]
return {
"request": request,
"project": project,
"scenario": scenario,
"components": components,
"parameters": parameters,
"options": options,
"currency_code": currency_code,
"category_options": _CAPEX_CATEGORY_OPTIONS,
"default_horizon": defaults["default_horizon"],
"last_updated_at": defaults["last_updated_at"],
"result": result,
"errors": errors or [],
"notices": notices or [],
"component_errors": component_errors or [],
"component_notices": component_notices or [],
"cancel_url": request.headers.get("Referer"),
"form_action": request.url.path,
"csrf_token": None,
}
def _format_error_location(location: tuple[Any, ...]) -> str:
path = ""
for part in location:
if isinstance(part, int):
path += f"[{part}]"
else:
if path:
path += f".{part}"
else:
path = str(part)
return path or "(input)"
def _partition_capex_error_messages(
errors: Sequence[Any],
) -> tuple[list[str], list[str]]:
general: list[str] = []
component_specific: list[str] = []
for error in errors:
if isinstance(error, dict):
mapping = error
else:
try:
mapping = dict(error)
except TypeError:
mapping = {}
location = tuple(mapping.get("loc", ()))
message = mapping.get("msg", "Invalid value")
formatted_location = _format_error_location(location)
entry = f"{formatted_location} - {message}"
if location and location[0] == "components":
component_specific.append(entry)
else:
general.append(entry)
return general, component_specific
def _capex_form_to_payload(form: FormData) -> dict[str, Any]:
data: dict[str, Any] = {}
components: dict[int, dict[str, Any]] = {}
parameters: dict[str, Any] = {}
options: dict[str, Any] = {}
for key, value in form.multi_items():
normalised_value = _normalise_form_value(value)
if key.startswith("components["):
try:
index_part = key[len("components["):]
index_str, remainder = index_part.split("]", 1)
field = remainder.strip()[1:-1]
index = int(index_str)
except (ValueError, IndexError):
continue
entry = components.setdefault(index, {})
entry[field] = normalised_value
continue
if key.startswith("parameters["):
field = key[len("parameters["):-1]
parameters[field] = normalised_value
continue
if key.startswith("options["):
field = key[len("options["):-1]
options[field] = normalised_value
continue
if key == "csrf_token":
continue
data[key] = normalised_value
if components:
ordered = [
components[index] for index in sorted(components.keys())
]
data["components"] = ordered
if parameters:
data["parameters"] = parameters
if options:
options["persist"] = _coerce_bool(options.get("persist"))
data["options"] = options
return data
async def _extract_capex_payload(request: Request) -> dict[str, Any]:
content_type = request.headers.get("content-type", "").lower()
if content_type.startswith("application/json"):
body = await request.json()
return body if isinstance(body, dict) else {}
form = await request.form()
return _capex_form_to_payload(form)
def _prepare_default_context(
request: Request,
*,
@@ -424,6 +701,205 @@ def _persist_profitability_snapshots(
uow.project_profitability.create(project_snapshot)
def _should_persist_capex(
*,
project: Project | None,
scenario: Scenario | None,
request_model: CapexCalculationRequest,
) -> bool:
"""Determine whether capex snapshots should be stored."""
persist_requested = bool(
getattr(request_model, "options", None)
and request_model.options.persist
)
return persist_requested and bool(project or scenario)
def _persist_capex_snapshots(
*,
uow: UnitOfWork,
project: Project | None,
scenario: Scenario | None,
user: User | None,
request_model: CapexCalculationRequest,
result: CapexCalculationResult,
) -> None:
if not _should_persist_capex(
project=project,
scenario=scenario,
request_model=request_model,
):
return
created_by_id = getattr(user, "id", None)
totals = result.totals
component_count = len(result.components)
payload = {
"request": request_model.model_dump(mode="json"),
"result": result.model_dump(),
}
if scenario and uow.scenario_capex:
scenario_snapshot = ScenarioCapexSnapshot(
scenario_id=scenario.id,
created_by_id=created_by_id,
calculation_source="calculations.capex",
currency_code=result.currency,
total_capex=float(totals.overall),
contingency_pct=float(totals.contingency_pct),
contingency_amount=float(totals.contingency_amount),
total_with_contingency=float(totals.with_contingency),
component_count=component_count,
payload=payload,
)
uow.scenario_capex.create(scenario_snapshot)
if project and uow.project_capex:
project_snapshot = ProjectCapexSnapshot(
project_id=project.id,
created_by_id=created_by_id,
calculation_source="calculations.capex",
currency_code=result.currency,
total_capex=float(totals.overall),
contingency_pct=float(totals.contingency_pct),
contingency_amount=float(totals.contingency_amount),
total_with_contingency=float(totals.with_contingency),
component_count=component_count,
payload=payload,
)
uow.project_capex.create(project_snapshot)
@router.get(
"/capex",
response_class=HTMLResponse,
name="calculations.capex_form",
)
def capex_form(
request: Request,
_: User = Depends(require_authenticated_user),
uow: UnitOfWork = Depends(get_unit_of_work),
project_id: int | None = Query(
None, description="Optional project identifier"),
scenario_id: int | None = Query(
None, description="Optional scenario identifier"),
) -> HTMLResponse:
"""Render the initial capex planner template with defaults."""
project, scenario = _load_project_and_scenario(
uow=uow, project_id=project_id, scenario_id=scenario_id
)
context = _prepare_capex_context(
request,
project=project,
scenario=scenario,
)
return templates.TemplateResponse("scenarios/capex.html", context)
@router.post(
"/capex",
name="calculations.capex_submit",
)
async def capex_submit(
request: Request,
_: User = Depends(require_authenticated_user),
uow: UnitOfWork = Depends(get_unit_of_work),
project_id: int | None = Query(
None, description="Optional project identifier"),
scenario_id: int | None = Query(
None, description="Optional scenario identifier"),
) -> Response:
"""Process capex submissions and return aggregated results."""
wants_json = _is_json_request(request)
payload_data = await _extract_capex_payload(request)
try:
request_model = CapexCalculationRequest.model_validate(payload_data)
result = calculate_initial_capex(request_model)
except ValidationError as exc:
if wants_json:
return JSONResponse(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
content={"errors": exc.errors()},
)
project, scenario = _load_project_and_scenario(
uow=uow, project_id=project_id, scenario_id=scenario_id
)
general_errors, component_errors = _partition_capex_error_messages(
exc.errors()
)
context = _prepare_capex_context(
request,
project=project,
scenario=scenario,
form_data=payload_data,
errors=general_errors,
component_errors=component_errors,
)
return templates.TemplateResponse(
"scenarios/capex.html",
context,
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
)
except CapexValidationError as exc:
if wants_json:
return JSONResponse(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
content={
"errors": list(exc.field_errors or []),
"message": exc.message,
},
)
project, scenario = _load_project_and_scenario(
uow=uow, project_id=project_id, scenario_id=scenario_id
)
errors = list(exc.field_errors or []) or [exc.message]
context = _prepare_capex_context(
request,
project=project,
scenario=scenario,
form_data=payload_data,
errors=errors,
)
return templates.TemplateResponse(
"scenarios/capex.html",
context,
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
)
project, scenario = _load_project_and_scenario(
uow=uow, project_id=project_id, scenario_id=scenario_id
)
if wants_json:
return JSONResponse(
status_code=status.HTTP_200_OK,
content=result.model_dump(),
)
context = _prepare_capex_context(
request,
project=project,
scenario=scenario,
form_data=request_model.model_dump(mode="json"),
result=result,
)
notices = _list_from_context(context, "notices")
notices.append("Initial capex calculation completed successfully.")
return templates.TemplateResponse(
"scenarios/capex.html",
context,
status_code=status.HTTP_200_OK,
)
@router.get(
"/profitability",
response_class=HTMLResponse,