- Introduced Pydantic schemas for profitability calculations in `schemas/calculations.py`. - Implemented service functions for profitability calculations in `services/calculations.py`. - Added new exception class `ProfitabilityValidationError` for handling validation errors. - Created repositories for managing project and scenario profitability snapshots. - Developed a utility script for verifying authenticated routes. - Added a new HTML template for the profitability calculator interface. - Implemented a script to fix user ID sequence in the database.
339 lines
14 KiB
HTML
339 lines
14 KiB
HTML
{% extends "base.html" %}
|
|
{% block title %}Profitability Calculator · CalMiner{% endblock %}
|
|
|
|
{% block content %}
|
|
<nav class="breadcrumb">
|
|
<a href="{{ url_for('projects.project_list_page') }}">Projects</a>
|
|
{% if project %}
|
|
<a href="{{ url_for('projects.view_project', project_id=project.id) }}">{{ project.name }}</a>
|
|
{% endif %}
|
|
{% if scenario %}
|
|
<a href="{{ url_for('scenarios.view_scenario', project_id=scenario.project_id, scenario_id=scenario.id) }}">{{ scenario.name }}</a>
|
|
{% endif %}
|
|
<span aria-current="page">Profitability</span>
|
|
</nav>
|
|
|
|
<header class="page-header">
|
|
<div>
|
|
<h1>Profitability Calculator</h1>
|
|
<p class="text-muted">Evaluate revenue, costs, and key financial metrics for a scenario.</p>
|
|
</div>
|
|
<div class="header-actions">
|
|
{% if cancel_url %}
|
|
<a class="btn" href="{{ cancel_url }}">Cancel</a>
|
|
{% endif %}
|
|
<button class="btn primary" type="submit" form="profitability-form">Run Calculation</button>
|
|
</div>
|
|
</header>
|
|
|
|
{% if errors %}
|
|
<div class="alert alert-error">
|
|
<h2 class="sr-only">Submission errors</h2>
|
|
<ul>
|
|
{% for message in errors %}
|
|
<li>{{ message }}</li>
|
|
{% endfor %}
|
|
</ul>
|
|
</div>
|
|
{% endif %}
|
|
|
|
{% if notices %}
|
|
<div class="alert alert-info">
|
|
<ul>
|
|
{% for message in notices %}
|
|
<li>{{ message }}</li>
|
|
{% endfor %}
|
|
</ul>
|
|
</div>
|
|
{% endif %}
|
|
|
|
<div class="layout-two-column">
|
|
<section class="panel">
|
|
<h2>Input Parameters</h2>
|
|
<form id="profitability-form" class="form scenario-form" method="post" action="{{ form_action }}">
|
|
<input type="hidden" name="csrf_token" value="{{ csrf_token or '' }}" />
|
|
<div class="form-grid">
|
|
<div class="form-group">
|
|
<label for="metal">Commodity</label>
|
|
<select id="metal" name="metal" required>
|
|
{% for metal in supported_metals %}
|
|
<option value="{{ metal.value }}" {% if data.metal == metal.value %}selected{% endif %}>{{ metal.label }}</option>
|
|
{% endfor %}
|
|
</select>
|
|
</div>
|
|
<div class="form-group">
|
|
<label for="ore_tonnage">Ore Tonnage (t)</label>
|
|
<input id="ore_tonnage" name="ore_tonnage" type="number" min="0" step="0.01" value="{{ data.ore_tonnage }}" required />
|
|
</div>
|
|
<div class="form-group">
|
|
<label for="head_grade_pct">Head Grade (%)</label>
|
|
<input id="head_grade_pct" name="head_grade_pct" type="number" min="0" max="100" step="0.01" value="{{ data.head_grade_pct }}" required />
|
|
</div>
|
|
<div class="form-group">
|
|
<label for="recovery_pct">Recovery (%)</label>
|
|
<input id="recovery_pct" name="recovery_pct" type="number" min="0" max="100" step="0.01" value="{{ data.recovery_pct }}" required />
|
|
</div>
|
|
<div class="form-group">
|
|
<label for="payable_pct">Payable (%)</label>
|
|
<input id="payable_pct" name="payable_pct" type="number" min="0" max="100" step="0.01" value="{{ data.payable_pct or metadata.default_payable_pct }}" />
|
|
<p class="field-help">Default {{ metadata.default_payable_pct or 100 }}% if blank.</p>
|
|
</div>
|
|
<div class="form-group">
|
|
<label for="reference_price">Reference Price</label>
|
|
<input id="reference_price" name="reference_price" type="number" min="0" step="0.01" value="{{ data.reference_price }}" required />
|
|
</div>
|
|
<div class="form-group">
|
|
<label for="fx_rate">FX Rate</label>
|
|
<input id="fx_rate" name="fx_rate" type="number" min="0" step="0.0001" value="{{ data.fx_rate or 1 }}" required />
|
|
</div>
|
|
<div class="form-group">
|
|
<label for="currency_code">Scenario Currency</label>
|
|
<input id="currency_code" name="currency_code" type="text" maxlength="3" value="{{ data.currency_code or scenario.currency or project.currency }}" />
|
|
</div>
|
|
</div>
|
|
|
|
<fieldset class="form-fieldset">
|
|
<legend>Processing Charges</legend>
|
|
<div class="form-grid">
|
|
<div class="form-group">
|
|
<label for="treatment_charge">Treatment Charge</label>
|
|
<input id="treatment_charge" name="treatment_charge" type="number" min="0" step="0.01" value="{{ data.treatment_charge }}" />
|
|
</div>
|
|
<div class="form-group">
|
|
<label for="smelting_charge">Smelting Charge</label>
|
|
<input id="smelting_charge" name="smelting_charge" type="number" min="0" step="0.01" value="{{ data.smelting_charge }}" />
|
|
</div>
|
|
<div class="form-group">
|
|
<label for="processing_opex">Processing Opex (per period)</label>
|
|
<input id="processing_opex" name="processing_opex" type="number" min="0" step="0.01" value="{{ data.processing_opex }}" />
|
|
</div>
|
|
</div>
|
|
</fieldset>
|
|
|
|
<fieldset class="form-fieldset">
|
|
<legend>Penalties & Premiums</legend>
|
|
<div class="form-grid">
|
|
<div class="form-group">
|
|
<label for="moisture_pct">Moisture (%)</label>
|
|
<input id="moisture_pct" name="moisture_pct" type="number" min="0" max="100" step="0.01" value="{{ data.moisture_pct }}" />
|
|
</div>
|
|
<div class="form-group">
|
|
<label for="moisture_threshold_pct">Moisture Threshold (%)</label>
|
|
<input id="moisture_threshold_pct" name="moisture_threshold_pct" type="number" min="0" max="100" step="0.01" value="{{ data.moisture_threshold_pct or metadata.moisture_threshold_pct }}" />
|
|
</div>
|
|
<div class="form-group">
|
|
<label for="moisture_penalty_per_pct">Moisture Penalty / %</label>
|
|
<input id="moisture_penalty_per_pct" name="moisture_penalty_per_pct" type="number" step="0.01" value="{{ data.moisture_penalty_per_pct or metadata.moisture_penalty_per_pct }}" />
|
|
</div>
|
|
<div class="form-group">
|
|
<label for="premiums">Premiums / Credits</label>
|
|
<input id="premiums" name="premiums" type="number" step="0.01" value="{{ data.premiums }}" />
|
|
</div>
|
|
</div>
|
|
<div class="impurity-table">
|
|
<label>Impurities</label>
|
|
<table>
|
|
<thead>
|
|
<tr>
|
|
<th scope="col">Name</th>
|
|
<th scope="col">Content (ppm)</th>
|
|
<th scope="col">Threshold (ppm)</th>
|
|
<th scope="col">Penalty / ppm</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{% set impurity_entries = data.impurities or metadata_impurities %}
|
|
{% for impurity in impurity_entries %}
|
|
<tr>
|
|
<td>
|
|
<input type="text" name="impurities[{{ loop.index0 }}][name]" value="{{ impurity.name }}" />
|
|
</td>
|
|
<td>
|
|
<input type="number" step="0.01" min="0" name="impurities[{{ loop.index0 }}][value]" value="{{ impurity.value }}" />
|
|
</td>
|
|
<td>
|
|
<input type="number" step="0.01" min="0" name="impurities[{{ loop.index0 }}][threshold]" value="{{ impurity.threshold }}" />
|
|
</td>
|
|
<td>
|
|
<input type="number" step="0.01" min="0" name="impurities[{{ loop.index0 }}][penalty]" value="{{ impurity.penalty }}" />
|
|
</td>
|
|
</tr>
|
|
{% else %}
|
|
<tr>
|
|
<td colspan="4" class="muted">No impurity penalties configured.</td>
|
|
</tr>
|
|
{% endfor %}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</fieldset>
|
|
|
|
<fieldset class="form-fieldset">
|
|
<legend>Capital & Discounting</legend>
|
|
<div class="form-grid">
|
|
<div class="form-group">
|
|
<label for="initial_capex">Initial Capex</label>
|
|
<input id="initial_capex" name="initial_capex" type="number" min="0" step="0.01" value="{{ data.initial_capex }}" />
|
|
</div>
|
|
<div class="form-group">
|
|
<label for="sustaining_capex">Sustaining Capex (per period)</label>
|
|
<input id="sustaining_capex" name="sustaining_capex" type="number" min="0" step="0.01" value="{{ data.sustaining_capex }}" />
|
|
</div>
|
|
<div class="form-group">
|
|
<label for="discount_rate">Discount Rate (%)</label>
|
|
<input id="discount_rate" name="discount_rate" type="number" min="0" max="100" step="0.01" value="{{ data.discount_rate or scenario.discount_rate }}" />
|
|
</div>
|
|
<div class="form-group">
|
|
<label for="periods">Evaluation Periods</label>
|
|
<input id="periods" name="periods" type="number" min="1" step="1" value="{{ data.periods or default_periods }}" />
|
|
</div>
|
|
</div>
|
|
</fieldset>
|
|
</form>
|
|
</section>
|
|
|
|
<aside class="panel">
|
|
<h2>Assumption Summary</h2>
|
|
<dl class="definition-list">
|
|
<div>
|
|
<dt>Default Payable</dt>
|
|
<dd>{{ metadata.default_payable_pct or 100 }}%</dd>
|
|
</div>
|
|
<div>
|
|
<dt>Moisture Threshold</dt>
|
|
<dd>{{ metadata.moisture_threshold_pct or 0 }}%</dd>
|
|
</div>
|
|
<div>
|
|
<dt>Moisture Penalty</dt>
|
|
<dd>{{ metadata.moisture_penalty_per_pct or 0 }}</dd>
|
|
</div>
|
|
<div>
|
|
<dt>Base Currency</dt>
|
|
<dd>{{ metadata.default_currency or "—" }}</dd>
|
|
</div>
|
|
</dl>
|
|
|
|
{% if metadata_impurities %}
|
|
<h3>Configured Impurities</h3>
|
|
<ul class="metric-list compact">
|
|
{% for impurity in metadata_impurities %}
|
|
<li>
|
|
<span>{{ impurity.name }}</span>
|
|
<strong>Threshold {{ impurity.threshold }} ppm · Penalty {{ impurity.penalty }}</strong>
|
|
</li>
|
|
{% endfor %}
|
|
</ul>
|
|
{% endif %}
|
|
|
|
<p class="muted">
|
|
Adjust values to reflect contract terms. Leave fields blank to use defaults sourced from pricing metadata.
|
|
</p>
|
|
</aside>
|
|
</div>
|
|
|
|
<section class="report-section">
|
|
<header class="section-header">
|
|
<h2>Calculation Results</h2>
|
|
<p class="section-subtitle">Outputs reflect the latest submission.</p>
|
|
</header>
|
|
|
|
{% if result %}
|
|
<div class="report-grid">
|
|
<article class="report-card">
|
|
<h3>Revenue Summary</h3>
|
|
<ul class="metric-list">
|
|
<li>
|
|
<span>Payable Metal</span>
|
|
<strong>{{ result.pricing.payable_metal_tonnes | default('—') }}</strong>
|
|
</li>
|
|
<li>
|
|
<span>Gross Revenue</span>
|
|
<strong>{{ result.pricing.gross_revenue | currency_display(result.pricing.currency) }}</strong>
|
|
</li>
|
|
<li>
|
|
<span>Net Revenue</span>
|
|
<strong>{{ result.pricing.net_revenue | currency_display(result.pricing.currency) }}</strong>
|
|
</li>
|
|
</ul>
|
|
</article>
|
|
|
|
<article class="report-card">
|
|
<h3>Cost Breakdown</h3>
|
|
<ul class="metric-list">
|
|
<li>
|
|
<span>Processing Opex</span>
|
|
<strong>{{ result.costs.processing_opex_total | currency_display(result.currency) }}</strong>
|
|
</li>
|
|
<li>
|
|
<span>Sustaining Capex</span>
|
|
<strong>{{ result.costs.sustaining_capex_total | currency_display(result.currency) }}</strong>
|
|
</li>
|
|
<li>
|
|
<span>Initial Capex</span>
|
|
<strong>{{ result.costs.initial_capex | currency_display(result.currency) }}</strong>
|
|
</li>
|
|
</ul>
|
|
</article>
|
|
|
|
<article class="report-card">
|
|
<h3>Key Metrics</h3>
|
|
<ul class="metric-list">
|
|
<li>
|
|
<span>NPV</span>
|
|
<strong>{{ result.metrics.npv | currency_display(result.currency) }}</strong>
|
|
</li>
|
|
<li>
|
|
<span>IRR</span>
|
|
<strong>{{ result.metrics.irr | percentage_display }}</strong>
|
|
</li>
|
|
<li>
|
|
<span>Payback</span>
|
|
<strong>{{ result.metrics.payback_period | period_display }}</strong>
|
|
</li>
|
|
<li>
|
|
<span>Margin</span>
|
|
<strong>{{ result.metrics.margin | percentage_display }}</strong>
|
|
</li>
|
|
</ul>
|
|
</article>
|
|
</div>
|
|
|
|
{% if result.cash_flows %}
|
|
<table class="metrics-table">
|
|
<thead>
|
|
<tr>
|
|
<th scope="col">Period</th>
|
|
<th scope="col">Revenue</th>
|
|
<th scope="col">Processing Opex</th>
|
|
<th scope="col">Sustaining Capex</th>
|
|
<th scope="col">Net Cash Flow</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{% for entry in result.cash_flows %}
|
|
<tr>
|
|
<th scope="row">{{ entry.period }}</th>
|
|
<td>{{ entry.revenue | currency_display(result.currency) }}</td>
|
|
<td>{{ entry.processing_opex | currency_display(result.currency) }}</td>
|
|
<td>{{ entry.sustaining_capex | currency_display(result.currency) }}</td>
|
|
<td>{{ entry.net | currency_display(result.currency) }}</td>
|
|
</tr>
|
|
{% endfor %}
|
|
</tbody>
|
|
</table>
|
|
{% endif %}
|
|
{% else %}
|
|
<p class="muted">Run a calculation to see profitability metrics.</p>
|
|
{% endif %}
|
|
</section>
|
|
|
|
<section class="report-section">
|
|
<header class="section-header">
|
|
<h2>Visualisations</h2>
|
|
<p class="section-subtitle">Charts render after calculations complete.</p>
|
|
</header>
|
|
<div id="profitability-chart" class="chart-container"></div>
|
|
<div id="cashflow-chart" class="chart-container"></div>
|
|
</section>
|
|
{% endblock %}
|