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