feat: Enhance currency handling and validation across scenarios

- Updated form template to prefill currency input with default value and added help text for clarity.
- Modified integration tests to assert more descriptive error messages for invalid currency codes.
- Introduced new tests for currency normalization and validation in various scenarios, including imports and exports.
- Added comprehensive tests for pricing calculations, ensuring defaults are respected and overrides function correctly.
- Implemented unit tests for pricing settings repository, ensuring CRUD operations and default settings are handled properly.
- Enhanced scenario pricing evaluation tests to validate currency handling and metadata defaults.
- Added simulation tests to ensure Monte Carlo runs are accurate and handle various distribution scenarios.
This commit is contained in:
2025-11-11 18:29:59 +01:00
parent 032e6d2681
commit 795a9f99f4
50 changed files with 5110 additions and 81 deletions

View File

@@ -0,0 +1,33 @@
{% if filters %}
<section class="report-filters">
<div class="report-card">
<h2>Active Filters</h2>
<dl class="definition-list">
{% if filters.scenario_ids %}
<div>
<dt>Scenario IDs</dt>
<dd>{{ filters.scenario_ids | join(', ') }}</dd>
</div>
{% endif %}
{% if filters.start_date %}
<div>
<dt>Start Date</dt>
<dd>{{ filters.start_date }}</dd>
</div>
{% endif %}
{% if filters.end_date %}
<div>
<dt>End Date</dt>
<dd>{{ filters.end_date }}</dd>
</div>
{% endif %}
{% if not (filters.scenario_ids or filters.start_date or filters.end_date) %}
<div>
<dt>Status</dt>
<dd>No filters applied</dd>
</div>
{% endif %}
</dl>
</div>
</section>
{% endif %}

View File

@@ -0,0 +1,46 @@
{% set sorted_metrics = metrics | dictsort %}
{% set ns = namespace(percentile_keys=[]) %}
{% if percentiles %}
{% set ns.percentile_keys = percentiles %}
{% elif sorted_metrics %}
{% set reference_percentiles = sorted_metrics[0][1].percentiles.keys() | list %}
{% set ns.percentile_keys = reference_percentiles %}
{% endif %}
{% if sorted_metrics %}
<table class="metrics-table">
<thead>
<tr>
<th scope="col">Metric</th>
<th scope="col">Mean</th>
{% for percentile in ns.percentile_keys %}
{% set percentile_label = '%g' % percentile %}
<th scope="col">P{{ percentile_label }}</th>
{% endfor %}
<th scope="col">Std Dev</th>
</tr>
</thead>
<tbody>
{% for metric_name, summary in sorted_metrics %}
<tr>
<th scope="row">{{ metric_name | replace('_', ' ') | title }}</th>
<td>{{ summary.mean | format_metric(metric_name, currency) }}</td>
{% for percentile in ns.percentile_keys %}
{% set percentile_key = '%g' % percentile %}
{% set percentile_value = summary.percentiles.get(percentile_key) %}
<td>
{% if percentile_value is not none %}
{{ percentile_value | format_metric(metric_name, currency) }}
{% else %}
{% endif %}
</td>
{% endfor %}
<td>{{ summary.std_dev | format_metric(metric_name, currency) }}</td>
</tr>
{% endfor %}
</tbody>
</table>
{% else %}
<p class="muted">Monte Carlo metrics are unavailable.</p>
{% endif %}

View File

@@ -0,0 +1,56 @@
{% if options %}
{% set distribution_enabled = options.distribution %}
{% set samples_enabled = options.samples and options.distribution %}
{% endif %}
<section class="report-options">
<div class="report-card">
<h2>Data Options</h2>
<ul class="metric-list compact">
<li>
<span>Monte Carlo Distribution</span>
<strong>
{% if options %}
{{ distribution_enabled and "Enabled" or "Disabled" }}
{% else %}
Not requested
{% endif %}
</strong>
</li>
<li>
<span>Sample Storage</span>
<strong>
{% if options %}
{% if options.samples %}
{% if samples_enabled %}
Enabled
{% else %}
Requires distribution
{% endif %}
{% else %}
Disabled
{% endif %}
{% else %}
Not requested
{% endif %}
</strong>
</li>
<li>
<span>Iterations</span>
<strong>{{ iterations }}</strong>
</li>
<li>
<span>Percentiles</span>
<strong>
{% if percentiles %}
{% for percentile in percentiles %}
{{ '%g' % percentile }}{% if not loop.last %}, {% endif %}
{% endfor %}
{% else %}
Defaults
{% endif %}
</strong>
</li>
</ul>
</div>
</section>

View File

@@ -0,0 +1,14 @@
<div class="scenario-actions">
<a
href="{{ request.url_for('reports.scenario_distribution_page', scenario_id=scenario.id) }}"
class="button button-secondary"
>
View Distribution
</a>
<a
href="{{ request.url_for('reports.scenario_distribution', scenario_id=scenario.id) }}"
class="button button-secondary"
>
Download JSON
</a>
</div>

View File

@@ -0,0 +1,24 @@
<header class="page-header">
<div>
<h1 class="page-title">{{ title }}</h1>
{% if subtitle %}
<p class="page-subtitle">{{ subtitle }}</p>
{% endif %}
</div>
{% if actions %}
<div class="page-actions">
{% for action in actions %}
{% set classes = action.classes or 'button button-secondary' %}
<a
href="{{ action.href }}"
class="{{ classes }}"
{% if action.target %}target="{{ action.target }}"{% endif %}
{% if action.rel %}rel="{{ action.rel }}"{% endif %}
{% if action.download %}download="{{ action.download }}"{% endif %}
>
{{ action.label }}
</a>
{% endfor %}
</div>
{% endif %}
</header>

View File

@@ -0,0 +1,205 @@
{% extends "base.html" %}
{% block title %}Project Summary | CalMiner{% endblock %}
{% block content %}
{% include "partials/reports_header.html" with context %}
{% include "partials/reports/options_card.html" with options=include_options iterations=iterations percentiles=percentiles %}
{% include "partials/reports/filters_card.html" with filters=filters %}
<section class="report-overview">
<div class="report-grid">
<article class="report-card">
<h2>Project Details</h2>
<dl class="definition-list">
<div>
<dt>Name</dt>
<dd>{{ project.name }}</dd>
</div>
<div>
<dt>Location</dt>
<dd>{{ project.location or "—" }}</dd>
</div>
<div>
<dt>Operation Type</dt>
<dd>{{ project.operation_type | replace("_", " ") | title }}</dd>
</div>
<div>
<dt>Scenarios</dt>
<dd>{{ scenario_count }}</dd>
</div>
<div>
<dt>Created</dt>
<dd>{{ project.created_at | format_datetime }}</dd>
</div>
<div>
<dt>Updated</dt>
<dd>{{ project.updated_at | format_datetime }}</dd>
</div>
</dl>
</article>
<article class="report-card">
<h2>Financial Summary</h2>
<ul class="metric-list">
<li>
<span>Total Inflows</span>
<strong>{{ aggregates.financials.total_inflows | currency_display(project.currency) }}</strong>
</li>
<li>
<span>Total Outflows</span>
<strong>{{ aggregates.financials.total_outflows | currency_display(project.currency) }}</strong>
</li>
<li>
<span>Net Cash Flow</span>
<strong>{{ aggregates.financials.total_net | currency_display(project.currency) }}</strong>
</li>
</ul>
</article>
<article class="report-card">
<h2>Deterministic Metrics</h2>
{% if aggregates.deterministic_metrics %}
<table class="metrics-table">
<thead>
<tr>
<th scope="col">Metric</th>
<th scope="col">Average</th>
<th scope="col">Best</th>
<th scope="col">Worst</th>
</tr>
</thead>
<tbody>
{% for key, metric in aggregates.deterministic_metrics.items() %}
<tr>
<th scope="row">{{ key | replace("_", " ") | title }}</th>
<td>{{ metric.average | format_metric(key, project.currency) }}</td>
<td>{{ metric.maximum | format_metric(key, project.currency) }}</td>
<td>{{ metric.minimum | format_metric(key, project.currency) }}</td>
</tr>
{% endfor %}
</tbody>
</table>
{% else %}
<p class="muted">Deterministic metrics are unavailable for the current filters.</p>
{% endif %}
</article>
</div>
</section>
<section class="report-section">
<header class="section-header">
<h2>Scenario Breakdown</h2>
<p class="section-subtitle">Deterministic metrics and Monte Carlo summaries for each scenario.</p>
</header>
{% if scenarios %}
{% for item in scenarios %}
<article class="scenario-card">
<div class="scenario-card-header">
<div>
<h3>{{ item.scenario.name }}</h3>
<p class="muted">{{ item.scenario.status | title }} · {{ item.scenario.primary_resource or "No primary resource" }}</p>
</div>
<div class="scenario-meta">
<span class="meta-label">Currency</span>
<span class="meta-value">{{ item.scenario.currency or project.currency or "—" }}</span>
</div>
{% include "partials/reports/scenario_actions.html" with scenario=item.scenario %}
</div>
<div class="scenario-grid">
<section class="scenario-panel">
<h4>Financial Totals</h4>
<ul class="metric-list compact">
<li>
<span>Inflows</span>
<strong>{{ item.financials.inflows | currency_display(item.scenario.currency or project.currency) }}</strong>
</li>
<li>
<span>Outflows</span>
<strong>{{ item.financials.outflows | currency_display(item.scenario.currency or project.currency) }}</strong>
</li>
<li>
<span>Net</span>
<strong>{{ item.financials.net | currency_display(item.scenario.currency or project.currency) }}</strong>
</li>
</ul>
<h5>By Category</h5>
{% if item.financials.by_category %}
<ul class="metric-list compact">
{% for label, value in item.financials.by_category.items() %}
<li>
<span>{{ label | replace("_", " ") | title }}</span>
<strong>{{ value | currency_display(item.scenario.currency or project.currency) }}</strong>
</li>
{% endfor %}
</ul>
{% else %}
<p class="muted">No financial inputs recorded.</p>
{% endif %}
</section>
<section class="scenario-panel">
<h4>Deterministic Metrics</h4>
<table class="metrics-table">
<tbody>
<tr>
<th scope="row">Discount Rate</th>
<td>{{ item.metrics.discount_rate | percentage_display }}</td>
</tr>
<tr>
<th scope="row">NPV</th>
<td>{{ item.metrics.npv | currency_display(item.scenario.currency or project.currency) }}</td>
</tr>
<tr>
<th scope="row">IRR</th>
<td>{{ item.metrics.irr | percentage_display }}</td>
</tr>
<tr>
<th scope="row">Payback Period</th>
<td>{{ item.metrics.payback_period | period_display }}</td>
</tr>
</tbody>
</table>
{% if item.metrics.notes %}
<ul class="note-list">
{% for note in item.metrics.notes %}
<li>{{ note }}</li>
{% endfor %}
</ul>
{% endif %}
</section>
<section class="scenario-panel">
<h4>Monte Carlo Summary</h4>
{% if item.monte_carlo and item.monte_carlo.available %}
<p class="muted">
Iterations: {{ item.monte_carlo.iterations }}
{% if percentiles %}
· Percentiles:
{% for percentile in percentiles %}
{{ '%g' % percentile }}{% if not loop.last %}, {% endif %}
{% endfor %}
{% endif %}
</p>
{% include "partials/reports/monte_carlo_table.html" with metrics=item.monte_carlo.metrics currency=item.scenario.currency or project.currency percentiles=percentiles %}
{% else %}
<p class="muted">Monte Carlo metrics are unavailable for this scenario.</p>
{% if item.monte_carlo and item.monte_carlo.notes %}
<ul class="note-list">
{% for note in item.monte_carlo.notes %}
<li>{{ note }}</li>
{% endfor %}
</ul>
{% endif %}
{% endif %}
</section>
</div>
</article>
{% endfor %}
{% else %}
<p class="muted">No scenarios match the current filters.</p>
{% endif %}
</section>
{% endblock %}

View File

@@ -0,0 +1,166 @@
{% extends "base.html" %}
{% block title %}Scenario Comparison | CalMiner{% endblock %}
{% block content %}
{% include "partials/reports_header.html" with context %}
{% include "partials/reports/options_card.html" with options=include_options iterations=iterations percentiles=percentiles %}
<section class="report-filters">
<div class="report-card">
<h2>Compared Scenarios</h2>
<ul class="metric-list compact">
{% for item in scenarios %}
<li>
<span>{{ item.scenario.name }}</span>
<strong>#{{ item.scenario.id }}</strong>
</li>
{% endfor %}
</ul>
</div>
</section>
<section class="report-overview">
<article class="report-card">
<h2>Project Details</h2>
<dl class="definition-list">
<div>
<dt>Name</dt>
<dd>{{ project.name }}</dd>
</div>
<div>
<dt>Location</dt>
<dd>{{ project.location or "—" }}</dd>
</div>
<div>
<dt>Operation Type</dt>
<dd>{{ project.operation_type | replace("_", " ") | title }}</dd>
</div>
<div>
<dt>Scenarios Compared</dt>
<dd>{{ scenarios | length }}</dd>
</div>
</dl>
</article>
<article class="report-card">
<h2>Comparison Summary</h2>
{% if comparison %}
<table class="metrics-table">
<thead>
<tr>
<th scope="col">Metric</th>
<th scope="col">Direction</th>
<th scope="col">Best Performer</th>
<th scope="col">Worst Performer</th>
<th scope="col">Average</th>
</tr>
</thead>
<tbody>
{% for key, metric in comparison.items() %}
<tr>
<th scope="row">{{ key | replace("_", " ") | title }}</th>
<td>{{ metric.direction | replace("_", " ") | title }}</td>
<td>
{% if metric.best %}
<strong>{{ metric.best.name }}</strong>
<span class="muted">({{ metric.best.value | format_metric(key, project.currency) }})</span>
{% else %}
{% endif %}
</td>
<td>
{% if metric.worst %}
<strong>{{ metric.worst.name }}</strong>
<span class="muted">({{ metric.worst.value | format_metric(key, project.currency) }})</span>
{% else %}
{% endif %}
</td>
<td>{{ metric.average | format_metric(key, project.currency) }}</td>
</tr>
{% endfor %}
</tbody>
</table>
{% else %}
<p class="muted">No deterministic metrics available for comparison.</p>
{% endif %}
</article>
</section>
<section class="report-section">
<header class="section-header">
<h2>Scenario Details</h2>
<p class="section-subtitle">Each scenario includes deterministic metrics and Monte Carlo summaries.</p>
</header>
{% for item in scenarios %}
<article class="scenario-card">
<div class="scenario-card-header">
<div>
<h3>{{ item.scenario.name }}</h3>
<p class="muted">{{ item.scenario.status | title }} · Currency: {{ item.scenario.currency or project.currency }}</p>
</div>
<div class="scenario-meta">
<span class="meta-label">Primary Resource</span>
<span class="meta-value">{{ item.scenario.primary_resource or "—" }}</span>
</div>
{% include "partials/reports/scenario_actions.html" with scenario=item.scenario %}
</div>
<div class="scenario-grid">
<section class="scenario-panel">
<h4>Deterministic Metrics</h4>
<table class="metrics-table">
<tbody>
<tr>
<th scope="row">NPV</th>
<td>{{ item.metrics.npv | currency_display(item.scenario.currency or project.currency) }}</td>
</tr>
<tr>
<th scope="row">IRR</th>
<td>{{ item.metrics.irr | percentage_display }}</td>
</tr>
<tr>
<th scope="row">Payback Period</th>
<td>{{ item.metrics.payback_period | period_display }}</td>
</tr>
</tbody>
</table>
{% if item.metrics.notes %}
<ul class="note-list">
{% for note in item.metrics.notes %}
<li>{{ note }}</li>
{% endfor %}
</ul>
{% endif %}
</section>
<section class="scenario-panel">
<h4>Monte Carlo Summary</h4>
{% if item.monte_carlo and item.monte_carlo.available %}
<p class="muted">
Iterations: {{ item.monte_carlo.iterations }}
{% if percentiles %}
· Percentiles:
{% for percentile in percentiles %}
{{ '%g' % percentile }}{% if not loop.last %}, {% endif %}
{% endfor %}
{% endif %}
</p>
{% include "partials/reports/monte_carlo_table.html" with metrics=item.monte_carlo.metrics currency=item.scenario.currency or project.currency percentiles=percentiles %}
{% else %}
<p class="muted">No Monte Carlo data available for this scenario.</p>
{% if item.monte_carlo and item.monte_carlo.notes %}
<ul class="note-list">
{% for note in item.monte_carlo.notes %}
<li>{{ note }}</li>
{% endfor %}
</ul>
{% endif %}
{% endif %}
</section>
</div>
</article>
{% endfor %}
</section>
{% endblock %}

View File

@@ -0,0 +1,149 @@
{% extends "base.html" %}
{% block title %}Scenario Distribution | CalMiner{% endblock %}
{% block content %}
{% include "partials/reports_header.html" with context %}
<section class="report-overview">
<div class="report-grid">
<article class="report-card">
<h2>Scenario Details</h2>
<dl class="definition-list">
<div>
<dt>Name</dt>
<dd>{{ scenario.name }}</dd>
</div>
<div>
<dt>Project ID</dt>
<dd>{{ scenario.project_id }}</dd>
</div>
<div>
<dt>Status</dt>
<dd>{{ scenario.status | title }}</dd>
</div>
<div>
<dt>Currency</dt>
<dd>{{ scenario.currency or "—" }}</dd>
</div>
<div>
<dt>Discount Rate</dt>
<dd>{{ metrics.discount_rate | percentage_display }}</dd>
</div>
<div>
<dt>Updated</dt>
<dd>{{ scenario.updated_at | format_datetime }}</dd>
</div>
</dl>
</article>
<article class="report-card">
<h2>Financial Totals</h2>
<ul class="metric-list">
<li>
<span>Inflows</span>
<strong>{{ summary.inflows | currency_display(scenario.currency) }}</strong>
</li>
<li>
<span>Outflows</span>
<strong>{{ summary.outflows | currency_display(scenario.currency) }}</strong>
</li>
<li>
<span>Net Cash Flow</span>
<strong>{{ summary.net | currency_display(scenario.currency) }}</strong>
</li>
</ul>
{% if summary.by_category %}
<h3>By Category</h3>
<ul class="metric-list compact">
{% for label, value in summary.by_category.items() %}
<li>
<span>{{ label | replace("_", " ") | title }}</span>
<strong>{{ value | currency_display(scenario.currency) }}</strong>
</li>
{% endfor %}
</ul>
{% endif %}
</article>
</div>
</section>
<section class="report-section">
<header class="section-header">
<h2>Deterministic Metrics</h2>
<p class="section-subtitle">Key financial indicators calculated from deterministic cash flows.</p>
</header>
<table class="metrics-table">
<tbody>
<tr>
<th scope="row">NPV</th>
<td>{{ metrics.npv | currency_display(scenario.currency) }}</td>
</tr>
<tr>
<th scope="row">IRR</th>
<td>{{ metrics.irr | percentage_display }}</td>
</tr>
<tr>
<th scope="row">Payback Period</th>
<td>{{ metrics.payback_period | period_display }}</td>
</tr>
</tbody>
</table>
{% if metrics.notes %}
<ul class="note-list">
{% for note in metrics.notes %}
<li>{{ note }}</li>
{% endfor %}
</ul>
{% endif %}
</section>
<section class="report-section">
<header class="section-header">
<h2>Monte Carlo Distribution</h2>
<p class="section-subtitle">Simulation-driven distributions contextualize stochastic variability.</p>
</header>
{% if monte_carlo and monte_carlo.available %}
<div class="simulation-summary">
<p>Iterations: {{ monte_carlo.iterations }} · Percentiles: {{ percentiles | join(", ") }}</p>
<table class="metrics-table">
<thead>
<tr>
<th scope="col">Metric</th>
<th scope="col">Mean</th>
<th scope="col">P5</th>
<th scope="col">Median</th>
<th scope="col">P95</th>
</tr>
</thead>
<tbody>
{% for metric, summary in monte_carlo.metrics.items() %}
<tr>
<th scope="row">{{ metric | replace("_", " ") | title }}</th>
<td>{{ summary.mean | format_metric(metric, scenario.currency) }}</td>
<td>{{ summary.percentiles['5'] | format_metric(metric, scenario.currency) }}</td>
<td>{{ summary.percentiles['50'] | format_metric(metric, scenario.currency) }}</td>
<td>{{ summary.percentiles['95'] | format_metric(metric, scenario.currency) }}</td>
</tr>
{% endfor %}
</tbody>
</table>
{% if monte_carlo.notes %}
<ul class="note-list">
{% for note in monte_carlo.notes %}
<li>{{ note }}</li>
{% endfor %}
</ul>
{% endif %}
</div>
{% else %}
<p class="muted">Monte Carlo output is unavailable for this scenario.</p>
{% if monte_carlo and monte_carlo.notes %}
<ul class="note-list">
{% for note in monte_carlo.notes %}
<li>{{ note }}</li>
{% endfor %}
</ul>
{% endif %}
{% endif %}
</section>
{% endblock %}

View File

@@ -49,7 +49,11 @@
<div class="form-group">
<label for="currency">Currency</label>
<input id="currency" name="currency" type="text" maxlength="3" value="{{ scenario.currency if scenario else '' }}" />
{% set currency_prefill = scenario.currency if scenario and scenario.currency else default_currency %}
<input id="currency" name="currency" type="text" maxlength="3" value="{{ currency_prefill or '' }}" placeholder="{{ default_currency or '' }}" />
{% if default_currency %}
<p class="field-help">Defaults to {{ default_currency }} when left blank.</p>
{% endif %}
</div>
<div class="form-group">