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

@@ -197,6 +197,127 @@ class CapexCalculationResult(BaseModel):
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__ = [
"ImpurityInput",
"ProfitabilityCalculationRequest",
@@ -212,5 +333,14 @@ __all__ = [
"CapexTotals",
"CapexTimelineEntry",
"CapexCalculationResult",
"ProcessingOpexComponentInput",
"ProcessingOpexParameters",
"ProcessingOpexOptions",
"ProcessingOpexCalculationRequest",
"ProcessingOpexCategoryBreakdown",
"ProcessingOpexTimelineEntry",
"ProcessingOpexMetrics",
"ProcessingOpexTotals",
"ProcessingOpexCalculationResult",
"ValidationError",
]