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:
@@ -2,8 +2,10 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections import defaultdict
|
||||
|
||||
from services.currency import CurrencyValidationError, normalise_currency
|
||||
from services.exceptions import ProfitabilityValidationError
|
||||
from services.exceptions import CapexValidationError, ProfitabilityValidationError
|
||||
from services.financial import (
|
||||
CashFlow,
|
||||
ConvergenceError,
|
||||
@@ -14,6 +16,13 @@ from services.financial import (
|
||||
)
|
||||
from services.pricing import PricingInput, PricingMetadata, PricingResult, calculate_pricing
|
||||
from schemas.calculations import (
|
||||
CapexCalculationRequest,
|
||||
CapexCalculationResult,
|
||||
CapexCategoryBreakdown,
|
||||
CapexComponentInput,
|
||||
CapexParameters,
|
||||
CapexTotals,
|
||||
CapexTimelineEntry,
|
||||
CashFlowEntry,
|
||||
ProfitabilityCalculationRequest,
|
||||
ProfitabilityCalculationResult,
|
||||
@@ -202,4 +211,125 @@ def calculate_profitability(
|
||||
)
|
||||
|
||||
|
||||
__all__ = ["calculate_profitability"]
|
||||
def calculate_initial_capex(
|
||||
request: CapexCalculationRequest,
|
||||
) -> CapexCalculationResult:
|
||||
"""Aggregate capex components into totals and timelines."""
|
||||
|
||||
if not request.components:
|
||||
raise CapexValidationError(
|
||||
"At least one capex component is required for calculation.",
|
||||
["components"],
|
||||
)
|
||||
|
||||
parameters = request.parameters
|
||||
|
||||
base_currency = parameters.currency_code
|
||||
if base_currency:
|
||||
try:
|
||||
base_currency = normalise_currency(base_currency)
|
||||
except CurrencyValidationError as exc:
|
||||
raise CapexValidationError(
|
||||
str(exc), ["parameters.currency_code"]
|
||||
) from exc
|
||||
|
||||
overall = 0.0
|
||||
category_totals: dict[str, float] = defaultdict(float)
|
||||
timeline_totals: dict[int, float] = defaultdict(float)
|
||||
normalised_components: list[CapexComponentInput] = []
|
||||
|
||||
for index, component in enumerate(request.components):
|
||||
amount = float(component.amount)
|
||||
overall += amount
|
||||
|
||||
category_totals[component.category] += amount
|
||||
|
||||
spend_year = component.spend_year or 0
|
||||
timeline_totals[spend_year] += amount
|
||||
|
||||
component_currency = component.currency
|
||||
if component_currency:
|
||||
try:
|
||||
component_currency = normalise_currency(component_currency)
|
||||
except CurrencyValidationError as exc:
|
||||
raise CapexValidationError(
|
||||
str(exc), [f"components[{index}].currency"]
|
||||
) from exc
|
||||
|
||||
if base_currency is None and component_currency:
|
||||
base_currency = component_currency
|
||||
elif (
|
||||
base_currency is not None
|
||||
and component_currency is not None
|
||||
and component_currency != base_currency
|
||||
):
|
||||
raise CapexValidationError(
|
||||
(
|
||||
"Component currency does not match the global currency. "
|
||||
f"Expected {base_currency}, got {component_currency}."
|
||||
),
|
||||
[f"components[{index}].currency"],
|
||||
)
|
||||
|
||||
normalised_components.append(
|
||||
CapexComponentInput(
|
||||
id=component.id,
|
||||
name=component.name,
|
||||
category=component.category,
|
||||
amount=amount,
|
||||
currency=component_currency,
|
||||
spend_year=component.spend_year,
|
||||
notes=component.notes,
|
||||
)
|
||||
)
|
||||
|
||||
contingency_pct = float(parameters.contingency_pct or 0.0)
|
||||
contingency_amount = overall * (contingency_pct / 100.0)
|
||||
grand_total = overall + contingency_amount
|
||||
|
||||
category_breakdowns: list[CapexCategoryBreakdown] = []
|
||||
if category_totals:
|
||||
for category, total in sorted(category_totals.items()):
|
||||
share = (total / overall * 100.0) if overall else None
|
||||
category_breakdowns.append(
|
||||
CapexCategoryBreakdown(
|
||||
category=category,
|
||||
amount=total,
|
||||
share=share,
|
||||
)
|
||||
)
|
||||
|
||||
cumulative = 0.0
|
||||
timeline_entries: list[CapexTimelineEntry] = []
|
||||
for year, spend in sorted(timeline_totals.items()):
|
||||
cumulative += spend
|
||||
timeline_entries.append(
|
||||
CapexTimelineEntry(year=year, spend=spend, cumulative=cumulative)
|
||||
)
|
||||
|
||||
try:
|
||||
currency = normalise_currency(base_currency) if base_currency else None
|
||||
except CurrencyValidationError as exc:
|
||||
raise CapexValidationError(
|
||||
str(exc), ["parameters.currency_code"]
|
||||
) from exc
|
||||
|
||||
totals = CapexTotals(
|
||||
overall=overall,
|
||||
contingency_pct=contingency_pct,
|
||||
contingency_amount=contingency_amount,
|
||||
with_contingency=grand_total,
|
||||
by_category=category_breakdowns,
|
||||
)
|
||||
|
||||
return CapexCalculationResult(
|
||||
totals=totals,
|
||||
timeline=timeline_entries,
|
||||
components=normalised_components,
|
||||
parameters=parameters,
|
||||
options=request.options,
|
||||
currency=currency,
|
||||
)
|
||||
|
||||
|
||||
__all__ = ["calculate_profitability", "calculate_initial_capex"]
|
||||
|
||||
Reference in New Issue
Block a user