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

@@ -1,13 +1,21 @@
# Changelog # Changelog
## 2025-11-13
- Delivered the initial capex planner end-to-end: added scaffolded UI in `templates/scenarios/capex.html`, wired GET/POST handlers through `routes/calculations.py`, implemented calculation logic plus snapshot persistence in `services/calculations.py` and `models/capex_snapshot.py`, updated navigation links, and introduced unit tests in `tests/services/test_calculations_capex.py`.
- Updated UI navigation to surface the processing opex planner by adding the sidebar link in `templates/partials/sidebar_nav.html`, wiring a scenario detail action in `templates/scenarios/detail.html`.
- Completed manual validation of the Initial Capex Planner UI flows (sidebar entry, scenario deep link, validation errors, successful calculation) with results captured in `manual_tests/initial_capex.md`, documented snapshot verification steps, and noted the optional JSON client check for future follow-up.
- Added processing opex calculation unit tests in `tests/services/test_calculations_processing_opex.py` covering success metrics, currency validation, frequency enforcement, and evaluation horizon extension.
- Documented the Processing Opex Planner workflow in `calminer-docs/userguide/processing_opex_planner.md`, linked it from the user guide index, extended `calminer-docs/architecture/08_concepts/02_data_model.md` with snapshot coverage, and captured the completion in `.github/instructions/DONE.md`.
- Implemented processing opex integration coverage in `tests/integration/test_processing_opex_calculations.py`, exercising HTML and JSON flows, verifying snapshot persistence, and asserting currency mismatch handling for form and API submissions.
- Executed the full pytest suite with coverage (211 tests) to confirm no regressions or warnings after the processing opex documentation updates.
## 2025-11-12 ## 2025-11-12
- Persisted initial capex calculations to project and scenario snapshot tables via shared helper in `routes/calculations.py`, centralised Jinja filter registration, and expanded integration tests to assert HTML/JSON flows create snapshots with correct totals and payload metadata (requires full-suite pytest run to satisfy 80% coverage gate).
- Resolved test suite regressions by registering the UI router in test fixtures, restoring `TABLE_DDLS` for enum validation checks, hardening token tamper detection, and reran the full pytest suite to confirm green builds.
- Fixed critical 500 error in reporting dashboard by correcting route reference in reporting.html template - changed 'reports.project_list_page' to 'projects.project_list_page' to resolve NoMatchFound error when accessing /ui/reporting. - Fixed critical 500 error in reporting dashboard by correcting route reference in reporting.html template - changed 'reports.project_list_page' to 'projects.project_list_page' to resolve NoMatchFound error when accessing /ui/reporting.
- Completed navigation validation by inventorying all sidebar navigation links, identifying missing routes for simulations, reporting, settings, themes, and currencies, created new UI routes in routes/ui.py with proper authentication guards, built corresponding templates (simulations.html, reporting.html, settings.html, theme_settings.html, currencies.html), registered the UI router in main.py, updated sidebar navigation to use route names instead of hardcoded URLs, and enhanced navigation.js to use dynamic URL resolution for proper route handling. - Completed navigation validation by inventorying all sidebar navigation links, identifying missing routes for simulations, reporting, settings, themes, and currencies, created new UI routes in routes/ui.py with proper authentication guards, built corresponding templates (simulations.html, reporting.html, settings.html, theme_settings.html, currencies.html), registered the UI router in main.py, updated sidebar navigation to use route names instead of hardcoded URLs, and enhanced navigation.js to use dynamic URL resolution for proper route handling.
- Fixed critical template rendering error in sidebar_nav.html where URL objects from request.url_for() were being used with string methods, causing TypeError. Added |string filters to convert URL objects to strings for proper template rendering. - Fixed critical template rendering error in sidebar_nav.html where URL objects from `request.url_for()` were being used with string methods, causing TypeError. Added `|string` filters to convert URL objects to strings for proper template rendering.
- Integrated Plotly charting for interactive visualizations in reporting templates, added chart generation methods to ReportingService (\_generate_npv_comparison_chart, \_generate_distribution_histogram), updated project summary and scenario distribution contexts to include chart JSON data, enhanced templates with chart containers and JavaScript rendering, added chart-container CSS styling, and validated all reporting tests pass. - Integrated Plotly charting for interactive visualizations in reporting templates, added chart generation methods to ReportingService (`generate_npv_comparison_chart`, `generate_distribution_histogram`), updated project summary and scenario distribution contexts to include chart JSON data, enhanced templates with chart containers and JavaScript rendering, added chart-container CSS styling, and validated all reporting tests pass.
- Completed local run verification: started application with `uvicorn main:app --reload` without errors, verified authenticated routes (/login, /, /projects/ui, /projects) load correctly with seeded data, and summarized findings for deployment pipeline readiness. - Completed local run verification: started application with `uvicorn main:app --reload` without errors, verified authenticated routes (/login, /, /projects/ui, /projects) load correctly with seeded data, and summarized findings for deployment pipeline readiness.
- Fixed docker-compose.override.yml command array to remove duplicate "uvicorn" entry, enabling successful container startup with uvicorn reload in development mode. - Fixed docker-compose.override.yml command array to remove duplicate "uvicorn" entry, enabling successful container startup with uvicorn reload in development mode.
@@ -17,7 +25,6 @@
- Replaced the Alembic migration workflow with the idempotent Pydantic-backed initializer (`scripts/init_db.py`), added a guarded reset utility (`scripts/reset_db.py`), removed migration artifacts/tooling (Alembic directory, config, Docker entrypoint), refreshed the container entrypoint to invoke `uvicorn` directly, and updated installation/architecture docs plus the README to direct developers to the new seeding/reset flow. - Replaced the Alembic migration workflow with the idempotent Pydantic-backed initializer (`scripts/init_db.py`), added a guarded reset utility (`scripts/reset_db.py`), removed migration artifacts/tooling (Alembic directory, config, Docker entrypoint), refreshed the container entrypoint to invoke `uvicorn` directly, and updated installation/architecture docs plus the README to direct developers to the new seeding/reset flow.
- Eliminated Bandit hardcoded-secret findings by replacing literal JWT tokens and passwords across auth/security tests with randomized helpers drawn from `tests/utils/security.py`, ensuring fixtures still assert expected behaviours. - Eliminated Bandit hardcoded-secret findings by replacing literal JWT tokens and passwords across auth/security tests with randomized helpers drawn from `tests/utils/security.py`, ensuring fixtures still assert expected behaviours.
- Centralized Bandit configuration in `pyproject.toml`, reran `bandit -c pyproject.toml -r calminer tests`, and verified the scan now reports zero issues. - Centralized Bandit configuration in `pyproject.toml`, reran `bandit -c pyproject.toml -r calminer tests`, and verified the scan now reports zero issues.
- Updated `.github/instructions/TODO.md` and `.github/instructions/DONE.md` to reflect the completed security scan remediation workflow.
- Diagnosed admin bootstrap failure caused by legacy `roles` schema, added Alembic migration `20251112_00_add_roles_metadata_columns.py` to backfill `display_name`, `description`, `created_at`, and `updated_at`, and verified the migration via full pytest run in the activated `.venv`. - Diagnosed admin bootstrap failure caused by legacy `roles` schema, added Alembic migration `20251112_00_add_roles_metadata_columns.py` to backfill `display_name`, `description`, `created_at`, and `updated_at`, and verified the migration via full pytest run in the activated `.venv`.
- Resolved Ruff E402 warnings by moving module docstrings ahead of `from __future__ import annotations` across currency and pricing service modules, dropped the unused `HTTPException` import in `monitoring/__init__.py`, and confirmed a clean `ruff check .` run. - Resolved Ruff E402 warnings by moving module docstrings ahead of `from __future__ import annotations` across currency and pricing service modules, dropped the unused `HTTPException` import in `monitoring/__init__.py`, and confirmed a clean `ruff check .` run.
- Enhanced the deploy job in `.gitea/workflows/cicache.yml` to capture Kubernetes pod, deployment, and container logs into `/logs/deployment/` for staging/production rollouts and publish them via a `deployment-logs` artifact, updating CI/CD documentation with retrieval instructions. - Enhanced the deploy job in `.gitea/workflows/cicache.yml` to capture Kubernetes pod, deployment, and container logs into `/logs/deployment/` for staging/production rollouts and publish them via a `deployment-logs` artifact, updating CI/CD documentation with retrieval instructions.
@@ -32,7 +39,6 @@
- Updated header and footer templates to consistently use `logo_big.png` image instead of text logo, with appropriate CSS styling for sizing. - Updated header and footer templates to consistently use `logo_big.png` image instead of text logo, with appropriate CSS styling for sizing.
- Centralised ISO-4217 currency validation across scenarios, imports, and export filters (`models/scenario.py`, `routes/scenarios.py`, `schemas/scenario.py`, `schemas/imports.py`, `services/export_query.py`) so malformed codes are rejected consistently at every entry point. - Centralised ISO-4217 currency validation across scenarios, imports, and export filters (`models/scenario.py`, `routes/scenarios.py`, `schemas/scenario.py`, `schemas/imports.py`, `services/export_query.py`) so malformed codes are rejected consistently at every entry point.
- Updated scenario services and UI flows to surface friendly validation errors and added regression coverage for imports, exports, API creation, and lifecycle flows ensuring currencies are normalised end-to-end. - Updated scenario services and UI flows to surface friendly validation errors and added regression coverage for imports, exports, API creation, and lifecycle flows ensuring currencies are normalised end-to-end.
- Recorded the completed “Ensure currency is used consistently” work in `.github/instructions/DONE.md` and ran the full pytest suite (150 tests) to verify the refactor.
- Linked projects to their pricing settings by updating SQLAlchemy models, repositories, seeding utilities, and migrations, and added regression tests to cover the new association and default backfill. - Linked projects to their pricing settings by updating SQLAlchemy models, repositories, seeding utilities, and migrations, and added regression tests to cover the new association and default backfill.
- Bootstrapped database-stored pricing settings at application startup, aligned initial data seeding with the database-first metadata flow, and added tests covering pricing bootstrap creation, project assignment, and idempotency. - Bootstrapped database-stored pricing settings at application startup, aligned initial data seeding with the database-first metadata flow, and added tests covering pricing bootstrap creation, project assignment, and idempotency.
- Extended pricing configuration support to prefer persisted metadata via `dependencies.get_pricing_metadata`, added retrieval tests for project/default fallbacks, and refreshed docs (`calminer-docs/specifications/price_calculation.md`, `pricing_settings_data_model.md`) to describe the database-backed workflow and bootstrap behaviour. - Extended pricing configuration support to prefer persisted metadata via `dependencies.get_pricing_metadata`, added retrieval tests for project/default fallbacks, and refreshed docs (`calminer-docs/specifications/price_calculation.md`, `pricing_settings_data_model.md`) to describe the database-backed workflow and bootstrap behaviour.

View File

@@ -29,6 +29,10 @@ from .simulation_parameter import SimulationParameter
from .user import Role, User, UserRole, password_context from .user import Role, User, UserRole, password_context
from .profitability_snapshot import ProjectProfitability, ScenarioProfitability from .profitability_snapshot import ProjectProfitability, ScenarioProfitability
from .capex_snapshot import ProjectCapexSnapshot, ScenarioCapexSnapshot from .capex_snapshot import ProjectCapexSnapshot, ScenarioCapexSnapshot
from .processing_opex_snapshot import (
ProjectProcessingOpexSnapshot,
ScenarioProcessingOpexSnapshot,
)
__all__ = [ __all__ = [
"FinancialCategory", "FinancialCategory",
@@ -37,12 +41,14 @@ __all__ = [
"Project", "Project",
"ProjectProfitability", "ProjectProfitability",
"ProjectCapexSnapshot", "ProjectCapexSnapshot",
"ProjectProcessingOpexSnapshot",
"PricingSettings", "PricingSettings",
"PricingMetalSettings", "PricingMetalSettings",
"PricingImpuritySettings", "PricingImpuritySettings",
"Scenario", "Scenario",
"ScenarioProfitability", "ScenarioProfitability",
"ScenarioCapexSnapshot", "ScenarioCapexSnapshot",
"ScenarioProcessingOpexSnapshot",
"ScenarioStatus", "ScenarioStatus",
"DistributionType", "DistributionType",
"SimulationParameter", "SimulationParameter",

View File

@@ -0,0 +1,109 @@
from __future__ import annotations
from datetime import datetime
from typing import TYPE_CHECKING
from sqlalchemy import JSON, Boolean, DateTime, ForeignKey, Integer, Numeric, String
from sqlalchemy.orm import Mapped, mapped_column, relationship
from sqlalchemy.sql import func
from config.database import Base
if TYPE_CHECKING: # pragma: no cover
from .project import Project
from .scenario import Scenario
from .user import User
class ProjectProcessingOpexSnapshot(Base):
"""Snapshot of recurring processing opex metrics at the project level."""
__tablename__ = "project_processing_opex_snapshots"
id: Mapped[int] = mapped_column(Integer, primary_key=True)
project_id: Mapped[int] = mapped_column(
ForeignKey("projects.id", ondelete="CASCADE"), nullable=False, index=True
)
created_by_id: Mapped[int | None] = mapped_column(
ForeignKey("users.id", ondelete="SET NULL"), nullable=True, index=True
)
calculation_source: Mapped[str | None] = mapped_column(String(64), nullable=True)
calculated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False, server_default=func.now()
)
currency_code: Mapped[str | None] = mapped_column(String(3), nullable=True)
overall_annual: Mapped[float | None] = mapped_column(Numeric(18, 2), nullable=True)
escalated_total: Mapped[float | None] = mapped_column(Numeric(18, 2), nullable=True)
annual_average: Mapped[float | None] = mapped_column(Numeric(18, 2), nullable=True)
evaluation_horizon_years: Mapped[int | None] = mapped_column(Integer, nullable=True)
escalation_pct: Mapped[float | None] = mapped_column(Numeric(12, 6), nullable=True)
apply_escalation: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True)
component_count: Mapped[int | None] = mapped_column(Integer, nullable=True)
payload: Mapped[dict | None] = mapped_column(JSON, nullable=True)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False, server_default=func.now()
)
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False, server_default=func.now(), onupdate=func.now()
)
project: Mapped[Project] = relationship(
"Project", back_populates="processing_opex_snapshots"
)
created_by: Mapped[User | None] = relationship("User")
def __repr__(self) -> str: # pragma: no cover
return (
"ProjectProcessingOpexSnapshot(id={id!r}, project_id={project_id!r}, overall_annual={overall_annual!r})".format(
id=self.id,
project_id=self.project_id,
overall_annual=self.overall_annual,
)
)
class ScenarioProcessingOpexSnapshot(Base):
"""Snapshot of processing opex metrics for an individual scenario."""
__tablename__ = "scenario_processing_opex_snapshots"
id: Mapped[int] = mapped_column(Integer, primary_key=True)
scenario_id: Mapped[int] = mapped_column(
ForeignKey("scenarios.id", ondelete="CASCADE"), nullable=False, index=True
)
created_by_id: Mapped[int | None] = mapped_column(
ForeignKey("users.id", ondelete="SET NULL"), nullable=True, index=True
)
calculation_source: Mapped[str | None] = mapped_column(String(64), nullable=True)
calculated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False, server_default=func.now()
)
currency_code: Mapped[str | None] = mapped_column(String(3), nullable=True)
overall_annual: Mapped[float | None] = mapped_column(Numeric(18, 2), nullable=True)
escalated_total: Mapped[float | None] = mapped_column(Numeric(18, 2), nullable=True)
annual_average: Mapped[float | None] = mapped_column(Numeric(18, 2), nullable=True)
evaluation_horizon_years: Mapped[int | None] = mapped_column(Integer, nullable=True)
escalation_pct: Mapped[float | None] = mapped_column(Numeric(12, 6), nullable=True)
apply_escalation: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True)
component_count: Mapped[int | None] = mapped_column(Integer, nullable=True)
payload: Mapped[dict | None] = mapped_column(JSON, nullable=True)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False, server_default=func.now()
)
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False, server_default=func.now(), onupdate=func.now()
)
scenario: Mapped[Scenario] = relationship(
"Scenario", back_populates="processing_opex_snapshots"
)
created_by: Mapped[User | None] = relationship("User")
def __repr__(self) -> str: # pragma: no cover
return (
"ScenarioProcessingOpexSnapshot(id={id!r}, scenario_id={scenario_id!r}, overall_annual={overall_annual!r})".format(
id=self.id,
scenario_id=self.scenario_id,
overall_annual=self.overall_annual,
)
)

View File

@@ -6,6 +6,7 @@ from typing import TYPE_CHECKING, List
from .enums import MiningOperationType, sql_enum from .enums import MiningOperationType, sql_enum
from .profitability_snapshot import ProjectProfitability from .profitability_snapshot import ProjectProfitability
from .capex_snapshot import ProjectCapexSnapshot from .capex_snapshot import ProjectCapexSnapshot
from .processing_opex_snapshot import ProjectProcessingOpexSnapshot
from sqlalchemy import DateTime, ForeignKey, Integer, String, Text from sqlalchemy import DateTime, ForeignKey, Integer, String, Text
from sqlalchemy.orm import Mapped, mapped_column, relationship from sqlalchemy.orm import Mapped, mapped_column, relationship
@@ -67,6 +68,13 @@ class Project(Base):
order_by=lambda: ProjectCapexSnapshot.calculated_at.desc(), order_by=lambda: ProjectCapexSnapshot.calculated_at.desc(),
passive_deletes=True, passive_deletes=True,
) )
processing_opex_snapshots: Mapped[List["ProjectProcessingOpexSnapshot"]] = relationship(
"ProjectProcessingOpexSnapshot",
back_populates="project",
cascade="all, delete-orphan",
order_by=lambda: ProjectProcessingOpexSnapshot.calculated_at.desc(),
passive_deletes=True,
)
@property @property
def latest_profitability(self) -> "ProjectProfitability | None": def latest_profitability(self) -> "ProjectProfitability | None":
@@ -84,5 +92,13 @@ class Project(Base):
return None return None
return self.capex_snapshots[0] return self.capex_snapshots[0]
@property
def latest_processing_opex(self) -> "ProjectProcessingOpexSnapshot | None":
"""Return the most recent processing opex snapshot, if any."""
if not self.processing_opex_snapshots:
return None
return self.processing_opex_snapshots[0]
def __repr__(self) -> str: # pragma: no cover - helpful for debugging def __repr__(self) -> str: # pragma: no cover - helpful for debugging
return f"Project(id={self.id!r}, name={self.name!r})" return f"Project(id={self.id!r}, name={self.name!r})"

View File

@@ -21,6 +21,7 @@ from services.currency import normalise_currency
from .enums import ResourceType, ScenarioStatus, sql_enum from .enums import ResourceType, ScenarioStatus, sql_enum
from .profitability_snapshot import ScenarioProfitability from .profitability_snapshot import ScenarioProfitability
from .capex_snapshot import ScenarioCapexSnapshot from .capex_snapshot import ScenarioCapexSnapshot
from .processing_opex_snapshot import ScenarioProcessingOpexSnapshot
if TYPE_CHECKING: # pragma: no cover if TYPE_CHECKING: # pragma: no cover
from .financial_input import FinancialInput from .financial_input import FinancialInput
@@ -91,6 +92,13 @@ class Scenario(Base):
order_by=lambda: ScenarioCapexSnapshot.calculated_at.desc(), order_by=lambda: ScenarioCapexSnapshot.calculated_at.desc(),
passive_deletes=True, passive_deletes=True,
) )
processing_opex_snapshots: Mapped[List["ScenarioProcessingOpexSnapshot"]] = relationship(
"ScenarioProcessingOpexSnapshot",
back_populates="scenario",
cascade="all, delete-orphan",
order_by=lambda: ScenarioProcessingOpexSnapshot.calculated_at.desc(),
passive_deletes=True,
)
@validates("currency") @validates("currency")
def _normalise_currency(self, key: str, value: str | None) -> str | None: def _normalise_currency(self, key: str, value: str | None) -> str | None:
@@ -115,3 +123,11 @@ class Scenario(Base):
if not self.capex_snapshots: if not self.capex_snapshots:
return None return None
return self.capex_snapshots[0] return self.capex_snapshots[0]
@property
def latest_processing_opex(self) -> "ScenarioProcessingOpexSnapshot | None":
"""Return the most recent processing opex snapshot for this scenario."""
if not self.processing_opex_snapshots:
return None
return self.processing_opex_snapshots[0]

View File

@@ -15,9 +15,11 @@ from dependencies import get_pricing_metadata, get_unit_of_work, require_authent
from models import ( from models import (
Project, Project,
ProjectCapexSnapshot, ProjectCapexSnapshot,
ProjectProcessingOpexSnapshot,
ProjectProfitability, ProjectProfitability,
Scenario, Scenario,
ScenarioCapexSnapshot, ScenarioCapexSnapshot,
ScenarioProcessingOpexSnapshot,
ScenarioProfitability, ScenarioProfitability,
User, User,
) )
@@ -27,11 +29,25 @@ from schemas.calculations import (
CapexCalculationResult, CapexCalculationResult,
CapexComponentInput, CapexComponentInput,
CapexParameters, CapexParameters,
ProcessingOpexCalculationRequest,
ProcessingOpexCalculationResult,
ProcessingOpexComponentInput,
ProcessingOpexOptions,
ProcessingOpexParameters,
ProfitabilityCalculationRequest, ProfitabilityCalculationRequest,
ProfitabilityCalculationResult, ProfitabilityCalculationResult,
) )
from services.calculations import calculate_initial_capex, calculate_profitability from services.calculations import (
from services.exceptions import CapexValidationError, EntityNotFoundError, ProfitabilityValidationError calculate_initial_capex,
calculate_processing_opex,
calculate_profitability,
)
from services.exceptions import (
CapexValidationError,
EntityNotFoundError,
OpexValidationError,
ProfitabilityValidationError,
)
from services.pricing import PricingMetadata from services.pricing import PricingMetadata
from services.unit_of_work import UnitOfWork from services.unit_of_work import UnitOfWork
from routes.template_filters import register_common_filters from routes.template_filters import register_common_filters
@@ -56,6 +72,26 @@ _CAPEX_CATEGORY_OPTIONS: tuple[dict[str, str], ...] = (
) )
_DEFAULT_CAPEX_HORIZON_YEARS = 5 _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]]: def _combine_impurity_metadata(metadata: PricingMetadata) -> list[dict[str, object]]:
"""Build impurity rows combining thresholds and penalties.""" """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: def _format_error_location(location: tuple[Any, ...]) -> str:
path = "" path = ""
for part in location: for part in location:
@@ -406,6 +607,87 @@ def _partition_capex_error_messages(
return general, component_specific 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]: def _capex_form_to_payload(form: FormData) -> dict[str, Any]:
data: dict[str, Any] = {} data: dict[str, Any] = {}
components: dict[int, 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 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]: async def _extract_capex_payload(request: Request) -> dict[str, Any]:
content_type = request.headers.get("content-type", "").lower() content_type = request.headers.get("content-type", "").lower()
if content_type.startswith("application/json"): if content_type.startswith("application/json"):
@@ -772,6 +1063,247 @@ def _persist_capex_snapshots(
uow.project_capex.create(project_snapshot) 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( @router.get(
"/capex", "/capex",
response_class=HTMLResponse, response_class=HTMLResponse,

View File

@@ -197,6 +197,127 @@ class CapexCalculationResult(BaseModel):
currency: str | None currency: str | None
class ProcessingOpexComponentInput(BaseModel):
"""Processing opex component entry supplied by the UI."""
id: int | None = Field(default=None, ge=1)
name: str = Field(..., min_length=1)
category: str = Field(..., min_length=1)
unit_cost: float = Field(..., ge=0)
quantity: float = Field(..., ge=0)
frequency: str = Field(..., min_length=1)
currency: str | None = Field(None, min_length=3, max_length=3)
period_start: int | None = Field(None, ge=0, le=240)
period_end: int | None = Field(None, ge=0, le=240)
notes: str | None = Field(None, max_length=500)
@field_validator("currency")
@classmethod
def _uppercase_currency(cls, value: str | None) -> str | None:
if value is None:
return None
return value.strip().upper()
@field_validator("category")
@classmethod
def _normalise_category(cls, value: str) -> str:
return value.strip().lower()
@field_validator("frequency")
@classmethod
def _normalise_frequency(cls, value: str) -> str:
return value.strip().lower()
@field_validator("name")
@classmethod
def _trim_name(cls, value: str) -> str:
return value.strip()
class ProcessingOpexParameters(BaseModel):
"""Global parameters applied to processing opex calculations."""
currency_code: str | None = Field(None, min_length=3, max_length=3)
escalation_pct: float | None = Field(None, ge=0, le=100)
discount_rate_pct: float | None = Field(None, ge=0, le=100)
evaluation_horizon_years: int | None = Field(10, ge=1, le=100)
apply_escalation: bool = True
@field_validator("currency_code")
@classmethod
def _uppercase_currency(cls, value: str | None) -> str | None:
if value is None:
return None
return value.strip().upper()
class ProcessingOpexOptions(BaseModel):
"""Optional behaviour flags for opex calculations."""
persist: bool = False
snapshot_notes: str | None = Field(None, max_length=500)
class ProcessingOpexCalculationRequest(BaseModel):
"""Request payload for processing opex aggregation."""
components: List[ProcessingOpexComponentInput] = Field(
default_factory=list)
parameters: ProcessingOpexParameters = Field(
default_factory=ProcessingOpexParameters, # type: ignore[arg-type]
)
options: ProcessingOpexOptions = Field(
default_factory=ProcessingOpexOptions, # type: ignore[arg-type]
)
class ProcessingOpexCategoryBreakdown(BaseModel):
"""Category breakdown for processing opex totals."""
category: str
annual_cost: float = Field(..., ge=0)
share: float | None = Field(None, ge=0, le=100)
class ProcessingOpexTimelineEntry(BaseModel):
"""Timeline entry representing cost over evaluation periods."""
period: int
base_cost: float = Field(..., ge=0)
escalated_cost: float | None = Field(None, ge=0)
class ProcessingOpexMetrics(BaseModel):
"""Derived KPIs for processing opex outputs."""
annual_average: float | None
cost_per_ton: float | None
class ProcessingOpexTotals(BaseModel):
"""Aggregated totals for processing opex."""
overall_annual: float = Field(..., ge=0)
escalated_total: float | None = Field(None, ge=0)
escalation_pct: float | None = Field(None, ge=0, le=100)
by_category: List[ProcessingOpexCategoryBreakdown] = Field(
default_factory=list
)
class ProcessingOpexCalculationResult(BaseModel):
"""Response body summarising processing opex calculations."""
totals: ProcessingOpexTotals
timeline: List[ProcessingOpexTimelineEntry] = Field(default_factory=list)
metrics: ProcessingOpexMetrics
components: List[ProcessingOpexComponentInput] = Field(
default_factory=list)
parameters: ProcessingOpexParameters
options: ProcessingOpexOptions
currency: str | None
__all__ = [ __all__ = [
"ImpurityInput", "ImpurityInput",
"ProfitabilityCalculationRequest", "ProfitabilityCalculationRequest",
@@ -212,5 +333,14 @@ __all__ = [
"CapexTotals", "CapexTotals",
"CapexTimelineEntry", "CapexTimelineEntry",
"CapexCalculationResult", "CapexCalculationResult",
"ProcessingOpexComponentInput",
"ProcessingOpexParameters",
"ProcessingOpexOptions",
"ProcessingOpexCalculationRequest",
"ProcessingOpexCategoryBreakdown",
"ProcessingOpexTimelineEntry",
"ProcessingOpexMetrics",
"ProcessingOpexTotals",
"ProcessingOpexCalculationResult",
"ValidationError", "ValidationError",
] ]

View File

@@ -3,9 +3,14 @@
from __future__ import annotations from __future__ import annotations
from collections import defaultdict from collections import defaultdict
from statistics import fmean
from services.currency import CurrencyValidationError, normalise_currency from services.currency import CurrencyValidationError, normalise_currency
from services.exceptions import CapexValidationError, ProfitabilityValidationError from services.exceptions import (
CapexValidationError,
OpexValidationError,
ProfitabilityValidationError,
)
from services.financial import ( from services.financial import (
CashFlow, CashFlow,
ConvergenceError, ConvergenceError,
@@ -24,6 +29,14 @@ from schemas.calculations import (
CapexTotals, CapexTotals,
CapexTimelineEntry, CapexTimelineEntry,
CashFlowEntry, CashFlowEntry,
ProcessingOpexCalculationRequest,
ProcessingOpexCalculationResult,
ProcessingOpexCategoryBreakdown,
ProcessingOpexComponentInput,
ProcessingOpexMetrics,
ProcessingOpexParameters,
ProcessingOpexTotals,
ProcessingOpexTimelineEntry,
ProfitabilityCalculationRequest, ProfitabilityCalculationRequest,
ProfitabilityCalculationResult, ProfitabilityCalculationResult,
ProfitabilityCosts, ProfitabilityCosts,
@@ -31,6 +44,15 @@ from schemas.calculations import (
) )
_FREQUENCY_MULTIPLIER = {
"daily": 365,
"weekly": 52,
"monthly": 12,
"quarterly": 4,
"annually": 1,
}
def _build_pricing_input( def _build_pricing_input(
request: ProfitabilityCalculationRequest, request: ProfitabilityCalculationRequest,
) -> PricingInput: ) -> PricingInput:
@@ -332,4 +354,183 @@ def calculate_initial_capex(
) )
__all__ = ["calculate_profitability", "calculate_initial_capex"] def calculate_processing_opex(
request: ProcessingOpexCalculationRequest,
) -> ProcessingOpexCalculationResult:
"""Aggregate processing opex components into annual totals and timeline."""
if not request.components:
raise OpexValidationError(
"At least one processing opex component is required for calculation.",
["components"],
)
parameters: ProcessingOpexParameters = request.parameters
base_currency = parameters.currency_code
if base_currency:
try:
base_currency = normalise_currency(base_currency)
except CurrencyValidationError as exc:
raise OpexValidationError(
str(exc), ["parameters.currency_code"]
) from exc
evaluation_horizon = parameters.evaluation_horizon_years or 1
if evaluation_horizon <= 0:
raise OpexValidationError(
"Evaluation horizon must be at least 1 year.",
["parameters.evaluation_horizon_years"],
)
escalation_pct = float(parameters.escalation_pct or 0.0)
apply_escalation = bool(parameters.apply_escalation)
category_totals: dict[str, float] = defaultdict(float)
timeline_totals: dict[int, float] = defaultdict(float)
timeline_escalated: dict[int, float] = defaultdict(float)
normalised_components: list[ProcessingOpexComponentInput] = []
max_period_end = evaluation_horizon
for index, component in enumerate(request.components):
frequency = component.frequency.lower()
multiplier = _FREQUENCY_MULTIPLIER.get(frequency)
if multiplier is None:
raise OpexValidationError(
f"Unsupported frequency '{component.frequency}'.",
[f"components[{index}].frequency"],
)
unit_cost = float(component.unit_cost)
quantity = float(component.quantity)
annual_cost = unit_cost * quantity * multiplier
period_start = component.period_start or 1
period_end = component.period_end or evaluation_horizon
if period_end < period_start:
raise OpexValidationError(
(
"Component period_end must be greater than or equal to "
"period_start."
),
[f"components[{index}].period_end"],
)
max_period_end = max(max_period_end, period_end)
component_currency = component.currency
if component_currency:
try:
component_currency = normalise_currency(component_currency)
except CurrencyValidationError as exc:
raise OpexValidationError(
str(exc), [f"components[{index}].currency"]
) from exc
if base_currency is None and component_currency:
base_currency = component_currency
elif (
base_currency is not None
and component_currency is not None
and component_currency != base_currency
):
raise OpexValidationError(
(
"Component currency does not match the global currency. "
f"Expected {base_currency}, got {component_currency}."
),
[f"components[{index}].currency"],
)
category_totals[component.category] += annual_cost
for period in range(period_start, period_end + 1):
timeline_totals[period] += annual_cost
normalised_components.append(
ProcessingOpexComponentInput(
id=component.id,
name=component.name,
category=component.category,
unit_cost=unit_cost,
quantity=quantity,
frequency=frequency,
currency=component_currency,
period_start=period_start,
period_end=period_end,
notes=component.notes,
)
)
evaluation_horizon = max(evaluation_horizon, max_period_end)
try:
currency = normalise_currency(base_currency) if base_currency else None
except CurrencyValidationError as exc:
raise OpexValidationError(
str(exc), ["parameters.currency_code"]
) from exc
timeline_entries: list[ProcessingOpexTimelineEntry] = []
escalated_values: list[float] = []
overall_annual = timeline_totals.get(1, 0.0)
escalated_total = 0.0
for period in range(1, evaluation_horizon + 1):
base_cost = timeline_totals.get(period, 0.0)
if apply_escalation:
factor = (1 + escalation_pct / 100.0) ** (period - 1)
else:
factor = 1.0
escalated_cost = base_cost * factor
timeline_escalated[period] = escalated_cost
escalated_total += escalated_cost
timeline_entries.append(
ProcessingOpexTimelineEntry(
period=period,
base_cost=base_cost,
escalated_cost=escalated_cost if apply_escalation else None,
)
)
escalated_values.append(escalated_cost)
category_breakdowns: list[ProcessingOpexCategoryBreakdown] = []
total_base = sum(category_totals.values())
for category, total in sorted(category_totals.items()):
share = (total / total_base * 100.0) if total_base else None
category_breakdowns.append(
ProcessingOpexCategoryBreakdown(
category=category,
annual_cost=total,
share=share,
)
)
metrics = ProcessingOpexMetrics(
annual_average=fmean(escalated_values) if escalated_values else None,
cost_per_ton=None,
)
totals = ProcessingOpexTotals(
overall_annual=overall_annual,
escalated_total=escalated_total if apply_escalation else None,
escalation_pct=escalation_pct if apply_escalation else None,
by_category=category_breakdowns,
)
return ProcessingOpexCalculationResult(
totals=totals,
timeline=timeline_entries,
metrics=metrics,
components=normalised_components,
parameters=parameters,
options=request.options,
currency=currency,
)
__all__ = [
"calculate_profitability",
"calculate_initial_capex",
"calculate_processing_opex",
]

View File

@@ -48,3 +48,14 @@ class CapexValidationError(Exception):
def __str__(self) -> str: # pragma: no cover - mirrors message for logging def __str__(self) -> str: # pragma: no cover - mirrors message for logging
return self.message return self.message
@dataclass(eq=False)
class OpexValidationError(Exception):
"""Raised when opex calculation inputs fail domain validation."""
message: str
field_errors: Sequence[str] | None = None
def __str__(self) -> str: # pragma: no cover - mirrors message for logging
return self.message

View File

@@ -17,10 +17,12 @@ from models import (
PricingSettings, PricingSettings,
ProjectCapexSnapshot, ProjectCapexSnapshot,
ProjectProfitability, ProjectProfitability,
ProjectProcessingOpexSnapshot,
Role, Role,
Scenario, Scenario,
ScenarioCapexSnapshot, ScenarioCapexSnapshot,
ScenarioProfitability, ScenarioProfitability,
ScenarioProcessingOpexSnapshot,
ScenarioStatus, ScenarioStatus,
SimulationParameter, SimulationParameter,
User, User,
@@ -571,6 +573,110 @@ class ScenarioCapexRepository:
self.session.delete(entity) self.session.delete(entity)
class ProjectProcessingOpexRepository:
"""Persistence operations for project-level processing opex snapshots."""
def __init__(self, session: Session) -> None:
self.session = session
def create(
self, snapshot: ProjectProcessingOpexSnapshot
) -> ProjectProcessingOpexSnapshot:
self.session.add(snapshot)
self.session.flush()
return snapshot
def list_for_project(
self,
project_id: int,
*,
limit: int | None = None,
) -> Sequence[ProjectProcessingOpexSnapshot]:
stmt = (
select(ProjectProcessingOpexSnapshot)
.where(ProjectProcessingOpexSnapshot.project_id == project_id)
.order_by(ProjectProcessingOpexSnapshot.calculated_at.desc())
)
if limit is not None:
stmt = stmt.limit(limit)
return self.session.execute(stmt).scalars().all()
def latest_for_project(
self,
project_id: int,
) -> ProjectProcessingOpexSnapshot | None:
stmt = (
select(ProjectProcessingOpexSnapshot)
.where(ProjectProcessingOpexSnapshot.project_id == project_id)
.order_by(ProjectProcessingOpexSnapshot.calculated_at.desc())
.limit(1)
)
return self.session.execute(stmt).scalar_one_or_none()
def delete(self, snapshot_id: int) -> None:
stmt = select(ProjectProcessingOpexSnapshot).where(
ProjectProcessingOpexSnapshot.id == snapshot_id
)
entity = self.session.execute(stmt).scalar_one_or_none()
if entity is None:
raise EntityNotFoundError(
f"Project processing opex snapshot {snapshot_id} not found"
)
self.session.delete(entity)
class ScenarioProcessingOpexRepository:
"""Persistence operations for scenario-level processing opex snapshots."""
def __init__(self, session: Session) -> None:
self.session = session
def create(
self, snapshot: ScenarioProcessingOpexSnapshot
) -> ScenarioProcessingOpexSnapshot:
self.session.add(snapshot)
self.session.flush()
return snapshot
def list_for_scenario(
self,
scenario_id: int,
*,
limit: int | None = None,
) -> Sequence[ScenarioProcessingOpexSnapshot]:
stmt = (
select(ScenarioProcessingOpexSnapshot)
.where(ScenarioProcessingOpexSnapshot.scenario_id == scenario_id)
.order_by(ScenarioProcessingOpexSnapshot.calculated_at.desc())
)
if limit is not None:
stmt = stmt.limit(limit)
return self.session.execute(stmt).scalars().all()
def latest_for_scenario(
self,
scenario_id: int,
) -> ScenarioProcessingOpexSnapshot | None:
stmt = (
select(ScenarioProcessingOpexSnapshot)
.where(ScenarioProcessingOpexSnapshot.scenario_id == scenario_id)
.order_by(ScenarioProcessingOpexSnapshot.calculated_at.desc())
.limit(1)
)
return self.session.execute(stmt).scalar_one_or_none()
def delete(self, snapshot_id: int) -> None:
stmt = select(ScenarioProcessingOpexSnapshot).where(
ScenarioProcessingOpexSnapshot.id == snapshot_id
)
entity = self.session.execute(stmt).scalar_one_or_none()
if entity is None:
raise EntityNotFoundError(
f"Scenario processing opex snapshot {snapshot_id} not found"
)
self.session.delete(entity)
class FinancialInputRepository: class FinancialInputRepository:
"""Persistence operations for FinancialInput entities.""" """Persistence operations for FinancialInput entities."""

View File

@@ -14,10 +14,12 @@ from services.repositories import (
PricingSettingsSeedResult, PricingSettingsSeedResult,
ProjectRepository, ProjectRepository,
ProjectProfitabilityRepository, ProjectProfitabilityRepository,
ProjectProcessingOpexRepository,
ProjectCapexRepository, ProjectCapexRepository,
RoleRepository, RoleRepository,
ScenarioRepository, ScenarioRepository,
ScenarioProfitabilityRepository, ScenarioProfitabilityRepository,
ScenarioProcessingOpexRepository,
ScenarioCapexRepository, ScenarioCapexRepository,
SimulationParameterRepository, SimulationParameterRepository,
UserRepository, UserRepository,
@@ -42,8 +44,10 @@ class UnitOfWork(AbstractContextManager["UnitOfWork"]):
self.simulation_parameters: SimulationParameterRepository | None = None self.simulation_parameters: SimulationParameterRepository | None = None
self.project_profitability: ProjectProfitabilityRepository | None = None self.project_profitability: ProjectProfitabilityRepository | None = None
self.project_capex: ProjectCapexRepository | None = None self.project_capex: ProjectCapexRepository | None = None
self.project_processing_opex: ProjectProcessingOpexRepository | None = None
self.scenario_profitability: ScenarioProfitabilityRepository | None = None self.scenario_profitability: ScenarioProfitabilityRepository | None = None
self.scenario_capex: ScenarioCapexRepository | None = None self.scenario_capex: ScenarioCapexRepository | None = None
self.scenario_processing_opex: ScenarioProcessingOpexRepository | None = None
self.users: UserRepository | None = None self.users: UserRepository | None = None
self.roles: RoleRepository | None = None self.roles: RoleRepository | None = None
self.pricing_settings: PricingSettingsRepository | None = None self.pricing_settings: PricingSettingsRepository | None = None
@@ -58,10 +62,14 @@ class UnitOfWork(AbstractContextManager["UnitOfWork"]):
self.project_profitability = ProjectProfitabilityRepository( self.project_profitability = ProjectProfitabilityRepository(
self.session) self.session)
self.project_capex = ProjectCapexRepository(self.session) self.project_capex = ProjectCapexRepository(self.session)
self.project_processing_opex = ProjectProcessingOpexRepository(
self.session)
self.scenario_profitability = ScenarioProfitabilityRepository( self.scenario_profitability = ScenarioProfitabilityRepository(
self.session self.session
) )
self.scenario_capex = ScenarioCapexRepository(self.session) self.scenario_capex = ScenarioCapexRepository(self.session)
self.scenario_processing_opex = ScenarioProcessingOpexRepository(
self.session)
self.users = UserRepository(self.session) self.users = UserRepository(self.session)
self.roles = RoleRepository(self.session) self.roles = RoleRepository(self.session)
self.pricing_settings = PricingSettingsRepository(self.session) self.pricing_settings = PricingSettingsRepository(self.session)
@@ -82,8 +90,10 @@ class UnitOfWork(AbstractContextManager["UnitOfWork"]):
self.simulation_parameters = None self.simulation_parameters = None
self.project_profitability = None self.project_profitability = None
self.project_capex = None self.project_capex = None
self.project_processing_opex = None
self.scenario_profitability = None self.scenario_profitability = None
self.scenario_capex = None self.scenario_capex = None
self.scenario_processing_opex = None
self.users = None self.users = None
self.roles = None self.roles = None
self.pricing_settings = None self.pricing_settings = None

View File

@@ -19,7 +19,7 @@ request.url_for('auth.password_reset_request_form') if request else
"match_prefix": "/"}, {"href": projects_href, "label": "Projects", "match_prefix": "/"}, {"href": projects_href, "label": "Projects",
"match_prefix": "/projects"}, {"href": project_create_href, "label": "New "match_prefix": "/projects"}, {"href": project_create_href, "label": "New
Project", "match_prefix": "/projects/create"}, {"href": "/imports/ui", "label": Project", "match_prefix": "/projects/create"}, {"href": "/imports/ui", "label":
"Imports", "match_prefix": "/imports"}, {"href": request.url_for('calculations.profitability_form') if request else '/calculations/profitability', "label": "Profitability Calculator", "match_prefix": "/calculations/profitability"}, {"href": request.url_for('calculations.capex_form') if request else '/calculations/capex', "label": "Initial Capex Planner", "match_prefix": "/calculations/capex"} ] }, { "label": "Insights", "links": [ "Imports", "match_prefix": "/imports"}, {"href": request.url_for('calculations.profitability_form') if request else '/calculations/profitability', "label": "Profitability Calculator", "match_prefix": "/calculations/profitability"}, {"href": request.url_for('calculations.processing_opex_form') if request else '/calculations/processing-opex', "label": "Processing Opex Planner", "match_prefix": "/calculations/processing-opex"}, {"href": request.url_for('calculations.capex_form') if request else '/calculations/capex', "label": "Initial Capex Planner", "match_prefix": "/calculations/capex"} ] }, { "label": "Insights", "links": [
{"href": "/ui/simulations", "label": "Simulations"}, {"href": "/ui/reporting", {"href": "/ui/simulations", "label": "Simulations"}, {"href": "/ui/reporting",
"label": "Reporting"} ] }, { "label": "Configuration", "links": [ { "href": "label": "Reporting"} ] }, { "label": "Configuration", "links": [ { "href":
"/ui/settings", "label": "Settings", "children": [ {"href": "/theme-settings", "/ui/settings", "label": "Settings", "children": [ {"href": "/theme-settings",

View File

@@ -14,9 +14,11 @@
<header class="page-header"> <header class="page-header">
{% set profitability_href = url_for('calculations.profitability_form') %} {% set profitability_href = url_for('calculations.profitability_form') %}
{% set processing_opex_href = url_for('calculations.processing_opex_form') %}
{% set capex_href = url_for('calculations.capex_form') %} {% set capex_href = url_for('calculations.capex_form') %}
{% if project and scenario %} {% if project and scenario %}
{% set profitability_href = profitability_href ~ '?project_id=' ~ project.id ~ '&scenario_id=' ~ scenario.id %} {% set profitability_href = profitability_href ~ '?project_id=' ~ project.id ~ '&scenario_id=' ~ scenario.id %}
{% set processing_opex_href = processing_opex_href ~ '?project_id=' ~ project.id ~ '&scenario_id=' ~ scenario.id %}
{% set capex_href = capex_href ~ '?project_id=' ~ project.id ~ '&scenario_id=' ~ scenario.id %} {% set capex_href = capex_href ~ '?project_id=' ~ project.id ~ '&scenario_id=' ~ scenario.id %}
{% endif %} {% endif %}
<div> <div>
@@ -26,6 +28,7 @@
<div class="header-actions"> <div class="header-actions">
<a class="btn" href="{{ url_for('projects.view_project', project_id=project.id) }}">Back to Project</a> <a class="btn" href="{{ url_for('projects.view_project', project_id=project.id) }}">Back to Project</a>
<a class="btn" href="{{ profitability_href }}">Profitability Calculator</a> <a class="btn" href="{{ profitability_href }}">Profitability Calculator</a>
<a class="btn" href="{{ processing_opex_href }}">Processing Opex Planner</a>
<a class="btn" href="{{ capex_href }}">Initial Capex Planner</a> <a class="btn" href="{{ capex_href }}">Initial Capex Planner</a>
<a class="btn primary" href="{{ url_for('scenarios.edit_scenario_form', scenario_id=scenario.id) }}">Edit Scenario</a> <a class="btn primary" href="{{ url_for('scenarios.edit_scenario_form', scenario_id=scenario.id) }}">Edit Scenario</a>
</div> </div>

View File

@@ -0,0 +1,305 @@
{% extends "base.html" %}
{% block title %}Processing Opex Planner · CalMiner{% endblock %}
{% block content %}
<nav class="breadcrumb">
<a href="{{ url_for('projects.project_list_page') }}">Projects</a>
{% if project %}
<a href="{{ url_for('projects.view_project', project_id=project.id) }}">{{ project.name }}</a>
{% endif %}
{% if scenario %}
<a href="{{ url_for('scenarios.view_scenario', scenario_id=scenario.id) }}">{{ scenario.name }}</a>
{% endif %}
<span aria-current="page">Processing Opex Planner</span>
</nav>
<header class="page-header">
<div>
<h1>Processing Opex Planner</h1>
<p class="text-muted">Capture recurring operational costs and review annual totals with escalation assumptions.</p>
</div>
<div class="header-actions">
{% if cancel_url %}
<a class="btn" href="{{ cancel_url }}">Cancel</a>
{% endif %}
<button class="btn primary" type="submit" form="processing-opex-form">Save &amp; Calculate</button>
</div>
</header>
{% if errors %}
<div class="alert alert-error">
<h2 class="sr-only">Submission errors</h2>
<ul>
{% for message in errors %}
<li>{{ message }}</li>
{% endfor %}
</ul>
</div>
{% endif %}
{% if notices %}
<div class="alert alert-info">
<ul>
{% for message in notices %}
<li>{{ message }}</li>
{% endfor %}
</ul>
</div>
{% endif %}
<form id="processing-opex-form" class="form scenario-form" method="post" action="{{ form_action }}">
<input type="hidden" name="csrf_token" value="{{ csrf_token or '' }}" />
<input type="hidden" name="options[persist]" value="{{ '1' if options and options.persist else '' }}" />
<div class="layout-two-column stackable">
<section class="panel">
<header class="section-header">
<h2>Opex Components</h2>
<p class="section-subtitle">List recurring cost items with frequency, unit cost, and quantities.</p>
</header>
<div class="table-actions">
<button class="btn secondary" type="button" data-action="add-opex-component">Add Component</button>
</div>
{% if component_errors %}
<div class="alert alert-error slim" role="alert" aria-live="polite">
<h3 class="sr-only">Component issues</h3>
<ul>
{% for message in component_errors %}
<li>{{ message }}</li>
{% endfor %}
</ul>
</div>
{% endif %}
{% if component_notices %}
<div class="alert alert-info slim" role="status" aria-live="polite">
<ul>
{% for message in component_notices %}
<li>{{ message }}</li>
{% endfor %}
</ul>
</div>
{% endif %}
<div class="table-responsive horizontal-scroll">
<table class="input-table">
<thead>
<tr>
<th scope="col">Category</th>
<th scope="col">Component</th>
<th scope="col">Unit Cost</th>
<th scope="col">Quantity</th>
<th scope="col">Frequency</th>
<th scope="col">Currency</th>
<th scope="col">Start Period</th>
<th scope="col">End Period</th>
</tr>
</thead>
<tbody>
{% set component_entries = (components if components else [{}]) %}
{% for component in component_entries %}
<tr data-row-index="{{ loop.index0 }}">
<td>
<input type="hidden" name="components[{{ loop.index0 }}][id]" value="{{ component.id or '' }}" />
<select name="components[{{ loop.index0 }}][category]">
{% for option in category_options %}
<option value="{{ option.value }}" {% if component.category == option.value %}selected{% endif %}>{{ option.label }}</option>
{% endfor %}
</select>
</td>
<td>
<input type="text" name="components[{{ loop.index0 }}][name]" value="{{ component.name or '' }}" />
</td>
<td>
<input type="number" min="0" step="0.01" name="components[{{ loop.index0 }}][unit_cost]" value="{{ component.unit_cost or '' }}" />
</td>
<td>
<input type="number" min="0" step="0.01" name="components[{{ loop.index0 }}][quantity]" value="{{ component.quantity or '' }}" />
</td>
<td>
<select name="components[{{ loop.index0 }}][frequency]">
{% for option in frequency_options %}
<option value="{{ option.value }}" {% if component.frequency == option.value %}selected{% endif %}>{{ option.label }}</option>
{% endfor %}
</select>
</td>
<td>
<input type="text" maxlength="3" name="components[{{ loop.index0 }}][currency]" value="{{ component.currency or currency_code or (scenario.currency if scenario else '') or (project.currency if project else '') }}" />
</td>
<td>
<input type="number" min="1" step="1" name="components[{{ loop.index0 }}][period_start]" value="{{ component.period_start or '' }}" />
</td>
<td>
<input type="number" min="1" step="1" name="components[{{ loop.index0 }}][period_end]" value="{{ component.period_end or '' }}" />
</td>
<td class="row-actions">
<button class="btn link" type="button" data-action="remove-opex-component">Remove</button>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
<p class="muted">Use start and end periods to indicate when the cost applies within the evaluation horizon.</p>
</section>
<aside class="panel">
<header class="section-header">
<h2>Global Parameters</h2>
<p class="section-subtitle">Control escalation and discount assumptions applied to totals.</p>
</header>
<div class="form-grid">
<div class="form-group">
<label for="opex_currency_code">Default Currency</label>
<input id="opex_currency_code" name="parameters[currency_code]" type="text" maxlength="3" value="{{ parameters.currency_code or currency_code or (scenario.currency if scenario else '') or (project.currency if project else '') }}" />
</div>
<div class="form-group">
<label for="opex_escalation_pct">Escalation (%)</label>
<input id="opex_escalation_pct" name="parameters[escalation_pct]" type="number" min="0" max="100" step="0.01" value="{{ parameters.escalation_pct if parameters.escalation_pct is not none else '' }}" />
</div>
<div class="form-group">
<label for="opex_discount_rate_pct">Discount Rate (%)</label>
<input id="opex_discount_rate_pct" name="parameters[discount_rate_pct]" type="number" min="0" max="100" step="0.01" value="{{ parameters.discount_rate_pct if parameters.discount_rate_pct is not none else (scenario.discount_rate if scenario else '') }}" />
</div>
<div class="form-group">
<label for="opex_horizon_years">Evaluation Horizon (years)</label>
<input id="opex_horizon_years" name="parameters[evaluation_horizon_years]" type="number" min="1" step="1" value="{{ parameters.evaluation_horizon_years if parameters.evaluation_horizon_years is not none else default_horizon }}" />
</div>
<div class="form-group checkbox">
<label>
<input type="checkbox" name="parameters[apply_escalation]" value="1" {% if parameters.apply_escalation %}checked{% endif %} />
Apply escalation to timeline totals
</label>
</div>
</div>
<div class="form-group">
<label for="snapshot_notes">Snapshot Notes</label>
<textarea id="snapshot_notes" name="options[snapshot_notes]" rows="3">{{ options.snapshot_notes or '' }}</textarea>
<p class="field-help">Optional. Appears alongside persisted snapshots.</p>
</div>
<div class="panel">
<h3>Assumptions</h3>
<dl class="definition-list">
<div>
<dt>Categories Configured</dt>
<dd>{{ category_options | length }}</dd>
</div>
<div>
<dt>Frequencies Supported</dt>
<dd>{{ frequency_options | length }}</dd>
</div>
<div>
<dt>Last Updated</dt>
<dd>{{ last_updated_at or '—' }}</dd>
</div>
</dl>
<p class="muted">Defaults reflect scenario preferences. Adjust before calculating.</p>
</div>
</aside>
</div>
</form>
<section class="report-section">
<header class="section-header">
<h2>Opex Summary</h2>
<p class="section-subtitle">Annual totals, escalation impacts, and category breakdowns.</p>
</header>
{% if result %}
<div class="report-grid">
<article class="report-card">
<h3>Annual Opex Total</h3>
<p class="metric">
<strong>{{ result.totals.overall_annual | currency_display(result.currency) }}</strong>
</p>
</article>
<article class="report-card">
<h3>Escalated Total</h3>
<p class="metric">
<strong>
{% if result.totals.escalated_total is not none %}
{{ result.totals.escalated_total | currency_display(result.currency) }}
{% else %}
{% endif %}
</strong>
</p>
</article>
<article class="report-card">
<h3>Annual Average (Escalated)</h3>
<p class="metric">
<strong>
{% if result.metrics.annual_average is not none %}
{{ result.metrics.annual_average | currency_display(result.currency) }}
{% else %}
{% endif %}
</strong>
</p>
</article>
</div>
<div class="panel">
<h3>Category Breakdown</h3>
<div class="table-responsive">
<table class="data-table">
<thead>
<tr>
<th scope="col">Category</th>
<th scope="col">Annual Cost</th>
<th scope="col">Share (%)</th>
</tr>
</thead>
<tbody>
{% for entry in result.totals.by_category %}
<tr>
<td>{{ entry.category | title }}</td>
<td>{{ entry.annual_cost | currency_display(result.currency) }}</td>
<td>{% if entry.share is not none %}{{ entry.share | round(2) }}{% else %}—{% endif %}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
<div class="panel">
<h3>Timeline</h3>
<div class="table-responsive">
<table class="data-table">
<thead>
<tr>
<th scope="col">Period</th>
<th scope="col">Base Cost</th>
<th scope="col">Escalated Cost</th>
</tr>
</thead>
<tbody>
{% for entry in result.timeline %}
<tr>
<td>{{ entry.period }}</td>
<td>{{ entry.base_cost | currency_display(result.currency) }}</td>
<td>
{% if entry.escalated_cost is not none %}
{{ entry.escalated_cost | currency_display(result.currency) }}
{% else %}
{% endif %}
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
{% else %}
<p class="muted">Run the calculation to populate summary metrics and timeline insights.</p>
{% endif %}
</section>
{% endblock %}

View File

@@ -0,0 +1,310 @@
from __future__ import annotations
from collections.abc import Callable
import pytest
from fastapi.testclient import TestClient
from services.unit_of_work import UnitOfWork
def _create_project(client: TestClient, name: str) -> int:
response = client.post(
"/projects",
json={
"name": name,
"location": "Nevada",
"operation_type": "open_pit",
"description": "Project for processing opex testing",
},
)
assert response.status_code == 201
return response.json()["id"]
def _create_scenario(client: TestClient, project_id: int, name: str) -> int:
response = client.post(
f"/projects/{project_id}/scenarios",
json={
"name": name,
"description": "Processing opex scenario",
"status": "draft",
"currency": "usd",
"primary_resource": "diesel",
},
)
assert response.status_code == 201
return response.json()["id"]
def test_processing_opex_calculation_html_flow(
client: TestClient,
unit_of_work_factory: Callable[[], UnitOfWork],
) -> None:
project_id = _create_project(client, "Opex HTML Project")
scenario_id = _create_scenario(client, project_id, "Opex HTML Scenario")
form_page = client.get(
f"/calculations/processing-opex?project_id={project_id}&scenario_id={scenario_id}"
)
assert form_page.status_code == 200
assert "Processing Opex Planner" in form_page.text
response = client.post(
f"/calculations/processing-opex?project_id={project_id}&scenario_id={scenario_id}",
data={
"components[0][name]": "Power",
"components[0][category]": "energy",
"components[0][unit_cost]": "1000",
"components[0][quantity]": "1",
"components[0][frequency]": "monthly",
"components[0][currency]": "USD",
"components[0][period_start]": "1",
"components[0][period_end]": "3",
"components[1][name]": "Maintenance",
"components[1][category]": "maintenance",
"components[1][unit_cost]": "2500",
"components[1][quantity]": "1",
"components[1][frequency]": "quarterly",
"components[1][currency]": "USD",
"components[1][period_start]": "1",
"components[1][period_end]": "2",
"parameters[currency_code]": "USD",
"parameters[escalation_pct]": "5",
"parameters[discount_rate_pct]": "3",
"parameters[evaluation_horizon_years]": "3",
"parameters[apply_escalation]": "1",
"options[persist]": "1",
"options[snapshot_notes]": "Processing opex HTML flow",
},
)
assert response.status_code == 200
assert "Processing opex calculation completed successfully." in response.text
assert "Opex Summary" in response.text
assert "$22,000.00" in response.text or "22,000" in response.text
with unit_of_work_factory() as uow:
assert uow.project_processing_opex is not None
assert uow.scenario_processing_opex is not None
project_snapshots = uow.project_processing_opex.list_for_project(
project_id)
scenario_snapshots = uow.scenario_processing_opex.list_for_scenario(
scenario_id)
assert len(project_snapshots) == 1
assert len(scenario_snapshots) == 1
project_snapshot = project_snapshots[0]
scenario_snapshot = scenario_snapshots[0]
assert project_snapshot.overall_annual is not None
assert float(
project_snapshot.overall_annual) == pytest.approx(22_000.0)
assert project_snapshot.escalated_total is not None
assert float(
project_snapshot.escalated_total) == pytest.approx(58_330.0)
assert project_snapshot.apply_escalation is True
assert project_snapshot.component_count == 2
assert project_snapshot.currency_code == "USD"
assert scenario_snapshot.overall_annual is not None
assert float(
scenario_snapshot.overall_annual) == pytest.approx(22_000.0)
assert scenario_snapshot.escalated_total is not None
assert float(
scenario_snapshot.escalated_total) == pytest.approx(58_330.0)
assert scenario_snapshot.apply_escalation is True
assert scenario_snapshot.component_count == 2
assert scenario_snapshot.currency_code == "USD"
def test_processing_opex_calculation_json_flow(
client: TestClient,
unit_of_work_factory: Callable[[], UnitOfWork],
) -> None:
project_id = _create_project(client, "Opex JSON Project")
scenario_id = _create_scenario(client, project_id, "Opex JSON Scenario")
payload = {
"components": [
{
"name": "Reagents",
"category": "materials",
"unit_cost": 400,
"quantity": 10,
"frequency": "monthly",
"currency": "USD",
"period_start": 1,
"period_end": 3,
},
{
"name": "Labor",
"category": "labor",
"unit_cost": 1500,
"quantity": 4,
"frequency": "weekly",
"currency": "USD",
"period_start": 1,
"period_end": 3,
},
{
"name": "Maintenance",
"category": "maintenance",
"unit_cost": 12000,
"quantity": 1,
"frequency": "annually",
"currency": "USD",
"period_start": 1,
"period_end": 3,
},
],
"parameters": {
"currency_code": "USD",
"escalation_pct": 4,
"discount_rate_pct": 2,
"evaluation_horizon_years": 3,
"apply_escalation": True,
},
"options": {"persist": True, "snapshot_notes": "Processing opex JSON flow"},
}
response = client.post(
f"/calculations/processing-opex?project_id={project_id}&scenario_id={scenario_id}",
json=payload,
)
assert response.status_code == 200
data = response.json()
assert data["currency"] == "USD"
expected_overall = 372_000.0
escalation_factor = 1 + (payload["parameters"]["escalation_pct"] / 100.0)
expected_timeline = [
expected_overall * (escalation_factor ** i) for i in range(payload["parameters"]["evaluation_horizon_years"])
]
expected_escalated_total = sum(expected_timeline)
expected_average = expected_escalated_total / len(expected_timeline)
assert data["totals"]["overall_annual"] == pytest.approx(expected_overall)
assert data["totals"]["escalated_total"] == pytest.approx(
expected_escalated_total)
assert data["totals"]["escalation_pct"] == pytest.approx(4.0)
by_category = {entry["category"] : entry for entry in data["totals"]["by_category"]}
assert by_category["materials"]["annual_cost"] == pytest.approx(48_000.0)
assert by_category["labor"]["annual_cost"] == pytest.approx(312_000.0)
assert by_category["maintenance"]["annual_cost"] == pytest.approx(12_000.0)
assert len(data["timeline"]) == 3
for index, entry in enumerate(data["timeline"], start=0):
assert entry["period"] == index + 1
assert entry["base_cost"] == pytest.approx(expected_overall)
assert entry["escalated_cost"] == pytest.approx(
expected_timeline[index])
assert data["metrics"]["annual_average"] == pytest.approx(expected_average)
with unit_of_work_factory() as uow:
assert uow.project_processing_opex is not None
assert uow.scenario_processing_opex is not None
project_snapshot = uow.project_processing_opex.latest_for_project(
project_id)
scenario_snapshot = uow.scenario_processing_opex.latest_for_scenario(
scenario_id)
assert project_snapshot is not None
assert scenario_snapshot is not None
assert project_snapshot.overall_annual is not None
assert float(project_snapshot.overall_annual) == pytest.approx(
expected_overall)
assert project_snapshot.escalated_total is not None
assert float(project_snapshot.escalated_total) == pytest.approx(
expected_escalated_total)
assert project_snapshot.apply_escalation is True
assert scenario_snapshot.annual_average is not None
assert float(scenario_snapshot.annual_average) == pytest.approx(
expected_average)
assert scenario_snapshot.apply_escalation is True
@pytest.mark.parametrize("content_type", ["form", "json"])
def test_processing_opex_calculation_currency_mismatch(
client: TestClient,
unit_of_work_factory: Callable[[], UnitOfWork],
content_type: str,
) -> None:
project_id = _create_project(
client, f"Opex {content_type.title()} Error Project")
scenario_id = _create_scenario(
client, project_id, f"Opex {content_type.title()} Error Scenario")
if content_type == "json":
payload = {
"components": [
{
"name": "Power",
"category": "energy",
"unit_cost": 500,
"quantity": 1,
"frequency": "monthly",
"currency": "USD",
"period_start": 1,
"period_end": 2,
}
],
"parameters": {"currency_code": "CAD"},
"options": {"persist": True},
}
response = client.post(
f"/calculations/processing-opex?project_id={project_id}&scenario_id={scenario_id}",
json=payload,
)
assert response.status_code == 422
body = response.json()
assert "Component currency does not match" in body.get("message", "")
assert any(
"components[0].currency" in entry for entry in body.get("errors", []))
else:
response = client.post(
f"/calculations/processing-opex?project_id={project_id}&scenario_id={scenario_id}",
data={
"components[0][name]": "Power",
"components[0][category]": "energy",
"components[0][unit_cost]": "500",
"components[0][quantity]": "1",
"components[0][frequency]": "monthly",
"components[0][currency]": "USD",
"components[0][period_start]": "1",
"components[0][period_end]": "2",
"parameters[currency_code]": "CAD",
"options[persist]": "1",
},
)
assert response.status_code == 422
assert hasattr(response, "context")
context = getattr(response, "context", {}) or {}
combined_errors = [
str(entry)
for entry in (
(context.get("errors") or [])
+ (context.get("component_errors") or [])
)
]
assert any(
"components[0].currency" in entry for entry in combined_errors)
with unit_of_work_factory() as uow:
assert uow.project_processing_opex is not None
assert uow.scenario_processing_opex is not None
project_snapshots = uow.project_processing_opex.list_for_project(
project_id)
scenario_snapshots = uow.scenario_processing_opex.list_for_scenario(
scenario_id)
assert project_snapshots == []
assert scenario_snapshots == []

View File

@@ -0,0 +1,159 @@
import pytest
from schemas.calculations import (
ProcessingOpexCalculationRequest,
ProcessingOpexComponentInput,
ProcessingOpexOptions,
ProcessingOpexParameters,
)
from services.calculations import calculate_processing_opex
from services.exceptions import OpexValidationError
def _component(**overrides) -> ProcessingOpexComponentInput:
defaults = {
"id": None,
"name": "Component",
"category": "energy",
"unit_cost": 1000.0,
"quantity": 1.0,
"frequency": "monthly",
"currency": "USD",
"period_start": 1,
"period_end": 1,
"notes": None,
}
defaults.update(overrides)
return ProcessingOpexComponentInput(**defaults)
def test_calculate_processing_opex_success():
request = ProcessingOpexCalculationRequest(
components=[
_component(
name="Power",
category="energy",
unit_cost=1000.0,
quantity=1,
frequency="monthly",
period_start=1,
period_end=3,
),
_component(
name="Maintenance",
category="maintenance",
unit_cost=2500.0,
quantity=1,
frequency="quarterly",
period_start=1,
period_end=2,
),
],
parameters=ProcessingOpexParameters(
currency_code="USD",
escalation_pct=5,
discount_rate_pct=None,
evaluation_horizon_years=2,
apply_escalation=True,
),
options=ProcessingOpexOptions(persist=True, snapshot_notes=None),
)
result = calculate_processing_opex(request)
assert result.currency == "USD"
assert result.options.persist is True
assert result.totals.overall_annual == pytest.approx(22_000.0)
assert result.totals.escalated_total == pytest.approx(58_330.0, rel=1e-4)
assert result.totals.escalation_pct == pytest.approx(5.0)
categories = {entry.category: entry for entry in result.totals.by_category}
assert categories["energy"].annual_cost == pytest.approx(12_000.0)
assert categories["maintenance"].annual_cost == pytest.approx(10_000.0)
assert len(result.timeline) == 3
timeline = {entry.period: entry for entry in result.timeline}
assert timeline[1].base_cost == pytest.approx(22_000.0)
assert timeline[2].base_cost == pytest.approx(22_000.0)
assert timeline[3].base_cost == pytest.approx(12_000.0)
assert timeline[1].escalated_cost == pytest.approx(22_000.0)
assert timeline[2].escalated_cost == pytest.approx(23_100.0, rel=1e-4)
assert timeline[3].escalated_cost == pytest.approx(13_230.0, rel=1e-4)
assert result.metrics.annual_average == pytest.approx(
19_443.3333, rel=1e-4)
assert len(result.components) == 2
assert result.components[0].frequency == "monthly"
assert result.components[1].frequency == "quarterly"
def test_calculate_processing_opex_currency_mismatch():
request = ProcessingOpexCalculationRequest(
components=[_component(currency="USD")],
parameters=ProcessingOpexParameters(
currency_code="CAD",
escalation_pct=None,
discount_rate_pct=None,
evaluation_horizon_years=10,
),
)
with pytest.raises(OpexValidationError) as exc:
calculate_processing_opex(request)
assert "Component currency does not match" in exc.value.message
assert exc.value.field_errors and "components[0].currency" in exc.value.field_errors[0]
def test_calculate_processing_opex_unsupported_frequency():
request = ProcessingOpexCalculationRequest(
components=[_component(frequency="biweekly")],
parameters=ProcessingOpexParameters(
currency_code="USD",
escalation_pct=None,
discount_rate_pct=None,
evaluation_horizon_years=2,
),
)
with pytest.raises(OpexValidationError) as exc:
calculate_processing_opex(request)
assert "Unsupported frequency" in exc.value.message
assert exc.value.field_errors and "components[0].frequency" in exc.value.field_errors[0]
def test_calculate_processing_opex_requires_components():
request = ProcessingOpexCalculationRequest(components=[])
with pytest.raises(OpexValidationError) as exc:
calculate_processing_opex(request)
assert "At least one processing opex component" in exc.value.message
assert exc.value.field_errors and "components" in exc.value.field_errors[0]
def test_calculate_processing_opex_extends_evaluation_horizon():
request = ProcessingOpexCalculationRequest(
components=[
_component(period_start=1, period_end=4),
],
parameters=ProcessingOpexParameters(
currency_code="USD",
discount_rate_pct=0,
escalation_pct=0,
evaluation_horizon_years=2,
apply_escalation=False,
),
)
result = calculate_processing_opex(request)
assert len(result.timeline) == 4
assert result.timeline[-1].period == 4
assert all(entry.escalated_cost is None for entry in result.timeline)
assert result.timeline[-1].base_cost == pytest.approx(12_000.0)
assert result.metrics.annual_average == pytest.approx(
12_000.0, rel=1e-4)