feat: Add profitability calculation schemas and service functions
- 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.
This commit is contained in:
11
main.py
11
main.py
@@ -10,6 +10,7 @@ from middleware.metrics import MetricsMiddleware
|
|||||||
from middleware.validation import validate_json
|
from middleware.validation import validate_json
|
||||||
from routes.auth import router as auth_router
|
from routes.auth import router as auth_router
|
||||||
from routes.dashboard import router as dashboard_router
|
from routes.dashboard import router as dashboard_router
|
||||||
|
from routes.calculations import router as calculations_router
|
||||||
from routes.imports import router as imports_router
|
from routes.imports import router as imports_router
|
||||||
from routes.exports import router as exports_router
|
from routes.exports import router as exports_router
|
||||||
from routes.projects import router as projects_router
|
from routes.projects import router as projects_router
|
||||||
@@ -40,7 +41,16 @@ async def health() -> dict[str, str]:
|
|||||||
return {"status": "ok"}
|
return {"status": "ok"}
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/favicon.ico", include_in_schema=False)
|
||||||
|
async def favicon() -> Response:
|
||||||
|
static_directory = "static"
|
||||||
|
img_directory = f"{static_directory}/img"
|
||||||
|
favicon_img = "logo_32x32.png"
|
||||||
|
return StaticFiles(directory=img_directory).lookup_path(favicon_img)[0]
|
||||||
|
|
||||||
|
|
||||||
@app.on_event("startup")
|
@app.on_event("startup")
|
||||||
|
# TODO: use lifespan events for startup/shutdown tasks
|
||||||
async def ensure_admin_bootstrap() -> None:
|
async def ensure_admin_bootstrap() -> None:
|
||||||
settings = get_settings()
|
settings = get_settings()
|
||||||
admin_settings = settings.admin_bootstrap_settings()
|
admin_settings = settings.admin_bootstrap_settings()
|
||||||
@@ -93,6 +103,7 @@ async def ensure_admin_bootstrap() -> None:
|
|||||||
|
|
||||||
|
|
||||||
app.include_router(dashboard_router)
|
app.include_router(dashboard_router)
|
||||||
|
app.include_router(calculations_router)
|
||||||
app.include_router(auth_router)
|
app.include_router(auth_router)
|
||||||
app.include_router(imports_router)
|
app.include_router(imports_router)
|
||||||
app.include_router(exports_router)
|
app.include_router(exports_router)
|
||||||
|
|||||||
@@ -27,16 +27,19 @@ from .project import Project
|
|||||||
from .scenario import Scenario
|
from .scenario import Scenario
|
||||||
from .simulation_parameter import SimulationParameter
|
from .simulation_parameter import SimulationParameter
|
||||||
from .user import Role, User, UserRole, password_context
|
from .user import Role, User, UserRole, password_context
|
||||||
|
from .profitability_snapshot import ProjectProfitability, ScenarioProfitability
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
"FinancialCategory",
|
"FinancialCategory",
|
||||||
"FinancialInput",
|
"FinancialInput",
|
||||||
"MiningOperationType",
|
"MiningOperationType",
|
||||||
"Project",
|
"Project",
|
||||||
|
"ProjectProfitability",
|
||||||
"PricingSettings",
|
"PricingSettings",
|
||||||
"PricingMetalSettings",
|
"PricingMetalSettings",
|
||||||
"PricingImpuritySettings",
|
"PricingImpuritySettings",
|
||||||
"Scenario",
|
"Scenario",
|
||||||
|
"ScenarioProfitability",
|
||||||
"ScenarioStatus",
|
"ScenarioStatus",
|
||||||
"DistributionType",
|
"DistributionType",
|
||||||
"SimulationParameter",
|
"SimulationParameter",
|
||||||
|
|||||||
133
models/profitability_snapshot.py
Normal file
133
models/profitability_snapshot.py
Normal file
@@ -0,0 +1,133 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
|
from sqlalchemy import JSON, DateTime, ForeignKey, Integer, Numeric, String
|
||||||
|
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||||
|
from sqlalchemy.sql import func
|
||||||
|
|
||||||
|
from config.database import Base
|
||||||
|
|
||||||
|
if TYPE_CHECKING: # pragma: no cover
|
||||||
|
from .project import Project
|
||||||
|
from .scenario import Scenario
|
||||||
|
from .user import User
|
||||||
|
|
||||||
|
|
||||||
|
class ProjectProfitability(Base):
|
||||||
|
"""Snapshot of aggregated profitability metrics at the project level."""
|
||||||
|
|
||||||
|
__tablename__ = "project_profitability_snapshots"
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
||||||
|
project_id: Mapped[int] = mapped_column(
|
||||||
|
ForeignKey("projects.id", ondelete="CASCADE"), nullable=False, index=True
|
||||||
|
)
|
||||||
|
created_by_id: Mapped[int | None] = mapped_column(
|
||||||
|
ForeignKey("users.id", ondelete="SET NULL"), nullable=True, index=True
|
||||||
|
)
|
||||||
|
calculation_source: Mapped[str | None] = mapped_column(
|
||||||
|
String(64), nullable=True)
|
||||||
|
calculated_at: Mapped[datetime] = mapped_column(
|
||||||
|
DateTime(timezone=True), nullable=False, server_default=func.now()
|
||||||
|
)
|
||||||
|
currency_code: Mapped[str | None] = mapped_column(String(3), nullable=True)
|
||||||
|
npv: Mapped[float | None] = mapped_column(Numeric(18, 2), nullable=True)
|
||||||
|
irr_pct: Mapped[float | None] = mapped_column(
|
||||||
|
Numeric(12, 6), nullable=True)
|
||||||
|
payback_period_years: Mapped[float | None] = mapped_column(
|
||||||
|
Numeric(12, 4), nullable=True
|
||||||
|
)
|
||||||
|
margin_pct: Mapped[float | None] = mapped_column(
|
||||||
|
Numeric(12, 6), nullable=True)
|
||||||
|
revenue_total: Mapped[float | None] = mapped_column(
|
||||||
|
Numeric(18, 2), nullable=True)
|
||||||
|
processing_opex_total: Mapped[float | None] = mapped_column(
|
||||||
|
Numeric(18, 2), nullable=True
|
||||||
|
)
|
||||||
|
sustaining_capex_total: Mapped[float | None] = mapped_column(
|
||||||
|
Numeric(18, 2), nullable=True
|
||||||
|
)
|
||||||
|
initial_capex: Mapped[float | None] = mapped_column(
|
||||||
|
Numeric(18, 2), nullable=True)
|
||||||
|
net_cash_flow_total: Mapped[float | None] = mapped_column(
|
||||||
|
Numeric(18, 2), nullable=True
|
||||||
|
)
|
||||||
|
payload: Mapped[dict | None] = mapped_column(JSON, nullable=True)
|
||||||
|
created_at: Mapped[datetime] = mapped_column(
|
||||||
|
DateTime(timezone=True), nullable=False, server_default=func.now()
|
||||||
|
)
|
||||||
|
updated_at: Mapped[datetime] = mapped_column(
|
||||||
|
DateTime(timezone=True), nullable=False, server_default=func.now(), onupdate=func.now()
|
||||||
|
)
|
||||||
|
|
||||||
|
project: Mapped[Project] = relationship(
|
||||||
|
"Project", back_populates="profitability_snapshots")
|
||||||
|
created_by: Mapped[User | None] = relationship("User")
|
||||||
|
|
||||||
|
def __repr__(self) -> str: # pragma: no cover
|
||||||
|
return (
|
||||||
|
"ProjectProfitability(id={id!r}, project_id={project_id!r}, npv={npv!r})".format(
|
||||||
|
id=self.id, project_id=self.project_id, npv=self.npv
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class ScenarioProfitability(Base):
|
||||||
|
"""Snapshot of profitability metrics for an individual scenario."""
|
||||||
|
|
||||||
|
__tablename__ = "scenario_profitability_snapshots"
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
||||||
|
scenario_id: Mapped[int] = mapped_column(
|
||||||
|
ForeignKey("scenarios.id", ondelete="CASCADE"), nullable=False, index=True
|
||||||
|
)
|
||||||
|
created_by_id: Mapped[int | None] = mapped_column(
|
||||||
|
ForeignKey("users.id", ondelete="SET NULL"), nullable=True, index=True
|
||||||
|
)
|
||||||
|
calculation_source: Mapped[str | None] = mapped_column(
|
||||||
|
String(64), nullable=True)
|
||||||
|
calculated_at: Mapped[datetime] = mapped_column(
|
||||||
|
DateTime(timezone=True), nullable=False, server_default=func.now()
|
||||||
|
)
|
||||||
|
currency_code: Mapped[str | None] = mapped_column(String(3), nullable=True)
|
||||||
|
npv: Mapped[float | None] = mapped_column(Numeric(18, 2), nullable=True)
|
||||||
|
irr_pct: Mapped[float | None] = mapped_column(
|
||||||
|
Numeric(12, 6), nullable=True)
|
||||||
|
payback_period_years: Mapped[float | None] = mapped_column(
|
||||||
|
Numeric(12, 4), nullable=True
|
||||||
|
)
|
||||||
|
margin_pct: Mapped[float | None] = mapped_column(
|
||||||
|
Numeric(12, 6), nullable=True)
|
||||||
|
revenue_total: Mapped[float | None] = mapped_column(
|
||||||
|
Numeric(18, 2), nullable=True)
|
||||||
|
processing_opex_total: Mapped[float | None] = mapped_column(
|
||||||
|
Numeric(18, 2), nullable=True
|
||||||
|
)
|
||||||
|
sustaining_capex_total: Mapped[float | None] = mapped_column(
|
||||||
|
Numeric(18, 2), nullable=True
|
||||||
|
)
|
||||||
|
initial_capex: Mapped[float | None] = mapped_column(
|
||||||
|
Numeric(18, 2), nullable=True)
|
||||||
|
net_cash_flow_total: Mapped[float | None] = mapped_column(
|
||||||
|
Numeric(18, 2), nullable=True
|
||||||
|
)
|
||||||
|
payload: Mapped[dict | None] = mapped_column(JSON, nullable=True)
|
||||||
|
created_at: Mapped[datetime] = mapped_column(
|
||||||
|
DateTime(timezone=True), nullable=False, server_default=func.now()
|
||||||
|
)
|
||||||
|
updated_at: Mapped[datetime] = mapped_column(
|
||||||
|
DateTime(timezone=True), nullable=False, server_default=func.now(), onupdate=func.now()
|
||||||
|
)
|
||||||
|
|
||||||
|
scenario: Mapped[Scenario] = relationship(
|
||||||
|
"Scenario", back_populates="profitability_snapshots")
|
||||||
|
created_by: Mapped[User | None] = relationship("User")
|
||||||
|
|
||||||
|
def __repr__(self) -> str: # pragma: no cover
|
||||||
|
return (
|
||||||
|
"ScenarioProfitability(id={id!r}, scenario_id={scenario_id!r}, npv={npv!r})".format(
|
||||||
|
id=self.id, scenario_id=self.scenario_id, npv=self.npv
|
||||||
|
)
|
||||||
|
)
|
||||||
@@ -4,6 +4,7 @@ from datetime import datetime
|
|||||||
from typing import TYPE_CHECKING, List
|
from typing import TYPE_CHECKING, List
|
||||||
|
|
||||||
from .enums import MiningOperationType, sql_enum
|
from .enums import MiningOperationType, sql_enum
|
||||||
|
from .profitability_snapshot import ProjectProfitability
|
||||||
|
|
||||||
from sqlalchemy import DateTime, ForeignKey, Integer, String, Text
|
from sqlalchemy import DateTime, ForeignKey, Integer, String, Text
|
||||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||||
@@ -51,6 +52,21 @@ class Project(Base):
|
|||||||
"PricingSettings",
|
"PricingSettings",
|
||||||
back_populates="projects",
|
back_populates="projects",
|
||||||
)
|
)
|
||||||
|
profitability_snapshots: Mapped[List["ProjectProfitability"]] = relationship(
|
||||||
|
"ProjectProfitability",
|
||||||
|
back_populates="project",
|
||||||
|
cascade="all, delete-orphan",
|
||||||
|
order_by=lambda: ProjectProfitability.calculated_at.desc(),
|
||||||
|
passive_deletes=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def latest_profitability(self) -> "ProjectProfitability | None":
|
||||||
|
"""Return the most recent profitability snapshot, if any."""
|
||||||
|
|
||||||
|
if not self.profitability_snapshots:
|
||||||
|
return None
|
||||||
|
return self.profitability_snapshots[0]
|
||||||
|
|
||||||
def __repr__(self) -> str: # pragma: no cover - helpful for debugging
|
def __repr__(self) -> str: # pragma: no cover - helpful for debugging
|
||||||
return f"Project(id={self.id!r}, name={self.name!r})"
|
return f"Project(id={self.id!r}, name={self.name!r})"
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ from sqlalchemy.sql import func
|
|||||||
from config.database import Base
|
from config.database import Base
|
||||||
from services.currency import normalise_currency
|
from services.currency import normalise_currency
|
||||||
from .enums import ResourceType, ScenarioStatus, sql_enum
|
from .enums import ResourceType, ScenarioStatus, sql_enum
|
||||||
|
from .profitability_snapshot import ScenarioProfitability
|
||||||
|
|
||||||
if TYPE_CHECKING: # pragma: no cover
|
if TYPE_CHECKING: # pragma: no cover
|
||||||
from .financial_input import FinancialInput
|
from .financial_input import FinancialInput
|
||||||
@@ -75,6 +76,13 @@ class Scenario(Base):
|
|||||||
cascade="all, delete-orphan",
|
cascade="all, delete-orphan",
|
||||||
passive_deletes=True,
|
passive_deletes=True,
|
||||||
)
|
)
|
||||||
|
profitability_snapshots: Mapped[List["ScenarioProfitability"]] = relationship(
|
||||||
|
"ScenarioProfitability",
|
||||||
|
back_populates="scenario",
|
||||||
|
cascade="all, delete-orphan",
|
||||||
|
order_by=lambda: ScenarioProfitability.calculated_at.desc(),
|
||||||
|
passive_deletes=True,
|
||||||
|
)
|
||||||
|
|
||||||
@validates("currency")
|
@validates("currency")
|
||||||
def _normalise_currency(self, key: str, value: str | None) -> str | None:
|
def _normalise_currency(self, key: str, value: str | None) -> str | None:
|
||||||
@@ -83,3 +91,11 @@ class Scenario(Base):
|
|||||||
|
|
||||||
def __repr__(self) -> str: # pragma: no cover
|
def __repr__(self) -> str: # pragma: no cover
|
||||||
return f"Scenario(id={self.id!r}, name={self.name!r}, project_id={self.project_id!r})"
|
return f"Scenario(id={self.id!r}, name={self.name!r}, project_id={self.project_id!r})"
|
||||||
|
|
||||||
|
@property
|
||||||
|
def latest_profitability(self) -> "ScenarioProfitability | None":
|
||||||
|
"""Return the most recent profitability snapshot for this scenario."""
|
||||||
|
|
||||||
|
if not self.profitability_snapshots:
|
||||||
|
return None
|
||||||
|
return self.profitability_snapshots[0]
|
||||||
|
|||||||
571
routes/calculations.py
Normal file
571
routes/calculations.py
Normal file
@@ -0,0 +1,571 @@
|
|||||||
|
"""Routes handling financial calculation workflows."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from decimal import Decimal
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, Query, Request, status
|
||||||
|
from fastapi.responses import HTMLResponse, JSONResponse, Response
|
||||||
|
from fastapi.templating import Jinja2Templates
|
||||||
|
from pydantic import ValidationError
|
||||||
|
from starlette.datastructures import FormData
|
||||||
|
|
||||||
|
from dependencies import get_pricing_metadata, get_unit_of_work, require_authenticated_user
|
||||||
|
from models import (
|
||||||
|
Project,
|
||||||
|
ProjectProfitability,
|
||||||
|
Scenario,
|
||||||
|
ScenarioProfitability,
|
||||||
|
User,
|
||||||
|
)
|
||||||
|
from schemas.calculations import (
|
||||||
|
ProfitabilityCalculationRequest,
|
||||||
|
ProfitabilityCalculationResult,
|
||||||
|
)
|
||||||
|
from services.calculations import calculate_profitability
|
||||||
|
from services.exceptions import EntityNotFoundError, ProfitabilityValidationError
|
||||||
|
from services.pricing import PricingMetadata
|
||||||
|
from services.unit_of_work import UnitOfWork
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/calculations", tags=["Calculations"])
|
||||||
|
templates = Jinja2Templates(directory="templates")
|
||||||
|
|
||||||
|
_SUPPORTED_METALS: tuple[dict[str, str], ...] = (
|
||||||
|
{"value": "copper", "label": "Copper"},
|
||||||
|
{"value": "gold", "label": "Gold"},
|
||||||
|
{"value": "lithium", "label": "Lithium"},
|
||||||
|
)
|
||||||
|
_SUPPORTED_METAL_VALUES = {entry["value"] for entry in _SUPPORTED_METALS}
|
||||||
|
_DEFAULT_EVALUATION_PERIODS = 10
|
||||||
|
|
||||||
|
|
||||||
|
def _combine_impurity_metadata(metadata: PricingMetadata) -> list[dict[str, object]]:
|
||||||
|
"""Build impurity rows combining thresholds and penalties."""
|
||||||
|
|
||||||
|
thresholds = getattr(metadata, "impurity_thresholds", {}) or {}
|
||||||
|
penalties = getattr(metadata, "impurity_penalty_per_ppm", {}) or {}
|
||||||
|
impurity_codes = sorted({*thresholds.keys(), *penalties.keys()})
|
||||||
|
|
||||||
|
combined: list[dict[str, object]] = []
|
||||||
|
for code in impurity_codes:
|
||||||
|
combined.append(
|
||||||
|
{
|
||||||
|
"name": code,
|
||||||
|
"threshold": float(thresholds.get(code, 0.0)),
|
||||||
|
"penalty": float(penalties.get(code, 0.0)),
|
||||||
|
"value": None,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return combined
|
||||||
|
|
||||||
|
|
||||||
|
def _value_or_blank(value: Any) -> Any:
|
||||||
|
if value is None:
|
||||||
|
return ""
|
||||||
|
if isinstance(value, Decimal):
|
||||||
|
return float(value)
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
def _normalise_impurity_entries(entries: Any) -> list[dict[str, Any]]:
|
||||||
|
if not entries:
|
||||||
|
return []
|
||||||
|
|
||||||
|
normalised: list[dict[str, Any]] = []
|
||||||
|
for entry in entries:
|
||||||
|
if isinstance(entry, dict):
|
||||||
|
getter = entry.get # type: ignore[assignment]
|
||||||
|
else:
|
||||||
|
def getter(key, default=None, _entry=entry): return getattr(
|
||||||
|
_entry, key, default)
|
||||||
|
|
||||||
|
normalised.append(
|
||||||
|
{
|
||||||
|
"name": getter("name", "") or "",
|
||||||
|
"value": _value_or_blank(getter("value")),
|
||||||
|
"threshold": _value_or_blank(getter("threshold")),
|
||||||
|
"penalty": _value_or_blank(getter("penalty")),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return normalised
|
||||||
|
|
||||||
|
|
||||||
|
def _build_default_form_data(
|
||||||
|
*,
|
||||||
|
metadata: PricingMetadata,
|
||||||
|
project: Project | None,
|
||||||
|
scenario: Scenario | None,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
payable_default = (
|
||||||
|
float(metadata.default_payable_pct)
|
||||||
|
if getattr(metadata, "default_payable_pct", None) is not None
|
||||||
|
else 100.0
|
||||||
|
)
|
||||||
|
moisture_threshold_default = (
|
||||||
|
float(metadata.moisture_threshold_pct)
|
||||||
|
if getattr(metadata, "moisture_threshold_pct", None) is not None
|
||||||
|
else 0.0
|
||||||
|
)
|
||||||
|
moisture_penalty_default = (
|
||||||
|
float(metadata.moisture_penalty_per_pct)
|
||||||
|
if getattr(metadata, "moisture_penalty_per_pct", None) is not None
|
||||||
|
else 0.0
|
||||||
|
)
|
||||||
|
|
||||||
|
base_metal_entry = next(iter(_SUPPORTED_METALS), None)
|
||||||
|
metal = base_metal_entry["value"] if base_metal_entry else ""
|
||||||
|
scenario_resource = getattr(scenario, "primary_resource", None)
|
||||||
|
if scenario_resource is not None:
|
||||||
|
candidate = getattr(scenario_resource, "value", str(scenario_resource))
|
||||||
|
if candidate in _SUPPORTED_METAL_VALUES:
|
||||||
|
metal = candidate
|
||||||
|
|
||||||
|
currency = ""
|
||||||
|
scenario_currency = getattr(scenario, "currency", None)
|
||||||
|
metadata_currency = getattr(metadata, "default_currency", None)
|
||||||
|
if scenario_currency:
|
||||||
|
currency = str(scenario_currency).upper()
|
||||||
|
elif metadata_currency:
|
||||||
|
currency = str(metadata_currency).upper()
|
||||||
|
|
||||||
|
discount_rate = ""
|
||||||
|
scenario_discount = getattr(scenario, "discount_rate", None)
|
||||||
|
if scenario_discount is not None:
|
||||||
|
discount_rate = float(scenario_discount) # type: ignore[arg-type]
|
||||||
|
|
||||||
|
return {
|
||||||
|
"metal": metal,
|
||||||
|
"ore_tonnage": "",
|
||||||
|
"head_grade_pct": "",
|
||||||
|
"recovery_pct": "",
|
||||||
|
"payable_pct": payable_default,
|
||||||
|
"reference_price": "",
|
||||||
|
"treatment_charge": "",
|
||||||
|
"smelting_charge": "",
|
||||||
|
"processing_opex": "",
|
||||||
|
"moisture_pct": "",
|
||||||
|
"moisture_threshold_pct": moisture_threshold_default,
|
||||||
|
"moisture_penalty_per_pct": moisture_penalty_default,
|
||||||
|
"premiums": "",
|
||||||
|
"fx_rate": 1.0,
|
||||||
|
"currency_code": currency,
|
||||||
|
"impurities": None,
|
||||||
|
"initial_capex": "",
|
||||||
|
"sustaining_capex": "",
|
||||||
|
"discount_rate": discount_rate,
|
||||||
|
"periods": _DEFAULT_EVALUATION_PERIODS,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _prepare_form_data_for_display(
|
||||||
|
*,
|
||||||
|
defaults: dict[str, Any],
|
||||||
|
overrides: dict[str, Any] | None = None,
|
||||||
|
allow_empty_override: bool = False,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
data = dict(defaults)
|
||||||
|
|
||||||
|
if overrides:
|
||||||
|
for key, value in overrides.items():
|
||||||
|
if key == "csrf_token":
|
||||||
|
continue
|
||||||
|
if key == "impurities":
|
||||||
|
data["impurities"] = _normalise_impurity_entries(value)
|
||||||
|
continue
|
||||||
|
if value is None and not allow_empty_override:
|
||||||
|
continue
|
||||||
|
data[key] = _value_or_blank(value)
|
||||||
|
|
||||||
|
# Normalise defaults and ensure strings for None.
|
||||||
|
for key, value in list(data.items()):
|
||||||
|
if key == "impurities":
|
||||||
|
if value is None:
|
||||||
|
data[key] = None
|
||||||
|
else:
|
||||||
|
data[key] = _normalise_impurity_entries(value)
|
||||||
|
continue
|
||||||
|
data[key] = _value_or_blank(value)
|
||||||
|
|
||||||
|
return data
|
||||||
|
|
||||||
|
|
||||||
|
def _prepare_default_context(
|
||||||
|
request: Request,
|
||||||
|
*,
|
||||||
|
project: Project | None = None,
|
||||||
|
scenario: Scenario | None = None,
|
||||||
|
metadata: PricingMetadata,
|
||||||
|
form_data: dict[str, Any] | None = None,
|
||||||
|
allow_empty_override: bool = False,
|
||||||
|
result: ProfitabilityCalculationResult | None = None,
|
||||||
|
) -> dict[str, object]:
|
||||||
|
"""Assemble template context shared across calculation endpoints."""
|
||||||
|
|
||||||
|
defaults = _build_default_form_data(
|
||||||
|
metadata=metadata,
|
||||||
|
project=project,
|
||||||
|
scenario=scenario,
|
||||||
|
)
|
||||||
|
data = _prepare_form_data_for_display(
|
||||||
|
defaults=defaults,
|
||||||
|
overrides=form_data,
|
||||||
|
allow_empty_override=allow_empty_override,
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"request": request,
|
||||||
|
"project": project,
|
||||||
|
"scenario": scenario,
|
||||||
|
"metadata": metadata,
|
||||||
|
"metadata_impurities": _combine_impurity_metadata(metadata),
|
||||||
|
"supported_metals": _SUPPORTED_METALS,
|
||||||
|
"data": data,
|
||||||
|
"result": result,
|
||||||
|
"errors": [],
|
||||||
|
"notices": [],
|
||||||
|
"cancel_url": request.headers.get("Referer"),
|
||||||
|
"form_action": request.url.path,
|
||||||
|
"csrf_token": None,
|
||||||
|
"default_periods": _DEFAULT_EVALUATION_PERIODS,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _load_project_and_scenario(
|
||||||
|
*,
|
||||||
|
uow: UnitOfWork,
|
||||||
|
project_id: int | None,
|
||||||
|
scenario_id: int | None,
|
||||||
|
) -> tuple[Project | None, Scenario | None]:
|
||||||
|
project: Project | None = None
|
||||||
|
scenario: Scenario | None = None
|
||||||
|
|
||||||
|
if project_id is not None and uow.projects is not None:
|
||||||
|
try:
|
||||||
|
project = uow.projects.get(project_id, with_children=False)
|
||||||
|
except EntityNotFoundError:
|
||||||
|
project = None
|
||||||
|
|
||||||
|
if scenario_id is not None and uow.scenarios is not None:
|
||||||
|
try:
|
||||||
|
scenario = uow.scenarios.get(scenario_id, with_children=False)
|
||||||
|
except EntityNotFoundError:
|
||||||
|
scenario = None
|
||||||
|
if scenario is not None and project is None:
|
||||||
|
project = scenario.project
|
||||||
|
|
||||||
|
return project, scenario
|
||||||
|
|
||||||
|
|
||||||
|
def _is_json_request(request: Request) -> bool:
|
||||||
|
content_type = request.headers.get("content-type", "").lower()
|
||||||
|
accept = request.headers.get("accept", "").lower()
|
||||||
|
return "application/json" in content_type or "application/json" in accept
|
||||||
|
|
||||||
|
|
||||||
|
def _normalise_form_value(value: Any) -> Any:
|
||||||
|
if isinstance(value, str):
|
||||||
|
stripped = value.strip()
|
||||||
|
return stripped if stripped != "" else None
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
def _form_to_payload(form: FormData) -> dict[str, Any]:
|
||||||
|
data: dict[str, Any] = {}
|
||||||
|
impurities: dict[int, dict[str, Any]] = {}
|
||||||
|
|
||||||
|
for key, value in form.multi_items():
|
||||||
|
normalised_value = _normalise_form_value(value)
|
||||||
|
if key.startswith("impurities[") and "]" in key:
|
||||||
|
try:
|
||||||
|
index_part = key.split("[", 1)[1]
|
||||||
|
index_str, remainder = index_part.split("]", 1)
|
||||||
|
field = remainder.strip("[]")
|
||||||
|
if not field:
|
||||||
|
continue
|
||||||
|
index = int(index_str)
|
||||||
|
except (ValueError, IndexError):
|
||||||
|
continue
|
||||||
|
entry = impurities.setdefault(index, {})
|
||||||
|
entry[field] = normalised_value
|
||||||
|
continue
|
||||||
|
|
||||||
|
if key == "csrf_token":
|
||||||
|
continue
|
||||||
|
data[key] = normalised_value
|
||||||
|
|
||||||
|
if impurities:
|
||||||
|
ordered = []
|
||||||
|
for _, entry in sorted(impurities.items()):
|
||||||
|
if not entry.get("name"):
|
||||||
|
continue
|
||||||
|
ordered.append(entry)
|
||||||
|
if ordered:
|
||||||
|
data["impurities"] = ordered
|
||||||
|
|
||||||
|
return data
|
||||||
|
|
||||||
|
|
||||||
|
async def _extract_payload(request: Request) -> dict[str, Any]:
|
||||||
|
if request.headers.get("content-type", "").lower().startswith("application/json"):
|
||||||
|
return await request.json()
|
||||||
|
form = await request.form()
|
||||||
|
return _form_to_payload(form)
|
||||||
|
|
||||||
|
|
||||||
|
def _list_from_context(context: dict[str, Any], key: str) -> list:
|
||||||
|
value = context.get(key)
|
||||||
|
if isinstance(value, list):
|
||||||
|
return value
|
||||||
|
new_list: list = []
|
||||||
|
context[key] = new_list
|
||||||
|
return new_list
|
||||||
|
|
||||||
|
|
||||||
|
def _should_persist_snapshot(
|
||||||
|
*,
|
||||||
|
project: Project | None,
|
||||||
|
scenario: Scenario | None,
|
||||||
|
payload: ProfitabilityCalculationRequest,
|
||||||
|
) -> bool:
|
||||||
|
"""Determine whether to persist the profitability result.
|
||||||
|
|
||||||
|
Current strategy persists automatically when a scenario or project context
|
||||||
|
is provided. This can be refined later to honour explicit user choices.
|
||||||
|
"""
|
||||||
|
|
||||||
|
return bool(scenario or project)
|
||||||
|
|
||||||
|
|
||||||
|
def _persist_profitability_snapshots(
|
||||||
|
*,
|
||||||
|
uow: UnitOfWork,
|
||||||
|
project: Project | None,
|
||||||
|
scenario: Scenario | None,
|
||||||
|
user: User | None,
|
||||||
|
request_model: ProfitabilityCalculationRequest,
|
||||||
|
result: ProfitabilityCalculationResult,
|
||||||
|
) -> None:
|
||||||
|
if not _should_persist_snapshot(project=project, scenario=scenario, payload=request_model):
|
||||||
|
return
|
||||||
|
|
||||||
|
created_by_id = getattr(user, "id", None)
|
||||||
|
|
||||||
|
revenue_total = float(result.pricing.net_revenue)
|
||||||
|
processing_total = float(result.costs.processing_opex_total)
|
||||||
|
sustaining_total = float(result.costs.sustaining_capex_total)
|
||||||
|
initial_capex = float(result.costs.initial_capex)
|
||||||
|
net_cash_flow_total = revenue_total - (
|
||||||
|
processing_total + sustaining_total + initial_capex
|
||||||
|
)
|
||||||
|
|
||||||
|
npv_value = (
|
||||||
|
float(result.metrics.npv)
|
||||||
|
if result.metrics.npv is not None
|
||||||
|
else None
|
||||||
|
)
|
||||||
|
irr_value = (
|
||||||
|
float(result.metrics.irr)
|
||||||
|
if result.metrics.irr is not None
|
||||||
|
else None
|
||||||
|
)
|
||||||
|
payback_value = (
|
||||||
|
float(result.metrics.payback_period)
|
||||||
|
if result.metrics.payback_period is not None
|
||||||
|
else None
|
||||||
|
)
|
||||||
|
margin_value = (
|
||||||
|
float(result.metrics.margin)
|
||||||
|
if result.metrics.margin is not None
|
||||||
|
else None
|
||||||
|
)
|
||||||
|
|
||||||
|
payload = {
|
||||||
|
"request": request_model.model_dump(mode="json"),
|
||||||
|
"result": result.model_dump(),
|
||||||
|
}
|
||||||
|
|
||||||
|
if scenario and uow.scenario_profitability:
|
||||||
|
scenario_snapshot = ScenarioProfitability(
|
||||||
|
scenario_id=scenario.id,
|
||||||
|
created_by_id=created_by_id,
|
||||||
|
calculation_source="calculations.profitability",
|
||||||
|
currency_code=result.currency,
|
||||||
|
npv=npv_value,
|
||||||
|
irr_pct=irr_value,
|
||||||
|
payback_period_years=payback_value,
|
||||||
|
margin_pct=margin_value,
|
||||||
|
revenue_total=revenue_total,
|
||||||
|
processing_opex_total=processing_total,
|
||||||
|
sustaining_capex_total=sustaining_total,
|
||||||
|
initial_capex=initial_capex,
|
||||||
|
net_cash_flow_total=net_cash_flow_total,
|
||||||
|
payload=payload,
|
||||||
|
)
|
||||||
|
uow.scenario_profitability.create(scenario_snapshot)
|
||||||
|
|
||||||
|
if project and uow.project_profitability:
|
||||||
|
project_snapshot = ProjectProfitability(
|
||||||
|
project_id=project.id,
|
||||||
|
created_by_id=created_by_id,
|
||||||
|
calculation_source="calculations.profitability",
|
||||||
|
currency_code=result.currency,
|
||||||
|
npv=npv_value,
|
||||||
|
irr_pct=irr_value,
|
||||||
|
payback_period_years=payback_value,
|
||||||
|
margin_pct=margin_value,
|
||||||
|
revenue_total=revenue_total,
|
||||||
|
processing_opex_total=processing_total,
|
||||||
|
sustaining_capex_total=sustaining_total,
|
||||||
|
initial_capex=initial_capex,
|
||||||
|
net_cash_flow_total=net_cash_flow_total,
|
||||||
|
payload=payload,
|
||||||
|
)
|
||||||
|
uow.project_profitability.create(project_snapshot)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get(
|
||||||
|
"/profitability",
|
||||||
|
response_class=HTMLResponse,
|
||||||
|
name="calculations.profitability_form",
|
||||||
|
)
|
||||||
|
def profitability_form(
|
||||||
|
request: Request,
|
||||||
|
_: User = Depends(require_authenticated_user),
|
||||||
|
metadata: PricingMetadata = Depends(get_pricing_metadata),
|
||||||
|
uow: UnitOfWork = Depends(get_unit_of_work),
|
||||||
|
project_id: int | None = Query(
|
||||||
|
None, description="Optional project identifier"),
|
||||||
|
scenario_id: int | None = Query(
|
||||||
|
None, description="Optional scenario identifier"),
|
||||||
|
) -> HTMLResponse:
|
||||||
|
"""Render the profitability calculation form with default metadata."""
|
||||||
|
|
||||||
|
project, scenario = _load_project_and_scenario(
|
||||||
|
uow=uow, project_id=project_id, scenario_id=scenario_id
|
||||||
|
)
|
||||||
|
context = _prepare_default_context(
|
||||||
|
request,
|
||||||
|
project=project,
|
||||||
|
scenario=scenario,
|
||||||
|
metadata=metadata,
|
||||||
|
)
|
||||||
|
|
||||||
|
return templates.TemplateResponse("scenarios/profitability.html", context)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post(
|
||||||
|
"/profitability",
|
||||||
|
name="calculations.profitability_submit",
|
||||||
|
)
|
||||||
|
async def profitability_submit(
|
||||||
|
request: Request,
|
||||||
|
current_user: User = Depends(require_authenticated_user),
|
||||||
|
metadata: PricingMetadata = Depends(get_pricing_metadata),
|
||||||
|
uow: UnitOfWork = Depends(get_unit_of_work),
|
||||||
|
project_id: int | None = Query(
|
||||||
|
None, description="Optional project identifier"),
|
||||||
|
scenario_id: int | None = Query(
|
||||||
|
None, description="Optional scenario identifier"),
|
||||||
|
) -> Response:
|
||||||
|
"""Handle profitability calculations and return HTML or JSON."""
|
||||||
|
|
||||||
|
wants_json = _is_json_request(request)
|
||||||
|
payload_data = await _extract_payload(request)
|
||||||
|
|
||||||
|
try:
|
||||||
|
request_model = ProfitabilityCalculationRequest.model_validate(
|
||||||
|
payload_data)
|
||||||
|
result = calculate_profitability(request_model, metadata=metadata)
|
||||||
|
except ValidationError as exc:
|
||||||
|
if wants_json:
|
||||||
|
return JSONResponse(
|
||||||
|
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
||||||
|
content={"errors": exc.errors()},
|
||||||
|
)
|
||||||
|
|
||||||
|
project, scenario = _load_project_and_scenario(
|
||||||
|
uow=uow, project_id=project_id, scenario_id=scenario_id
|
||||||
|
)
|
||||||
|
context = _prepare_default_context(
|
||||||
|
request,
|
||||||
|
project=project,
|
||||||
|
scenario=scenario,
|
||||||
|
metadata=metadata,
|
||||||
|
form_data=payload_data,
|
||||||
|
allow_empty_override=True,
|
||||||
|
)
|
||||||
|
errors = _list_from_context(context, "errors")
|
||||||
|
errors.extend(
|
||||||
|
[f"{err['loc']} - {err['msg']}" for err in exc.errors()]
|
||||||
|
)
|
||||||
|
return templates.TemplateResponse(
|
||||||
|
"scenarios/profitability.html",
|
||||||
|
context,
|
||||||
|
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
||||||
|
)
|
||||||
|
except ProfitabilityValidationError as exc:
|
||||||
|
if wants_json:
|
||||||
|
return JSONResponse(
|
||||||
|
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
||||||
|
content={
|
||||||
|
"errors": exc.field_errors or [],
|
||||||
|
"message": exc.message,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
project, scenario = _load_project_and_scenario(
|
||||||
|
uow=uow, project_id=project_id, scenario_id=scenario_id
|
||||||
|
)
|
||||||
|
context = _prepare_default_context(
|
||||||
|
request,
|
||||||
|
project=project,
|
||||||
|
scenario=scenario,
|
||||||
|
metadata=metadata,
|
||||||
|
form_data=payload_data,
|
||||||
|
allow_empty_override=True,
|
||||||
|
)
|
||||||
|
messages = list(exc.field_errors or []) or [exc.message]
|
||||||
|
errors = _list_from_context(context, "errors")
|
||||||
|
errors.extend(messages)
|
||||||
|
return templates.TemplateResponse(
|
||||||
|
"scenarios/profitability.html",
|
||||||
|
context,
|
||||||
|
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
||||||
|
)
|
||||||
|
|
||||||
|
project, scenario = _load_project_and_scenario(
|
||||||
|
uow=uow, project_id=project_id, scenario_id=scenario_id
|
||||||
|
)
|
||||||
|
|
||||||
|
_persist_profitability_snapshots(
|
||||||
|
uow=uow,
|
||||||
|
project=project,
|
||||||
|
scenario=scenario,
|
||||||
|
user=current_user,
|
||||||
|
request_model=request_model,
|
||||||
|
result=result,
|
||||||
|
)
|
||||||
|
|
||||||
|
if wants_json:
|
||||||
|
return JSONResponse(
|
||||||
|
status_code=status.HTTP_200_OK,
|
||||||
|
content=result.model_dump(),
|
||||||
|
)
|
||||||
|
|
||||||
|
context = _prepare_default_context(
|
||||||
|
request,
|
||||||
|
project=project,
|
||||||
|
scenario=scenario,
|
||||||
|
metadata=metadata,
|
||||||
|
form_data=request_model.model_dump(mode="json"),
|
||||||
|
result=result,
|
||||||
|
)
|
||||||
|
notices = _list_from_context(context, "notices")
|
||||||
|
notices.append("Profitability calculation completed successfully.")
|
||||||
|
|
||||||
|
return templates.TemplateResponse(
|
||||||
|
"scenarios/profitability.html",
|
||||||
|
context,
|
||||||
|
status_code=status.HTTP_200_OK,
|
||||||
|
)
|
||||||
108
schemas/calculations.py
Normal file
108
schemas/calculations.py
Normal file
@@ -0,0 +1,108 @@
|
|||||||
|
"""Pydantic schemas for calculation workflows."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import List, Optional
|
||||||
|
|
||||||
|
from pydantic import BaseModel, Field, PositiveFloat, ValidationError, field_validator
|
||||||
|
|
||||||
|
from services.pricing import PricingResult
|
||||||
|
|
||||||
|
|
||||||
|
class ImpurityInput(BaseModel):
|
||||||
|
"""Impurity configuration row supplied by the client."""
|
||||||
|
|
||||||
|
name: str = Field(..., min_length=1)
|
||||||
|
value: float | None = Field(None, ge=0)
|
||||||
|
threshold: float | None = Field(None, ge=0)
|
||||||
|
penalty: float | None = Field(None)
|
||||||
|
|
||||||
|
@field_validator("name")
|
||||||
|
@classmethod
|
||||||
|
def _normalise_name(cls, value: str) -> str:
|
||||||
|
return value.strip()
|
||||||
|
|
||||||
|
|
||||||
|
class ProfitabilityCalculationRequest(BaseModel):
|
||||||
|
"""Request payload for profitability calculations."""
|
||||||
|
|
||||||
|
metal: str = Field(..., min_length=1)
|
||||||
|
ore_tonnage: PositiveFloat
|
||||||
|
head_grade_pct: float = Field(..., gt=0, le=100)
|
||||||
|
recovery_pct: float = Field(..., gt=0, le=100)
|
||||||
|
payable_pct: float | None = Field(None, gt=0, le=100)
|
||||||
|
reference_price: PositiveFloat
|
||||||
|
treatment_charge: float = Field(0, ge=0)
|
||||||
|
smelting_charge: float = Field(0, ge=0)
|
||||||
|
moisture_pct: float = Field(0, ge=0, le=100)
|
||||||
|
moisture_threshold_pct: float | None = Field(None, ge=0, le=100)
|
||||||
|
moisture_penalty_per_pct: float | None = None
|
||||||
|
premiums: float = Field(0)
|
||||||
|
fx_rate: PositiveFloat = Field(1)
|
||||||
|
currency_code: str | None = Field(None, min_length=3, max_length=3)
|
||||||
|
processing_opex: float = Field(0, ge=0)
|
||||||
|
sustaining_capex: float = Field(0, ge=0)
|
||||||
|
initial_capex: float = Field(0, ge=0)
|
||||||
|
discount_rate: float | None = Field(None, ge=0, le=100)
|
||||||
|
periods: int = Field(10, ge=1, le=120)
|
||||||
|
impurities: List[ImpurityInput] = Field(default_factory=list)
|
||||||
|
|
||||||
|
@field_validator("currency_code")
|
||||||
|
@classmethod
|
||||||
|
def _uppercase_currency(cls, value: str | None) -> str | None:
|
||||||
|
if value is None:
|
||||||
|
return None
|
||||||
|
return value.strip().upper()
|
||||||
|
|
||||||
|
@field_validator("metal")
|
||||||
|
@classmethod
|
||||||
|
def _normalise_metal(cls, value: str) -> str:
|
||||||
|
return value.strip().lower()
|
||||||
|
|
||||||
|
|
||||||
|
class ProfitabilityCosts(BaseModel):
|
||||||
|
"""Aggregated cost components for profitability output."""
|
||||||
|
|
||||||
|
processing_opex_total: float
|
||||||
|
sustaining_capex_total: float
|
||||||
|
initial_capex: float
|
||||||
|
|
||||||
|
|
||||||
|
class ProfitabilityMetrics(BaseModel):
|
||||||
|
"""Financial KPIs yielded by the profitability calculation."""
|
||||||
|
|
||||||
|
npv: float | None
|
||||||
|
irr: float | None
|
||||||
|
payback_period: float | None
|
||||||
|
margin: float | None
|
||||||
|
|
||||||
|
|
||||||
|
class CashFlowEntry(BaseModel):
|
||||||
|
"""Normalized cash flow row for reporting and charting."""
|
||||||
|
|
||||||
|
period: int
|
||||||
|
revenue: float
|
||||||
|
processing_opex: float
|
||||||
|
sustaining_capex: float
|
||||||
|
net: float
|
||||||
|
|
||||||
|
|
||||||
|
class ProfitabilityCalculationResult(BaseModel):
|
||||||
|
"""Response body summarizing profitability calculation outputs."""
|
||||||
|
|
||||||
|
pricing: PricingResult
|
||||||
|
costs: ProfitabilityCosts
|
||||||
|
metrics: ProfitabilityMetrics
|
||||||
|
cash_flows: list[CashFlowEntry]
|
||||||
|
currency: str | None
|
||||||
|
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"ImpurityInput",
|
||||||
|
"ProfitabilityCalculationRequest",
|
||||||
|
"ProfitabilityCosts",
|
||||||
|
"ProfitabilityMetrics",
|
||||||
|
"CashFlowEntry",
|
||||||
|
"ProfitabilityCalculationResult",
|
||||||
|
"ValidationError",
|
||||||
|
]
|
||||||
112
scripts/_route_verification.py
Normal file
112
scripts/_route_verification.py
Normal file
@@ -0,0 +1,112 @@
|
|||||||
|
"""Utility script to verify key authenticated routes respond without errors."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
import urllib.parse
|
||||||
|
from http.client import HTTPConnection
|
||||||
|
from http.cookies import SimpleCookie
|
||||||
|
from typing import Dict, List, Tuple
|
||||||
|
|
||||||
|
HOST = "127.0.0.1"
|
||||||
|
PORT = 8000
|
||||||
|
|
||||||
|
cookies: Dict[str, str] = {}
|
||||||
|
|
||||||
|
|
||||||
|
def _update_cookies(headers: List[Tuple[str, str]]) -> None:
|
||||||
|
for name, value in headers:
|
||||||
|
if name.lower() != "set-cookie":
|
||||||
|
continue
|
||||||
|
cookie = SimpleCookie()
|
||||||
|
cookie.load(value)
|
||||||
|
for key, morsel in cookie.items():
|
||||||
|
cookies[key] = morsel.value
|
||||||
|
|
||||||
|
|
||||||
|
def _cookie_header() -> str | None:
|
||||||
|
if not cookies:
|
||||||
|
return None
|
||||||
|
return "; ".join(f"{key}={value}" for key, value in cookies.items())
|
||||||
|
|
||||||
|
|
||||||
|
def request(method: str, path: str, *, body: bytes | None = None, headers: Dict[str, str] | None = None) -> Tuple[int, Dict[str, str], bytes]:
|
||||||
|
conn = HTTPConnection(HOST, PORT, timeout=10)
|
||||||
|
prepared_headers = {"User-Agent": "route-checker"}
|
||||||
|
if headers:
|
||||||
|
prepared_headers.update(headers)
|
||||||
|
cookie_header = _cookie_header()
|
||||||
|
if cookie_header:
|
||||||
|
prepared_headers["Cookie"] = cookie_header
|
||||||
|
|
||||||
|
conn.request(method, path, body=body, headers=prepared_headers)
|
||||||
|
resp = conn.getresponse()
|
||||||
|
payload = resp.read()
|
||||||
|
status = resp.status
|
||||||
|
reason = resp.reason
|
||||||
|
response_headers = {name: value for name, value in resp.getheaders()}
|
||||||
|
_update_cookies(list(resp.getheaders()))
|
||||||
|
conn.close()
|
||||||
|
print(f"{method} {path} -> {status} {reason}")
|
||||||
|
return status, response_headers, payload
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
status, _, _ = request("GET", "/login")
|
||||||
|
if status != 200:
|
||||||
|
print("Unexpected status for GET /login", file=sys.stderr)
|
||||||
|
return 1
|
||||||
|
|
||||||
|
admin_username = os.getenv("CALMINER_SEED_ADMIN_USERNAME", "admin")
|
||||||
|
admin_password = os.getenv("CALMINER_SEED_ADMIN_PASSWORD", "M11ffpgm.")
|
||||||
|
login_payload = urllib.parse.urlencode(
|
||||||
|
{"username": admin_username, "password": admin_password}
|
||||||
|
).encode()
|
||||||
|
status, headers, _ = request(
|
||||||
|
"POST",
|
||||||
|
"/login",
|
||||||
|
body=login_payload,
|
||||||
|
headers={"Content-Type": "application/x-www-form-urlencoded"},
|
||||||
|
)
|
||||||
|
if status not in {200, 303}:
|
||||||
|
print("Login failed", file=sys.stderr)
|
||||||
|
return 1
|
||||||
|
|
||||||
|
location = headers.get("Location", "/")
|
||||||
|
redirect_path = urllib.parse.urlsplit(location).path or "/"
|
||||||
|
request("GET", redirect_path)
|
||||||
|
|
||||||
|
request("GET", "/")
|
||||||
|
request("GET", "/projects/ui")
|
||||||
|
|
||||||
|
status, headers, body = request(
|
||||||
|
"GET",
|
||||||
|
"/projects",
|
||||||
|
headers={"Accept": "application/json"},
|
||||||
|
)
|
||||||
|
projects: List[dict] = []
|
||||||
|
if headers.get("Content-Type", "").startswith("application/json"):
|
||||||
|
projects = json.loads(body.decode())
|
||||||
|
|
||||||
|
if projects:
|
||||||
|
project_id = projects[0]["id"]
|
||||||
|
request("GET", f"/projects/{project_id}/view")
|
||||||
|
status, headers, body = request(
|
||||||
|
"GET",
|
||||||
|
f"/projects/{project_id}/scenarios",
|
||||||
|
headers={"Accept": "application/json"},
|
||||||
|
)
|
||||||
|
scenarios: List[dict] = []
|
||||||
|
if headers.get("Content-Type", "").startswith("application/json"):
|
||||||
|
scenarios = json.loads(body.decode())
|
||||||
|
if scenarios:
|
||||||
|
scenario_id = scenarios[0]["id"]
|
||||||
|
request("GET", f"/scenarios/{scenario_id}/view")
|
||||||
|
|
||||||
|
print("Cookies:", cookies)
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
raise SystemExit(main())
|
||||||
15
scripts/apply_users_sequence_fix.py
Normal file
15
scripts/apply_users_sequence_fix.py
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
from sqlalchemy import create_engine, text
|
||||||
|
from config.database import DATABASE_URL
|
||||||
|
|
||||||
|
engine = create_engine(DATABASE_URL, future=True)
|
||||||
|
sqls = [
|
||||||
|
"CREATE SEQUENCE IF NOT EXISTS users_id_seq;",
|
||||||
|
"ALTER TABLE users ALTER COLUMN id SET DEFAULT nextval('users_id_seq');",
|
||||||
|
"SELECT setval('users_id_seq', COALESCE((SELECT MAX(id) FROM users), 1));",
|
||||||
|
"ALTER SEQUENCE users_id_seq OWNED BY users.id;",
|
||||||
|
]
|
||||||
|
with engine.begin() as conn:
|
||||||
|
for s in sqls:
|
||||||
|
print('EXECUTING:', s)
|
||||||
|
conn.execute(text(s))
|
||||||
|
print('SEQUENCE fix applied')
|
||||||
@@ -1,10 +1,12 @@
|
|||||||
"""Service layer utilities."""
|
"""Service layer utilities."""
|
||||||
|
|
||||||
from .pricing import calculate_pricing, PricingInput, PricingMetadata, PricingResult
|
from .pricing import calculate_pricing, PricingInput, PricingMetadata, PricingResult
|
||||||
|
from .calculations import calculate_profitability
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
"calculate_pricing",
|
"calculate_pricing",
|
||||||
"PricingInput",
|
"PricingInput",
|
||||||
"PricingMetadata",
|
"PricingMetadata",
|
||||||
"PricingResult",
|
"PricingResult",
|
||||||
|
"calculate_profitability",
|
||||||
]
|
]
|
||||||
|
|||||||
205
services/calculations.py
Normal file
205
services/calculations.py
Normal file
@@ -0,0 +1,205 @@
|
|||||||
|
"""Service functions for financial calculations."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from services.currency import CurrencyValidationError, normalise_currency
|
||||||
|
from services.exceptions import ProfitabilityValidationError
|
||||||
|
from services.financial import (
|
||||||
|
CashFlow,
|
||||||
|
ConvergenceError,
|
||||||
|
PaybackNotReachedError,
|
||||||
|
internal_rate_of_return,
|
||||||
|
net_present_value,
|
||||||
|
payback_period,
|
||||||
|
)
|
||||||
|
from services.pricing import PricingInput, PricingMetadata, PricingResult, calculate_pricing
|
||||||
|
from schemas.calculations import (
|
||||||
|
CashFlowEntry,
|
||||||
|
ProfitabilityCalculationRequest,
|
||||||
|
ProfitabilityCalculationResult,
|
||||||
|
ProfitabilityCosts,
|
||||||
|
ProfitabilityMetrics,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _build_pricing_input(
|
||||||
|
request: ProfitabilityCalculationRequest,
|
||||||
|
) -> PricingInput:
|
||||||
|
"""Construct a pricing input instance including impurity overrides."""
|
||||||
|
|
||||||
|
impurity_values: dict[str, float] = {}
|
||||||
|
impurity_thresholds: dict[str, float] = {}
|
||||||
|
impurity_penalties: dict[str, float] = {}
|
||||||
|
|
||||||
|
for impurity in request.impurities:
|
||||||
|
code = impurity.name.strip()
|
||||||
|
if not code:
|
||||||
|
continue
|
||||||
|
code = code.upper()
|
||||||
|
if impurity.value is not None:
|
||||||
|
impurity_values[code] = float(impurity.value)
|
||||||
|
if impurity.threshold is not None:
|
||||||
|
impurity_thresholds[code] = float(impurity.threshold)
|
||||||
|
if impurity.penalty is not None:
|
||||||
|
impurity_penalties[code] = float(impurity.penalty)
|
||||||
|
|
||||||
|
pricing_input = PricingInput(
|
||||||
|
metal=request.metal,
|
||||||
|
ore_tonnage=request.ore_tonnage,
|
||||||
|
head_grade_pct=request.head_grade_pct,
|
||||||
|
recovery_pct=request.recovery_pct,
|
||||||
|
payable_pct=request.payable_pct,
|
||||||
|
reference_price=request.reference_price,
|
||||||
|
treatment_charge=request.treatment_charge,
|
||||||
|
smelting_charge=request.smelting_charge,
|
||||||
|
moisture_pct=request.moisture_pct,
|
||||||
|
moisture_threshold_pct=request.moisture_threshold_pct,
|
||||||
|
moisture_penalty_per_pct=request.moisture_penalty_per_pct,
|
||||||
|
impurity_ppm=impurity_values,
|
||||||
|
impurity_thresholds=impurity_thresholds,
|
||||||
|
impurity_penalty_per_ppm=impurity_penalties,
|
||||||
|
premiums=request.premiums,
|
||||||
|
fx_rate=request.fx_rate,
|
||||||
|
currency_code=request.currency_code,
|
||||||
|
)
|
||||||
|
|
||||||
|
return pricing_input
|
||||||
|
|
||||||
|
|
||||||
|
def _generate_cash_flows(
|
||||||
|
*,
|
||||||
|
periods: int,
|
||||||
|
net_per_period: float,
|
||||||
|
initial_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)
|
||||||
|
]
|
||||||
|
cash_flow_entries: list[CashFlowEntry] = [
|
||||||
|
CashFlowEntry(
|
||||||
|
period=0,
|
||||||
|
revenue=0.0,
|
||||||
|
processing_opex=0.0,
|
||||||
|
sustaining_capex=0.0,
|
||||||
|
net=-initial_capex,
|
||||||
|
)
|
||||||
|
]
|
||||||
|
|
||||||
|
for period in range(1, periods + 1):
|
||||||
|
cash_flow_models.append(
|
||||||
|
CashFlow(amount=net_per_period, period_index=period))
|
||||||
|
cash_flow_entries.append(
|
||||||
|
CashFlowEntry(
|
||||||
|
period=period,
|
||||||
|
revenue=0.0,
|
||||||
|
processing_opex=0.0,
|
||||||
|
sustaining_capex=0.0,
|
||||||
|
net=net_per_period,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
return cash_flow_models, cash_flow_entries
|
||||||
|
|
||||||
|
|
||||||
|
def calculate_profitability(
|
||||||
|
request: ProfitabilityCalculationRequest,
|
||||||
|
*,
|
||||||
|
metadata: PricingMetadata,
|
||||||
|
) -> ProfitabilityCalculationResult:
|
||||||
|
"""Calculate profitability metrics using pricing inputs and cost data."""
|
||||||
|
|
||||||
|
if request.periods <= 0:
|
||||||
|
raise ProfitabilityValidationError(
|
||||||
|
"Evaluation periods must be at least 1.", ["periods"]
|
||||||
|
)
|
||||||
|
|
||||||
|
pricing_input = _build_pricing_input(request)
|
||||||
|
try:
|
||||||
|
pricing_result: PricingResult = calculate_pricing(
|
||||||
|
pricing_input, metadata=metadata
|
||||||
|
)
|
||||||
|
except CurrencyValidationError as exc:
|
||||||
|
raise ProfitabilityValidationError(
|
||||||
|
str(exc), ["currency_code"]) from exc
|
||||||
|
|
||||||
|
periods = request.periods
|
||||||
|
revenue_total = float(pricing_result.net_revenue)
|
||||||
|
revenue_per_period = revenue_total / periods
|
||||||
|
|
||||||
|
processing_total = float(request.processing_opex) * periods
|
||||||
|
sustaining_total = float(request.sustaining_capex) * periods
|
||||||
|
initial_capex = float(request.initial_capex)
|
||||||
|
|
||||||
|
net_per_period = (
|
||||||
|
revenue_per_period
|
||||||
|
- float(request.processing_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,
|
||||||
|
)
|
||||||
|
|
||||||
|
# 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.sustaining_capex = float(request.sustaining_capex)
|
||||||
|
entry.net = net_per_period
|
||||||
|
|
||||||
|
discount_rate = (request.discount_rate or 0.0) / 100.0
|
||||||
|
|
||||||
|
npv_value = net_present_value(discount_rate, cash_flow_models)
|
||||||
|
|
||||||
|
try:
|
||||||
|
irr_value = internal_rate_of_return(cash_flow_models) * 100.0
|
||||||
|
except (ValueError, ZeroDivisionError, ConvergenceError):
|
||||||
|
irr_value = None
|
||||||
|
|
||||||
|
try:
|
||||||
|
payback_value = payback_period(cash_flow_models)
|
||||||
|
except (ValueError, PaybackNotReachedError):
|
||||||
|
payback_value = None
|
||||||
|
|
||||||
|
total_costs = processing_total + sustaining_total + initial_capex
|
||||||
|
total_net = revenue_total - total_costs
|
||||||
|
|
||||||
|
if revenue_total == 0:
|
||||||
|
margin_value = None
|
||||||
|
else:
|
||||||
|
margin_value = (total_net / revenue_total) * 100.0
|
||||||
|
|
||||||
|
currency = request.currency_code or pricing_result.currency
|
||||||
|
try:
|
||||||
|
currency = normalise_currency(currency)
|
||||||
|
except CurrencyValidationError as exc:
|
||||||
|
raise ProfitabilityValidationError(
|
||||||
|
str(exc), ["currency_code"]) from exc
|
||||||
|
|
||||||
|
costs = ProfitabilityCosts(
|
||||||
|
processing_opex_total=processing_total,
|
||||||
|
sustaining_capex_total=sustaining_total,
|
||||||
|
initial_capex=initial_capex,
|
||||||
|
)
|
||||||
|
|
||||||
|
metrics = ProfitabilityMetrics(
|
||||||
|
npv=npv_value,
|
||||||
|
irr=irr_value,
|
||||||
|
payback_period=payback_value,
|
||||||
|
margin=margin_value,
|
||||||
|
)
|
||||||
|
|
||||||
|
return ProfitabilityCalculationResult(
|
||||||
|
pricing=pricing_result,
|
||||||
|
costs=costs,
|
||||||
|
metrics=metrics,
|
||||||
|
cash_flows=cash_flow_entries,
|
||||||
|
currency=currency,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
__all__ = ["calculate_profitability"]
|
||||||
@@ -26,3 +26,14 @@ class ScenarioValidationError(Exception):
|
|||||||
|
|
||||||
def __str__(self) -> str: # pragma: no cover - mirrors message for logging
|
def __str__(self) -> str: # pragma: no cover - mirrors message for logging
|
||||||
return self.message
|
return self.message
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(eq=False)
|
||||||
|
class ProfitabilityValidationError(Exception):
|
||||||
|
"""Raised when profitability calculation inputs fail domain validation."""
|
||||||
|
|
||||||
|
message: str
|
||||||
|
field_errors: Sequence[str] | None = None
|
||||||
|
|
||||||
|
def __str__(self) -> str: # pragma: no cover - mirrors message for logging
|
||||||
|
return self.message
|
||||||
|
|||||||
@@ -15,8 +15,10 @@ from models import (
|
|||||||
PricingImpuritySettings,
|
PricingImpuritySettings,
|
||||||
PricingMetalSettings,
|
PricingMetalSettings,
|
||||||
PricingSettings,
|
PricingSettings,
|
||||||
|
ProjectProfitability,
|
||||||
Role,
|
Role,
|
||||||
Scenario,
|
Scenario,
|
||||||
|
ScenarioProfitability,
|
||||||
ScenarioStatus,
|
ScenarioStatus,
|
||||||
SimulationParameter,
|
SimulationParameter,
|
||||||
User,
|
User,
|
||||||
@@ -367,6 +369,106 @@ class ScenarioRepository:
|
|||||||
self.session.delete(scenario)
|
self.session.delete(scenario)
|
||||||
|
|
||||||
|
|
||||||
|
class ProjectProfitabilityRepository:
|
||||||
|
"""Persistence operations for project-level profitability snapshots."""
|
||||||
|
|
||||||
|
def __init__(self, session: Session) -> None:
|
||||||
|
self.session = session
|
||||||
|
|
||||||
|
def create(self, snapshot: ProjectProfitability) -> ProjectProfitability:
|
||||||
|
self.session.add(snapshot)
|
||||||
|
self.session.flush()
|
||||||
|
return snapshot
|
||||||
|
|
||||||
|
def list_for_project(
|
||||||
|
self,
|
||||||
|
project_id: int,
|
||||||
|
*,
|
||||||
|
limit: int | None = None,
|
||||||
|
) -> Sequence[ProjectProfitability]:
|
||||||
|
stmt = (
|
||||||
|
select(ProjectProfitability)
|
||||||
|
.where(ProjectProfitability.project_id == project_id)
|
||||||
|
.order_by(ProjectProfitability.calculated_at.desc())
|
||||||
|
)
|
||||||
|
if limit is not None:
|
||||||
|
stmt = stmt.limit(limit)
|
||||||
|
return self.session.execute(stmt).scalars().all()
|
||||||
|
|
||||||
|
def latest_for_project(
|
||||||
|
self,
|
||||||
|
project_id: int,
|
||||||
|
) -> ProjectProfitability | None:
|
||||||
|
stmt = (
|
||||||
|
select(ProjectProfitability)
|
||||||
|
.where(ProjectProfitability.project_id == project_id)
|
||||||
|
.order_by(ProjectProfitability.calculated_at.desc())
|
||||||
|
.limit(1)
|
||||||
|
)
|
||||||
|
return self.session.execute(stmt).scalar_one_or_none()
|
||||||
|
|
||||||
|
def delete(self, snapshot_id: int) -> None:
|
||||||
|
stmt = select(ProjectProfitability).where(
|
||||||
|
ProjectProfitability.id == snapshot_id
|
||||||
|
)
|
||||||
|
entity = self.session.execute(stmt).scalar_one_or_none()
|
||||||
|
if entity is None:
|
||||||
|
raise EntityNotFoundError(
|
||||||
|
f"Project profitability snapshot {snapshot_id} not found"
|
||||||
|
)
|
||||||
|
self.session.delete(entity)
|
||||||
|
|
||||||
|
|
||||||
|
class ScenarioProfitabilityRepository:
|
||||||
|
"""Persistence operations for scenario-level profitability snapshots."""
|
||||||
|
|
||||||
|
def __init__(self, session: Session) -> None:
|
||||||
|
self.session = session
|
||||||
|
|
||||||
|
def create(self, snapshot: ScenarioProfitability) -> ScenarioProfitability:
|
||||||
|
self.session.add(snapshot)
|
||||||
|
self.session.flush()
|
||||||
|
return snapshot
|
||||||
|
|
||||||
|
def list_for_scenario(
|
||||||
|
self,
|
||||||
|
scenario_id: int,
|
||||||
|
*,
|
||||||
|
limit: int | None = None,
|
||||||
|
) -> Sequence[ScenarioProfitability]:
|
||||||
|
stmt = (
|
||||||
|
select(ScenarioProfitability)
|
||||||
|
.where(ScenarioProfitability.scenario_id == scenario_id)
|
||||||
|
.order_by(ScenarioProfitability.calculated_at.desc())
|
||||||
|
)
|
||||||
|
if limit is not None:
|
||||||
|
stmt = stmt.limit(limit)
|
||||||
|
return self.session.execute(stmt).scalars().all()
|
||||||
|
|
||||||
|
def latest_for_scenario(
|
||||||
|
self,
|
||||||
|
scenario_id: int,
|
||||||
|
) -> ScenarioProfitability | None:
|
||||||
|
stmt = (
|
||||||
|
select(ScenarioProfitability)
|
||||||
|
.where(ScenarioProfitability.scenario_id == scenario_id)
|
||||||
|
.order_by(ScenarioProfitability.calculated_at.desc())
|
||||||
|
.limit(1)
|
||||||
|
)
|
||||||
|
return self.session.execute(stmt).scalar_one_or_none()
|
||||||
|
|
||||||
|
def delete(self, snapshot_id: int) -> None:
|
||||||
|
stmt = select(ScenarioProfitability).where(
|
||||||
|
ScenarioProfitability.id == snapshot_id
|
||||||
|
)
|
||||||
|
entity = self.session.execute(stmt).scalar_one_or_none()
|
||||||
|
if entity is None:
|
||||||
|
raise EntityNotFoundError(
|
||||||
|
f"Scenario profitability snapshot {snapshot_id} not found"
|
||||||
|
)
|
||||||
|
self.session.delete(entity)
|
||||||
|
|
||||||
|
|
||||||
class FinancialInputRepository:
|
class FinancialInputRepository:
|
||||||
"""Persistence operations for FinancialInput entities."""
|
"""Persistence operations for FinancialInput entities."""
|
||||||
|
|
||||||
|
|||||||
@@ -13,8 +13,10 @@ from services.repositories import (
|
|||||||
PricingSettingsRepository,
|
PricingSettingsRepository,
|
||||||
PricingSettingsSeedResult,
|
PricingSettingsSeedResult,
|
||||||
ProjectRepository,
|
ProjectRepository,
|
||||||
|
ProjectProfitabilityRepository,
|
||||||
RoleRepository,
|
RoleRepository,
|
||||||
ScenarioRepository,
|
ScenarioRepository,
|
||||||
|
ScenarioProfitabilityRepository,
|
||||||
SimulationParameterRepository,
|
SimulationParameterRepository,
|
||||||
UserRepository,
|
UserRepository,
|
||||||
ensure_admin_user as ensure_admin_user_record,
|
ensure_admin_user as ensure_admin_user_record,
|
||||||
@@ -36,6 +38,8 @@ class UnitOfWork(AbstractContextManager["UnitOfWork"]):
|
|||||||
self.scenarios: ScenarioRepository | None = None
|
self.scenarios: ScenarioRepository | None = None
|
||||||
self.financial_inputs: FinancialInputRepository | None = None
|
self.financial_inputs: FinancialInputRepository | None = None
|
||||||
self.simulation_parameters: SimulationParameterRepository | None = None
|
self.simulation_parameters: SimulationParameterRepository | None = None
|
||||||
|
self.project_profitability: ProjectProfitabilityRepository | None = None
|
||||||
|
self.scenario_profitability: ScenarioProfitabilityRepository | None = None
|
||||||
self.users: UserRepository | None = None
|
self.users: UserRepository | None = None
|
||||||
self.roles: RoleRepository | None = None
|
self.roles: RoleRepository | None = None
|
||||||
self.pricing_settings: PricingSettingsRepository | None = None
|
self.pricing_settings: PricingSettingsRepository | None = None
|
||||||
@@ -47,6 +51,11 @@ class UnitOfWork(AbstractContextManager["UnitOfWork"]):
|
|||||||
self.financial_inputs = FinancialInputRepository(self.session)
|
self.financial_inputs = FinancialInputRepository(self.session)
|
||||||
self.simulation_parameters = SimulationParameterRepository(
|
self.simulation_parameters = SimulationParameterRepository(
|
||||||
self.session)
|
self.session)
|
||||||
|
self.project_profitability = ProjectProfitabilityRepository(
|
||||||
|
self.session)
|
||||||
|
self.scenario_profitability = ScenarioProfitabilityRepository(
|
||||||
|
self.session
|
||||||
|
)
|
||||||
self.users = UserRepository(self.session)
|
self.users = UserRepository(self.session)
|
||||||
self.roles = RoleRepository(self.session)
|
self.roles = RoleRepository(self.session)
|
||||||
self.pricing_settings = PricingSettingsRepository(self.session)
|
self.pricing_settings = PricingSettingsRepository(self.session)
|
||||||
@@ -65,6 +74,8 @@ class UnitOfWork(AbstractContextManager["UnitOfWork"]):
|
|||||||
self.scenarios = None
|
self.scenarios = None
|
||||||
self.financial_inputs = None
|
self.financial_inputs = None
|
||||||
self.simulation_parameters = None
|
self.simulation_parameters = None
|
||||||
|
self.project_profitability = None
|
||||||
|
self.scenario_profitability = None
|
||||||
self.users = None
|
self.users = None
|
||||||
self.roles = None
|
self.roles = None
|
||||||
self.pricing_settings = None
|
self.pricing_settings = None
|
||||||
|
|||||||
338
templates/scenarios/profitability.html
Normal file
338
templates/scenarios/profitability.html
Normal file
@@ -0,0 +1,338 @@
|
|||||||
|
{% 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 %}
|
||||||
Reference in New Issue
Block a user