feat: add scenarios list page with metrics and quick actions
- 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:
@@ -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",
|
||||
]
|
||||
|
||||
Reference in New Issue
Block a user