feat: Add Processing Opex functionality

- Introduced OpexValidationError for handling validation errors in processing opex calculations.
- Implemented ProjectProcessingOpexRepository and ScenarioProcessingOpexRepository for managing project and scenario-level processing opex snapshots.
- Enhanced UnitOfWork to include repositories for processing opex.
- Updated sidebar navigation and scenario detail templates to include links to the new Processing Opex Planner.
- Created a new template for the Processing Opex Planner with form handling for input components and parameters.
- Developed integration tests for processing opex calculations, covering HTML and JSON flows, including validation for currency mismatches and unsupported frequencies.
- Added unit tests for the calculation logic, ensuring correct handling of various scenarios and edge cases.
This commit is contained in:
2025-11-13 09:26:57 +01:00
parent 1240b08740
commit 1feae7ff85
16 changed files with 1931 additions and 11 deletions

View File

@@ -15,9 +15,11 @@ from dependencies import get_pricing_metadata, get_unit_of_work, require_authent
from models import (
Project,
ProjectCapexSnapshot,
ProjectProcessingOpexSnapshot,
ProjectProfitability,
Scenario,
ScenarioCapexSnapshot,
ScenarioProcessingOpexSnapshot,
ScenarioProfitability,
User,
)
@@ -27,11 +29,25 @@ from schemas.calculations import (
CapexCalculationResult,
CapexComponentInput,
CapexParameters,
ProcessingOpexCalculationRequest,
ProcessingOpexCalculationResult,
ProcessingOpexComponentInput,
ProcessingOpexOptions,
ProcessingOpexParameters,
ProfitabilityCalculationRequest,
ProfitabilityCalculationResult,
)
from services.calculations import calculate_initial_capex, calculate_profitability
from services.exceptions import CapexValidationError, EntityNotFoundError, ProfitabilityValidationError
from services.calculations import (
calculate_initial_capex,
calculate_processing_opex,
calculate_profitability,
)
from services.exceptions import (
CapexValidationError,
EntityNotFoundError,
OpexValidationError,
ProfitabilityValidationError,
)
from services.pricing import PricingMetadata
from services.unit_of_work import UnitOfWork
from routes.template_filters import register_common_filters
@@ -56,6 +72,26 @@ _CAPEX_CATEGORY_OPTIONS: tuple[dict[str, str], ...] = (
)
_DEFAULT_CAPEX_HORIZON_YEARS = 5
_OPEX_CATEGORY_OPTIONS: tuple[dict[str, str], ...] = (
{"value": "labor", "label": "Labor"},
{"value": "materials", "label": "Materials"},
{"value": "energy", "label": "Energy"},
{"value": "maintenance", "label": "Maintenance"},
{"value": "other", "label": "Other"},
)
_OPEX_FREQUENCY_OPTIONS: tuple[dict[str, str], ...] = (
{"value": "daily", "label": "Daily"},
{"value": "weekly", "label": "Weekly"},
{"value": "monthly", "label": "Monthly"},
{"value": "quarterly", "label": "Quarterly"},
{"value": "annually", "label": "Annually"},
)
_DEFAULT_OPEX_HORIZON_YEARS = 5
_PROCESSING_OPEX_TEMPLATE = "scenarios/opex.html"
def _combine_impurity_metadata(metadata: PricingMetadata) -> list[dict[str, object]]:
"""Build impurity rows combining thresholds and penalties."""
@@ -366,6 +402,171 @@ def _prepare_capex_context(
}
def _serialise_opex_component_entry(component: Any) -> dict[str, Any]:
if isinstance(component, ProcessingOpexComponentInput):
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),
"unit_cost": getattr(component, "unit_cost", None),
"quantity": getattr(component, "quantity", None),
"frequency": getattr(component, "frequency", None),
"currency": getattr(component, "currency", None),
"period_start": getattr(component, "period_start", None),
"period_end": getattr(component, "period_end", None),
"notes": getattr(component, "notes", None),
}
return {
"id": raw.get("id"),
"name": _value_or_blank(raw.get("name")),
"category": raw.get("category") or "labor",
"unit_cost": _value_or_blank(raw.get("unit_cost")),
"quantity": _value_or_blank(raw.get("quantity")),
"frequency": raw.get("frequency") or "monthly",
"currency": _value_or_blank(raw.get("currency")),
"period_start": _value_or_blank(raw.get("period_start")),
"period_end": _value_or_blank(raw.get("period_end")),
"notes": _value_or_blank(raw.get("notes")),
}
def _serialise_opex_parameters(parameters: Any) -> dict[str, Any]:
if isinstance(parameters, ProcessingOpexParameters):
raw = parameters.model_dump()
elif isinstance(parameters, dict):
raw = dict(parameters)
else:
raw = {}
return {
"currency_code": _value_or_blank(raw.get("currency_code")),
"escalation_pct": _value_or_blank(raw.get("escalation_pct")),
"discount_rate_pct": _value_or_blank(raw.get("discount_rate_pct")),
"evaluation_horizon_years": _value_or_blank(
raw.get("evaluation_horizon_years")
),
"apply_escalation": _coerce_bool(raw.get("apply_escalation", True)),
}
def _serialise_opex_options(options: Any) -> dict[str, Any]:
if isinstance(options, ProcessingOpexOptions):
raw = options.model_dump()
elif isinstance(options, dict):
raw = dict(options)
else:
raw = {}
return {
"persist": _coerce_bool(raw.get("persist", False)),
"snapshot_notes": _value_or_blank(raw.get("snapshot_notes")),
}
def _build_opex_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)
last_updated_at = getattr(scenario, "opex_updated_at", None)
return {
"components": [],
"parameters": {
"currency_code": currency or None,
"escalation_pct": None,
"discount_rate_pct": discount_rate,
"evaluation_horizon_years": _DEFAULT_OPEX_HORIZON_YEARS,
"apply_escalation": True,
},
"options": {
"persist": bool(scenario or project),
"snapshot_notes": None,
},
"currency_code": currency or None,
"default_horizon": _DEFAULT_OPEX_HORIZON_YEARS,
"last_updated_at": last_updated_at,
}
def _prepare_opex_context(
request: Request,
*,
project: Project | None,
scenario: Scenario | None,
form_data: dict[str, Any] | None = None,
result: ProcessingOpexCalculationResult | 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_opex_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_opex_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_opex_parameters(form_data.get("parameters"))
)
parameters = _serialise_opex_parameters(raw_parameters)
raw_options = defaults["options"].copy()
if form_data and form_data.get("options"):
raw_options.update(_serialise_opex_options(form_data.get("options")))
options = _serialise_opex_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": _OPEX_CATEGORY_OPTIONS,
"frequency_options": _OPEX_FREQUENCY_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:
@@ -406,6 +607,87 @@ def _partition_capex_error_messages(
return general, component_specific
def _partition_opex_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 _opex_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]
if field == "apply_escalation":
parameters[field] = _coerce_bool(normalised_value)
else:
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:
if "persist" in options:
options["persist"] = _coerce_bool(options.get("persist"))
data["options"] = options
return data
def _capex_form_to_payload(form: FormData) -> dict[str, Any]:
data: dict[str, Any] = {}
components: dict[int, dict[str, Any]] = {}
@@ -458,6 +740,15 @@ def _capex_form_to_payload(form: FormData) -> dict[str, Any]:
return data
async def _extract_opex_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 _opex_form_to_payload(form)
async def _extract_capex_payload(request: Request) -> dict[str, Any]:
content_type = request.headers.get("content-type", "").lower()
if content_type.startswith("application/json"):
@@ -772,6 +1063,247 @@ def _persist_capex_snapshots(
uow.project_capex.create(project_snapshot)
def _should_persist_opex(
*,
project: Project | None,
scenario: Scenario | None,
request_model: ProcessingOpexCalculationRequest,
) -> bool:
persist_requested = bool(
getattr(request_model, "options", None)
and request_model.options.persist
)
return persist_requested and bool(project or scenario)
def _persist_opex_snapshots(
*,
uow: UnitOfWork,
project: Project | None,
scenario: Scenario | None,
user: User | None,
request_model: ProcessingOpexCalculationRequest,
result: ProcessingOpexCalculationResult,
) -> None:
if not _should_persist_opex(
project=project,
scenario=scenario,
request_model=request_model,
):
return
created_by_id = getattr(user, "id", None)
totals = result.totals
metrics = result.metrics
parameters = result.parameters
overall_annual = float(totals.overall_annual)
escalated_total = (
float(totals.escalated_total)
if totals.escalated_total is not None
else None
)
annual_average = (
float(metrics.annual_average)
if metrics.annual_average is not None
else None
)
evaluation_horizon = (
int(parameters.evaluation_horizon_years)
if parameters.evaluation_horizon_years is not None
else None
)
escalation_pct = (
float(totals.escalation_pct)
if totals.escalation_pct is not None
else (
float(parameters.escalation_pct)
if parameters.escalation_pct is not None and parameters.apply_escalation
else None
)
)
apply_escalation = bool(parameters.apply_escalation)
component_count = len(result.components)
payload = {
"request": request_model.model_dump(mode="json"),
"result": result.model_dump(),
}
if scenario and uow.scenario_processing_opex:
scenario_snapshot = ScenarioProcessingOpexSnapshot(
scenario_id=scenario.id,
created_by_id=created_by_id,
calculation_source="calculations.processing_opex",
currency_code=result.currency,
overall_annual=overall_annual,
escalated_total=escalated_total,
annual_average=annual_average,
evaluation_horizon_years=evaluation_horizon,
escalation_pct=escalation_pct,
apply_escalation=apply_escalation,
component_count=component_count,
payload=payload,
)
uow.scenario_processing_opex.create(scenario_snapshot)
if project and uow.project_processing_opex:
project_snapshot = ProjectProcessingOpexSnapshot(
project_id=project.id,
created_by_id=created_by_id,
calculation_source="calculations.processing_opex",
currency_code=result.currency,
overall_annual=overall_annual,
escalated_total=escalated_total,
annual_average=annual_average,
evaluation_horizon_years=evaluation_horizon,
escalation_pct=escalation_pct,
apply_escalation=apply_escalation,
component_count=component_count,
payload=payload,
)
uow.project_processing_opex.create(project_snapshot)
@router.get(
"/processing-opex",
response_class=HTMLResponse,
name="calculations.processing_opex_form",
)
def processing_opex_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 processing opex planner with default context."""
project, scenario = _load_project_and_scenario(
uow=uow, project_id=project_id, scenario_id=scenario_id
)
context = _prepare_opex_context(
request,
project=project,
scenario=scenario,
)
return templates.TemplateResponse(_PROCESSING_OPEX_TEMPLATE, context)
@router.post(
"/processing-opex",
name="calculations.processing_opex_submit",
)
async def processing_opex_submit(
request: Request,
current_user: 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:
"""Handle processing opex submissions and respond with HTML or JSON."""
wants_json = _is_json_request(request)
payload_data = await _extract_opex_payload(request)
try:
request_model = ProcessingOpexCalculationRequest.model_validate(
payload_data
)
result = calculate_processing_opex(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_opex_error_messages(
exc.errors()
)
context = _prepare_opex_context(
request,
project=project,
scenario=scenario,
form_data=payload_data,
errors=general_errors,
component_errors=component_errors,
)
return templates.TemplateResponse(
_PROCESSING_OPEX_TEMPLATE,
context,
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
)
except OpexValidationError 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_opex_context(
request,
project=project,
scenario=scenario,
form_data=payload_data,
errors=errors,
)
return templates.TemplateResponse(
_PROCESSING_OPEX_TEMPLATE,
context,
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
)
project, scenario = _load_project_and_scenario(
uow=uow, project_id=project_id, scenario_id=scenario_id
)
_persist_opex_snapshots(
uow=uow,
project=project,
scenario=scenario,
user=current_user,
request_model=request_model,
result=result,
)
if wants_json:
return JSONResponse(
status_code=status.HTTP_200_OK,
content=result.model_dump(),
)
context = _prepare_opex_context(
request,
project=project,
scenario=scenario,
form_data=request_model.model_dump(mode="json"),
result=result,
)
notices = _list_from_context(context, "notices")
notices.append("Processing opex calculation completed successfully.")
return templates.TemplateResponse(
_PROCESSING_OPEX_TEMPLATE,
context,
status_code=status.HTTP_200_OK,
)
@router.get(
"/capex",
response_class=HTMLResponse,