feat: Add profitability calculation schemas and service functions

- Introduced Pydantic schemas for profitability calculations in `schemas/calculations.py`.
- Implemented service functions for profitability calculations in `services/calculations.py`.
- Added new exception class `ProfitabilityValidationError` for handling validation errors.
- Created repositories for managing project and scenario profitability snapshots.
- Developed a utility script for verifying authenticated routes.
- Added a new HTML template for the profitability calculator interface.
- Implemented a script to fix user ID sequence in the database.
This commit is contained in:
2025-11-12 22:22:29 +01:00
parent 6d496a599e
commit b1a6df9f90
15 changed files with 1654 additions and 0 deletions

108
schemas/calculations.py Normal file
View File

@@ -0,0 +1,108 @@
"""Pydantic schemas for calculation workflows."""
from __future__ import annotations
from typing import List, Optional
from pydantic import BaseModel, Field, PositiveFloat, ValidationError, field_validator
from services.pricing import PricingResult
class ImpurityInput(BaseModel):
"""Impurity configuration row supplied by the client."""
name: str = Field(..., min_length=1)
value: float | None = Field(None, ge=0)
threshold: float | None = Field(None, ge=0)
penalty: float | None = Field(None)
@field_validator("name")
@classmethod
def _normalise_name(cls, value: str) -> str:
return value.strip()
class ProfitabilityCalculationRequest(BaseModel):
"""Request payload for profitability calculations."""
metal: str = Field(..., min_length=1)
ore_tonnage: PositiveFloat
head_grade_pct: float = Field(..., gt=0, le=100)
recovery_pct: float = Field(..., gt=0, le=100)
payable_pct: float | None = Field(None, gt=0, le=100)
reference_price: PositiveFloat
treatment_charge: float = Field(0, ge=0)
smelting_charge: float = Field(0, ge=0)
moisture_pct: float = Field(0, ge=0, le=100)
moisture_threshold_pct: float | None = Field(None, ge=0, le=100)
moisture_penalty_per_pct: float | None = None
premiums: float = Field(0)
fx_rate: PositiveFloat = Field(1)
currency_code: str | None = Field(None, min_length=3, max_length=3)
processing_opex: float = Field(0, ge=0)
sustaining_capex: float = Field(0, ge=0)
initial_capex: float = Field(0, ge=0)
discount_rate: float | None = Field(None, ge=0, le=100)
periods: int = Field(10, ge=1, le=120)
impurities: List[ImpurityInput] = Field(default_factory=list)
@field_validator("currency_code")
@classmethod
def _uppercase_currency(cls, value: str | None) -> str | None:
if value is None:
return None
return value.strip().upper()
@field_validator("metal")
@classmethod
def _normalise_metal(cls, value: str) -> str:
return value.strip().lower()
class ProfitabilityCosts(BaseModel):
"""Aggregated cost components for profitability output."""
processing_opex_total: float
sustaining_capex_total: float
initial_capex: float
class ProfitabilityMetrics(BaseModel):
"""Financial KPIs yielded by the profitability calculation."""
npv: float | None
irr: float | None
payback_period: float | None
margin: float | None
class CashFlowEntry(BaseModel):
"""Normalized cash flow row for reporting and charting."""
period: int
revenue: float
processing_opex: float
sustaining_capex: float
net: float
class ProfitabilityCalculationResult(BaseModel):
"""Response body summarizing profitability calculation outputs."""
pricing: PricingResult
costs: ProfitabilityCosts
metrics: ProfitabilityMetrics
cash_flows: list[CashFlowEntry]
currency: str | None
__all__ = [
"ImpurityInput",
"ProfitabilityCalculationRequest",
"ProfitabilityCosts",
"ProfitabilityMetrics",
"CashFlowEntry",
"ProfitabilityCalculationResult",
"ValidationError",
]