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:
@@ -17,6 +17,7 @@ from dependencies import get_auth_session, get_import_ingestion_service, get_uni
|
||||
from models import User
|
||||
from routes.auth import router as auth_router
|
||||
from routes.dashboard import router as dashboard_router
|
||||
from routes.calculations import router as calculations_router
|
||||
from routes.projects import router as projects_router
|
||||
from routes.scenarios import router as scenarios_router
|
||||
from routes.imports import router as imports_router
|
||||
@@ -57,6 +58,7 @@ def app(session_factory: sessionmaker) -> FastAPI:
|
||||
application = FastAPI()
|
||||
application.include_router(auth_router)
|
||||
application.include_router(dashboard_router)
|
||||
application.include_router(calculations_router)
|
||||
application.include_router(projects_router)
|
||||
application.include_router(scenarios_router)
|
||||
application.include_router(imports_router)
|
||||
|
||||
123
tests/integration/test_capex_calculations.py
Normal file
123
tests/integration/test_capex_calculations.py
Normal file
@@ -0,0 +1,123 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
|
||||
def _create_project(client: TestClient, name: str) -> int:
|
||||
response = client.post(
|
||||
"/projects",
|
||||
json={
|
||||
"name": name,
|
||||
"location": "Nevada",
|
||||
"operation_type": "open_pit",
|
||||
"description": "Project for capex testing",
|
||||
},
|
||||
)
|
||||
assert response.status_code == 201
|
||||
return response.json()["id"]
|
||||
|
||||
|
||||
def _create_scenario(client: TestClient, project_id: int, name: str) -> int:
|
||||
response = client.post(
|
||||
f"/projects/{project_id}/scenarios",
|
||||
json={
|
||||
"name": name,
|
||||
"description": "Capex scenario",
|
||||
"status": "draft",
|
||||
"currency": "usd",
|
||||
"primary_resource": "diesel",
|
||||
},
|
||||
)
|
||||
assert response.status_code == 201
|
||||
return response.json()["id"]
|
||||
|
||||
|
||||
def test_capex_calculation_html_flow(client: TestClient) -> None:
|
||||
project_id = _create_project(client, "Capex HTML Project")
|
||||
scenario_id = _create_scenario(client, project_id, "Capex HTML Scenario")
|
||||
|
||||
form_page = client.get(
|
||||
f"/calculations/capex?project_id={project_id}&scenario_id={scenario_id}"
|
||||
)
|
||||
assert form_page.status_code == 200
|
||||
assert "Initial Capex Planner" in form_page.text
|
||||
|
||||
response = client.post(
|
||||
f"/calculations/capex?project_id={project_id}&scenario_id={scenario_id}",
|
||||
data={
|
||||
"components[0][name]": "Crusher",
|
||||
"components[0][category]": "equipment",
|
||||
"components[0][amount]": "1200000",
|
||||
"components[0][currency]": "USD",
|
||||
"components[0][spend_year]": "0",
|
||||
"components[1][name]": "Conveyor",
|
||||
"components[1][category]": "equipment",
|
||||
"components[1][amount]": "800000",
|
||||
"components[1][currency]": "USD",
|
||||
"components[1][spend_year]": "1",
|
||||
"parameters[currency_code]": "USD",
|
||||
"parameters[contingency_pct]": "5",
|
||||
"parameters[discount_rate_pct]": "8",
|
||||
"parameters[evaluation_horizon_years]": "5",
|
||||
"options[persist]": "1",
|
||||
},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
assert "Initial capex calculation completed successfully." in response.text
|
||||
assert "Capex Summary" in response.text
|
||||
assert "$1,200,000.00" in response.text or "1,200,000" in response.text
|
||||
assert "USD" in response.text
|
||||
|
||||
|
||||
def test_capex_calculation_json_flow(client: TestClient) -> None:
|
||||
project_id = _create_project(client, "Capex JSON Project")
|
||||
scenario_id = _create_scenario(client, project_id, "Capex JSON Scenario")
|
||||
|
||||
payload = {
|
||||
"components": [
|
||||
{
|
||||
"name": "Camp",
|
||||
"category": "infrastructure",
|
||||
"amount": 600000,
|
||||
"currency": "USD",
|
||||
"spend_year": 0,
|
||||
},
|
||||
{
|
||||
"name": "Power",
|
||||
"category": "infrastructure",
|
||||
"amount": 400000,
|
||||
"currency": "USD",
|
||||
"spend_year": 1,
|
||||
},
|
||||
],
|
||||
"parameters": {
|
||||
"currency_code": "USD",
|
||||
"contingency_pct": 12.5,
|
||||
"discount_rate_pct": 6.5,
|
||||
"evaluation_horizon_years": 4,
|
||||
},
|
||||
"options": {"persist": True},
|
||||
}
|
||||
|
||||
response = client.post(
|
||||
f"/calculations/capex?project_id={project_id}&scenario_id={scenario_id}",
|
||||
json=payload,
|
||||
)
|
||||
assert response.status_code == 200
|
||||
|
||||
data = response.json()
|
||||
assert data["currency"] == "USD"
|
||||
assert data["totals"]["overall"] == 1_000_000
|
||||
assert data["totals"]["contingency_pct"] == pytest.approx(12.5)
|
||||
assert data["totals"]["contingency_amount"] == pytest.approx(125000)
|
||||
assert data["totals"]["with_contingency"] == pytest.approx(1_125_000)
|
||||
|
||||
by_category = {row["category"]: row for row in data["totals"]["by_category"]}
|
||||
assert by_category["infrastructure"]["amount"] == pytest.approx(1_000_000)
|
||||
assert by_category["infrastructure"]["share"] == pytest.approx(100)
|
||||
|
||||
assert len(data["timeline"]) == 2
|
||||
assert data["timeline"][0]["year"] == 0
|
||||
assert data["timeline"][0]["spend"] == pytest.approx(600_000)
|
||||
assert data["timeline"][1]["cumulative"] == pytest.approx(1_000_000)
|
||||
93
tests/services/test_calculations_capex.py
Normal file
93
tests/services/test_calculations_capex.py
Normal 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]
|
||||
Reference in New Issue
Block a user