- 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.
94 lines
3.2 KiB
Python
94 lines
3.2 KiB
Python
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]
|