feat: add scenarios list page with metrics and quick actions
Some checks failed
CI / test (push) Has been skipped
CI / build (push) Has been skipped
CI / lint (push) Failing after 15s
CI / deploy (push) Has been skipped

- Introduced a new template for listing scenarios associated with a project.
- Added metrics for total, active, draft, and archived scenarios.
- Implemented quick actions for creating new scenarios and reviewing project overview.
- Enhanced navigation with breadcrumbs for better user experience.

refactor: update Opex and Profitability templates for consistency

- Changed titles and button labels for clarity in Opex and Profitability templates.
- Updated form IDs and action URLs for better alignment with new naming conventions.
- Improved navigation links to include scenario and project overviews.

test: add integration tests for Opex calculations

- Created new tests for Opex calculation HTML and JSON flows.
- Validated successful calculations and ensured correct data persistence.
- Implemented tests for currency mismatch and unsupported frequency scenarios.

test: enhance project and scenario route tests

- Added tests to verify scenario list rendering and calculator shortcuts.
- Ensured scenario detail pages link back to the portfolio correctly.
- Validated project detail pages show associated scenarios accurately.
This commit is contained in:
2025-11-13 16:21:36 +01:00
parent 4f00bf0d3c
commit 522b1e4105
54 changed files with 3419 additions and 700 deletions

View File

@@ -29,14 +29,14 @@ from schemas.calculations import (
CapexTotals,
CapexTimelineEntry,
CashFlowEntry,
ProcessingOpexCalculationRequest,
ProcessingOpexCalculationResult,
ProcessingOpexCategoryBreakdown,
ProcessingOpexComponentInput,
ProcessingOpexMetrics,
ProcessingOpexParameters,
ProcessingOpexTotals,
ProcessingOpexTimelineEntry,
OpexCalculationRequest,
OpexCalculationResult,
OpexCategoryBreakdown,
OpexComponentInput,
OpexMetrics,
OpexParameters,
OpexTotals,
OpexTimelineEntry,
ProfitabilityCalculationRequest,
ProfitabilityCalculationResult,
ProfitabilityCosts,
@@ -101,20 +101,20 @@ def _generate_cash_flows(
*,
periods: int,
net_per_period: float,
initial_capex: float,
capex: float,
) -> tuple[list[CashFlow], list[CashFlowEntry]]:
"""Create cash flow structures for financial metric calculations."""
cash_flow_models: list[CashFlow] = [
CashFlow(amount=-initial_capex, period_index=0)
CashFlow(amount=-capex, period_index=0)
]
cash_flow_entries: list[CashFlowEntry] = [
CashFlowEntry(
period=0,
revenue=0.0,
processing_opex=0.0,
opex=0.0,
sustaining_capex=0.0,
net=-initial_capex,
net=-capex,
)
]
@@ -125,7 +125,7 @@ def _generate_cash_flows(
CashFlowEntry(
period=period,
revenue=0.0,
processing_opex=0.0,
opex=0.0,
sustaining_capex=0.0,
net=net_per_period,
)
@@ -159,26 +159,26 @@ def calculate_profitability(
revenue_total = float(pricing_result.net_revenue)
revenue_per_period = revenue_total / periods
processing_total = float(request.processing_opex) * periods
processing_total = float(request.opex) * periods
sustaining_total = float(request.sustaining_capex) * periods
initial_capex = float(request.initial_capex)
capex = float(request.capex)
net_per_period = (
revenue_per_period
- float(request.processing_opex)
- float(request.opex)
- float(request.sustaining_capex)
)
cash_flow_models, cash_flow_entries = _generate_cash_flows(
periods=periods,
net_per_period=net_per_period,
initial_capex=initial_capex,
capex=capex,
)
# Update per-period entries to include explicit costs for presentation
for entry in cash_flow_entries[1:]:
entry.revenue = revenue_per_period
entry.processing_opex = float(request.processing_opex)
entry.opex = float(request.opex)
entry.sustaining_capex = float(request.sustaining_capex)
entry.net = net_per_period
@@ -196,7 +196,7 @@ def calculate_profitability(
except (ValueError, PaybackNotReachedError):
payback_value = None
total_costs = processing_total + sustaining_total + initial_capex
total_costs = processing_total + sustaining_total + capex
total_net = revenue_total - total_costs
if revenue_total == 0:
@@ -212,9 +212,9 @@ def calculate_profitability(
str(exc), ["currency_code"]) from exc
costs = ProfitabilityCosts(
processing_opex_total=processing_total,
opex_total=processing_total,
sustaining_capex_total=sustaining_total,
initial_capex=initial_capex,
capex=capex,
)
metrics = ProfitabilityMetrics(
@@ -354,18 +354,18 @@ def calculate_initial_capex(
)
def calculate_processing_opex(
request: ProcessingOpexCalculationRequest,
) -> ProcessingOpexCalculationResult:
"""Aggregate processing opex components into annual totals and timeline."""
def calculate_opex(
request: OpexCalculationRequest,
) -> OpexCalculationResult:
"""Aggregate opex components into annual totals and timeline."""
if not request.components:
raise OpexValidationError(
"At least one processing opex component is required for calculation.",
"At least one opex component is required for calculation.",
["components"],
)
parameters: ProcessingOpexParameters = request.parameters
parameters: OpexParameters = request.parameters
base_currency = parameters.currency_code
if base_currency:
try:
@@ -388,7 +388,7 @@ def calculate_processing_opex(
category_totals: dict[str, float] = defaultdict(float)
timeline_totals: dict[int, float] = defaultdict(float)
timeline_escalated: dict[int, float] = defaultdict(float)
normalised_components: list[ProcessingOpexComponentInput] = []
normalised_components: list[OpexComponentInput] = []
max_period_end = evaluation_horizon
@@ -448,7 +448,7 @@ def calculate_processing_opex(
timeline_totals[period] += annual_cost
normalised_components.append(
ProcessingOpexComponentInput(
OpexComponentInput(
id=component.id,
name=component.name,
category=component.category,
@@ -471,7 +471,7 @@ def calculate_processing_opex(
str(exc), ["parameters.currency_code"]
) from exc
timeline_entries: list[ProcessingOpexTimelineEntry] = []
timeline_entries: list[OpexTimelineEntry] = []
escalated_values: list[float] = []
overall_annual = timeline_totals.get(1, 0.0)
escalated_total = 0.0
@@ -486,7 +486,7 @@ def calculate_processing_opex(
timeline_escalated[period] = escalated_cost
escalated_total += escalated_cost
timeline_entries.append(
ProcessingOpexTimelineEntry(
OpexTimelineEntry(
period=period,
base_cost=base_cost,
escalated_cost=escalated_cost if apply_escalation else None,
@@ -494,31 +494,31 @@ def calculate_processing_opex(
)
escalated_values.append(escalated_cost)
category_breakdowns: list[ProcessingOpexCategoryBreakdown] = []
category_breakdowns: list[OpexCategoryBreakdown] = []
total_base = sum(category_totals.values())
for category, total in sorted(category_totals.items()):
share = (total / total_base * 100.0) if total_base else None
category_breakdowns.append(
ProcessingOpexCategoryBreakdown(
OpexCategoryBreakdown(
category=category,
annual_cost=total,
share=share,
)
)
metrics = ProcessingOpexMetrics(
metrics = OpexMetrics(
annual_average=fmean(escalated_values) if escalated_values else None,
cost_per_ton=None,
)
totals = ProcessingOpexTotals(
totals = OpexTotals(
overall_annual=overall_annual,
escalated_total=escalated_total if apply_escalation else None,
escalation_pct=escalation_pct if apply_escalation else None,
by_category=category_breakdowns,
)
return ProcessingOpexCalculationResult(
return OpexCalculationResult(
totals=totals,
timeline=timeline_entries,
metrics=metrics,
@@ -532,5 +532,5 @@ def calculate_processing_opex(
__all__ = [
"calculate_profitability",
"calculate_initial_capex",
"calculate_processing_opex",
"calculate_opex",
]