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:
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user