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

@@ -0,0 +1,93 @@
import pytest
from schemas.calculations import (
CapexCalculationOptions,
CapexCalculationRequest,
CapexComponentInput,
CapexParameters,
)
from services.calculations import calculate_initial_capex
from services.exceptions import CapexValidationError
def _component(**kwargs) -> CapexComponentInput:
defaults = {
"id": None,
"name": "Component",
"category": "equipment",
"amount": 1_000_000.0,
"currency": "USD",
"spend_year": 0,
"notes": None,
}
defaults.update(kwargs)
return CapexComponentInput(**defaults)
def test_calculate_initial_capex_success():
request = CapexCalculationRequest(
components=[
_component(name="Crusher", category="equipment",
amount=1_200_000, spend_year=0),
_component(name="Conveyor", category="equipment",
amount=800_000, spend_year=1),
_component(name="Camp", category="infrastructure",
amount=600_000, spend_year=1, currency="usd"),
],
parameters=CapexParameters(
currency_code="USD",
contingency_pct=10,
discount_rate_pct=8,
evaluation_horizon_years=5,
),
options=CapexCalculationOptions(persist=True),
)
result = calculate_initial_capex(request)
assert result.currency == "USD"
assert result.options.persist is True
assert result.totals.overall == pytest.approx(2_600_000)
assert result.totals.contingency_pct == pytest.approx(10)
assert result.totals.contingency_amount == pytest.approx(260_000)
assert result.totals.with_contingency == pytest.approx(2_860_000)
by_category = {row.category: row for row in result.totals.by_category}
assert by_category["equipment"].amount == pytest.approx(2_000_000)
assert by_category["infrastructure"].amount == pytest.approx(600_000)
assert by_category["equipment"].share == pytest.approx(76.923, rel=1e-3)
assert by_category["infrastructure"].share == pytest.approx(
23.077, rel=1e-3)
timeline = {(entry.year, entry.spend): entry.cumulative for entry in result.timeline}
assert timeline[(0, 1_200_000)] == pytest.approx(1_200_000)
assert timeline[(1, 1_400_000)] == pytest.approx(2_600_000)
assert len(result.components) == 3
assert result.components[2].currency == "USD"
def test_calculate_initial_capex_currency_mismatch_raises():
request = CapexCalculationRequest(
components=[
_component(amount=500_000, currency="USD"),
],
parameters=CapexParameters(currency_code="CAD"),
)
with pytest.raises(CapexValidationError) as exc:
calculate_initial_capex(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_initial_capex_requires_components():
request = CapexCalculationRequest(components=[])
with pytest.raises(CapexValidationError) as exc:
calculate_initial_capex(request)
assert "At least one capex component" in exc.value.message
assert exc.value.field_errors and "components" in exc.value.field_errors[0]