feat: Implement initial capex calculation feature

- Added CapexComponentInput, CapexParameters, CapexCalculationRequest, CapexCalculationResult, and related schemas for capex calculations.
- Introduced calculate_initial_capex function to aggregate capex components and compute totals and timelines.
- Created ProjectCapexRepository and ScenarioCapexRepository for managing capex snapshots in the database.
- Developed capex.html template for capturing and displaying initial capex data.
- Registered common Jinja2 filters for formatting currency and percentages.
- Implemented unit and integration tests for capex calculation functionality.
- Updated unit of work to include new repositories for capex management.
This commit is contained in:
2025-11-12 23:51:52 +01:00
parent 6c1570a254
commit d9fd82b2e3
16 changed files with 1566 additions and 93 deletions

View File

@@ -97,6 +97,106 @@ class ProfitabilityCalculationResult(BaseModel):
currency: str | None
class CapexComponentInput(BaseModel):
"""Capex 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)
amount: float = Field(..., ge=0)
currency: str | None = Field(None, min_length=3, max_length=3)
spend_year: int | None = Field(None, ge=0, le=120)
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("name")
@classmethod
def _trim_name(cls, value: str) -> str:
return value.strip()
class CapexParameters(BaseModel):
"""Global parameters applied to capex calculations."""
currency_code: str | None = Field(None, min_length=3, max_length=3)
contingency_pct: float | None = Field(0, 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)
@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 CapexCalculationOptions(BaseModel):
"""Optional behaviour flags for capex calculations."""
persist: bool = False
class CapexCalculationRequest(BaseModel):
"""Request payload for capex aggregation."""
components: List[CapexComponentInput] = Field(default_factory=list)
parameters: CapexParameters = Field(
default_factory=CapexParameters, # type: ignore[arg-type]
)
options: CapexCalculationOptions = Field(
default_factory=CapexCalculationOptions, # type: ignore[arg-type]
)
class CapexCategoryBreakdown(BaseModel):
"""Breakdown entry describing category totals."""
category: str
amount: float = Field(..., ge=0)
share: float | None = Field(None, ge=0, le=100)
class CapexTotals(BaseModel):
"""Aggregated totals for capex workflows."""
overall: float = Field(..., ge=0)
contingency_pct: float = Field(0, ge=0, le=100)
contingency_amount: float = Field(..., ge=0)
with_contingency: float = Field(..., ge=0)
by_category: List[CapexCategoryBreakdown] = Field(default_factory=list)
class CapexTimelineEntry(BaseModel):
"""Spend profile entry grouped by year."""
year: int
spend: float = Field(..., ge=0)
cumulative: float = Field(..., ge=0)
class CapexCalculationResult(BaseModel):
"""Response body for capex calculations."""
totals: CapexTotals
timeline: List[CapexTimelineEntry] = Field(default_factory=list)
components: List[CapexComponentInput] = Field(default_factory=list)
parameters: CapexParameters
options: CapexCalculationOptions
currency: str | None
__all__ = [
"ImpurityInput",
"ProfitabilityCalculationRequest",
@@ -104,5 +204,13 @@ __all__ = [
"ProfitabilityMetrics",
"CashFlowEntry",
"ProfitabilityCalculationResult",
"CapexComponentInput",
"CapexParameters",
"CapexCalculationOptions",
"CapexCalculationRequest",
"CapexCategoryBreakdown",
"CapexTotals",
"CapexTimelineEntry",
"CapexCalculationResult",
"ValidationError",
]