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:
14
changelog.md
14
changelog.md
@@ -24,7 +24,6 @@
|
|||||||
|
|
||||||
## 2025-11-10
|
## 2025-11-10
|
||||||
|
|
||||||
- Extended authorization helper layer with project/scenario ownership lookups, integrated them into FastAPI dependencies, refreshed pytest fixtures to keep the suite authenticated, and documented the new patterns across RBAC plan and security guides.
|
|
||||||
- Added dedicated pytest coverage for guard dependencies, exercising success plus failure paths (missing session, inactive user, missing roles, project/scenario access errors) via `tests/test_dependencies_guards.py`.
|
- Added dedicated pytest coverage for guard dependencies, exercising success plus failure paths (missing session, inactive user, missing roles, project/scenario access errors) via `tests/test_dependencies_guards.py`.
|
||||||
- Added integration tests in `tests/test_authorization_integration.py` verifying anonymous 401 responses, role-based 403s, and authorized project manager flows across API and UI endpoints.
|
- Added integration tests in `tests/test_authorization_integration.py` verifying anonymous 401 responses, role-based 403s, and authorized project manager flows across API and UI endpoints.
|
||||||
- Implemented environment-driven admin bootstrap settings, wired the `bootstrap_admin` helper into FastAPI startup, added pytest coverage for creation/idempotency/reset logic, and documented operational guidance in the RBAC plan and security concept.
|
- Implemented environment-driven admin bootstrap settings, wired the `bootstrap_admin` helper into FastAPI startup, added pytest coverage for creation/idempotency/reset logic, and documented operational guidance in the RBAC plan and security concept.
|
||||||
@@ -34,3 +33,16 @@
|
|||||||
- Added `services/importers.py` to load CSV/XLSX files into the new import schemas, pulled in `openpyxl` for Excel support, and covered the parsing behaviour with `tests/test_import_parsing.py`.
|
- Added `services/importers.py` to load CSV/XLSX files into the new import schemas, pulled in `openpyxl` for Excel support, and covered the parsing behaviour with `tests/test_import_parsing.py`.
|
||||||
- Expanded the import ingestion workflow with staging previews, transactional persistence commits, FastAPI preview/commit endpoints under `/imports`, and new API tests (`tests/test_import_ingestion.py`, `tests/test_import_api.py`) ensuring end-to-end coverage.
|
- Expanded the import ingestion workflow with staging previews, transactional persistence commits, FastAPI preview/commit endpoints under `/imports`, and new API tests (`tests/test_import_ingestion.py`, `tests/test_import_api.py`) ensuring end-to-end coverage.
|
||||||
- Added persistent audit logging via `ImportExportLog`, structured log emission, Prometheus metrics instrumentation, `/metrics` endpoint exposure, and updated operator/deployment documentation to guide monitoring setup.
|
- Added persistent audit logging via `ImportExportLog`, structured log emission, Prometheus metrics instrumentation, `/metrics` endpoint exposure, and updated operator/deployment documentation to guide monitoring setup.
|
||||||
|
|
||||||
|
## 2025-11-11
|
||||||
|
|
||||||
|
- Centralised ISO-4217 currency validation across scenarios, imports, and export filters (`models/scenario.py`, `routes/scenarios.py`, `schemas/scenario.py`, `schemas/imports.py`, `services/export_query.py`) so malformed codes are rejected consistently at every entry point.
|
||||||
|
- Updated scenario services and UI flows to surface friendly validation errors and added regression coverage for imports, exports, API creation, and lifecycle flows ensuring currencies are normalised end-to-end.
|
||||||
|
- Recorded the completed “Ensure currency is used consistently” work in `.github/instructions/DONE.md` and ran the full pytest suite (150 tests) to verify the refactor.
|
||||||
|
- Linked projects to their pricing settings by updating SQLAlchemy models, repositories, seeding utilities, and migrations, and added regression tests to cover the new association and default backfill.
|
||||||
|
- Bootstrapped database-stored pricing settings at application startup, aligned initial data seeding with the database-first metadata flow, and added tests covering pricing bootstrap creation, project assignment, and idempotency.
|
||||||
|
- Extended pricing configuration support to prefer persisted metadata via `dependencies.get_pricing_metadata`, added retrieval tests for project/default fallbacks, and refreshed docs (`calminer-docs/specifications/price_calculation.md`, `pricing_settings_data_model.md`) to describe the database-backed workflow and bootstrap behaviour.
|
||||||
|
- Added `services/financial.py` NPV, IRR, and payback helpers with robust cash-flow normalisation, convergence safeguards, and fractional period support, plus comprehensive pytest coverage exercising representative project scenarios and failure modes.
|
||||||
|
- Authored `calminer-docs/specifications/financial_metrics.md` capturing DCF assumptions, solver behaviours, and worked examples, and cross-linked the architecture concepts to the new reference for consistent navigation.
|
||||||
|
- Implemented `services/simulation.py` Monte Carlo engine with configurable distributions, summary aggregation, and reproducible RNG seeding, introduced regression tests in `tests/test_simulation.py`, and documented configuration/usage in `calminer-docs/specifications/monte_carlo_simulation.md` with architecture cross-links.
|
||||||
|
- Polished reporting HTML contexts by cleaning stray fragments in `routes/reports.py`, adding download action metadata for project and scenario pages, and generating scenario comparison download URLs with correctly serialised repeated `scenario_ids` parameters.
|
||||||
|
|||||||
@@ -7,6 +7,8 @@ from functools import lru_cache
|
|||||||
|
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
|
|
||||||
|
from services.pricing import PricingMetadata
|
||||||
|
|
||||||
from services.security import JWTSettings
|
from services.security import JWTSettings
|
||||||
|
|
||||||
|
|
||||||
@@ -56,6 +58,10 @@ class Settings:
|
|||||||
admin_password: str = "ChangeMe123!"
|
admin_password: str = "ChangeMe123!"
|
||||||
admin_roles: tuple[str, ...] = ("admin",)
|
admin_roles: tuple[str, ...] = ("admin",)
|
||||||
admin_force_reset: bool = False
|
admin_force_reset: bool = False
|
||||||
|
pricing_default_payable_pct: float = 100.0
|
||||||
|
pricing_default_currency: str | None = "USD"
|
||||||
|
pricing_moisture_threshold_pct: float = 8.0
|
||||||
|
pricing_moisture_penalty_per_pct: float = 0.0
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def from_environment(cls) -> "Settings":
|
def from_environment(cls) -> "Settings":
|
||||||
@@ -105,6 +111,18 @@ class Settings:
|
|||||||
admin_force_reset=cls._bool_from_env(
|
admin_force_reset=cls._bool_from_env(
|
||||||
"CALMINER_SEED_FORCE", False
|
"CALMINER_SEED_FORCE", False
|
||||||
),
|
),
|
||||||
|
pricing_default_payable_pct=cls._float_from_env(
|
||||||
|
"CALMINER_PRICING_DEFAULT_PAYABLE_PCT", 100.0
|
||||||
|
),
|
||||||
|
pricing_default_currency=cls._optional_str(
|
||||||
|
"CALMINER_PRICING_DEFAULT_CURRENCY", "USD"
|
||||||
|
),
|
||||||
|
pricing_moisture_threshold_pct=cls._float_from_env(
|
||||||
|
"CALMINER_PRICING_MOISTURE_THRESHOLD_PCT", 8.0
|
||||||
|
),
|
||||||
|
pricing_moisture_penalty_per_pct=cls._float_from_env(
|
||||||
|
"CALMINER_PRICING_MOISTURE_PENALTY_PER_PCT", 0.0
|
||||||
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
@@ -145,6 +163,23 @@ class Settings:
|
|||||||
seen.add(role_name)
|
seen.add(role_name)
|
||||||
return tuple(ordered)
|
return tuple(ordered)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _float_from_env(name: str, default: float) -> float:
|
||||||
|
raw_value = os.getenv(name)
|
||||||
|
if raw_value is None:
|
||||||
|
return default
|
||||||
|
try:
|
||||||
|
return float(raw_value)
|
||||||
|
except ValueError:
|
||||||
|
return default
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _optional_str(name: str, default: str | None = None) -> str | None:
|
||||||
|
raw_value = os.getenv(name)
|
||||||
|
if raw_value is None or raw_value.strip() == "":
|
||||||
|
return default
|
||||||
|
return raw_value.strip()
|
||||||
|
|
||||||
def jwt_settings(self) -> JWTSettings:
|
def jwt_settings(self) -> JWTSettings:
|
||||||
"""Build runtime JWT settings compatible with token helpers."""
|
"""Build runtime JWT settings compatible with token helpers."""
|
||||||
|
|
||||||
@@ -180,6 +215,16 @@ class Settings:
|
|||||||
force_reset=self.admin_force_reset,
|
force_reset=self.admin_force_reset,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def pricing_metadata(self) -> PricingMetadata:
|
||||||
|
"""Build pricing metadata defaults."""
|
||||||
|
|
||||||
|
return PricingMetadata(
|
||||||
|
default_payable_pct=self.pricing_default_payable_pct,
|
||||||
|
default_currency=self.pricing_default_currency,
|
||||||
|
moisture_threshold_pct=self.pricing_moisture_threshold_pct,
|
||||||
|
moisture_penalty_per_pct=self.pricing_moisture_penalty_per_pct,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@lru_cache(maxsize=1)
|
@lru_cache(maxsize=1)
|
||||||
def get_settings() -> Settings:
|
def get_settings() -> Settings:
|
||||||
|
|||||||
@@ -22,6 +22,9 @@ from services.session import (
|
|||||||
)
|
)
|
||||||
from services.unit_of_work import UnitOfWork
|
from services.unit_of_work import UnitOfWork
|
||||||
from services.importers import ImportIngestionService
|
from services.importers import ImportIngestionService
|
||||||
|
from services.pricing import PricingMetadata
|
||||||
|
from services.scenario_evaluation import ScenarioPricingConfig, ScenarioPricingEvaluator
|
||||||
|
from services.repositories import pricing_settings_to_metadata
|
||||||
|
|
||||||
|
|
||||||
def get_unit_of_work() -> Generator[UnitOfWork, None, None]:
|
def get_unit_of_work() -> Generator[UnitOfWork, None, None]:
|
||||||
@@ -46,6 +49,29 @@ def get_application_settings() -> Settings:
|
|||||||
return get_settings()
|
return get_settings()
|
||||||
|
|
||||||
|
|
||||||
|
def get_pricing_metadata(
|
||||||
|
settings: Settings = Depends(get_application_settings),
|
||||||
|
uow: UnitOfWork = Depends(get_unit_of_work),
|
||||||
|
) -> PricingMetadata:
|
||||||
|
"""Return pricing metadata defaults sourced from persisted pricing settings."""
|
||||||
|
|
||||||
|
stored = uow.get_pricing_metadata()
|
||||||
|
if stored is not None:
|
||||||
|
return stored
|
||||||
|
|
||||||
|
fallback = settings.pricing_metadata()
|
||||||
|
seed_result = uow.ensure_default_pricing_settings(metadata=fallback)
|
||||||
|
return pricing_settings_to_metadata(seed_result.settings)
|
||||||
|
|
||||||
|
|
||||||
|
def get_pricing_evaluator(
|
||||||
|
metadata: PricingMetadata = Depends(get_pricing_metadata),
|
||||||
|
) -> ScenarioPricingEvaluator:
|
||||||
|
"""Provide a configured scenario pricing evaluator."""
|
||||||
|
|
||||||
|
return ScenarioPricingEvaluator(ScenarioPricingConfig(metadata=metadata))
|
||||||
|
|
||||||
|
|
||||||
def get_jwt_settings() -> JWTSettings:
|
def get_jwt_settings() -> JWTSettings:
|
||||||
"""Provide JWT runtime configuration derived from settings."""
|
"""Provide JWT runtime configuration derived from settings."""
|
||||||
|
|
||||||
|
|||||||
22
main.py
22
main.py
@@ -19,9 +19,10 @@ from routes.dashboard import router as dashboard_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
|
||||||
|
from routes.reports import router as reports_router
|
||||||
from routes.scenarios import router as scenarios_router
|
from routes.scenarios import router as scenarios_router
|
||||||
from monitoring import router as monitoring_router
|
from monitoring import router as monitoring_router
|
||||||
from services.bootstrap import bootstrap_admin
|
from services.bootstrap import bootstrap_admin, bootstrap_pricing_settings
|
||||||
|
|
||||||
# Initialize database schema (imports above ensure models are registered)
|
# Initialize database schema (imports above ensure models are registered)
|
||||||
Base.metadata.create_all(bind=engine)
|
Base.metadata.create_all(bind=engine)
|
||||||
@@ -47,9 +48,12 @@ async def health() -> dict[str, str]:
|
|||||||
|
|
||||||
@app.on_event("startup")
|
@app.on_event("startup")
|
||||||
async def ensure_admin_bootstrap() -> None:
|
async def ensure_admin_bootstrap() -> None:
|
||||||
settings = get_settings().admin_bootstrap_settings()
|
settings = get_settings()
|
||||||
|
admin_settings = settings.admin_bootstrap_settings()
|
||||||
|
pricing_metadata = settings.pricing_metadata()
|
||||||
try:
|
try:
|
||||||
role_result, admin_result = bootstrap_admin(settings=settings)
|
role_result, admin_result = bootstrap_admin(settings=admin_settings)
|
||||||
|
pricing_result = bootstrap_pricing_settings(metadata=pricing_metadata)
|
||||||
logger.info(
|
logger.info(
|
||||||
"Admin bootstrap completed: roles=%s created=%s updated=%s rotated=%s assigned=%s",
|
"Admin bootstrap completed: roles=%s created=%s updated=%s rotated=%s assigned=%s",
|
||||||
role_result.ensured,
|
role_result.ensured,
|
||||||
@@ -58,8 +62,17 @@ async def ensure_admin_bootstrap() -> None:
|
|||||||
admin_result.password_rotated,
|
admin_result.password_rotated,
|
||||||
admin_result.roles_granted,
|
admin_result.roles_granted,
|
||||||
)
|
)
|
||||||
|
logger.info(
|
||||||
|
"Pricing settings bootstrap completed: slug=%s created=%s updated_fields=%s impurity_upserts=%s projects_assigned=%s",
|
||||||
|
pricing_result.seed.settings.slug,
|
||||||
|
pricing_result.seed.created,
|
||||||
|
pricing_result.seed.updated_fields,
|
||||||
|
pricing_result.seed.impurity_upserts,
|
||||||
|
pricing_result.projects_assigned,
|
||||||
|
)
|
||||||
except Exception: # pragma: no cover - defensive logging
|
except Exception: # pragma: no cover - defensive logging
|
||||||
logger.exception("Failed to bootstrap administrator account")
|
logger.exception(
|
||||||
|
"Failed to bootstrap administrator or pricing settings")
|
||||||
|
|
||||||
|
|
||||||
app.include_router(dashboard_router)
|
app.include_router(dashboard_router)
|
||||||
@@ -68,6 +81,7 @@ app.include_router(imports_router)
|
|||||||
app.include_router(exports_router)
|
app.include_router(exports_router)
|
||||||
app.include_router(projects_router)
|
app.include_router(projects_router)
|
||||||
app.include_router(scenarios_router)
|
app.include_router(scenarios_router)
|
||||||
|
app.include_router(reports_router)
|
||||||
app.include_router(monitoring_router)
|
app.include_router(monitoring_router)
|
||||||
|
|
||||||
app.mount("/static", StaticFiles(directory="static"), name="static")
|
app.mount("/static", StaticFiles(directory="static"), name="static")
|
||||||
|
|||||||
@@ -11,6 +11,11 @@ from .metadata import (
|
|||||||
StochasticVariable,
|
StochasticVariable,
|
||||||
StochasticVariableDescriptor,
|
StochasticVariableDescriptor,
|
||||||
)
|
)
|
||||||
|
from .pricing_settings import (
|
||||||
|
PricingImpuritySettings,
|
||||||
|
PricingMetalSettings,
|
||||||
|
PricingSettings,
|
||||||
|
)
|
||||||
from .project import MiningOperationType, Project
|
from .project import MiningOperationType, Project
|
||||||
from .scenario import Scenario, ScenarioStatus
|
from .scenario import Scenario, ScenarioStatus
|
||||||
from .simulation_parameter import DistributionType, SimulationParameter
|
from .simulation_parameter import DistributionType, SimulationParameter
|
||||||
@@ -21,6 +26,9 @@ __all__ = [
|
|||||||
"FinancialInput",
|
"FinancialInput",
|
||||||
"MiningOperationType",
|
"MiningOperationType",
|
||||||
"Project",
|
"Project",
|
||||||
|
"PricingSettings",
|
||||||
|
"PricingMetalSettings",
|
||||||
|
"PricingImpuritySettings",
|
||||||
"Scenario",
|
"Scenario",
|
||||||
"ScenarioStatus",
|
"ScenarioStatus",
|
||||||
"DistributionType",
|
"DistributionType",
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ from sqlalchemy.sql import func
|
|||||||
|
|
||||||
from config.database import Base
|
from config.database import Base
|
||||||
from .metadata import CostBucket
|
from .metadata import CostBucket
|
||||||
|
from services.currency import normalise_currency
|
||||||
|
|
||||||
if TYPE_CHECKING: # pragma: no cover
|
if TYPE_CHECKING: # pragma: no cover
|
||||||
from .scenario import Scenario
|
from .scenario import Scenario
|
||||||
@@ -73,16 +74,12 @@ class FinancialInput(Base):
|
|||||||
DateTime(timezone=True), nullable=False, server_default=func.now(), onupdate=func.now()
|
DateTime(timezone=True), nullable=False, server_default=func.now(), onupdate=func.now()
|
||||||
)
|
)
|
||||||
|
|
||||||
scenario: Mapped["Scenario"] = relationship("Scenario", back_populates="financial_inputs")
|
scenario: Mapped["Scenario"] = relationship(
|
||||||
|
"Scenario", back_populates="financial_inputs")
|
||||||
|
|
||||||
@validates("currency")
|
@validates("currency")
|
||||||
def _validate_currency(self, key: str, value: str | None) -> str | None:
|
def _validate_currency(self, key: str, value: str | None) -> str | None:
|
||||||
if value is None:
|
return normalise_currency(value)
|
||||||
return value
|
|
||||||
value = value.upper()
|
|
||||||
if len(value) != 3:
|
|
||||||
raise ValueError("Currency code must be a 3-letter ISO 4217 value")
|
|
||||||
return value
|
|
||||||
|
|
||||||
def __repr__(self) -> str: # pragma: no cover
|
def __repr__(self) -> str: # pragma: no cover
|
||||||
return f"FinancialInput(id={self.id!r}, scenario_id={self.scenario_id!r}, name={self.name!r})"
|
return f"FinancialInput(id={self.id!r}, scenario_id={self.scenario_id!r}, name={self.name!r})"
|
||||||
|
|||||||
176
models/pricing_settings.py
Normal file
176
models/pricing_settings.py
Normal file
@@ -0,0 +1,176 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
"""Database models for persisted pricing configuration settings."""
|
||||||
|
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
|
from sqlalchemy import (
|
||||||
|
JSON,
|
||||||
|
DateTime,
|
||||||
|
ForeignKey,
|
||||||
|
Integer,
|
||||||
|
Numeric,
|
||||||
|
String,
|
||||||
|
Text,
|
||||||
|
UniqueConstraint,
|
||||||
|
)
|
||||||
|
from sqlalchemy.orm import Mapped, mapped_column, relationship, validates
|
||||||
|
from sqlalchemy.sql import func
|
||||||
|
|
||||||
|
from config.database import Base
|
||||||
|
from services.currency import normalise_currency
|
||||||
|
|
||||||
|
if TYPE_CHECKING: # pragma: no cover
|
||||||
|
from .project import Project
|
||||||
|
|
||||||
|
|
||||||
|
class PricingSettings(Base):
|
||||||
|
"""Persisted pricing defaults applied to scenario evaluations."""
|
||||||
|
|
||||||
|
__tablename__ = "pricing_settings"
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
||||||
|
name: Mapped[str] = mapped_column(String(128), nullable=False, unique=True)
|
||||||
|
slug: Mapped[str] = mapped_column(String(64), nullable=False, unique=True)
|
||||||
|
description: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||||
|
default_currency: Mapped[str | None] = mapped_column(
|
||||||
|
String(3), nullable=True)
|
||||||
|
default_payable_pct: Mapped[float] = mapped_column(
|
||||||
|
Numeric(5, 2), nullable=False, default=100.0
|
||||||
|
)
|
||||||
|
moisture_threshold_pct: Mapped[float] = mapped_column(
|
||||||
|
Numeric(5, 2), nullable=False, default=8.0
|
||||||
|
)
|
||||||
|
moisture_penalty_per_pct: Mapped[float] = mapped_column(
|
||||||
|
Numeric(14, 4), nullable=False, default=0.0
|
||||||
|
)
|
||||||
|
metadata_payload: Mapped[dict | None] = mapped_column(
|
||||||
|
"metadata", 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()
|
||||||
|
)
|
||||||
|
|
||||||
|
metal_overrides: Mapped[list["PricingMetalSettings"]] = relationship(
|
||||||
|
"PricingMetalSettings",
|
||||||
|
back_populates="pricing_settings",
|
||||||
|
cascade="all, delete-orphan",
|
||||||
|
passive_deletes=True,
|
||||||
|
)
|
||||||
|
impurity_overrides: Mapped[list["PricingImpuritySettings"]] = relationship(
|
||||||
|
"PricingImpuritySettings",
|
||||||
|
back_populates="pricing_settings",
|
||||||
|
cascade="all, delete-orphan",
|
||||||
|
passive_deletes=True,
|
||||||
|
)
|
||||||
|
projects: Mapped[list["Project"]] = relationship(
|
||||||
|
"Project",
|
||||||
|
back_populates="pricing_settings",
|
||||||
|
cascade="all",
|
||||||
|
)
|
||||||
|
|
||||||
|
@validates("slug")
|
||||||
|
def _normalise_slug(self, key: str, value: str) -> str:
|
||||||
|
return value.strip().lower()
|
||||||
|
|
||||||
|
@validates("default_currency")
|
||||||
|
def _validate_currency(self, key: str, value: str | None) -> str | None:
|
||||||
|
return normalise_currency(value)
|
||||||
|
|
||||||
|
def __repr__(self) -> str: # pragma: no cover
|
||||||
|
return f"PricingSettings(id={self.id!r}, slug={self.slug!r})"
|
||||||
|
|
||||||
|
|
||||||
|
class PricingMetalSettings(Base):
|
||||||
|
"""Contract-specific overrides for a particular metal."""
|
||||||
|
|
||||||
|
__tablename__ = "pricing_metal_settings"
|
||||||
|
__table_args__ = (
|
||||||
|
UniqueConstraint(
|
||||||
|
"pricing_settings_id", "metal_code", name="uq_pricing_metal_settings_code"
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
||||||
|
pricing_settings_id: Mapped[int] = mapped_column(
|
||||||
|
ForeignKey("pricing_settings.id", ondelete="CASCADE"), nullable=False, index=True
|
||||||
|
)
|
||||||
|
metal_code: Mapped[str] = mapped_column(String(32), nullable=False)
|
||||||
|
payable_pct: Mapped[float | None] = mapped_column(
|
||||||
|
Numeric(5, 2), nullable=True)
|
||||||
|
moisture_threshold_pct: Mapped[float | None] = mapped_column(
|
||||||
|
Numeric(5, 2), nullable=True)
|
||||||
|
moisture_penalty_per_pct: Mapped[float | None] = mapped_column(
|
||||||
|
Numeric(14, 4), nullable=True
|
||||||
|
)
|
||||||
|
data: 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()
|
||||||
|
)
|
||||||
|
|
||||||
|
pricing_settings: Mapped["PricingSettings"] = relationship(
|
||||||
|
"PricingSettings", back_populates="metal_overrides"
|
||||||
|
)
|
||||||
|
|
||||||
|
@validates("metal_code")
|
||||||
|
def _normalise_metal_code(self, key: str, value: str) -> str:
|
||||||
|
return value.strip().lower()
|
||||||
|
|
||||||
|
def __repr__(self) -> str: # pragma: no cover
|
||||||
|
return (
|
||||||
|
"PricingMetalSettings(" # noqa: ISC001
|
||||||
|
f"id={self.id!r}, pricing_settings_id={self.pricing_settings_id!r}, "
|
||||||
|
f"metal_code={self.metal_code!r})"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class PricingImpuritySettings(Base):
|
||||||
|
"""Impurity penalty thresholds associated with pricing settings."""
|
||||||
|
|
||||||
|
__tablename__ = "pricing_impurity_settings"
|
||||||
|
__table_args__ = (
|
||||||
|
UniqueConstraint(
|
||||||
|
"pricing_settings_id",
|
||||||
|
"impurity_code",
|
||||||
|
name="uq_pricing_impurity_settings_code",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
||||||
|
pricing_settings_id: Mapped[int] = mapped_column(
|
||||||
|
ForeignKey("pricing_settings.id", ondelete="CASCADE"), nullable=False, index=True
|
||||||
|
)
|
||||||
|
impurity_code: Mapped[str] = mapped_column(String(32), nullable=False)
|
||||||
|
threshold_ppm: Mapped[float] = mapped_column(
|
||||||
|
Numeric(14, 4), nullable=False, default=0.0)
|
||||||
|
penalty_per_ppm: Mapped[float] = mapped_column(
|
||||||
|
Numeric(14, 4), nullable=False, default=0.0)
|
||||||
|
notes: Mapped[str | None] = mapped_column(Text, 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()
|
||||||
|
)
|
||||||
|
|
||||||
|
pricing_settings: Mapped["PricingSettings"] = relationship(
|
||||||
|
"PricingSettings", back_populates="impurity_overrides"
|
||||||
|
)
|
||||||
|
|
||||||
|
@validates("impurity_code")
|
||||||
|
def _normalise_impurity_code(self, key: str, value: str) -> str:
|
||||||
|
return value.strip().upper()
|
||||||
|
|
||||||
|
def __repr__(self) -> str: # pragma: no cover
|
||||||
|
return (
|
||||||
|
"PricingImpuritySettings(" # noqa: ISC001
|
||||||
|
f"id={self.id!r}, pricing_settings_id={self.pricing_settings_id!r}, "
|
||||||
|
f"impurity_code={self.impurity_code!r})"
|
||||||
|
)
|
||||||
@@ -4,7 +4,7 @@ from datetime import datetime
|
|||||||
from enum import Enum
|
from enum import Enum
|
||||||
from typing import TYPE_CHECKING, List
|
from typing import TYPE_CHECKING, List
|
||||||
|
|
||||||
from sqlalchemy import DateTime, Enum as SQLEnum, Integer, String, Text
|
from sqlalchemy import DateTime, Enum as SQLEnum, ForeignKey, Integer, String, Text
|
||||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||||
from sqlalchemy.sql import func
|
from sqlalchemy.sql import func
|
||||||
|
|
||||||
@@ -12,6 +12,7 @@ from config.database import Base
|
|||||||
|
|
||||||
if TYPE_CHECKING: # pragma: no cover
|
if TYPE_CHECKING: # pragma: no cover
|
||||||
from .scenario import Scenario
|
from .scenario import Scenario
|
||||||
|
from .pricing_settings import PricingSettings
|
||||||
|
|
||||||
|
|
||||||
class MiningOperationType(str, Enum):
|
class MiningOperationType(str, Enum):
|
||||||
@@ -38,6 +39,10 @@ class Project(Base):
|
|||||||
SQLEnum(MiningOperationType), nullable=False, default=MiningOperationType.OTHER
|
SQLEnum(MiningOperationType), nullable=False, default=MiningOperationType.OTHER
|
||||||
)
|
)
|
||||||
description: Mapped[str | None] = mapped_column(Text, nullable=True)
|
description: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||||
|
pricing_settings_id: Mapped[int | None] = mapped_column(
|
||||||
|
ForeignKey("pricing_settings.id", ondelete="SET NULL"),
|
||||||
|
nullable=True,
|
||||||
|
)
|
||||||
created_at: Mapped[datetime] = mapped_column(
|
created_at: Mapped[datetime] = mapped_column(
|
||||||
DateTime(timezone=True), nullable=False, server_default=func.now()
|
DateTime(timezone=True), nullable=False, server_default=func.now()
|
||||||
)
|
)
|
||||||
@@ -51,6 +56,10 @@ class Project(Base):
|
|||||||
cascade="all, delete-orphan",
|
cascade="all, delete-orphan",
|
||||||
passive_deletes=True,
|
passive_deletes=True,
|
||||||
)
|
)
|
||||||
|
pricing_settings: Mapped["PricingSettings | None"] = relationship(
|
||||||
|
"PricingSettings",
|
||||||
|
back_populates="projects",
|
||||||
|
)
|
||||||
|
|
||||||
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})"
|
||||||
|
|||||||
@@ -14,10 +14,11 @@ from sqlalchemy import (
|
|||||||
String,
|
String,
|
||||||
Text,
|
Text,
|
||||||
)
|
)
|
||||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
from sqlalchemy.orm import Mapped, mapped_column, relationship, validates
|
||||||
from sqlalchemy.sql import func
|
from sqlalchemy.sql import func
|
||||||
|
|
||||||
from config.database import Base
|
from config.database import Base
|
||||||
|
from services.currency import normalise_currency
|
||||||
from .metadata import ResourceType
|
from .metadata import ResourceType
|
||||||
|
|
||||||
if TYPE_CHECKING: # pragma: no cover
|
if TYPE_CHECKING: # pragma: no cover
|
||||||
@@ -50,7 +51,8 @@ class Scenario(Base):
|
|||||||
)
|
)
|
||||||
start_date: Mapped[date | None] = mapped_column(Date, nullable=True)
|
start_date: Mapped[date | None] = mapped_column(Date, nullable=True)
|
||||||
end_date: Mapped[date | None] = mapped_column(Date, nullable=True)
|
end_date: Mapped[date | None] = mapped_column(Date, nullable=True)
|
||||||
discount_rate: Mapped[float | None] = mapped_column(Numeric(5, 2), nullable=True)
|
discount_rate: Mapped[float | None] = mapped_column(
|
||||||
|
Numeric(5, 2), nullable=True)
|
||||||
currency: Mapped[str | None] = mapped_column(String(3), nullable=True)
|
currency: Mapped[str | None] = mapped_column(String(3), nullable=True)
|
||||||
primary_resource: Mapped[ResourceType | None] = mapped_column(
|
primary_resource: Mapped[ResourceType | None] = mapped_column(
|
||||||
SQLEnum(ResourceType), nullable=True
|
SQLEnum(ResourceType), nullable=True
|
||||||
@@ -62,7 +64,8 @@ class Scenario(Base):
|
|||||||
DateTime(timezone=True), nullable=False, server_default=func.now(), onupdate=func.now()
|
DateTime(timezone=True), nullable=False, server_default=func.now(), onupdate=func.now()
|
||||||
)
|
)
|
||||||
|
|
||||||
project: Mapped["Project"] = relationship("Project", back_populates="scenarios")
|
project: Mapped["Project"] = relationship(
|
||||||
|
"Project", back_populates="scenarios")
|
||||||
financial_inputs: Mapped[List["FinancialInput"]] = relationship(
|
financial_inputs: Mapped[List["FinancialInput"]] = relationship(
|
||||||
"FinancialInput",
|
"FinancialInput",
|
||||||
back_populates="scenario",
|
back_populates="scenario",
|
||||||
@@ -76,5 +79,10 @@ class Scenario(Base):
|
|||||||
passive_deletes=True,
|
passive_deletes=True,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@validates("currency")
|
||||||
|
def _normalise_currency(self, key: str, value: str | None) -> str | None:
|
||||||
|
# Normalise to uppercase ISO-4217; raises when the code is malformed.
|
||||||
|
return normalise_currency(value)
|
||||||
|
|
||||||
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})"
|
||||||
|
|||||||
@@ -121,9 +121,32 @@ async def export_projects(
|
|||||||
) -> Response:
|
) -> Response:
|
||||||
project_repo = _ensure_repository(
|
project_repo = _ensure_repository(
|
||||||
getattr(uow, "projects", None), "Project")
|
getattr(uow, "projects", None), "Project")
|
||||||
|
start = time.perf_counter()
|
||||||
try:
|
try:
|
||||||
start = time.perf_counter()
|
|
||||||
projects = project_repo.filtered_for_export(request.filters)
|
projects = project_repo.filtered_for_export(request.filters)
|
||||||
|
except ValueError as exc:
|
||||||
|
_record_export_audit(
|
||||||
|
uow=uow,
|
||||||
|
dataset="projects",
|
||||||
|
status="failure",
|
||||||
|
export_format=request.format,
|
||||||
|
row_count=0,
|
||||||
|
filename=None,
|
||||||
|
)
|
||||||
|
logger.warning(
|
||||||
|
"export.validation_failed",
|
||||||
|
extra={
|
||||||
|
"event": "export",
|
||||||
|
"dataset": "projects",
|
||||||
|
"status": "validation_failed",
|
||||||
|
"format": request.format.value,
|
||||||
|
"error": str(exc),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
|
||||||
|
detail=str(exc),
|
||||||
|
) from exc
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
_record_export_audit(
|
_record_export_audit(
|
||||||
uow=uow,
|
uow=uow,
|
||||||
@@ -145,7 +168,6 @@ async def export_projects(
|
|||||||
raise exc
|
raise exc
|
||||||
|
|
||||||
filename = f"projects-{_timestamp_suffix()}"
|
filename = f"projects-{_timestamp_suffix()}"
|
||||||
start = time.perf_counter()
|
|
||||||
|
|
||||||
if request.format == ExportFormat.CSV:
|
if request.format == ExportFormat.CSV:
|
||||||
stream = stream_projects_to_csv(projects)
|
stream = stream_projects_to_csv(projects)
|
||||||
@@ -226,10 +248,33 @@ async def export_scenarios(
|
|||||||
) -> Response:
|
) -> Response:
|
||||||
scenario_repo = _ensure_repository(
|
scenario_repo = _ensure_repository(
|
||||||
getattr(uow, "scenarios", None), "Scenario")
|
getattr(uow, "scenarios", None), "Scenario")
|
||||||
|
start = time.perf_counter()
|
||||||
try:
|
try:
|
||||||
start = time.perf_counter()
|
|
||||||
scenarios = scenario_repo.filtered_for_export(
|
scenarios = scenario_repo.filtered_for_export(
|
||||||
request.filters, include_project=True)
|
request.filters, include_project=True)
|
||||||
|
except ValueError as exc:
|
||||||
|
_record_export_audit(
|
||||||
|
uow=uow,
|
||||||
|
dataset="scenarios",
|
||||||
|
status="failure",
|
||||||
|
export_format=request.format,
|
||||||
|
row_count=0,
|
||||||
|
filename=None,
|
||||||
|
)
|
||||||
|
logger.warning(
|
||||||
|
"export.validation_failed",
|
||||||
|
extra={
|
||||||
|
"event": "export",
|
||||||
|
"dataset": "scenarios",
|
||||||
|
"status": "validation_failed",
|
||||||
|
"format": request.format.value,
|
||||||
|
"error": str(exc),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
|
||||||
|
detail=str(exc),
|
||||||
|
) from exc
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
_record_export_audit(
|
_record_export_audit(
|
||||||
uow=uow,
|
uow=uow,
|
||||||
@@ -251,7 +296,6 @@ async def export_scenarios(
|
|||||||
raise exc
|
raise exc
|
||||||
|
|
||||||
filename = f"scenarios-{_timestamp_suffix()}"
|
filename = f"scenarios-{_timestamp_suffix()}"
|
||||||
start = time.perf_counter()
|
|
||||||
|
|
||||||
if request.format == ExportFormat.CSV:
|
if request.format == ExportFormat.CSV:
|
||||||
stream = stream_scenarios_to_csv(scenarios)
|
stream = stream_scenarios_to_csv(scenarios)
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ from fastapi.responses import HTMLResponse, RedirectResponse
|
|||||||
from fastapi.templating import Jinja2Templates
|
from fastapi.templating import Jinja2Templates
|
||||||
|
|
||||||
from dependencies import (
|
from dependencies import (
|
||||||
|
get_pricing_metadata,
|
||||||
get_unit_of_work,
|
get_unit_of_work,
|
||||||
require_any_role,
|
require_any_role,
|
||||||
require_project_resource,
|
require_project_resource,
|
||||||
@@ -15,6 +16,7 @@ from dependencies import (
|
|||||||
from models import MiningOperationType, Project, ScenarioStatus, User
|
from models import MiningOperationType, Project, ScenarioStatus, User
|
||||||
from schemas.project import ProjectCreate, ProjectRead, ProjectUpdate
|
from schemas.project import ProjectCreate, ProjectRead, ProjectUpdate
|
||||||
from services.exceptions import EntityConflictError, EntityNotFoundError
|
from services.exceptions import EntityConflictError, EntityNotFoundError
|
||||||
|
from services.pricing import PricingMetadata
|
||||||
from services.unit_of_work import UnitOfWork
|
from services.unit_of_work import UnitOfWork
|
||||||
|
|
||||||
router = APIRouter(prefix="/projects", tags=["Projects"])
|
router = APIRouter(prefix="/projects", tags=["Projects"])
|
||||||
@@ -54,6 +56,7 @@ def create_project(
|
|||||||
payload: ProjectCreate,
|
payload: ProjectCreate,
|
||||||
_: User = Depends(require_roles(*MANAGE_ROLES)),
|
_: User = Depends(require_roles(*MANAGE_ROLES)),
|
||||||
uow: UnitOfWork = Depends(get_unit_of_work),
|
uow: UnitOfWork = Depends(get_unit_of_work),
|
||||||
|
metadata: PricingMetadata = Depends(get_pricing_metadata),
|
||||||
) -> ProjectRead:
|
) -> ProjectRead:
|
||||||
project = Project(**payload.model_dump())
|
project = Project(**payload.model_dump())
|
||||||
try:
|
try:
|
||||||
@@ -62,6 +65,9 @@ def create_project(
|
|||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=status.HTTP_409_CONFLICT, detail=str(exc)
|
status_code=status.HTTP_409_CONFLICT, detail=str(exc)
|
||||||
) from exc
|
) from exc
|
||||||
|
default_settings = uow.ensure_default_pricing_settings(
|
||||||
|
metadata=metadata).settings
|
||||||
|
uow.set_project_pricing_settings(created, default_settings)
|
||||||
return _to_read_model(created)
|
return _to_read_model(created)
|
||||||
|
|
||||||
|
|
||||||
@@ -122,6 +128,7 @@ def create_project_submit(
|
|||||||
operation_type: str = Form(...),
|
operation_type: str = Form(...),
|
||||||
description: str | None = Form(None),
|
description: str | None = Form(None),
|
||||||
uow: UnitOfWork = Depends(get_unit_of_work),
|
uow: UnitOfWork = Depends(get_unit_of_work),
|
||||||
|
metadata: PricingMetadata = Depends(get_pricing_metadata),
|
||||||
):
|
):
|
||||||
def _normalise(value: str | None) -> str | None:
|
def _normalise(value: str | None) -> str | None:
|
||||||
if value is None:
|
if value is None:
|
||||||
@@ -152,7 +159,7 @@ def create_project_submit(
|
|||||||
description=_normalise(description),
|
description=_normalise(description),
|
||||||
)
|
)
|
||||||
try:
|
try:
|
||||||
_require_project_repo(uow).create(project)
|
created = _require_project_repo(uow).create(project)
|
||||||
except EntityConflictError as exc:
|
except EntityConflictError as exc:
|
||||||
return templates.TemplateResponse(
|
return templates.TemplateResponse(
|
||||||
request,
|
request,
|
||||||
@@ -167,6 +174,10 @@ def create_project_submit(
|
|||||||
status_code=status.HTTP_409_CONFLICT,
|
status_code=status.HTTP_409_CONFLICT,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
default_settings = uow.ensure_default_pricing_settings(
|
||||||
|
metadata=metadata).settings
|
||||||
|
uow.set_project_pricing_settings(created, default_settings)
|
||||||
|
|
||||||
return RedirectResponse(
|
return RedirectResponse(
|
||||||
request.url_for("projects.project_list_page"),
|
request.url_for("projects.project_list_page"),
|
||||||
status_code=status.HTTP_303_SEE_OTHER,
|
status_code=status.HTTP_303_SEE_OTHER,
|
||||||
|
|||||||
512
routes/reports.py
Normal file
512
routes/reports.py
Normal file
@@ -0,0 +1,512 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import date
|
||||||
|
from urllib.parse import urlencode
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException, Query, Request, status
|
||||||
|
from fastapi.encoders import jsonable_encoder
|
||||||
|
from fastapi.responses import HTMLResponse
|
||||||
|
from fastapi.templating import Jinja2Templates
|
||||||
|
|
||||||
|
from dependencies import (
|
||||||
|
get_unit_of_work,
|
||||||
|
require_any_role,
|
||||||
|
require_project_resource,
|
||||||
|
require_roles,
|
||||||
|
require_scenario_resource,
|
||||||
|
)
|
||||||
|
from models import Project, Scenario, User
|
||||||
|
from services.exceptions import EntityNotFoundError, ScenarioValidationError
|
||||||
|
from services.reporting import (
|
||||||
|
DEFAULT_ITERATIONS,
|
||||||
|
IncludeOptions,
|
||||||
|
ReportFilters,
|
||||||
|
ReportingService,
|
||||||
|
parse_include_tokens,
|
||||||
|
validate_percentiles,
|
||||||
|
)
|
||||||
|
from services.unit_of_work import UnitOfWork
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/reports", tags=["Reports"])
|
||||||
|
templates = Jinja2Templates(directory="templates")
|
||||||
|
|
||||||
|
READ_ROLES = ("viewer", "analyst", "project_manager", "admin")
|
||||||
|
MANAGE_ROLES = ("project_manager", "admin")
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/projects/{project_id}", name="reports.project_summary")
|
||||||
|
def project_summary_report(
|
||||||
|
project: Project = Depends(require_project_resource()),
|
||||||
|
_: User = Depends(require_any_role(*READ_ROLES)),
|
||||||
|
uow: UnitOfWork = Depends(get_unit_of_work),
|
||||||
|
include: str | None = Query(
|
||||||
|
None,
|
||||||
|
description="Comma-separated include tokens (distribution,samples,all).",
|
||||||
|
),
|
||||||
|
scenario_ids: list[int] | None = Query(
|
||||||
|
None,
|
||||||
|
alias="scenario_ids",
|
||||||
|
description="Repeatable scenario identifier filter.",
|
||||||
|
),
|
||||||
|
start_date: date | None = Query(
|
||||||
|
None,
|
||||||
|
description="Filter scenarios starting on or after this date.",
|
||||||
|
),
|
||||||
|
end_date: date | None = Query(
|
||||||
|
None,
|
||||||
|
description="Filter scenarios ending on or before this date.",
|
||||||
|
),
|
||||||
|
fmt: str = Query(
|
||||||
|
"json",
|
||||||
|
alias="format",
|
||||||
|
description="Response format (json only for this endpoint).",
|
||||||
|
),
|
||||||
|
iterations: int | None = Query(
|
||||||
|
None,
|
||||||
|
gt=0,
|
||||||
|
description="Override Monte Carlo iteration count when distribution is included.",
|
||||||
|
),
|
||||||
|
percentiles: list[float] | None = Query(
|
||||||
|
None,
|
||||||
|
description="Percentiles (0-100) for Monte Carlo summaries when included.",
|
||||||
|
),
|
||||||
|
) -> dict[str, object]:
|
||||||
|
if fmt.lower() != "json":
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_406_NOT_ACCEPTABLE,
|
||||||
|
detail="Only JSON responses are supported; use the HTML endpoint for templates.",
|
||||||
|
)
|
||||||
|
|
||||||
|
include_options = parse_include_tokens(include)
|
||||||
|
try:
|
||||||
|
percentile_values = validate_percentiles(percentiles)
|
||||||
|
except ValueError as exc:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
||||||
|
detail=str(exc),
|
||||||
|
) from exc
|
||||||
|
|
||||||
|
scenario_filter = ReportFilters(
|
||||||
|
scenario_ids=set(scenario_ids) if scenario_ids else None,
|
||||||
|
start_date=start_date,
|
||||||
|
end_date=end_date,
|
||||||
|
)
|
||||||
|
|
||||||
|
service = ReportingService(uow)
|
||||||
|
report = service.project_summary(
|
||||||
|
project,
|
||||||
|
filters=scenario_filter,
|
||||||
|
include=include_options,
|
||||||
|
iterations=iterations or DEFAULT_ITERATIONS,
|
||||||
|
percentiles=percentile_values,
|
||||||
|
)
|
||||||
|
return jsonable_encoder(report)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get(
|
||||||
|
"/projects/{project_id}/scenarios/compare",
|
||||||
|
name="reports.project_scenario_comparison",
|
||||||
|
)
|
||||||
|
def project_scenario_comparison_report(
|
||||||
|
project: Project = Depends(require_project_resource()),
|
||||||
|
_: User = Depends(require_any_role(*READ_ROLES)),
|
||||||
|
uow: UnitOfWork = Depends(get_unit_of_work),
|
||||||
|
scenario_ids: list[int] = Query(
|
||||||
|
..., alias="scenario_ids", description="Repeatable scenario identifier."),
|
||||||
|
include: str | None = Query(
|
||||||
|
None,
|
||||||
|
description="Comma-separated include tokens (distribution,samples,all).",
|
||||||
|
),
|
||||||
|
fmt: str = Query(
|
||||||
|
"json",
|
||||||
|
alias="format",
|
||||||
|
description="Response format (json only for this endpoint).",
|
||||||
|
),
|
||||||
|
iterations: int | None = Query(
|
||||||
|
None,
|
||||||
|
gt=0,
|
||||||
|
description="Override Monte Carlo iteration count when distribution is included.",
|
||||||
|
),
|
||||||
|
percentiles: list[float] | None = Query(
|
||||||
|
None,
|
||||||
|
description="Percentiles (0-100) for Monte Carlo summaries when included.",
|
||||||
|
),
|
||||||
|
) -> dict[str, object]:
|
||||||
|
unique_ids = list(dict.fromkeys(scenario_ids))
|
||||||
|
if len(unique_ids) < 2:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
||||||
|
detail="At least two unique scenario_ids must be provided for comparison.",
|
||||||
|
)
|
||||||
|
if fmt.lower() != "json":
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_406_NOT_ACCEPTABLE,
|
||||||
|
detail="Only JSON responses are supported; use the HTML endpoint for templates.",
|
||||||
|
)
|
||||||
|
|
||||||
|
include_options = parse_include_tokens(include)
|
||||||
|
try:
|
||||||
|
percentile_values = validate_percentiles(percentiles)
|
||||||
|
except ValueError as exc:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
||||||
|
detail=str(exc),
|
||||||
|
) from exc
|
||||||
|
|
||||||
|
try:
|
||||||
|
scenarios = uow.validate_scenarios_for_comparison(unique_ids)
|
||||||
|
except ScenarioValidationError as exc:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
||||||
|
detail={
|
||||||
|
"code": exc.code,
|
||||||
|
"message": exc.message,
|
||||||
|
"scenario_ids": list(exc.scenario_ids or []),
|
||||||
|
},
|
||||||
|
) from exc
|
||||||
|
except EntityNotFoundError as exc:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
detail=str(exc),
|
||||||
|
) from exc
|
||||||
|
|
||||||
|
if any(scenario.project_id != project.id for scenario in scenarios):
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
detail="One or more scenarios are not associated with this project.",
|
||||||
|
)
|
||||||
|
|
||||||
|
service = ReportingService(uow)
|
||||||
|
report = service.scenario_comparison(
|
||||||
|
project,
|
||||||
|
scenarios,
|
||||||
|
include=include_options,
|
||||||
|
iterations=iterations or DEFAULT_ITERATIONS,
|
||||||
|
percentiles=percentile_values,
|
||||||
|
)
|
||||||
|
return jsonable_encoder(report)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get(
|
||||||
|
"/scenarios/{scenario_id}/distribution",
|
||||||
|
name="reports.scenario_distribution",
|
||||||
|
)
|
||||||
|
def scenario_distribution_report(
|
||||||
|
scenario: Scenario = Depends(require_scenario_resource()),
|
||||||
|
_: User = Depends(require_any_role(*READ_ROLES)),
|
||||||
|
uow: UnitOfWork = Depends(get_unit_of_work),
|
||||||
|
include: str | None = Query(
|
||||||
|
None,
|
||||||
|
description="Comma-separated include tokens (samples,all).",
|
||||||
|
),
|
||||||
|
fmt: str = Query(
|
||||||
|
"json",
|
||||||
|
alias="format",
|
||||||
|
description="Response format (json only for this endpoint).",
|
||||||
|
),
|
||||||
|
iterations: int | None = Query(
|
||||||
|
None,
|
||||||
|
gt=0,
|
||||||
|
description="Override Monte Carlo iteration count (default applies otherwise).",
|
||||||
|
),
|
||||||
|
percentiles: list[float] | None = Query(
|
||||||
|
None,
|
||||||
|
description="Percentiles (0-100) for Monte Carlo summaries.",
|
||||||
|
),
|
||||||
|
) -> dict[str, object]:
|
||||||
|
if fmt.lower() != "json":
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_406_NOT_ACCEPTABLE,
|
||||||
|
detail="Only JSON responses are supported; use the HTML endpoint for templates.",
|
||||||
|
)
|
||||||
|
|
||||||
|
requested = parse_include_tokens(include)
|
||||||
|
include_options = IncludeOptions(
|
||||||
|
distribution=True, samples=requested.samples)
|
||||||
|
|
||||||
|
try:
|
||||||
|
percentile_values = validate_percentiles(percentiles)
|
||||||
|
except ValueError as exc:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
||||||
|
detail=str(exc),
|
||||||
|
) from exc
|
||||||
|
|
||||||
|
service = ReportingService(uow)
|
||||||
|
report = service.scenario_distribution(
|
||||||
|
scenario,
|
||||||
|
include=include_options,
|
||||||
|
iterations=iterations or DEFAULT_ITERATIONS,
|
||||||
|
percentiles=percentile_values,
|
||||||
|
)
|
||||||
|
return jsonable_encoder(report)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get(
|
||||||
|
"/projects/{project_id}/ui",
|
||||||
|
response_class=HTMLResponse,
|
||||||
|
include_in_schema=False,
|
||||||
|
name="reports.project_summary_page",
|
||||||
|
)
|
||||||
|
def project_summary_page(
|
||||||
|
request: Request,
|
||||||
|
project: Project = Depends(require_project_resource()),
|
||||||
|
_: User = Depends(require_any_role(*READ_ROLES)),
|
||||||
|
uow: UnitOfWork = Depends(get_unit_of_work),
|
||||||
|
include: str | None = Query(
|
||||||
|
None,
|
||||||
|
description="Comma-separated include tokens (distribution,samples,all).",
|
||||||
|
),
|
||||||
|
scenario_ids: list[int] | None = Query(
|
||||||
|
None,
|
||||||
|
alias="scenario_ids",
|
||||||
|
description="Repeatable scenario identifier filter.",
|
||||||
|
),
|
||||||
|
start_date: date | None = Query(
|
||||||
|
None,
|
||||||
|
description="Filter scenarios starting on or after this date.",
|
||||||
|
),
|
||||||
|
end_date: date | None = Query(
|
||||||
|
None,
|
||||||
|
description="Filter scenarios ending on or before this date.",
|
||||||
|
),
|
||||||
|
iterations: int | None = Query(
|
||||||
|
None,
|
||||||
|
gt=0,
|
||||||
|
description="Override Monte Carlo iteration count when distribution is included.",
|
||||||
|
),
|
||||||
|
percentiles: list[float] | None = Query(
|
||||||
|
None,
|
||||||
|
description="Percentiles (0-100) for Monte Carlo summaries when included.",
|
||||||
|
),
|
||||||
|
) -> HTMLResponse:
|
||||||
|
include_options = parse_include_tokens(include)
|
||||||
|
try:
|
||||||
|
percentile_values = validate_percentiles(percentiles)
|
||||||
|
except ValueError as exc:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
||||||
|
detail=str(exc),
|
||||||
|
) from exc
|
||||||
|
|
||||||
|
scenario_filter = ReportFilters(
|
||||||
|
scenario_ids=set(scenario_ids) if scenario_ids else None,
|
||||||
|
start_date=start_date,
|
||||||
|
end_date=end_date,
|
||||||
|
)
|
||||||
|
|
||||||
|
service = ReportingService(uow)
|
||||||
|
report = service.project_summary(
|
||||||
|
project,
|
||||||
|
filters=scenario_filter,
|
||||||
|
include=include_options,
|
||||||
|
iterations=iterations or DEFAULT_ITERATIONS,
|
||||||
|
percentiles=percentile_values,
|
||||||
|
)
|
||||||
|
context = {
|
||||||
|
"request": request,
|
||||||
|
"project": report["project"],
|
||||||
|
"scenario_count": report["scenario_count"],
|
||||||
|
"aggregates": report["aggregates"],
|
||||||
|
"scenarios": report["scenarios"],
|
||||||
|
"filters": report["filters"],
|
||||||
|
"include_options": include_options,
|
||||||
|
"iterations": iterations or DEFAULT_ITERATIONS,
|
||||||
|
"percentiles": percentile_values,
|
||||||
|
"title": f"Project Summary · {project.name}",
|
||||||
|
"subtitle": "Aggregated financial and simulation insights across scenarios.",
|
||||||
|
"actions": [
|
||||||
|
{
|
||||||
|
"href": request.url_for(
|
||||||
|
"reports.project_summary",
|
||||||
|
project_id=project.id,
|
||||||
|
),
|
||||||
|
"label": "Download JSON",
|
||||||
|
}
|
||||||
|
],
|
||||||
|
}
|
||||||
|
return templates.TemplateResponse(
|
||||||
|
request,
|
||||||
|
"reports/project_summary.html",
|
||||||
|
context,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get(
|
||||||
|
"/projects/{project_id}/scenarios/compare/ui",
|
||||||
|
response_class=HTMLResponse,
|
||||||
|
include_in_schema=False,
|
||||||
|
name="reports.project_scenario_comparison_page",
|
||||||
|
)
|
||||||
|
def project_scenario_comparison_page(
|
||||||
|
request: Request,
|
||||||
|
project: Project = Depends(require_project_resource()),
|
||||||
|
_: User = Depends(require_any_role(*READ_ROLES)),
|
||||||
|
uow: UnitOfWork = Depends(get_unit_of_work),
|
||||||
|
scenario_ids: list[int] = Query(
|
||||||
|
..., alias="scenario_ids", description="Repeatable scenario identifier."),
|
||||||
|
include: str | None = Query(
|
||||||
|
None,
|
||||||
|
description="Comma-separated include tokens (distribution,samples,all).",
|
||||||
|
),
|
||||||
|
iterations: int | None = Query(
|
||||||
|
None,
|
||||||
|
gt=0,
|
||||||
|
description="Override Monte Carlo iteration count when distribution is included.",
|
||||||
|
),
|
||||||
|
percentiles: list[float] | None = Query(
|
||||||
|
None,
|
||||||
|
description="Percentiles (0-100) for Monte Carlo summaries when included.",
|
||||||
|
),
|
||||||
|
) -> HTMLResponse:
|
||||||
|
unique_ids = list(dict.fromkeys(scenario_ids))
|
||||||
|
if len(unique_ids) < 2:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
||||||
|
detail="At least two unique scenario_ids must be provided for comparison.",
|
||||||
|
)
|
||||||
|
|
||||||
|
include_options = parse_include_tokens(include)
|
||||||
|
try:
|
||||||
|
percentile_values = validate_percentiles(percentiles)
|
||||||
|
except ValueError as exc:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
||||||
|
detail=str(exc),
|
||||||
|
) from exc
|
||||||
|
|
||||||
|
try:
|
||||||
|
scenarios = uow.validate_scenarios_for_comparison(unique_ids)
|
||||||
|
except ScenarioValidationError as exc:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
||||||
|
detail={
|
||||||
|
"code": exc.code,
|
||||||
|
"message": exc.message,
|
||||||
|
"scenario_ids": list(exc.scenario_ids or []),
|
||||||
|
},
|
||||||
|
) from exc
|
||||||
|
except EntityNotFoundError as exc:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
detail=str(exc),
|
||||||
|
) from exc
|
||||||
|
|
||||||
|
if any(scenario.project_id != project.id for scenario in scenarios):
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
detail="One or more scenarios are not associated with this project.",
|
||||||
|
)
|
||||||
|
|
||||||
|
service = ReportingService(uow)
|
||||||
|
report = service.scenario_comparison(
|
||||||
|
project,
|
||||||
|
scenarios,
|
||||||
|
include=include_options,
|
||||||
|
iterations=iterations or DEFAULT_ITERATIONS,
|
||||||
|
percentiles=percentile_values,
|
||||||
|
)
|
||||||
|
comparison_json_url = request.url_for(
|
||||||
|
"reports.project_scenario_comparison",
|
||||||
|
project_id=project.id,
|
||||||
|
)
|
||||||
|
comparison_query = urlencode(
|
||||||
|
[("scenario_ids", str(identifier)) for identifier in unique_ids]
|
||||||
|
)
|
||||||
|
if comparison_query:
|
||||||
|
comparison_json_url = f"{comparison_json_url}?{comparison_query}"
|
||||||
|
|
||||||
|
context = {
|
||||||
|
"request": request,
|
||||||
|
"project": report["project"],
|
||||||
|
"scenarios": report["scenarios"],
|
||||||
|
"comparison": report["comparison"],
|
||||||
|
"include_options": include_options,
|
||||||
|
"iterations": iterations or DEFAULT_ITERATIONS,
|
||||||
|
"percentiles": percentile_values,
|
||||||
|
"title": f"Scenario Comparison · {project.name}",
|
||||||
|
"subtitle": "Evaluate deterministic metrics and Monte Carlo trends side by side.",
|
||||||
|
"actions": [
|
||||||
|
{
|
||||||
|
"href": comparison_json_url,
|
||||||
|
"label": "Download JSON",
|
||||||
|
}
|
||||||
|
],
|
||||||
|
}
|
||||||
|
return templates.TemplateResponse(
|
||||||
|
request,
|
||||||
|
"reports/scenario_comparison.html",
|
||||||
|
context,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get(
|
||||||
|
"/scenarios/{scenario_id}/distribution/ui",
|
||||||
|
response_class=HTMLResponse,
|
||||||
|
include_in_schema=False,
|
||||||
|
name="reports.scenario_distribution_page",
|
||||||
|
)
|
||||||
|
def scenario_distribution_page(
|
||||||
|
request: Request,
|
||||||
|
scenario: Scenario = Depends(require_scenario_resource()),
|
||||||
|
_: User = Depends(require_any_role(*READ_ROLES)),
|
||||||
|
uow: UnitOfWork = Depends(get_unit_of_work),
|
||||||
|
include: str | None = Query(
|
||||||
|
None,
|
||||||
|
description="Comma-separated include tokens (samples,all).",
|
||||||
|
),
|
||||||
|
iterations: int | None = Query(
|
||||||
|
None,
|
||||||
|
gt=0,
|
||||||
|
description="Override Monte Carlo iteration count (default applies otherwise).",
|
||||||
|
),
|
||||||
|
percentiles: list[float] | None = Query(
|
||||||
|
None,
|
||||||
|
description="Percentiles (0-100) for Monte Carlo summaries.",
|
||||||
|
),
|
||||||
|
) -> HTMLResponse:
|
||||||
|
requested = parse_include_tokens(include)
|
||||||
|
include_options = IncludeOptions(
|
||||||
|
distribution=True, samples=requested.samples)
|
||||||
|
|
||||||
|
try:
|
||||||
|
percentile_values = validate_percentiles(percentiles)
|
||||||
|
except ValueError as exc:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
||||||
|
detail=str(exc),
|
||||||
|
) from exc
|
||||||
|
|
||||||
|
service = ReportingService(uow)
|
||||||
|
report = service.scenario_distribution(
|
||||||
|
scenario,
|
||||||
|
include=include_options,
|
||||||
|
iterations=iterations or DEFAULT_ITERATIONS,
|
||||||
|
percentiles=percentile_values,
|
||||||
|
)
|
||||||
|
context = {
|
||||||
|
"request": request,
|
||||||
|
"scenario": report["scenario"],
|
||||||
|
"summary": report["summary"],
|
||||||
|
"metrics": report["metrics"],
|
||||||
|
"monte_carlo": report["monte_carlo"],
|
||||||
|
"include_options": include_options,
|
||||||
|
"iterations": iterations or DEFAULT_ITERATIONS,
|
||||||
|
"percentiles": percentile_values,
|
||||||
|
"title": f"Scenario Distribution · {scenario.name}",
|
||||||
|
"subtitle": "Deterministic and simulated distributions for a single scenario.",
|
||||||
|
"actions": [
|
||||||
|
{
|
||||||
|
"href": request.url_for(
|
||||||
|
"reports.scenario_distribution",
|
||||||
|
scenario_id=scenario.id,
|
||||||
|
),
|
||||||
|
"label": "Download JSON",
|
||||||
|
}
|
||||||
|
],
|
||||||
|
}
|
||||||
|
return templates.TemplateResponse(
|
||||||
|
request,
|
||||||
|
"reports/scenario_distribution.html",
|
||||||
|
context,
|
||||||
|
)
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from datetime import date
|
from datetime import date
|
||||||
|
from types import SimpleNamespace
|
||||||
from typing import List
|
from typing import List
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, Form, HTTPException, Request, status
|
from fastapi import APIRouter, Depends, Form, HTTPException, Request, status
|
||||||
@@ -8,6 +9,7 @@ from fastapi.responses import HTMLResponse, RedirectResponse
|
|||||||
from fastapi.templating import Jinja2Templates
|
from fastapi.templating import Jinja2Templates
|
||||||
|
|
||||||
from dependencies import (
|
from dependencies import (
|
||||||
|
get_pricing_metadata,
|
||||||
get_unit_of_work,
|
get_unit_of_work,
|
||||||
require_any_role,
|
require_any_role,
|
||||||
require_roles,
|
require_roles,
|
||||||
@@ -21,11 +23,13 @@ from schemas.scenario import (
|
|||||||
ScenarioRead,
|
ScenarioRead,
|
||||||
ScenarioUpdate,
|
ScenarioUpdate,
|
||||||
)
|
)
|
||||||
|
from services.currency import CurrencyValidationError, normalise_currency
|
||||||
from services.exceptions import (
|
from services.exceptions import (
|
||||||
EntityConflictError,
|
EntityConflictError,
|
||||||
EntityNotFoundError,
|
EntityNotFoundError,
|
||||||
ScenarioValidationError,
|
ScenarioValidationError,
|
||||||
)
|
)
|
||||||
|
from services.pricing import PricingMetadata
|
||||||
from services.unit_of_work import UnitOfWork
|
from services.unit_of_work import UnitOfWork
|
||||||
|
|
||||||
router = APIRouter(tags=["Scenarios"])
|
router = APIRouter(tags=["Scenarios"])
|
||||||
@@ -143,6 +147,7 @@ def create_scenario_for_project(
|
|||||||
payload: ScenarioCreate,
|
payload: ScenarioCreate,
|
||||||
_: User = Depends(require_roles(*MANAGE_ROLES)),
|
_: User = Depends(require_roles(*MANAGE_ROLES)),
|
||||||
uow: UnitOfWork = Depends(get_unit_of_work),
|
uow: UnitOfWork = Depends(get_unit_of_work),
|
||||||
|
metadata: PricingMetadata = Depends(get_pricing_metadata),
|
||||||
) -> ScenarioRead:
|
) -> ScenarioRead:
|
||||||
project_repo = _require_project_repo(uow)
|
project_repo = _require_project_repo(uow)
|
||||||
scenario_repo = _require_scenario_repo(uow)
|
scenario_repo = _require_scenario_repo(uow)
|
||||||
@@ -152,7 +157,10 @@ def create_scenario_for_project(
|
|||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=status.HTTP_404_NOT_FOUND, detail=str(exc)) from exc
|
status_code=status.HTTP_404_NOT_FOUND, detail=str(exc)) from exc
|
||||||
|
|
||||||
scenario = Scenario(project_id=project_id, **payload.model_dump())
|
scenario_data = payload.model_dump()
|
||||||
|
if not scenario_data.get("currency") and metadata.default_currency:
|
||||||
|
scenario_data["currency"] = metadata.default_currency
|
||||||
|
scenario = Scenario(project_id=project_id, **scenario_data)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
created = scenario_repo.create(scenario)
|
created = scenario_repo.create(scenario)
|
||||||
@@ -219,6 +227,33 @@ def _parse_discount_rate(value: str | None) -> float | None:
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _scenario_form_state(
|
||||||
|
*,
|
||||||
|
project_id: int,
|
||||||
|
name: str,
|
||||||
|
description: str | None,
|
||||||
|
status: ScenarioStatus,
|
||||||
|
start_date: date | None,
|
||||||
|
end_date: date | None,
|
||||||
|
discount_rate: float | None,
|
||||||
|
currency: str | None,
|
||||||
|
primary_resource: ResourceType | None,
|
||||||
|
scenario_id: int | None = None,
|
||||||
|
) -> SimpleNamespace:
|
||||||
|
return SimpleNamespace(
|
||||||
|
id=scenario_id,
|
||||||
|
project_id=project_id,
|
||||||
|
name=name,
|
||||||
|
description=description,
|
||||||
|
status=status,
|
||||||
|
start_date=start_date,
|
||||||
|
end_date=end_date,
|
||||||
|
discount_rate=discount_rate,
|
||||||
|
currency=currency,
|
||||||
|
primary_resource=primary_resource,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@router.get(
|
@router.get(
|
||||||
"/projects/{project_id}/scenarios/new",
|
"/projects/{project_id}/scenarios/new",
|
||||||
response_class=HTMLResponse,
|
response_class=HTMLResponse,
|
||||||
@@ -230,6 +265,7 @@ def create_scenario_form(
|
|||||||
request: Request,
|
request: Request,
|
||||||
_: User = Depends(require_roles(*MANAGE_ROLES)),
|
_: User = Depends(require_roles(*MANAGE_ROLES)),
|
||||||
uow: UnitOfWork = Depends(get_unit_of_work),
|
uow: UnitOfWork = Depends(get_unit_of_work),
|
||||||
|
metadata: PricingMetadata = Depends(get_pricing_metadata),
|
||||||
) -> HTMLResponse:
|
) -> HTMLResponse:
|
||||||
try:
|
try:
|
||||||
project = _require_project_repo(uow).get(project_id)
|
project = _require_project_repo(uow).get(project_id)
|
||||||
@@ -252,6 +288,7 @@ def create_scenario_form(
|
|||||||
"cancel_url": request.url_for(
|
"cancel_url": request.url_for(
|
||||||
"projects.view_project", project_id=project_id
|
"projects.view_project", project_id=project_id
|
||||||
),
|
),
|
||||||
|
"default_currency": metadata.default_currency,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -274,6 +311,7 @@ def create_scenario_submit(
|
|||||||
currency: str | None = Form(None),
|
currency: str | None = Form(None),
|
||||||
primary_resource: str | None = Form(None),
|
primary_resource: str | None = Form(None),
|
||||||
uow: UnitOfWork = Depends(get_unit_of_work),
|
uow: UnitOfWork = Depends(get_unit_of_work),
|
||||||
|
metadata: PricingMetadata = Depends(get_pricing_metadata),
|
||||||
):
|
):
|
||||||
project_repo = _require_project_repo(uow)
|
project_repo = _require_project_repo(uow)
|
||||||
scenario_repo = _require_scenario_repo(uow)
|
scenario_repo = _require_scenario_repo(uow)
|
||||||
@@ -296,17 +334,59 @@ def create_scenario_submit(
|
|||||||
except ValueError:
|
except ValueError:
|
||||||
resource_enum = None
|
resource_enum = None
|
||||||
|
|
||||||
currency_value = _normalise(currency)
|
name_value = name.strip()
|
||||||
currency_value = currency_value.upper() if currency_value else None
|
description_value = _normalise(description)
|
||||||
|
start_date_value = _parse_date(start_date)
|
||||||
|
end_date_value = _parse_date(end_date)
|
||||||
|
discount_rate_value = _parse_discount_rate(discount_rate)
|
||||||
|
currency_input = _normalise(currency)
|
||||||
|
effective_currency = currency_input or metadata.default_currency
|
||||||
|
|
||||||
|
try:
|
||||||
|
currency_value = (
|
||||||
|
normalise_currency(effective_currency)
|
||||||
|
if effective_currency else None
|
||||||
|
)
|
||||||
|
except CurrencyValidationError as exc:
|
||||||
|
form_state = _scenario_form_state(
|
||||||
|
project_id=project_id,
|
||||||
|
name=name_value,
|
||||||
|
description=description_value,
|
||||||
|
status=status_enum,
|
||||||
|
start_date=start_date_value,
|
||||||
|
end_date=end_date_value,
|
||||||
|
discount_rate=discount_rate_value,
|
||||||
|
currency=currency_input or metadata.default_currency,
|
||||||
|
primary_resource=resource_enum,
|
||||||
|
)
|
||||||
|
return templates.TemplateResponse(
|
||||||
|
request,
|
||||||
|
"scenarios/form.html",
|
||||||
|
{
|
||||||
|
"project": project,
|
||||||
|
"scenario": form_state,
|
||||||
|
"scenario_statuses": _scenario_status_choices(),
|
||||||
|
"resource_types": _resource_type_choices(),
|
||||||
|
"form_action": request.url_for(
|
||||||
|
"scenarios.create_scenario_submit", project_id=project_id
|
||||||
|
),
|
||||||
|
"cancel_url": request.url_for(
|
||||||
|
"projects.view_project", project_id=project_id
|
||||||
|
),
|
||||||
|
"error": str(exc),
|
||||||
|
"default_currency": metadata.default_currency,
|
||||||
|
},
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
|
)
|
||||||
|
|
||||||
scenario = Scenario(
|
scenario = Scenario(
|
||||||
project_id=project_id,
|
project_id=project_id,
|
||||||
name=name.strip(),
|
name=name_value,
|
||||||
description=_normalise(description),
|
description=description_value,
|
||||||
status=status_enum,
|
status=status_enum,
|
||||||
start_date=_parse_date(start_date),
|
start_date=start_date_value,
|
||||||
end_date=_parse_date(end_date),
|
end_date=end_date_value,
|
||||||
discount_rate=_parse_discount_rate(discount_rate),
|
discount_rate=discount_rate_value,
|
||||||
currency=currency_value,
|
currency=currency_value,
|
||||||
primary_resource=resource_enum,
|
primary_resource=resource_enum,
|
||||||
)
|
)
|
||||||
@@ -329,6 +409,7 @@ def create_scenario_submit(
|
|||||||
"projects.view_project", project_id=project_id
|
"projects.view_project", project_id=project_id
|
||||||
),
|
),
|
||||||
"error": "Scenario could not be created.",
|
"error": "Scenario could not be created.",
|
||||||
|
"default_currency": metadata.default_currency,
|
||||||
},
|
},
|
||||||
status_code=status.HTTP_409_CONFLICT,
|
status_code=status.HTTP_409_CONFLICT,
|
||||||
)
|
)
|
||||||
@@ -392,6 +473,7 @@ def edit_scenario_form(
|
|||||||
require_scenario_resource(require_manage=True)
|
require_scenario_resource(require_manage=True)
|
||||||
),
|
),
|
||||||
uow: UnitOfWork = Depends(get_unit_of_work),
|
uow: UnitOfWork = Depends(get_unit_of_work),
|
||||||
|
metadata: PricingMetadata = Depends(get_pricing_metadata),
|
||||||
) -> HTMLResponse:
|
) -> HTMLResponse:
|
||||||
project = _require_project_repo(uow).get(scenario.project_id)
|
project = _require_project_repo(uow).get(scenario.project_id)
|
||||||
|
|
||||||
@@ -409,6 +491,7 @@ def edit_scenario_form(
|
|||||||
"cancel_url": request.url_for(
|
"cancel_url": request.url_for(
|
||||||
"scenarios.view_scenario", scenario_id=scenario.id
|
"scenarios.view_scenario", scenario_id=scenario.id
|
||||||
),
|
),
|
||||||
|
"default_currency": metadata.default_currency,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -432,22 +515,17 @@ def edit_scenario_submit(
|
|||||||
currency: str | None = Form(None),
|
currency: str | None = Form(None),
|
||||||
primary_resource: str | None = Form(None),
|
primary_resource: str | None = Form(None),
|
||||||
uow: UnitOfWork = Depends(get_unit_of_work),
|
uow: UnitOfWork = Depends(get_unit_of_work),
|
||||||
|
metadata: PricingMetadata = Depends(get_pricing_metadata),
|
||||||
):
|
):
|
||||||
project = _require_project_repo(uow).get(scenario.project_id)
|
project = _require_project_repo(uow).get(scenario.project_id)
|
||||||
|
|
||||||
scenario.name = name.strip()
|
name_value = name.strip()
|
||||||
scenario.description = _normalise(description)
|
description_value = _normalise(description)
|
||||||
try:
|
try:
|
||||||
scenario.status = ScenarioStatus(status_value)
|
scenario.status = ScenarioStatus(status_value)
|
||||||
except ValueError:
|
except ValueError:
|
||||||
scenario.status = ScenarioStatus.DRAFT
|
scenario.status = ScenarioStatus.DRAFT
|
||||||
scenario.start_date = _parse_date(start_date)
|
status_enum = scenario.status
|
||||||
scenario.end_date = _parse_date(end_date)
|
|
||||||
|
|
||||||
scenario.discount_rate = _parse_discount_rate(discount_rate)
|
|
||||||
|
|
||||||
currency_value = _normalise(currency)
|
|
||||||
scenario.currency = currency_value.upper() if currency_value else None
|
|
||||||
|
|
||||||
resource_enum = None
|
resource_enum = None
|
||||||
if primary_resource:
|
if primary_resource:
|
||||||
@@ -455,6 +533,53 @@ def edit_scenario_submit(
|
|||||||
resource_enum = ResourceType(primary_resource)
|
resource_enum = ResourceType(primary_resource)
|
||||||
except ValueError:
|
except ValueError:
|
||||||
resource_enum = None
|
resource_enum = None
|
||||||
|
|
||||||
|
start_date_value = _parse_date(start_date)
|
||||||
|
end_date_value = _parse_date(end_date)
|
||||||
|
discount_rate_value = _parse_discount_rate(discount_rate)
|
||||||
|
currency_input = _normalise(currency)
|
||||||
|
|
||||||
|
try:
|
||||||
|
currency_value = normalise_currency(currency_input)
|
||||||
|
except CurrencyValidationError as exc:
|
||||||
|
form_state = _scenario_form_state(
|
||||||
|
scenario_id=scenario.id,
|
||||||
|
project_id=scenario.project_id,
|
||||||
|
name=name_value,
|
||||||
|
description=description_value,
|
||||||
|
status=status_enum,
|
||||||
|
start_date=start_date_value,
|
||||||
|
end_date=end_date_value,
|
||||||
|
discount_rate=discount_rate_value,
|
||||||
|
currency=currency_input,
|
||||||
|
primary_resource=resource_enum,
|
||||||
|
)
|
||||||
|
return templates.TemplateResponse(
|
||||||
|
request,
|
||||||
|
"scenarios/form.html",
|
||||||
|
{
|
||||||
|
"project": project,
|
||||||
|
"scenario": form_state,
|
||||||
|
"scenario_statuses": _scenario_status_choices(),
|
||||||
|
"resource_types": _resource_type_choices(),
|
||||||
|
"form_action": request.url_for(
|
||||||
|
"scenarios.edit_scenario_submit", scenario_id=scenario.id
|
||||||
|
),
|
||||||
|
"cancel_url": request.url_for(
|
||||||
|
"scenarios.view_scenario", scenario_id=scenario.id
|
||||||
|
),
|
||||||
|
"error": str(exc),
|
||||||
|
"default_currency": metadata.default_currency,
|
||||||
|
},
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
|
)
|
||||||
|
|
||||||
|
scenario.name = name_value
|
||||||
|
scenario.description = description_value
|
||||||
|
scenario.start_date = start_date_value
|
||||||
|
scenario.end_date = end_date_value
|
||||||
|
scenario.discount_rate = discount_rate_value
|
||||||
|
scenario.currency = currency_value
|
||||||
scenario.primary_resource = resource_enum
|
scenario.primary_resource = resource_enum
|
||||||
|
|
||||||
uow.flush()
|
uow.flush()
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ from typing import Literal
|
|||||||
from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator
|
from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator
|
||||||
|
|
||||||
from models import MiningOperationType, ResourceType, ScenarioStatus
|
from models import MiningOperationType, ResourceType, ScenarioStatus
|
||||||
|
from services.currency import CurrencyValidationError, normalise_currency
|
||||||
|
|
||||||
PreviewStateLiteral = Literal["new", "update", "skip", "error"]
|
PreviewStateLiteral = Literal["new", "update", "skip", "error"]
|
||||||
|
|
||||||
@@ -142,14 +143,13 @@ class ScenarioImportRow(BaseModel):
|
|||||||
@field_validator("currency", mode="before")
|
@field_validator("currency", mode="before")
|
||||||
@classmethod
|
@classmethod
|
||||||
def normalise_currency(cls, value: Any | None) -> str | None:
|
def normalise_currency(cls, value: Any | None) -> str | None:
|
||||||
if value is None:
|
text = _strip_or_none(value)
|
||||||
|
if text is None:
|
||||||
return None
|
return None
|
||||||
text = _normalise_string(value).upper()
|
try:
|
||||||
if not text:
|
return normalise_currency(text)
|
||||||
return None
|
except CurrencyValidationError as exc:
|
||||||
if len(text) != 3:
|
raise ValueError(str(exc)) from exc
|
||||||
raise ValueError("Currency code must be a 3-letter ISO value")
|
|
||||||
return text
|
|
||||||
|
|
||||||
@field_validator("discount_rate", mode="before")
|
@field_validator("discount_rate", mode="before")
|
||||||
@classmethod
|
@classmethod
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ from datetime import date, datetime
|
|||||||
from pydantic import BaseModel, ConfigDict, field_validator, model_validator
|
from pydantic import BaseModel, ConfigDict, field_validator, model_validator
|
||||||
|
|
||||||
from models import ResourceType, ScenarioStatus
|
from models import ResourceType, ScenarioStatus
|
||||||
|
from services.currency import CurrencyValidationError, normalise_currency
|
||||||
|
|
||||||
|
|
||||||
class ScenarioBase(BaseModel):
|
class ScenarioBase(BaseModel):
|
||||||
@@ -23,11 +24,15 @@ class ScenarioBase(BaseModel):
|
|||||||
@classmethod
|
@classmethod
|
||||||
def normalise_currency(cls, value: str | None) -> str | None:
|
def normalise_currency(cls, value: str | None) -> str | None:
|
||||||
if value is None:
|
if value is None:
|
||||||
return value
|
return None
|
||||||
value = value.upper()
|
candidate = value if isinstance(value, str) else str(value)
|
||||||
if len(value) != 3:
|
candidate = candidate.strip()
|
||||||
raise ValueError("Currency code must be a 3-letter ISO value")
|
if not candidate:
|
||||||
return value
|
return None
|
||||||
|
try:
|
||||||
|
return normalise_currency(candidate)
|
||||||
|
except CurrencyValidationError as exc:
|
||||||
|
raise ValueError(str(exc)) from exc
|
||||||
|
|
||||||
|
|
||||||
class ScenarioCreate(ScenarioBase):
|
class ScenarioCreate(ScenarioBase):
|
||||||
@@ -50,11 +55,15 @@ class ScenarioUpdate(BaseModel):
|
|||||||
@classmethod
|
@classmethod
|
||||||
def normalise_currency(cls, value: str | None) -> str | None:
|
def normalise_currency(cls, value: str | None) -> str | None:
|
||||||
if value is None:
|
if value is None:
|
||||||
return value
|
return None
|
||||||
value = value.upper()
|
candidate = value if isinstance(value, str) else str(value)
|
||||||
if len(value) != 3:
|
candidate = candidate.strip()
|
||||||
raise ValueError("Currency code must be a 3-letter ISO value")
|
if not candidate:
|
||||||
return value
|
return None
|
||||||
|
try:
|
||||||
|
return normalise_currency(candidate)
|
||||||
|
except CurrencyValidationError as exc:
|
||||||
|
raise ValueError(str(exc)) from exc
|
||||||
|
|
||||||
|
|
||||||
class ScenarioRead(ScenarioBase):
|
class ScenarioRead(ScenarioBase):
|
||||||
@@ -75,7 +84,8 @@ class ScenarioComparisonRequest(BaseModel):
|
|||||||
def ensure_minimum_ids(self) -> "ScenarioComparisonRequest":
|
def ensure_minimum_ids(self) -> "ScenarioComparisonRequest":
|
||||||
unique_ids: list[int] = list(dict.fromkeys(self.scenario_ids))
|
unique_ids: list[int] = list(dict.fromkeys(self.scenario_ids))
|
||||||
if len(unique_ids) < 2:
|
if len(unique_ids) < 2:
|
||||||
raise ValueError("At least two unique scenario identifiers are required for comparison.")
|
raise ValueError(
|
||||||
|
"At least two unique scenario identifiers are required for comparison.")
|
||||||
self.scenario_ids = unique_ids
|
self.scenario_ids = unique_ids
|
||||||
return self
|
return self
|
||||||
|
|
||||||
|
|||||||
@@ -7,8 +7,15 @@ from typing import Callable, Iterable
|
|||||||
|
|
||||||
from dotenv import load_dotenv
|
from dotenv import load_dotenv
|
||||||
|
|
||||||
|
from config.settings import Settings
|
||||||
from models import Role, User
|
from models import Role, User
|
||||||
from services.repositories import DEFAULT_ROLE_DEFINITIONS, RoleRepository, UserRepository
|
from services.repositories import (
|
||||||
|
DEFAULT_ROLE_DEFINITIONS,
|
||||||
|
PricingSettingsSeedResult,
|
||||||
|
RoleRepository,
|
||||||
|
UserRepository,
|
||||||
|
ensure_default_pricing_settings,
|
||||||
|
)
|
||||||
from services.unit_of_work import UnitOfWork
|
from services.unit_of_work import UnitOfWork
|
||||||
|
|
||||||
|
|
||||||
@@ -45,7 +52,8 @@ def parse_bool(value: str | None) -> bool:
|
|||||||
def normalise_role_list(raw_value: str | None) -> tuple[str, ...]:
|
def normalise_role_list(raw_value: str | None) -> tuple[str, ...]:
|
||||||
if not raw_value:
|
if not raw_value:
|
||||||
return ("admin",)
|
return ("admin",)
|
||||||
parts = [segment.strip() for segment in raw_value.split(",") if segment.strip()]
|
parts = [segment.strip()
|
||||||
|
for segment in raw_value.split(",") if segment.strip()]
|
||||||
if "admin" not in parts:
|
if "admin" not in parts:
|
||||||
parts.insert(0, "admin")
|
parts.insert(0, "admin")
|
||||||
seen: set[str] = set()
|
seen: set[str] = set()
|
||||||
@@ -59,7 +67,8 @@ def normalise_role_list(raw_value: str | None) -> tuple[str, ...]:
|
|||||||
|
|
||||||
def load_config() -> SeedConfig:
|
def load_config() -> SeedConfig:
|
||||||
load_dotenv()
|
load_dotenv()
|
||||||
admin_email = os.getenv("CALMINER_SEED_ADMIN_EMAIL", "admin@calminer.local")
|
admin_email = os.getenv("CALMINER_SEED_ADMIN_EMAIL",
|
||||||
|
"admin@calminer.local")
|
||||||
admin_username = os.getenv("CALMINER_SEED_ADMIN_USERNAME", "admin")
|
admin_username = os.getenv("CALMINER_SEED_ADMIN_USERNAME", "admin")
|
||||||
admin_password = os.getenv("CALMINER_SEED_ADMIN_PASSWORD", "ChangeMe123!")
|
admin_password = os.getenv("CALMINER_SEED_ADMIN_PASSWORD", "ChangeMe123!")
|
||||||
admin_roles = normalise_role_list(os.getenv("CALMINER_SEED_ADMIN_ROLES"))
|
admin_roles = normalise_role_list(os.getenv("CALMINER_SEED_ADMIN_ROLES"))
|
||||||
@@ -140,12 +149,15 @@ def ensure_admin_user(
|
|||||||
for role_name in config.admin_roles:
|
for role_name in config.admin_roles:
|
||||||
role = role_repo.get_by_name(role_name)
|
role = role_repo.get_by_name(role_name)
|
||||||
if role is None:
|
if role is None:
|
||||||
logging.warning("Role '%s' is not defined and will be skipped", role_name)
|
logging.warning(
|
||||||
|
"Role '%s' is not defined and will be skipped", role_name)
|
||||||
continue
|
continue
|
||||||
already_assigned = any(assignment.role_id == role.id for assignment in user.role_assignments)
|
already_assigned = any(assignment.role_id ==
|
||||||
|
role.id for assignment in user.role_assignments)
|
||||||
if already_assigned:
|
if already_assigned:
|
||||||
continue
|
continue
|
||||||
user_repo.assign_role(user_id=user.id, role_id=role.id, granted_by=user.id)
|
user_repo.assign_role(
|
||||||
|
user_id=user.id, role_id=role.id, granted_by=user.id)
|
||||||
roles_granted += 1
|
roles_granted += 1
|
||||||
|
|
||||||
return AdminSeedResult(
|
return AdminSeedResult(
|
||||||
@@ -164,9 +176,33 @@ def seed_initial_data(
|
|||||||
logging.info("Starting initial data seeding")
|
logging.info("Starting initial data seeding")
|
||||||
factory = unit_of_work_factory or UnitOfWork
|
factory = unit_of_work_factory or UnitOfWork
|
||||||
with factory() as uow:
|
with factory() as uow:
|
||||||
assert uow.roles is not None and uow.users is not None
|
assert (
|
||||||
|
uow.roles is not None
|
||||||
|
and uow.users is not None
|
||||||
|
and uow.pricing_settings is not None
|
||||||
|
and uow.projects is not None
|
||||||
|
)
|
||||||
role_result = ensure_default_roles(uow.roles)
|
role_result = ensure_default_roles(uow.roles)
|
||||||
admin_result = ensure_admin_user(uow.users, uow.roles, config)
|
admin_result = ensure_admin_user(uow.users, uow.roles, config)
|
||||||
|
pricing_metadata = uow.get_pricing_metadata()
|
||||||
|
metadata_source = "database"
|
||||||
|
if pricing_metadata is None:
|
||||||
|
pricing_metadata = Settings.from_environment().pricing_metadata()
|
||||||
|
metadata_source = "environment"
|
||||||
|
pricing_result: PricingSettingsSeedResult = ensure_default_pricing_settings(
|
||||||
|
uow.pricing_settings,
|
||||||
|
metadata=pricing_metadata,
|
||||||
|
)
|
||||||
|
|
||||||
|
projects_without_pricing = [
|
||||||
|
project
|
||||||
|
for project in uow.projects.list(with_pricing=True)
|
||||||
|
if project.pricing_settings is None
|
||||||
|
]
|
||||||
|
assigned_projects = 0
|
||||||
|
for project in projects_without_pricing:
|
||||||
|
uow.set_project_pricing_settings(project, pricing_result.settings)
|
||||||
|
assigned_projects += 1
|
||||||
logging.info(
|
logging.info(
|
||||||
"Roles processed: %s total, %s created, %s updated",
|
"Roles processed: %s total, %s created, %s updated",
|
||||||
role_result.total,
|
role_result.total,
|
||||||
@@ -180,4 +216,16 @@ def seed_initial_data(
|
|||||||
admin_result.password_rotated,
|
admin_result.password_rotated,
|
||||||
admin_result.roles_granted,
|
admin_result.roles_granted,
|
||||||
)
|
)
|
||||||
|
logging.info(
|
||||||
|
"Pricing settings ensured (source=%s): slug=%s created=%s updated_fields=%s impurity_upserts=%s",
|
||||||
|
metadata_source,
|
||||||
|
pricing_result.settings.slug,
|
||||||
|
pricing_result.created,
|
||||||
|
pricing_result.updated_fields,
|
||||||
|
pricing_result.impurity_upserts,
|
||||||
|
)
|
||||||
|
logging.info(
|
||||||
|
"Projects updated with default pricing settings: %s",
|
||||||
|
assigned_projects,
|
||||||
|
)
|
||||||
logging.info("Initial data seeding completed successfully")
|
logging.info("Initial data seeding completed successfully")
|
||||||
@@ -1 +1,10 @@
|
|||||||
"""Service layer utilities."""
|
"""Service layer utilities."""
|
||||||
|
|
||||||
|
from .pricing import calculate_pricing, PricingInput, PricingMetadata, PricingResult
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"calculate_pricing",
|
||||||
|
"PricingInput",
|
||||||
|
"PricingMetadata",
|
||||||
|
"PricingResult",
|
||||||
|
]
|
||||||
|
|||||||
@@ -6,7 +6,11 @@ from typing import Callable
|
|||||||
|
|
||||||
from config.settings import AdminBootstrapSettings
|
from config.settings import AdminBootstrapSettings
|
||||||
from models import User
|
from models import User
|
||||||
from services.repositories import ensure_default_roles
|
from services.pricing import PricingMetadata
|
||||||
|
from services.repositories import (
|
||||||
|
PricingSettingsSeedResult,
|
||||||
|
ensure_default_roles,
|
||||||
|
)
|
||||||
from services.unit_of_work import UnitOfWork
|
from services.unit_of_work import UnitOfWork
|
||||||
|
|
||||||
|
|
||||||
@@ -27,6 +31,12 @@ class AdminBootstrapResult:
|
|||||||
roles_granted: int
|
roles_granted: int
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(slots=True)
|
||||||
|
class PricingBootstrapResult:
|
||||||
|
seed: PricingSettingsSeedResult
|
||||||
|
projects_assigned: int
|
||||||
|
|
||||||
|
|
||||||
def bootstrap_admin(
|
def bootstrap_admin(
|
||||||
*,
|
*,
|
||||||
settings: AdminBootstrapSettings,
|
settings: AdminBootstrapSettings,
|
||||||
@@ -127,3 +137,37 @@ def _bootstrap_admin_user(
|
|||||||
password_rotated=password_rotated,
|
password_rotated=password_rotated,
|
||||||
roles_granted=roles_granted,
|
roles_granted=roles_granted,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def bootstrap_pricing_settings(
|
||||||
|
*,
|
||||||
|
metadata: PricingMetadata,
|
||||||
|
unit_of_work_factory: Callable[[], UnitOfWork] = UnitOfWork,
|
||||||
|
default_slug: str = "default",
|
||||||
|
) -> PricingBootstrapResult:
|
||||||
|
"""Ensure baseline pricing settings exist and projects reference them."""
|
||||||
|
|
||||||
|
with unit_of_work_factory() as uow:
|
||||||
|
seed_result = uow.ensure_default_pricing_settings(
|
||||||
|
metadata=metadata,
|
||||||
|
slug=default_slug,
|
||||||
|
)
|
||||||
|
|
||||||
|
assigned = 0
|
||||||
|
if uow.projects:
|
||||||
|
default_settings = seed_result.settings
|
||||||
|
projects = uow.projects.list(with_pricing=True)
|
||||||
|
for project in projects:
|
||||||
|
if project.pricing_settings is None:
|
||||||
|
uow.set_project_pricing_settings(project, default_settings)
|
||||||
|
assigned += 1
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
"Pricing bootstrap result: slug=%s created=%s updated_fields=%s impurity_upserts=%s projects_assigned=%s",
|
||||||
|
seed_result.settings.slug,
|
||||||
|
seed_result.created,
|
||||||
|
seed_result.updated_fields,
|
||||||
|
seed_result.impurity_upserts,
|
||||||
|
assigned,
|
||||||
|
)
|
||||||
|
return PricingBootstrapResult(seed=seed_result, projects_assigned=assigned)
|
||||||
|
|||||||
43
services/currency.py
Normal file
43
services/currency.py
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
"""Utilities for currency normalization within pricing and financial workflows."""
|
||||||
|
|
||||||
|
import re
|
||||||
|
from dataclasses import dataclass
|
||||||
|
|
||||||
|
VALID_CURRENCY_PATTERN = re.compile(r"^[A-Z]{3}$")
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class CurrencyValidationError(ValueError):
|
||||||
|
"""Raised when a currency code fails validation."""
|
||||||
|
|
||||||
|
code: str
|
||||||
|
|
||||||
|
def __str__(self) -> str: # pragma: no cover - dataclass repr not required in tests
|
||||||
|
return f"Invalid currency code: {self.code!r}"
|
||||||
|
|
||||||
|
|
||||||
|
def normalise_currency(code: str | None) -> str | None:
|
||||||
|
"""Normalise currency codes to uppercase ISO-4217 values."""
|
||||||
|
|
||||||
|
if code is None:
|
||||||
|
return None
|
||||||
|
candidate = code.strip().upper()
|
||||||
|
if not VALID_CURRENCY_PATTERN.match(candidate):
|
||||||
|
raise CurrencyValidationError(candidate)
|
||||||
|
return candidate
|
||||||
|
|
||||||
|
|
||||||
|
def require_currency(code: str | None, default: str | None = None) -> str:
|
||||||
|
"""Return normalised currency code, falling back to default when missing."""
|
||||||
|
|
||||||
|
normalised = normalise_currency(code)
|
||||||
|
if normalised is not None:
|
||||||
|
return normalised
|
||||||
|
if default is None:
|
||||||
|
raise CurrencyValidationError("<missing currency>")
|
||||||
|
fallback = normalise_currency(default)
|
||||||
|
if fallback is None:
|
||||||
|
raise CurrencyValidationError("<invalid default currency>")
|
||||||
|
return fallback
|
||||||
@@ -5,6 +5,7 @@ from datetime import date, datetime
|
|||||||
from typing import Iterable
|
from typing import Iterable
|
||||||
|
|
||||||
from models import MiningOperationType, ResourceType, ScenarioStatus
|
from models import MiningOperationType, ResourceType, ScenarioStatus
|
||||||
|
from services.currency import CurrencyValidationError, normalise_currency
|
||||||
|
|
||||||
|
|
||||||
def _normalise_lower_strings(values: Iterable[str]) -> tuple[str, ...]:
|
def _normalise_lower_strings(values: Iterable[str]) -> tuple[str, ...]:
|
||||||
@@ -19,15 +20,22 @@ def _normalise_lower_strings(values: Iterable[str]) -> tuple[str, ...]:
|
|||||||
return tuple(sorted(unique))
|
return tuple(sorted(unique))
|
||||||
|
|
||||||
|
|
||||||
def _normalise_upper_strings(values: Iterable[str]) -> tuple[str, ...]:
|
def _normalise_upper_strings(values: Iterable[str | None]) -> tuple[str, ...]:
|
||||||
unique: set[str] = set()
|
unique: set[str] = set()
|
||||||
for value in values:
|
for value in values:
|
||||||
if not value:
|
if value is None:
|
||||||
continue
|
continue
|
||||||
trimmed = value.strip().upper()
|
candidate = value if isinstance(value, str) else str(value)
|
||||||
if not trimmed:
|
candidate = candidate.strip()
|
||||||
|
if not candidate:
|
||||||
continue
|
continue
|
||||||
unique.add(trimmed)
|
try:
|
||||||
|
normalised = normalise_currency(candidate)
|
||||||
|
except CurrencyValidationError as exc:
|
||||||
|
raise ValueError(str(exc)) from exc
|
||||||
|
if normalised is None:
|
||||||
|
continue
|
||||||
|
unique.add(normalised)
|
||||||
return tuple(sorted(unique))
|
return tuple(sorted(unique))
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
248
services/financial.py
Normal file
248
services/financial.py
Normal file
@@ -0,0 +1,248 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
"""Financial calculation helpers for project evaluation metrics."""
|
||||||
|
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from datetime import date, datetime
|
||||||
|
from math import isclose, isfinite
|
||||||
|
from typing import Iterable, List, Sequence, Tuple
|
||||||
|
|
||||||
|
Number = float
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class CashFlow:
|
||||||
|
"""Represents a dated cash flow in scenario currency."""
|
||||||
|
|
||||||
|
amount: Number
|
||||||
|
period_index: int | None = None
|
||||||
|
date: date | datetime | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class ConvergenceError(RuntimeError):
|
||||||
|
"""Raised when an iterative solver fails to converge."""
|
||||||
|
|
||||||
|
|
||||||
|
class PaybackNotReachedError(RuntimeError):
|
||||||
|
"""Raised when cumulative cash flows never reach a non-negative total."""
|
||||||
|
|
||||||
|
|
||||||
|
def _coerce_date(value: date | datetime) -> date:
|
||||||
|
if isinstance(value, datetime):
|
||||||
|
return value.date()
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
def normalize_cash_flows(
|
||||||
|
cash_flows: Iterable[CashFlow],
|
||||||
|
*,
|
||||||
|
compounds_per_year: int = 1,
|
||||||
|
) -> List[Tuple[Number, float]]:
|
||||||
|
"""Normalise cash flows to ``(amount, periods)`` tuples.
|
||||||
|
|
||||||
|
When explicit ``period_index`` values are provided they take precedence. If
|
||||||
|
only dates are supplied, the first dated cash flow anchors the timeline and
|
||||||
|
subsequent cash flows convert their day offsets into fractional periods
|
||||||
|
based on ``compounds_per_year``. When neither a period index nor a date is
|
||||||
|
present, cash flows are treated as sequential periods in input order.
|
||||||
|
"""
|
||||||
|
|
||||||
|
flows: Sequence[CashFlow] = list(cash_flows)
|
||||||
|
if not flows:
|
||||||
|
return []
|
||||||
|
|
||||||
|
if compounds_per_year <= 0:
|
||||||
|
raise ValueError("compounds_per_year must be a positive integer")
|
||||||
|
|
||||||
|
base_date: date | None = None
|
||||||
|
for flow in flows:
|
||||||
|
if flow.date is not None:
|
||||||
|
base_date = _coerce_date(flow.date)
|
||||||
|
break
|
||||||
|
|
||||||
|
normalised: List[Tuple[Number, float]] = []
|
||||||
|
for idx, flow in enumerate(flows):
|
||||||
|
amount = float(flow.amount)
|
||||||
|
if flow.period_index is not None:
|
||||||
|
periods = float(flow.period_index)
|
||||||
|
elif flow.date is not None and base_date is not None:
|
||||||
|
current_date = _coerce_date(flow.date)
|
||||||
|
delta_days = (current_date - base_date).days
|
||||||
|
period_length_days = 365.0 / float(compounds_per_year)
|
||||||
|
periods = delta_days / period_length_days
|
||||||
|
else:
|
||||||
|
periods = float(idx)
|
||||||
|
normalised.append((amount, periods))
|
||||||
|
|
||||||
|
return normalised
|
||||||
|
|
||||||
|
|
||||||
|
def discount_factor(rate: Number, periods: float, *, compounds_per_year: int = 1) -> float:
|
||||||
|
"""Return the factor used to discount a value ``periods`` steps in the future."""
|
||||||
|
|
||||||
|
if compounds_per_year <= 0:
|
||||||
|
raise ValueError("compounds_per_year must be a positive integer")
|
||||||
|
|
||||||
|
periodic_rate = rate / float(compounds_per_year)
|
||||||
|
return (1.0 + periodic_rate) ** (-periods)
|
||||||
|
|
||||||
|
|
||||||
|
def net_present_value(
|
||||||
|
rate: Number,
|
||||||
|
cash_flows: Iterable[CashFlow],
|
||||||
|
*,
|
||||||
|
residual_value: Number | None = None,
|
||||||
|
residual_periods: float | None = None,
|
||||||
|
compounds_per_year: int = 1,
|
||||||
|
) -> float:
|
||||||
|
"""Calculate Net Present Value for ``cash_flows``.
|
||||||
|
|
||||||
|
``rate`` is a decimal (``0.1`` for 10%). Cash flows are discounted using the
|
||||||
|
given compounding frequency. When ``residual_value`` is provided it is
|
||||||
|
discounted at ``residual_periods`` periods; by default the value occurs one
|
||||||
|
period after the final cash flow.
|
||||||
|
"""
|
||||||
|
|
||||||
|
normalised = normalize_cash_flows(
|
||||||
|
cash_flows,
|
||||||
|
compounds_per_year=compounds_per_year,
|
||||||
|
)
|
||||||
|
|
||||||
|
if not normalised and residual_value is None:
|
||||||
|
return 0.0
|
||||||
|
|
||||||
|
total = 0.0
|
||||||
|
for amount, periods in normalised:
|
||||||
|
factor = discount_factor(
|
||||||
|
rate, periods, compounds_per_year=compounds_per_year)
|
||||||
|
total += amount * factor
|
||||||
|
|
||||||
|
if residual_value is not None:
|
||||||
|
if residual_periods is None:
|
||||||
|
last_period = normalised[-1][1] if normalised else 0.0
|
||||||
|
residual_periods = last_period + 1.0
|
||||||
|
factor = discount_factor(
|
||||||
|
rate, residual_periods, compounds_per_year=compounds_per_year)
|
||||||
|
total += float(residual_value) * factor
|
||||||
|
|
||||||
|
return total
|
||||||
|
|
||||||
|
|
||||||
|
def internal_rate_of_return(
|
||||||
|
cash_flows: Iterable[CashFlow],
|
||||||
|
*,
|
||||||
|
guess: Number = 0.1,
|
||||||
|
max_iterations: int = 100,
|
||||||
|
tolerance: float = 1e-6,
|
||||||
|
compounds_per_year: int = 1,
|
||||||
|
) -> float:
|
||||||
|
"""Return the internal rate of return for ``cash_flows``.
|
||||||
|
|
||||||
|
Uses Newton-Raphson iteration with a bracketed fallback when the derivative
|
||||||
|
becomes unstable. Raises :class:`ConvergenceError` if no root is found.
|
||||||
|
"""
|
||||||
|
|
||||||
|
flows = normalize_cash_flows(
|
||||||
|
cash_flows,
|
||||||
|
compounds_per_year=compounds_per_year,
|
||||||
|
)
|
||||||
|
if not flows:
|
||||||
|
raise ValueError("cash_flows must contain at least one item")
|
||||||
|
|
||||||
|
amounts = [amount for amount, _ in flows]
|
||||||
|
if not any(amount < 0 for amount in amounts) or not any(amount > 0 for amount in amounts):
|
||||||
|
raise ValueError("cash_flows must include both negative and positive values")
|
||||||
|
|
||||||
|
def _npv_with_flows(rate: float) -> float:
|
||||||
|
periodic_rate = rate / float(compounds_per_year)
|
||||||
|
if periodic_rate <= -1.0:
|
||||||
|
return float("inf")
|
||||||
|
total = 0.0
|
||||||
|
for amount, periods in flows:
|
||||||
|
factor = (1.0 + periodic_rate) ** (-periods)
|
||||||
|
total += amount * factor
|
||||||
|
return total
|
||||||
|
|
||||||
|
def _derivative(rate: float) -> float:
|
||||||
|
periodic_rate = rate / float(compounds_per_year)
|
||||||
|
if periodic_rate <= -1.0:
|
||||||
|
return float("inf")
|
||||||
|
derivative = 0.0
|
||||||
|
for amount, periods in flows:
|
||||||
|
factor = (1.0 + periodic_rate) ** (-periods - 1.0)
|
||||||
|
derivative += -amount * periods * factor / float(compounds_per_year)
|
||||||
|
return derivative
|
||||||
|
|
||||||
|
rate = float(guess)
|
||||||
|
for _ in range(max_iterations):
|
||||||
|
value = _npv_with_flows(rate)
|
||||||
|
if isclose(value, 0.0, abs_tol=tolerance):
|
||||||
|
return rate
|
||||||
|
derivative = _derivative(rate)
|
||||||
|
if derivative == 0.0 or not isfinite(derivative):
|
||||||
|
break
|
||||||
|
next_rate = rate - value / derivative
|
||||||
|
if abs(next_rate - rate) < tolerance:
|
||||||
|
return next_rate
|
||||||
|
rate = next_rate
|
||||||
|
|
||||||
|
# Fallback to bracketed bisection between sensible bounds.
|
||||||
|
lower_bound = -0.99 * float(compounds_per_year)
|
||||||
|
upper_bound = 10.0
|
||||||
|
lower_value = _npv_with_flows(lower_bound)
|
||||||
|
upper_value = _npv_with_flows(upper_bound)
|
||||||
|
|
||||||
|
attempts = 0
|
||||||
|
while lower_value * upper_value > 0 and attempts < 12:
|
||||||
|
upper_bound *= 2.0
|
||||||
|
upper_value = _npv_with_flows(upper_bound)
|
||||||
|
attempts += 1
|
||||||
|
|
||||||
|
if lower_value * upper_value > 0:
|
||||||
|
raise ConvergenceError("IRR could not be bracketed within default bounds")
|
||||||
|
|
||||||
|
for _ in range(max_iterations * 2):
|
||||||
|
midpoint = (lower_bound + upper_bound) / 2.0
|
||||||
|
mid_value = _npv_with_flows(midpoint)
|
||||||
|
if isclose(mid_value, 0.0, abs_tol=tolerance):
|
||||||
|
return midpoint
|
||||||
|
if lower_value * mid_value < 0:
|
||||||
|
upper_bound = midpoint
|
||||||
|
upper_value = mid_value
|
||||||
|
else:
|
||||||
|
lower_bound = midpoint
|
||||||
|
lower_value = mid_value
|
||||||
|
raise ConvergenceError("IRR solver failed to converge")
|
||||||
|
|
||||||
|
|
||||||
|
def payback_period(
|
||||||
|
cash_flows: Iterable[CashFlow],
|
||||||
|
*,
|
||||||
|
allow_fractional: bool = True,
|
||||||
|
compounds_per_year: int = 1,
|
||||||
|
) -> float:
|
||||||
|
"""Return the period index where cumulative cash flow becomes non-negative."""
|
||||||
|
|
||||||
|
flows = normalize_cash_flows(
|
||||||
|
cash_flows,
|
||||||
|
compounds_per_year=compounds_per_year,
|
||||||
|
)
|
||||||
|
if not flows:
|
||||||
|
raise ValueError("cash_flows must contain at least one item")
|
||||||
|
|
||||||
|
flows = sorted(flows, key=lambda item: item[1])
|
||||||
|
cumulative = 0.0
|
||||||
|
previous_period = flows[0][1]
|
||||||
|
|
||||||
|
for index, (amount, periods) in enumerate(flows):
|
||||||
|
next_cumulative = cumulative + amount
|
||||||
|
if next_cumulative >= 0.0:
|
||||||
|
if not allow_fractional or isclose(amount, 0.0):
|
||||||
|
return periods
|
||||||
|
prev_period = previous_period if index > 0 else periods
|
||||||
|
fraction = -cumulative / amount
|
||||||
|
return prev_period + fraction * (periods - prev_period)
|
||||||
|
cumulative = next_cumulative
|
||||||
|
previous_period = periods
|
||||||
|
|
||||||
|
raise PaybackNotReachedError("Cumulative cash flow never becomes non-negative")
|
||||||
176
services/pricing.py
Normal file
176
services/pricing.py
Normal file
@@ -0,0 +1,176 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
"""Pricing service implementing commodity revenue calculations.
|
||||||
|
|
||||||
|
This module exposes data models and helpers for computing product pricing
|
||||||
|
according to the formulas outlined in
|
||||||
|
``calminer-docs/specifications/price_calculation.md``. It focuses on the core
|
||||||
|
calculation steps (payable metal, penalties, net revenue) and is intended to be
|
||||||
|
composed within broader scenario evaluation workflows.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from typing import Mapping
|
||||||
|
|
||||||
|
from pydantic import BaseModel, Field, PositiveFloat, field_validator
|
||||||
|
from services.currency import require_currency
|
||||||
|
|
||||||
|
|
||||||
|
class PricingInput(BaseModel):
|
||||||
|
"""Normalized inputs for pricing calculations."""
|
||||||
|
|
||||||
|
metal: str = Field(..., min_length=1)
|
||||||
|
ore_tonnage: PositiveFloat = Field(
|
||||||
|
..., description="Total ore mass processed (metric tonnes)")
|
||||||
|
head_grade_pct: PositiveFloat = Field(..., gt=0,
|
||||||
|
le=100, description="Head grade as percent")
|
||||||
|
recovery_pct: PositiveFloat = Field(..., gt=0,
|
||||||
|
le=100, description="Recovery rate percent")
|
||||||
|
payable_pct: float | None = Field(
|
||||||
|
None, gt=0, le=100, description="Contractual payable percentage")
|
||||||
|
reference_price: PositiveFloat = Field(
|
||||||
|
..., description="Reference price in base currency per unit")
|
||||||
|
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 = Field(None)
|
||||||
|
impurity_ppm: Mapping[str, float] = Field(default_factory=dict)
|
||||||
|
impurity_thresholds: Mapping[str, float] = Field(default_factory=dict)
|
||||||
|
impurity_penalty_per_ppm: Mapping[str, float] = Field(default_factory=dict)
|
||||||
|
premiums: float = Field(0)
|
||||||
|
fx_rate: PositiveFloat = Field(
|
||||||
|
1, description="Multiplier to convert to scenario currency")
|
||||||
|
currency_code: str | None = Field(
|
||||||
|
None, description="Optional explicit currency override")
|
||||||
|
|
||||||
|
@field_validator("impurity_ppm", mode="before")
|
||||||
|
@classmethod
|
||||||
|
def _validate_impurity_mapping(cls, value):
|
||||||
|
if isinstance(value, Mapping):
|
||||||
|
return {k: float(v) for k, v in value.items()}
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
class PricingResult(BaseModel):
|
||||||
|
"""Structured output summarising pricing computation results."""
|
||||||
|
|
||||||
|
metal: str
|
||||||
|
ore_tonnage: float
|
||||||
|
head_grade_pct: float
|
||||||
|
recovery_pct: float
|
||||||
|
payable_metal_tonnes: float
|
||||||
|
reference_price: float
|
||||||
|
gross_revenue: float
|
||||||
|
moisture_penalty: float
|
||||||
|
impurity_penalty: float
|
||||||
|
treatment_smelt_charges: float
|
||||||
|
premiums: float
|
||||||
|
net_revenue: float
|
||||||
|
currency: str | None
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class PricingMetadata:
|
||||||
|
"""Metadata defaults applied when explicit inputs are omitted."""
|
||||||
|
|
||||||
|
default_payable_pct: float = 100.0
|
||||||
|
default_currency: str | None = "USD"
|
||||||
|
moisture_threshold_pct: float = 8.0
|
||||||
|
moisture_penalty_per_pct: float = 0.0
|
||||||
|
impurity_thresholds: Mapping[str, float] = field(default_factory=dict)
|
||||||
|
impurity_penalty_per_ppm: Mapping[str, float] = field(default_factory=dict)
|
||||||
|
|
||||||
|
|
||||||
|
def calculate_pricing(
|
||||||
|
pricing_input: PricingInput,
|
||||||
|
*,
|
||||||
|
metadata: PricingMetadata | None = None,
|
||||||
|
currency: str | None = None,
|
||||||
|
) -> PricingResult:
|
||||||
|
"""Calculate pricing metrics for the provided commodity input.
|
||||||
|
|
||||||
|
Parameters
|
||||||
|
----------
|
||||||
|
pricing_input:
|
||||||
|
Normalised input data including ore tonnage, grades, charges, and
|
||||||
|
optional penalties.
|
||||||
|
metadata:
|
||||||
|
Optional default metadata applied when specific values are omitted from
|
||||||
|
``pricing_input``.
|
||||||
|
currency:
|
||||||
|
Optional override for the output currency label. Falls back to
|
||||||
|
``metadata.default_currency`` when not provided.
|
||||||
|
"""
|
||||||
|
|
||||||
|
applied_metadata = metadata or PricingMetadata()
|
||||||
|
|
||||||
|
payable_pct = (
|
||||||
|
pricing_input.payable_pct
|
||||||
|
if pricing_input.payable_pct is not None
|
||||||
|
else applied_metadata.default_payable_pct
|
||||||
|
)
|
||||||
|
moisture_threshold = (
|
||||||
|
pricing_input.moisture_threshold_pct
|
||||||
|
if pricing_input.moisture_threshold_pct is not None
|
||||||
|
else applied_metadata.moisture_threshold_pct
|
||||||
|
)
|
||||||
|
moisture_penalty_factor = (
|
||||||
|
pricing_input.moisture_penalty_per_pct
|
||||||
|
if pricing_input.moisture_penalty_per_pct is not None
|
||||||
|
else applied_metadata.moisture_penalty_per_pct
|
||||||
|
)
|
||||||
|
|
||||||
|
impurity_thresholds = {
|
||||||
|
**applied_metadata.impurity_thresholds,
|
||||||
|
**pricing_input.impurity_thresholds,
|
||||||
|
}
|
||||||
|
impurity_penalty_factors = {
|
||||||
|
**applied_metadata.impurity_penalty_per_ppm,
|
||||||
|
**pricing_input.impurity_penalty_per_ppm,
|
||||||
|
}
|
||||||
|
|
||||||
|
q_metal = pricing_input.ore_tonnage * (pricing_input.head_grade_pct / 100.0) * (
|
||||||
|
pricing_input.recovery_pct / 100.0
|
||||||
|
)
|
||||||
|
payable_metal = q_metal * (payable_pct / 100.0)
|
||||||
|
|
||||||
|
gross_revenue_ref = payable_metal * pricing_input.reference_price
|
||||||
|
charges = pricing_input.treatment_charge + pricing_input.smelting_charge
|
||||||
|
|
||||||
|
moisture_excess = max(0.0, pricing_input.moisture_pct - moisture_threshold)
|
||||||
|
moisture_penalty = moisture_excess * moisture_penalty_factor
|
||||||
|
|
||||||
|
impurity_penalty_total = 0.0
|
||||||
|
for impurity, value in pricing_input.impurity_ppm.items():
|
||||||
|
threshold = impurity_thresholds.get(impurity, 0.0)
|
||||||
|
penalty_factor = impurity_penalty_factors.get(impurity, 0.0)
|
||||||
|
impurity_penalty_total += max(0.0, value - threshold) * penalty_factor
|
||||||
|
|
||||||
|
net_revenue_ref = (
|
||||||
|
gross_revenue_ref - charges - moisture_penalty - impurity_penalty_total
|
||||||
|
)
|
||||||
|
net_revenue_ref += pricing_input.premiums
|
||||||
|
|
||||||
|
net_revenue = net_revenue_ref * pricing_input.fx_rate
|
||||||
|
|
||||||
|
currency_code = require_currency(
|
||||||
|
currency or pricing_input.currency_code,
|
||||||
|
default=applied_metadata.default_currency,
|
||||||
|
)
|
||||||
|
|
||||||
|
return PricingResult(
|
||||||
|
metal=pricing_input.metal,
|
||||||
|
ore_tonnage=pricing_input.ore_tonnage,
|
||||||
|
head_grade_pct=pricing_input.head_grade_pct,
|
||||||
|
recovery_pct=pricing_input.recovery_pct,
|
||||||
|
payable_metal_tonnes=payable_metal,
|
||||||
|
reference_price=pricing_input.reference_price,
|
||||||
|
gross_revenue=gross_revenue_ref,
|
||||||
|
moisture_penalty=moisture_penalty,
|
||||||
|
impurity_penalty=impurity_penalty_total,
|
||||||
|
treatment_smelt_charges=charges,
|
||||||
|
premiums=pricing_input.premiums,
|
||||||
|
net_revenue=net_revenue,
|
||||||
|
currency=currency_code,
|
||||||
|
)
|
||||||
676
services/reporting.py
Normal file
676
services/reporting.py
Normal file
@@ -0,0 +1,676 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
"""Reporting service layer aggregating deterministic and simulation metrics."""
|
||||||
|
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from datetime import date
|
||||||
|
import math
|
||||||
|
from typing import Iterable, Mapping, Sequence
|
||||||
|
|
||||||
|
from models import FinancialCategory, Project, Scenario
|
||||||
|
from services.financial import (
|
||||||
|
CashFlow,
|
||||||
|
ConvergenceError,
|
||||||
|
PaybackNotReachedError,
|
||||||
|
internal_rate_of_return,
|
||||||
|
net_present_value,
|
||||||
|
payback_period,
|
||||||
|
)
|
||||||
|
from services.simulation import (
|
||||||
|
CashFlowSpec,
|
||||||
|
SimulationConfig,
|
||||||
|
SimulationMetric,
|
||||||
|
SimulationResult,
|
||||||
|
run_monte_carlo,
|
||||||
|
)
|
||||||
|
from services.unit_of_work import UnitOfWork
|
||||||
|
|
||||||
|
DEFAULT_DISCOUNT_RATE = 0.1
|
||||||
|
DEFAULT_ITERATIONS = 500
|
||||||
|
DEFAULT_PERCENTILES: tuple[float, float, float] = (5.0, 50.0, 95.0)
|
||||||
|
|
||||||
|
_COST_CATEGORY_SIGNS: Mapping[FinancialCategory, float] = {
|
||||||
|
FinancialCategory.REVENUE: 1.0,
|
||||||
|
FinancialCategory.CAPITAL_EXPENDITURE: -1.0,
|
||||||
|
FinancialCategory.OPERATING_EXPENDITURE: -1.0,
|
||||||
|
FinancialCategory.CONTINGENCY: -1.0,
|
||||||
|
FinancialCategory.OTHER: -1.0,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class IncludeOptions:
|
||||||
|
"""Flags controlling optional sections in report payloads."""
|
||||||
|
|
||||||
|
distribution: bool = False
|
||||||
|
samples: bool = False
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(slots=True)
|
||||||
|
class ReportFilters:
|
||||||
|
"""Filter parameters applied when selecting scenarios for a report."""
|
||||||
|
|
||||||
|
scenario_ids: set[int] | None = None
|
||||||
|
start_date: date | None = None
|
||||||
|
end_date: date | None = None
|
||||||
|
|
||||||
|
def matches(self, scenario: Scenario) -> bool:
|
||||||
|
if self.scenario_ids is not None and scenario.id not in self.scenario_ids:
|
||||||
|
return False
|
||||||
|
if self.start_date and scenario.start_date and scenario.start_date < self.start_date:
|
||||||
|
return False
|
||||||
|
if self.end_date and scenario.end_date and scenario.end_date > self.end_date:
|
||||||
|
return False
|
||||||
|
return True
|
||||||
|
|
||||||
|
def to_dict(self) -> dict[str, object]:
|
||||||
|
payload: dict[str, object] = {}
|
||||||
|
if self.scenario_ids is not None:
|
||||||
|
payload["scenario_ids"] = sorted(self.scenario_ids)
|
||||||
|
if self.start_date is not None:
|
||||||
|
payload["start_date"] = self.start_date
|
||||||
|
if self.end_date is not None:
|
||||||
|
payload["end_date"] = self.end_date
|
||||||
|
return payload
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(slots=True)
|
||||||
|
class ScenarioFinancialTotals:
|
||||||
|
currency: str | None
|
||||||
|
inflows: float
|
||||||
|
outflows: float
|
||||||
|
net: float
|
||||||
|
by_category: dict[str, float]
|
||||||
|
|
||||||
|
def to_dict(self) -> dict[str, object]:
|
||||||
|
return {
|
||||||
|
"currency": self.currency,
|
||||||
|
"inflows": _round_optional(self.inflows),
|
||||||
|
"outflows": _round_optional(self.outflows),
|
||||||
|
"net": _round_optional(self.net),
|
||||||
|
"by_category": {
|
||||||
|
key: _round_optional(value) for key, value in sorted(self.by_category.items())
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(slots=True)
|
||||||
|
class ScenarioDeterministicMetrics:
|
||||||
|
currency: str | None
|
||||||
|
discount_rate: float
|
||||||
|
compounds_per_year: int
|
||||||
|
npv: float | None
|
||||||
|
irr: float | None
|
||||||
|
payback_period: float | None
|
||||||
|
notes: list[str] = field(default_factory=list)
|
||||||
|
|
||||||
|
def to_dict(self) -> dict[str, object]:
|
||||||
|
return {
|
||||||
|
"currency": self.currency,
|
||||||
|
"discount_rate": _round_optional(self.discount_rate, digits=4),
|
||||||
|
"compounds_per_year": self.compounds_per_year,
|
||||||
|
"npv": _round_optional(self.npv),
|
||||||
|
"irr": _round_optional(self.irr, digits=6),
|
||||||
|
"payback_period": _round_optional(self.payback_period, digits=4),
|
||||||
|
"notes": self.notes,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(slots=True)
|
||||||
|
class ScenarioMonteCarloResult:
|
||||||
|
available: bool
|
||||||
|
notes: list[str] = field(default_factory=list)
|
||||||
|
result: SimulationResult | None = None
|
||||||
|
include_samples: bool = False
|
||||||
|
|
||||||
|
def to_dict(self) -> dict[str, object]:
|
||||||
|
if not self.available or self.result is None:
|
||||||
|
return {
|
||||||
|
"available": False,
|
||||||
|
"notes": self.notes,
|
||||||
|
}
|
||||||
|
|
||||||
|
metrics: dict[str, dict[str, object]] = {}
|
||||||
|
for metric, summary in self.result.summaries.items():
|
||||||
|
metrics[metric.value] = {
|
||||||
|
"mean": _round_optional(summary.mean),
|
||||||
|
"std_dev": _round_optional(summary.std_dev),
|
||||||
|
"minimum": _round_optional(summary.minimum),
|
||||||
|
"maximum": _round_optional(summary.maximum),
|
||||||
|
"percentiles": {
|
||||||
|
f"{percentile:g}": _round_optional(value)
|
||||||
|
for percentile, value in sorted(summary.percentiles.items())
|
||||||
|
},
|
||||||
|
"sample_size": summary.sample_size,
|
||||||
|
"failed_runs": summary.failed_runs,
|
||||||
|
}
|
||||||
|
|
||||||
|
samples_payload: dict[str, list[float | None]] | None = None
|
||||||
|
if self.include_samples and self.result.samples:
|
||||||
|
samples_payload = {}
|
||||||
|
for metric, samples in self.result.samples.items():
|
||||||
|
samples_payload[metric.value] = [
|
||||||
|
_sanitize_float(sample) for sample in samples.tolist()
|
||||||
|
]
|
||||||
|
|
||||||
|
payload: dict[str, object] = {
|
||||||
|
"available": True,
|
||||||
|
"iterations": self.result.iterations,
|
||||||
|
"metrics": metrics,
|
||||||
|
"notes": self.notes,
|
||||||
|
}
|
||||||
|
if samples_payload:
|
||||||
|
payload["samples"] = samples_payload
|
||||||
|
return payload
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(slots=True)
|
||||||
|
class ScenarioReport:
|
||||||
|
scenario: Scenario
|
||||||
|
totals: ScenarioFinancialTotals
|
||||||
|
deterministic: ScenarioDeterministicMetrics
|
||||||
|
monte_carlo: ScenarioMonteCarloResult | None
|
||||||
|
|
||||||
|
def to_dict(self) -> dict[str, object]:
|
||||||
|
scenario_info = {
|
||||||
|
"id": self.scenario.id,
|
||||||
|
"project_id": self.scenario.project_id,
|
||||||
|
"name": self.scenario.name,
|
||||||
|
"description": self.scenario.description,
|
||||||
|
"status": self.scenario.status.value,
|
||||||
|
"start_date": self.scenario.start_date,
|
||||||
|
"end_date": self.scenario.end_date,
|
||||||
|
"currency": self.scenario.currency,
|
||||||
|
"primary_resource": self.scenario.primary_resource.value
|
||||||
|
if self.scenario.primary_resource
|
||||||
|
else None,
|
||||||
|
"discount_rate": _round_optional(self.deterministic.discount_rate, digits=4),
|
||||||
|
"created_at": self.scenario.created_at,
|
||||||
|
"updated_at": self.scenario.updated_at,
|
||||||
|
"simulation_parameter_count": len(self.scenario.simulation_parameters or []),
|
||||||
|
}
|
||||||
|
payload: dict[str, object] = {
|
||||||
|
"scenario": scenario_info,
|
||||||
|
"financials": self.totals.to_dict(),
|
||||||
|
"metrics": self.deterministic.to_dict(),
|
||||||
|
}
|
||||||
|
if self.monte_carlo is not None:
|
||||||
|
payload["monte_carlo"] = self.monte_carlo.to_dict()
|
||||||
|
return payload
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(slots=True)
|
||||||
|
class AggregatedMetric:
|
||||||
|
average: float | None
|
||||||
|
minimum: float | None
|
||||||
|
maximum: float | None
|
||||||
|
|
||||||
|
def to_dict(self) -> dict[str, object]:
|
||||||
|
return {
|
||||||
|
"average": _round_optional(self.average),
|
||||||
|
"minimum": _round_optional(self.minimum),
|
||||||
|
"maximum": _round_optional(self.maximum),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(slots=True)
|
||||||
|
class ProjectAggregates:
|
||||||
|
total_inflows: float
|
||||||
|
total_outflows: float
|
||||||
|
total_net: float
|
||||||
|
deterministic_metrics: dict[str, AggregatedMetric]
|
||||||
|
|
||||||
|
def to_dict(self) -> dict[str, object]:
|
||||||
|
return {
|
||||||
|
"financials": {
|
||||||
|
"total_inflows": _round_optional(self.total_inflows),
|
||||||
|
"total_outflows": _round_optional(self.total_outflows),
|
||||||
|
"total_net": _round_optional(self.total_net),
|
||||||
|
},
|
||||||
|
"deterministic_metrics": {
|
||||||
|
metric: data.to_dict()
|
||||||
|
for metric, data in sorted(self.deterministic_metrics.items())
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(slots=True)
|
||||||
|
class MetricComparison:
|
||||||
|
metric: str
|
||||||
|
direction: str
|
||||||
|
best: tuple[int, str, float] | None
|
||||||
|
worst: tuple[int, str, float] | None
|
||||||
|
average: float | None
|
||||||
|
|
||||||
|
def to_dict(self) -> dict[str, object]:
|
||||||
|
return {
|
||||||
|
"metric": self.metric,
|
||||||
|
"direction": self.direction,
|
||||||
|
"best": _comparison_entry(self.best),
|
||||||
|
"worst": _comparison_entry(self.worst),
|
||||||
|
"average": _round_optional(self.average),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def parse_include_tokens(raw: str | None) -> IncludeOptions:
|
||||||
|
tokens: set[str] = set()
|
||||||
|
if raw:
|
||||||
|
for part in raw.split(","):
|
||||||
|
token = part.strip().lower()
|
||||||
|
if token:
|
||||||
|
tokens.add(token)
|
||||||
|
if "all" in tokens:
|
||||||
|
return IncludeOptions(distribution=True, samples=True)
|
||||||
|
return IncludeOptions(
|
||||||
|
distribution=bool({"distribution", "monte_carlo", "mc"} & tokens),
|
||||||
|
samples="samples" in tokens,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def validate_percentiles(values: Sequence[float] | None) -> tuple[float, ...]:
|
||||||
|
if not values:
|
||||||
|
return DEFAULT_PERCENTILES
|
||||||
|
seen: set[float] = set()
|
||||||
|
cleaned: list[float] = []
|
||||||
|
for value in values:
|
||||||
|
percentile = float(value)
|
||||||
|
if percentile < 0.0 or percentile > 100.0:
|
||||||
|
raise ValueError("Percentiles must be between 0 and 100.")
|
||||||
|
if percentile not in seen:
|
||||||
|
seen.add(percentile)
|
||||||
|
cleaned.append(percentile)
|
||||||
|
if not cleaned:
|
||||||
|
return DEFAULT_PERCENTILES
|
||||||
|
return tuple(cleaned)
|
||||||
|
|
||||||
|
|
||||||
|
class ReportingService:
|
||||||
|
"""Coordinates project and scenario reporting aggregation."""
|
||||||
|
|
||||||
|
def __init__(self, uow: UnitOfWork) -> None:
|
||||||
|
self._uow = uow
|
||||||
|
|
||||||
|
def project_summary(
|
||||||
|
self,
|
||||||
|
project: Project,
|
||||||
|
*,
|
||||||
|
filters: ReportFilters,
|
||||||
|
include: IncludeOptions,
|
||||||
|
iterations: int,
|
||||||
|
percentiles: tuple[float, ...],
|
||||||
|
) -> dict[str, object]:
|
||||||
|
scenarios = self._load_scenarios(project.id, filters)
|
||||||
|
reports = [
|
||||||
|
self._build_scenario_report(
|
||||||
|
scenario,
|
||||||
|
include_distribution=include.distribution,
|
||||||
|
include_samples=include.samples,
|
||||||
|
iterations=iterations,
|
||||||
|
percentiles=percentiles,
|
||||||
|
)
|
||||||
|
for scenario in scenarios
|
||||||
|
]
|
||||||
|
aggregates = self._aggregate_project(reports)
|
||||||
|
return {
|
||||||
|
"project": _project_payload(project),
|
||||||
|
"scenario_count": len(reports),
|
||||||
|
"filters": filters.to_dict(),
|
||||||
|
"aggregates": aggregates.to_dict(),
|
||||||
|
"scenarios": [report.to_dict() for report in reports],
|
||||||
|
}
|
||||||
|
|
||||||
|
def scenario_comparison(
|
||||||
|
self,
|
||||||
|
project: Project,
|
||||||
|
scenarios: Sequence[Scenario],
|
||||||
|
*,
|
||||||
|
include: IncludeOptions,
|
||||||
|
iterations: int,
|
||||||
|
percentiles: tuple[float, ...],
|
||||||
|
) -> dict[str, object]:
|
||||||
|
reports = [
|
||||||
|
self._build_scenario_report(
|
||||||
|
self._reload_scenario(scenario.id),
|
||||||
|
include_distribution=include.distribution,
|
||||||
|
include_samples=include.samples,
|
||||||
|
iterations=iterations,
|
||||||
|
percentiles=percentiles,
|
||||||
|
)
|
||||||
|
for scenario in scenarios
|
||||||
|
]
|
||||||
|
comparison = {
|
||||||
|
metric: data.to_dict()
|
||||||
|
for metric, data in self._build_comparisons(reports).items()
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
"project": _project_payload(project),
|
||||||
|
"scenarios": [report.to_dict() for report in reports],
|
||||||
|
"comparison": comparison,
|
||||||
|
}
|
||||||
|
|
||||||
|
def scenario_distribution(
|
||||||
|
self,
|
||||||
|
scenario: Scenario,
|
||||||
|
*,
|
||||||
|
include: IncludeOptions,
|
||||||
|
iterations: int,
|
||||||
|
percentiles: tuple[float, ...],
|
||||||
|
) -> dict[str, object]:
|
||||||
|
report = self._build_scenario_report(
|
||||||
|
self._reload_scenario(scenario.id),
|
||||||
|
include_distribution=True,
|
||||||
|
include_samples=include.samples,
|
||||||
|
iterations=iterations,
|
||||||
|
percentiles=percentiles,
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
"scenario": report.to_dict()["scenario"],
|
||||||
|
"summary": report.totals.to_dict(),
|
||||||
|
"metrics": report.deterministic.to_dict(),
|
||||||
|
"monte_carlo": (
|
||||||
|
report.monte_carlo.to_dict() if report.monte_carlo else {
|
||||||
|
"available": False}
|
||||||
|
),
|
||||||
|
}
|
||||||
|
|
||||||
|
def _load_scenarios(self, project_id: int, filters: ReportFilters) -> list[Scenario]:
|
||||||
|
repo = self._require_scenario_repo()
|
||||||
|
scenarios = repo.list_for_project(project_id, with_children=True)
|
||||||
|
return [scenario for scenario in scenarios if filters.matches(scenario)]
|
||||||
|
|
||||||
|
def _reload_scenario(self, scenario_id: int) -> Scenario:
|
||||||
|
repo = self._require_scenario_repo()
|
||||||
|
return repo.get(scenario_id, with_children=True)
|
||||||
|
|
||||||
|
def _build_scenario_report(
|
||||||
|
self,
|
||||||
|
scenario: Scenario,
|
||||||
|
*,
|
||||||
|
include_distribution: bool,
|
||||||
|
include_samples: bool,
|
||||||
|
iterations: int,
|
||||||
|
percentiles: tuple[float, ...],
|
||||||
|
) -> ScenarioReport:
|
||||||
|
cash_flows, totals = _build_cash_flows(scenario)
|
||||||
|
deterministic = _calculate_deterministic_metrics(
|
||||||
|
scenario, cash_flows, totals)
|
||||||
|
monte_carlo: ScenarioMonteCarloResult | None = None
|
||||||
|
if include_distribution:
|
||||||
|
monte_carlo = _run_monte_carlo(
|
||||||
|
scenario,
|
||||||
|
cash_flows,
|
||||||
|
include_samples=include_samples,
|
||||||
|
iterations=iterations,
|
||||||
|
percentiles=percentiles,
|
||||||
|
)
|
||||||
|
return ScenarioReport(
|
||||||
|
scenario=scenario,
|
||||||
|
totals=totals,
|
||||||
|
deterministic=deterministic,
|
||||||
|
monte_carlo=monte_carlo,
|
||||||
|
)
|
||||||
|
|
||||||
|
def _aggregate_project(self, reports: Sequence[ScenarioReport]) -> ProjectAggregates:
|
||||||
|
total_inflows = sum(report.totals.inflows for report in reports)
|
||||||
|
total_outflows = sum(report.totals.outflows for report in reports)
|
||||||
|
total_net = sum(report.totals.net for report in reports)
|
||||||
|
|
||||||
|
metrics: dict[str, AggregatedMetric] = {}
|
||||||
|
for metric_name in ("npv", "irr", "payback_period"):
|
||||||
|
values = [
|
||||||
|
getattr(report.deterministic, metric_name)
|
||||||
|
for report in reports
|
||||||
|
if getattr(report.deterministic, metric_name) is not None
|
||||||
|
]
|
||||||
|
if values:
|
||||||
|
metrics[metric_name] = AggregatedMetric(
|
||||||
|
average=sum(values) / len(values),
|
||||||
|
minimum=min(values),
|
||||||
|
maximum=max(values),
|
||||||
|
)
|
||||||
|
return ProjectAggregates(
|
||||||
|
total_inflows=total_inflows,
|
||||||
|
total_outflows=total_outflows,
|
||||||
|
total_net=total_net,
|
||||||
|
deterministic_metrics=metrics,
|
||||||
|
)
|
||||||
|
|
||||||
|
def _build_comparisons(
|
||||||
|
self, reports: Sequence[ScenarioReport]
|
||||||
|
) -> Mapping[str, MetricComparison]:
|
||||||
|
comparisons: dict[str, MetricComparison] = {}
|
||||||
|
for metric_name, direction in (
|
||||||
|
("npv", "higher_is_better"),
|
||||||
|
("irr", "higher_is_better"),
|
||||||
|
("payback_period", "lower_is_better"),
|
||||||
|
):
|
||||||
|
entries: list[tuple[int, str, float]] = []
|
||||||
|
for report in reports:
|
||||||
|
value = getattr(report.deterministic, metric_name)
|
||||||
|
if value is None:
|
||||||
|
continue
|
||||||
|
entries.append(
|
||||||
|
(report.scenario.id, report.scenario.name, value))
|
||||||
|
if not entries:
|
||||||
|
continue
|
||||||
|
if direction == "higher_is_better":
|
||||||
|
best = max(entries, key=lambda item: item[2])
|
||||||
|
worst = min(entries, key=lambda item: item[2])
|
||||||
|
else:
|
||||||
|
best = min(entries, key=lambda item: item[2])
|
||||||
|
worst = max(entries, key=lambda item: item[2])
|
||||||
|
average = sum(item[2] for item in entries) / len(entries)
|
||||||
|
comparisons[metric_name] = MetricComparison(
|
||||||
|
metric=metric_name,
|
||||||
|
direction=direction,
|
||||||
|
best=best,
|
||||||
|
worst=worst,
|
||||||
|
average=average,
|
||||||
|
)
|
||||||
|
return comparisons
|
||||||
|
|
||||||
|
def _require_scenario_repo(self):
|
||||||
|
if not self._uow.scenarios:
|
||||||
|
raise RuntimeError("Scenario repository not initialised")
|
||||||
|
return self._uow.scenarios
|
||||||
|
|
||||||
|
|
||||||
|
def _build_cash_flows(scenario: Scenario) -> tuple[list[CashFlow], ScenarioFinancialTotals]:
|
||||||
|
cash_flows: list[CashFlow] = []
|
||||||
|
by_category: dict[str, float] = {}
|
||||||
|
inflows = 0.0
|
||||||
|
outflows = 0.0
|
||||||
|
net = 0.0
|
||||||
|
period_index = 0
|
||||||
|
|
||||||
|
for financial_input in scenario.financial_inputs or []:
|
||||||
|
sign = _COST_CATEGORY_SIGNS.get(financial_input.category, -1.0)
|
||||||
|
amount = float(financial_input.amount) * sign
|
||||||
|
net += amount
|
||||||
|
if amount >= 0:
|
||||||
|
inflows += amount
|
||||||
|
else:
|
||||||
|
outflows += -amount
|
||||||
|
by_category.setdefault(financial_input.category.value, 0.0)
|
||||||
|
by_category[financial_input.category.value] += amount
|
||||||
|
|
||||||
|
if financial_input.effective_date is not None:
|
||||||
|
cash_flows.append(
|
||||||
|
CashFlow(amount=amount, date=financial_input.effective_date)
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
cash_flows.append(
|
||||||
|
CashFlow(amount=amount, period_index=period_index))
|
||||||
|
period_index += 1
|
||||||
|
|
||||||
|
currency = scenario.currency
|
||||||
|
if currency is None and scenario.financial_inputs:
|
||||||
|
currency = scenario.financial_inputs[0].currency
|
||||||
|
|
||||||
|
totals = ScenarioFinancialTotals(
|
||||||
|
currency=currency,
|
||||||
|
inflows=inflows,
|
||||||
|
outflows=outflows,
|
||||||
|
net=net,
|
||||||
|
by_category=by_category,
|
||||||
|
)
|
||||||
|
return cash_flows, totals
|
||||||
|
|
||||||
|
|
||||||
|
def _calculate_deterministic_metrics(
|
||||||
|
scenario: Scenario,
|
||||||
|
cash_flows: Sequence[CashFlow],
|
||||||
|
totals: ScenarioFinancialTotals,
|
||||||
|
) -> ScenarioDeterministicMetrics:
|
||||||
|
notes: list[str] = []
|
||||||
|
discount_rate = _normalise_discount_rate(scenario.discount_rate)
|
||||||
|
if scenario.discount_rate is None:
|
||||||
|
notes.append(
|
||||||
|
f"Discount rate not set; defaulted to {discount_rate:.2%}."
|
||||||
|
)
|
||||||
|
|
||||||
|
if not cash_flows:
|
||||||
|
notes.append(
|
||||||
|
"No financial inputs available for deterministic metrics.")
|
||||||
|
return ScenarioDeterministicMetrics(
|
||||||
|
currency=totals.currency,
|
||||||
|
discount_rate=discount_rate,
|
||||||
|
compounds_per_year=1,
|
||||||
|
npv=None,
|
||||||
|
irr=None,
|
||||||
|
payback_period=None,
|
||||||
|
notes=notes,
|
||||||
|
)
|
||||||
|
|
||||||
|
npv_value: float | None
|
||||||
|
try:
|
||||||
|
npv_value = net_present_value(
|
||||||
|
discount_rate,
|
||||||
|
cash_flows,
|
||||||
|
compounds_per_year=1,
|
||||||
|
)
|
||||||
|
except ValueError as exc:
|
||||||
|
npv_value = None
|
||||||
|
notes.append(f"NPV unavailable: {exc}.")
|
||||||
|
|
||||||
|
irr_value: float | None
|
||||||
|
try:
|
||||||
|
irr_value = internal_rate_of_return(
|
||||||
|
cash_flows,
|
||||||
|
compounds_per_year=1,
|
||||||
|
)
|
||||||
|
except (ValueError, ConvergenceError) as exc:
|
||||||
|
irr_value = None
|
||||||
|
notes.append(f"IRR unavailable: {exc}.")
|
||||||
|
|
||||||
|
payback_value: float | None
|
||||||
|
try:
|
||||||
|
payback_value = payback_period(
|
||||||
|
cash_flows,
|
||||||
|
compounds_per_year=1,
|
||||||
|
)
|
||||||
|
except (ValueError, PaybackNotReachedError) as exc:
|
||||||
|
payback_value = None
|
||||||
|
notes.append(f"Payback period unavailable: {exc}.")
|
||||||
|
|
||||||
|
return ScenarioDeterministicMetrics(
|
||||||
|
currency=totals.currency,
|
||||||
|
discount_rate=discount_rate,
|
||||||
|
compounds_per_year=1,
|
||||||
|
npv=npv_value,
|
||||||
|
irr=irr_value,
|
||||||
|
payback_period=payback_value,
|
||||||
|
notes=notes,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _run_monte_carlo(
|
||||||
|
scenario: Scenario,
|
||||||
|
cash_flows: Sequence[CashFlow],
|
||||||
|
*,
|
||||||
|
include_samples: bool,
|
||||||
|
iterations: int,
|
||||||
|
percentiles: tuple[float, ...],
|
||||||
|
) -> ScenarioMonteCarloResult:
|
||||||
|
if not cash_flows:
|
||||||
|
return ScenarioMonteCarloResult(
|
||||||
|
available=False,
|
||||||
|
notes=["No financial inputs available for Monte Carlo simulation."],
|
||||||
|
)
|
||||||
|
|
||||||
|
discount_rate = _normalise_discount_rate(scenario.discount_rate)
|
||||||
|
specs = [CashFlowSpec(cash_flow=flow) for flow in cash_flows]
|
||||||
|
notes: list[str] = []
|
||||||
|
if not scenario.simulation_parameters:
|
||||||
|
notes.append(
|
||||||
|
"Scenario has no stochastic parameters; simulation mirrors deterministic cash flows."
|
||||||
|
)
|
||||||
|
config = SimulationConfig(
|
||||||
|
iterations=iterations,
|
||||||
|
discount_rate=discount_rate,
|
||||||
|
metrics=(
|
||||||
|
SimulationMetric.NPV,
|
||||||
|
SimulationMetric.IRR,
|
||||||
|
SimulationMetric.PAYBACK,
|
||||||
|
),
|
||||||
|
percentiles=percentiles,
|
||||||
|
return_samples=include_samples,
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
result = run_monte_carlo(specs, config)
|
||||||
|
except Exception as exc: # pragma: no cover - safeguard for unexpected failures
|
||||||
|
notes.append(f"Simulation failed: {exc}.")
|
||||||
|
return ScenarioMonteCarloResult(available=False, notes=notes)
|
||||||
|
return ScenarioMonteCarloResult(
|
||||||
|
available=True,
|
||||||
|
notes=notes,
|
||||||
|
result=result,
|
||||||
|
include_samples=include_samples,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _normalise_discount_rate(value: float | None) -> float:
|
||||||
|
if value is None:
|
||||||
|
return DEFAULT_DISCOUNT_RATE
|
||||||
|
rate = float(value)
|
||||||
|
if rate > 1.0:
|
||||||
|
return rate / 100.0
|
||||||
|
return rate
|
||||||
|
|
||||||
|
|
||||||
|
def _sanitize_float(value: float | None) -> float | None:
|
||||||
|
if value is None:
|
||||||
|
return None
|
||||||
|
if math.isnan(value) or math.isinf(value):
|
||||||
|
return None
|
||||||
|
return float(value)
|
||||||
|
|
||||||
|
|
||||||
|
def _round_optional(value: float | None, *, digits: int = 2) -> float | None:
|
||||||
|
clean = _sanitize_float(value)
|
||||||
|
if clean is None:
|
||||||
|
return None
|
||||||
|
return round(clean, digits)
|
||||||
|
|
||||||
|
|
||||||
|
def _comparison_entry(entry: tuple[int, str, float] | None) -> dict[str, object] | None:
|
||||||
|
if entry is None:
|
||||||
|
return None
|
||||||
|
scenario_id, name, value = entry
|
||||||
|
return {
|
||||||
|
"scenario_id": scenario_id,
|
||||||
|
"name": name,
|
||||||
|
"value": _round_optional(value),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _project_payload(project: Project) -> dict[str, object]:
|
||||||
|
return {
|
||||||
|
"id": project.id,
|
||||||
|
"name": project.name,
|
||||||
|
"location": project.location,
|
||||||
|
"operation_type": project.operation_type.value,
|
||||||
|
"description": project.description,
|
||||||
|
"created_at": project.created_at,
|
||||||
|
"updated_at": project.updated_at,
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from collections.abc import Iterable
|
from collections.abc import Iterable
|
||||||
|
from dataclasses import dataclass
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from typing import Mapping, Sequence
|
from typing import Mapping, Sequence
|
||||||
|
|
||||||
@@ -11,6 +12,9 @@ from sqlalchemy.orm import Session, joinedload, selectinload
|
|||||||
from models import (
|
from models import (
|
||||||
FinancialInput,
|
FinancialInput,
|
||||||
Project,
|
Project,
|
||||||
|
PricingImpuritySettings,
|
||||||
|
PricingMetalSettings,
|
||||||
|
PricingSettings,
|
||||||
ResourceType,
|
ResourceType,
|
||||||
Role,
|
Role,
|
||||||
Scenario,
|
Scenario,
|
||||||
@@ -21,6 +25,7 @@ from models import (
|
|||||||
)
|
)
|
||||||
from services.exceptions import EntityConflictError, EntityNotFoundError
|
from services.exceptions import EntityConflictError, EntityNotFoundError
|
||||||
from services.export_query import ProjectExportFilters, ScenarioExportFilters
|
from services.export_query import ProjectExportFilters, ScenarioExportFilters
|
||||||
|
from services.pricing import PricingMetadata
|
||||||
|
|
||||||
|
|
||||||
class ProjectRepository:
|
class ProjectRepository:
|
||||||
@@ -29,10 +34,17 @@ class ProjectRepository:
|
|||||||
def __init__(self, session: Session) -> None:
|
def __init__(self, session: Session) -> None:
|
||||||
self.session = session
|
self.session = session
|
||||||
|
|
||||||
def list(self, *, with_children: bool = False) -> Sequence[Project]:
|
def list(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
with_children: bool = False,
|
||||||
|
with_pricing: bool = False,
|
||||||
|
) -> Sequence[Project]:
|
||||||
stmt = select(Project).order_by(Project.created_at)
|
stmt = select(Project).order_by(Project.created_at)
|
||||||
if with_children:
|
if with_children:
|
||||||
stmt = stmt.options(selectinload(Project.scenarios))
|
stmt = stmt.options(selectinload(Project.scenarios))
|
||||||
|
if with_pricing:
|
||||||
|
stmt = stmt.options(selectinload(Project.pricing_settings))
|
||||||
return self.session.execute(stmt).scalars().all()
|
return self.session.execute(stmt).scalars().all()
|
||||||
|
|
||||||
def count(self) -> int:
|
def count(self) -> int:
|
||||||
@@ -47,10 +59,18 @@ class ProjectRepository:
|
|||||||
)
|
)
|
||||||
return self.session.execute(stmt).scalars().all()
|
return self.session.execute(stmt).scalars().all()
|
||||||
|
|
||||||
def get(self, project_id: int, *, with_children: bool = False) -> Project:
|
def get(
|
||||||
|
self,
|
||||||
|
project_id: int,
|
||||||
|
*,
|
||||||
|
with_children: bool = False,
|
||||||
|
with_pricing: bool = False,
|
||||||
|
) -> Project:
|
||||||
stmt = select(Project).where(Project.id == project_id)
|
stmt = select(Project).where(Project.id == project_id)
|
||||||
if with_children:
|
if with_children:
|
||||||
stmt = stmt.options(joinedload(Project.scenarios))
|
stmt = stmt.options(joinedload(Project.scenarios))
|
||||||
|
if with_pricing:
|
||||||
|
stmt = stmt.options(joinedload(Project.pricing_settings))
|
||||||
result = self.session.execute(stmt)
|
result = self.session.execute(stmt)
|
||||||
if with_children:
|
if with_children:
|
||||||
result = result.unique()
|
result = result.unique()
|
||||||
@@ -86,10 +106,13 @@ class ProjectRepository:
|
|||||||
filters: ProjectExportFilters | None = None,
|
filters: ProjectExportFilters | None = None,
|
||||||
*,
|
*,
|
||||||
include_scenarios: bool = False,
|
include_scenarios: bool = False,
|
||||||
|
include_pricing: bool = False,
|
||||||
) -> Sequence[Project]:
|
) -> Sequence[Project]:
|
||||||
stmt = select(Project)
|
stmt = select(Project)
|
||||||
if include_scenarios:
|
if include_scenarios:
|
||||||
stmt = stmt.options(selectinload(Project.scenarios))
|
stmt = stmt.options(selectinload(Project.scenarios))
|
||||||
|
if include_pricing:
|
||||||
|
stmt = stmt.options(selectinload(Project.pricing_settings))
|
||||||
|
|
||||||
if filters:
|
if filters:
|
||||||
ids = filters.normalised_ids()
|
ids = filters.normalised_ids()
|
||||||
@@ -131,6 +154,18 @@ class ProjectRepository:
|
|||||||
project = self.get(project_id)
|
project = self.get(project_id)
|
||||||
self.session.delete(project)
|
self.session.delete(project)
|
||||||
|
|
||||||
|
def set_pricing_settings(
|
||||||
|
self,
|
||||||
|
project: Project,
|
||||||
|
pricing_settings: PricingSettings | None,
|
||||||
|
) -> Project:
|
||||||
|
project.pricing_settings = pricing_settings
|
||||||
|
project.pricing_settings_id = (
|
||||||
|
pricing_settings.id if pricing_settings is not None else None
|
||||||
|
)
|
||||||
|
self.session.flush()
|
||||||
|
return project
|
||||||
|
|
||||||
|
|
||||||
class ScenarioRepository:
|
class ScenarioRepository:
|
||||||
"""Persistence operations for Scenario entities."""
|
"""Persistence operations for Scenario entities."""
|
||||||
@@ -138,13 +173,26 @@ class ScenarioRepository:
|
|||||||
def __init__(self, session: Session) -> None:
|
def __init__(self, session: Session) -> None:
|
||||||
self.session = session
|
self.session = session
|
||||||
|
|
||||||
def list_for_project(self, project_id: int) -> Sequence[Scenario]:
|
def list_for_project(
|
||||||
|
self,
|
||||||
|
project_id: int,
|
||||||
|
*,
|
||||||
|
with_children: bool = False,
|
||||||
|
) -> Sequence[Scenario]:
|
||||||
stmt = (
|
stmt = (
|
||||||
select(Scenario)
|
select(Scenario)
|
||||||
.where(Scenario.project_id == project_id)
|
.where(Scenario.project_id == project_id)
|
||||||
.order_by(Scenario.created_at)
|
.order_by(Scenario.created_at)
|
||||||
)
|
)
|
||||||
return self.session.execute(stmt).scalars().all()
|
if with_children:
|
||||||
|
stmt = stmt.options(
|
||||||
|
selectinload(Scenario.financial_inputs),
|
||||||
|
selectinload(Scenario.simulation_parameters),
|
||||||
|
)
|
||||||
|
result = self.session.execute(stmt)
|
||||||
|
if with_children:
|
||||||
|
result = result.unique()
|
||||||
|
return result.scalars().all()
|
||||||
|
|
||||||
def count(self) -> int:
|
def count(self) -> int:
|
||||||
stmt = select(func.count(Scenario.id))
|
stmt = select(func.count(Scenario.id))
|
||||||
@@ -376,6 +424,101 @@ class SimulationParameterRepository:
|
|||||||
self.session.delete(entity)
|
self.session.delete(entity)
|
||||||
|
|
||||||
|
|
||||||
|
class PricingSettingsRepository:
|
||||||
|
"""Persistence operations for pricing configuration entities."""
|
||||||
|
|
||||||
|
def __init__(self, session: Session) -> None:
|
||||||
|
self.session = session
|
||||||
|
|
||||||
|
def list(self, *, include_children: bool = False) -> Sequence[PricingSettings]:
|
||||||
|
stmt = select(PricingSettings).order_by(PricingSettings.created_at)
|
||||||
|
if include_children:
|
||||||
|
stmt = stmt.options(
|
||||||
|
selectinload(PricingSettings.metal_overrides),
|
||||||
|
selectinload(PricingSettings.impurity_overrides),
|
||||||
|
)
|
||||||
|
result = self.session.execute(stmt)
|
||||||
|
if include_children:
|
||||||
|
result = result.unique()
|
||||||
|
return result.scalars().all()
|
||||||
|
|
||||||
|
def get(self, settings_id: int, *, include_children: bool = False) -> PricingSettings:
|
||||||
|
stmt = select(PricingSettings).where(PricingSettings.id == settings_id)
|
||||||
|
if include_children:
|
||||||
|
stmt = stmt.options(
|
||||||
|
selectinload(PricingSettings.metal_overrides),
|
||||||
|
selectinload(PricingSettings.impurity_overrides),
|
||||||
|
)
|
||||||
|
result = self.session.execute(stmt)
|
||||||
|
if include_children:
|
||||||
|
result = result.unique()
|
||||||
|
settings = result.scalar_one_or_none()
|
||||||
|
if settings is None:
|
||||||
|
raise EntityNotFoundError(
|
||||||
|
f"Pricing settings {settings_id} not found")
|
||||||
|
return settings
|
||||||
|
|
||||||
|
def find_by_slug(
|
||||||
|
self,
|
||||||
|
slug: str,
|
||||||
|
*,
|
||||||
|
include_children: bool = False,
|
||||||
|
) -> PricingSettings | None:
|
||||||
|
normalised = slug.strip().lower()
|
||||||
|
stmt = select(PricingSettings).where(
|
||||||
|
PricingSettings.slug == normalised)
|
||||||
|
if include_children:
|
||||||
|
stmt = stmt.options(
|
||||||
|
selectinload(PricingSettings.metal_overrides),
|
||||||
|
selectinload(PricingSettings.impurity_overrides),
|
||||||
|
)
|
||||||
|
result = self.session.execute(stmt)
|
||||||
|
if include_children:
|
||||||
|
result = result.unique()
|
||||||
|
return result.scalar_one_or_none()
|
||||||
|
|
||||||
|
def get_by_slug(self, slug: str, *, include_children: bool = False) -> PricingSettings:
|
||||||
|
settings = self.find_by_slug(slug, include_children=include_children)
|
||||||
|
if settings is None:
|
||||||
|
raise EntityNotFoundError(
|
||||||
|
f"Pricing settings slug '{slug}' not found"
|
||||||
|
)
|
||||||
|
return settings
|
||||||
|
|
||||||
|
def create(self, settings: PricingSettings) -> PricingSettings:
|
||||||
|
self.session.add(settings)
|
||||||
|
try:
|
||||||
|
self.session.flush()
|
||||||
|
except IntegrityError as exc: # pragma: no cover - relies on DB constraints
|
||||||
|
raise EntityConflictError(
|
||||||
|
"Pricing settings violates constraints") from exc
|
||||||
|
return settings
|
||||||
|
|
||||||
|
def delete(self, settings_id: int) -> None:
|
||||||
|
settings = self.get(settings_id, include_children=True)
|
||||||
|
self.session.delete(settings)
|
||||||
|
|
||||||
|
def attach_metal_override(
|
||||||
|
self,
|
||||||
|
settings: PricingSettings,
|
||||||
|
override: PricingMetalSettings,
|
||||||
|
) -> PricingMetalSettings:
|
||||||
|
settings.metal_overrides.append(override)
|
||||||
|
self.session.add(override)
|
||||||
|
self.session.flush()
|
||||||
|
return override
|
||||||
|
|
||||||
|
def attach_impurity_override(
|
||||||
|
self,
|
||||||
|
settings: PricingSettings,
|
||||||
|
override: PricingImpuritySettings,
|
||||||
|
) -> PricingImpuritySettings:
|
||||||
|
settings.impurity_overrides.append(override)
|
||||||
|
self.session.add(override)
|
||||||
|
self.session.flush()
|
||||||
|
return override
|
||||||
|
|
||||||
|
|
||||||
class RoleRepository:
|
class RoleRepository:
|
||||||
"""Persistence operations for Role entities."""
|
"""Persistence operations for Role entities."""
|
||||||
|
|
||||||
@@ -507,6 +650,159 @@ class UserRepository:
|
|||||||
self.session.flush()
|
self.session.flush()
|
||||||
|
|
||||||
|
|
||||||
|
DEFAULT_PRICING_SETTINGS_NAME = "Default Pricing Settings"
|
||||||
|
DEFAULT_PRICING_SETTINGS_DESCRIPTION = (
|
||||||
|
"Default pricing configuration generated from environment metadata."
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(slots=True)
|
||||||
|
class PricingSettingsSeedResult:
|
||||||
|
settings: PricingSettings
|
||||||
|
created: bool
|
||||||
|
updated_fields: int
|
||||||
|
impurity_upserts: int
|
||||||
|
|
||||||
|
|
||||||
|
def ensure_default_pricing_settings(
|
||||||
|
repo: PricingSettingsRepository,
|
||||||
|
*,
|
||||||
|
metadata: PricingMetadata,
|
||||||
|
slug: str = "default",
|
||||||
|
name: str | None = None,
|
||||||
|
description: str | None = None,
|
||||||
|
) -> PricingSettingsSeedResult:
|
||||||
|
"""Ensure a baseline pricing settings record exists and matches metadata defaults."""
|
||||||
|
|
||||||
|
normalised_slug = (slug or "default").strip().lower() or "default"
|
||||||
|
target_name = name or DEFAULT_PRICING_SETTINGS_NAME
|
||||||
|
target_description = description or DEFAULT_PRICING_SETTINGS_DESCRIPTION
|
||||||
|
|
||||||
|
updated_fields = 0
|
||||||
|
impurity_upserts = 0
|
||||||
|
|
||||||
|
try:
|
||||||
|
settings = repo.get_by_slug(normalised_slug, include_children=True)
|
||||||
|
created = False
|
||||||
|
except EntityNotFoundError:
|
||||||
|
settings = PricingSettings(
|
||||||
|
name=target_name,
|
||||||
|
slug=normalised_slug,
|
||||||
|
description=target_description,
|
||||||
|
default_currency=metadata.default_currency,
|
||||||
|
default_payable_pct=metadata.default_payable_pct,
|
||||||
|
moisture_threshold_pct=metadata.moisture_threshold_pct,
|
||||||
|
moisture_penalty_per_pct=metadata.moisture_penalty_per_pct,
|
||||||
|
)
|
||||||
|
settings.metadata_payload = None
|
||||||
|
settings = repo.create(settings)
|
||||||
|
created = True
|
||||||
|
else:
|
||||||
|
if settings.name != target_name:
|
||||||
|
settings.name = target_name
|
||||||
|
updated_fields += 1
|
||||||
|
if target_description and settings.description != target_description:
|
||||||
|
settings.description = target_description
|
||||||
|
updated_fields += 1
|
||||||
|
if settings.default_currency != metadata.default_currency:
|
||||||
|
settings.default_currency = metadata.default_currency
|
||||||
|
updated_fields += 1
|
||||||
|
if float(settings.default_payable_pct) != float(metadata.default_payable_pct):
|
||||||
|
settings.default_payable_pct = metadata.default_payable_pct
|
||||||
|
updated_fields += 1
|
||||||
|
if float(settings.moisture_threshold_pct) != float(metadata.moisture_threshold_pct):
|
||||||
|
settings.moisture_threshold_pct = metadata.moisture_threshold_pct
|
||||||
|
updated_fields += 1
|
||||||
|
if float(settings.moisture_penalty_per_pct) != float(metadata.moisture_penalty_per_pct):
|
||||||
|
settings.moisture_penalty_per_pct = metadata.moisture_penalty_per_pct
|
||||||
|
updated_fields += 1
|
||||||
|
|
||||||
|
impurity_thresholds = {
|
||||||
|
code.strip().upper(): float(value)
|
||||||
|
for code, value in (metadata.impurity_thresholds or {}).items()
|
||||||
|
if code.strip()
|
||||||
|
}
|
||||||
|
impurity_penalties = {
|
||||||
|
code.strip().upper(): float(value)
|
||||||
|
for code, value in (metadata.impurity_penalty_per_ppm or {}).items()
|
||||||
|
if code.strip()
|
||||||
|
}
|
||||||
|
|
||||||
|
if impurity_thresholds or impurity_penalties:
|
||||||
|
existing_map = {
|
||||||
|
override.impurity_code: override
|
||||||
|
for override in settings.impurity_overrides
|
||||||
|
}
|
||||||
|
target_codes = set(impurity_thresholds) | set(impurity_penalties)
|
||||||
|
for code in sorted(target_codes):
|
||||||
|
threshold_value = impurity_thresholds.get(code, 0.0)
|
||||||
|
penalty_value = impurity_penalties.get(code, 0.0)
|
||||||
|
existing = existing_map.get(code)
|
||||||
|
if existing is None:
|
||||||
|
repo.attach_impurity_override(
|
||||||
|
settings,
|
||||||
|
PricingImpuritySettings(
|
||||||
|
impurity_code=code,
|
||||||
|
threshold_ppm=threshold_value,
|
||||||
|
penalty_per_ppm=penalty_value,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
impurity_upserts += 1
|
||||||
|
continue
|
||||||
|
changed = False
|
||||||
|
if float(existing.threshold_ppm) != float(threshold_value):
|
||||||
|
existing.threshold_ppm = threshold_value
|
||||||
|
changed = True
|
||||||
|
if float(existing.penalty_per_ppm) != float(penalty_value):
|
||||||
|
existing.penalty_per_ppm = penalty_value
|
||||||
|
changed = True
|
||||||
|
if changed:
|
||||||
|
updated_fields += 1
|
||||||
|
|
||||||
|
if updated_fields > 0 or impurity_upserts > 0:
|
||||||
|
repo.session.flush()
|
||||||
|
|
||||||
|
return PricingSettingsSeedResult(
|
||||||
|
settings=settings,
|
||||||
|
created=created,
|
||||||
|
updated_fields=updated_fields,
|
||||||
|
impurity_upserts=impurity_upserts,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def pricing_settings_to_metadata(settings: PricingSettings) -> PricingMetadata:
|
||||||
|
"""Convert a persisted pricing settings record into metadata defaults."""
|
||||||
|
|
||||||
|
payload = settings.metadata_payload or {}
|
||||||
|
payload_thresholds = payload.get("impurity_thresholds") or {}
|
||||||
|
payload_penalties = payload.get("impurity_penalty_per_ppm") or {}
|
||||||
|
|
||||||
|
thresholds: dict[str, float] = {
|
||||||
|
code.strip().upper(): float(value)
|
||||||
|
for code, value in payload_thresholds.items()
|
||||||
|
if isinstance(code, str) and code.strip()
|
||||||
|
}
|
||||||
|
penalties: dict[str, float] = {
|
||||||
|
code.strip().upper(): float(value)
|
||||||
|
for code, value in payload_penalties.items()
|
||||||
|
if isinstance(code, str) and code.strip()
|
||||||
|
}
|
||||||
|
|
||||||
|
for override in settings.impurity_overrides:
|
||||||
|
code = override.impurity_code.strip().upper()
|
||||||
|
thresholds[code] = float(override.threshold_ppm)
|
||||||
|
penalties[code] = float(override.penalty_per_ppm)
|
||||||
|
|
||||||
|
return PricingMetadata(
|
||||||
|
default_payable_pct=float(settings.default_payable_pct),
|
||||||
|
default_currency=settings.default_currency,
|
||||||
|
moisture_threshold_pct=float(settings.moisture_threshold_pct),
|
||||||
|
moisture_penalty_per_pct=float(settings.moisture_penalty_per_pct),
|
||||||
|
impurity_thresholds=thresholds,
|
||||||
|
impurity_penalty_per_ppm=penalties,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
DEFAULT_ROLE_DEFINITIONS: tuple[dict[str, str], ...] = (
|
DEFAULT_ROLE_DEFINITIONS: tuple[dict[str, str], ...] = (
|
||||||
{
|
{
|
||||||
"name": "admin",
|
"name": "admin",
|
||||||
|
|||||||
54
services/scenario_evaluation.py
Normal file
54
services/scenario_evaluation.py
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
"""Scenario evaluation services including pricing integration."""
|
||||||
|
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from typing import Iterable, Mapping
|
||||||
|
|
||||||
|
from models.scenario import Scenario
|
||||||
|
from services.pricing import (
|
||||||
|
PricingInput,
|
||||||
|
PricingMetadata,
|
||||||
|
PricingResult,
|
||||||
|
calculate_pricing,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(slots=True)
|
||||||
|
class ScenarioPricingConfig:
|
||||||
|
"""Configuration for pricing evaluation within a scenario."""
|
||||||
|
|
||||||
|
metadata: PricingMetadata | None = None
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(slots=True)
|
||||||
|
class ScenarioPricingSnapshot:
|
||||||
|
"""Captured pricing results for a scenario."""
|
||||||
|
|
||||||
|
scenario_id: int
|
||||||
|
results: list[PricingResult]
|
||||||
|
|
||||||
|
|
||||||
|
class ScenarioPricingEvaluator:
|
||||||
|
"""Evaluate scenario profitability inputs using pricing services."""
|
||||||
|
|
||||||
|
def __init__(self, config: ScenarioPricingConfig | None = None) -> None:
|
||||||
|
self._config = config or ScenarioPricingConfig()
|
||||||
|
|
||||||
|
def evaluate(
|
||||||
|
self,
|
||||||
|
scenario: Scenario,
|
||||||
|
*,
|
||||||
|
inputs: Iterable[PricingInput],
|
||||||
|
metadata_override: PricingMetadata | None = None,
|
||||||
|
) -> ScenarioPricingSnapshot:
|
||||||
|
metadata = metadata_override or self._config.metadata
|
||||||
|
results: list[PricingResult] = []
|
||||||
|
for pricing_input in inputs:
|
||||||
|
result = calculate_pricing(
|
||||||
|
pricing_input,
|
||||||
|
metadata=metadata,
|
||||||
|
currency=scenario.currency,
|
||||||
|
)
|
||||||
|
results.append(result)
|
||||||
|
return ScenarioPricingSnapshot(scenario_id=scenario.id, results=results)
|
||||||
352
services/simulation.py
Normal file
352
services/simulation.py
Normal file
@@ -0,0 +1,352 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from enum import Enum
|
||||||
|
from typing import Any, Dict, Iterable, Mapping, Sequence
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
from numpy.random import Generator, default_rng
|
||||||
|
|
||||||
|
from .financial import (
|
||||||
|
CashFlow,
|
||||||
|
ConvergenceError,
|
||||||
|
PaybackNotReachedError,
|
||||||
|
internal_rate_of_return,
|
||||||
|
net_present_value,
|
||||||
|
payback_period,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class DistributionConfigError(ValueError):
|
||||||
|
"""Raised when a distribution specification is invalid."""
|
||||||
|
|
||||||
|
|
||||||
|
class SimulationMetric(Enum):
|
||||||
|
"""Supported Monte Carlo summary metrics."""
|
||||||
|
|
||||||
|
NPV = "npv"
|
||||||
|
IRR = "irr"
|
||||||
|
PAYBACK = "payback"
|
||||||
|
|
||||||
|
|
||||||
|
class DistributionType(Enum):
|
||||||
|
"""Supported probability distribution families."""
|
||||||
|
|
||||||
|
NORMAL = "normal"
|
||||||
|
LOGNORMAL = "lognormal"
|
||||||
|
TRIANGULAR = "triangular"
|
||||||
|
DISCRETE = "discrete"
|
||||||
|
|
||||||
|
|
||||||
|
class DistributionSource(Enum):
|
||||||
|
"""Origins for parameter values when sourcing dynamically."""
|
||||||
|
|
||||||
|
STATIC = "static"
|
||||||
|
SCENARIO_FIELD = "scenario_field"
|
||||||
|
METADATA_KEY = "metadata_key"
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class DistributionSpec:
|
||||||
|
"""Defines the stochastic behaviour for a single cash flow."""
|
||||||
|
|
||||||
|
type: DistributionType
|
||||||
|
parameters: Mapping[str, Any]
|
||||||
|
source: DistributionSource = DistributionSource.STATIC
|
||||||
|
source_key: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class CashFlowSpec:
|
||||||
|
"""Pairs a baseline cash flow with an optional distribution."""
|
||||||
|
|
||||||
|
cash_flow: CashFlow
|
||||||
|
distribution: DistributionSpec | None = None
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class SimulationConfig:
|
||||||
|
"""Controls Monte Carlo simulation behaviour."""
|
||||||
|
|
||||||
|
iterations: int
|
||||||
|
discount_rate: float
|
||||||
|
seed: int | None = None
|
||||||
|
metrics: Sequence[SimulationMetric] = (
|
||||||
|
SimulationMetric.NPV, SimulationMetric.IRR, SimulationMetric.PAYBACK)
|
||||||
|
percentiles: Sequence[float] = (5.0, 50.0, 95.0)
|
||||||
|
compounds_per_year: int = 1
|
||||||
|
return_samples: bool = False
|
||||||
|
residual_value: float | None = None
|
||||||
|
residual_periods: float | None = None
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class MetricSummary:
|
||||||
|
"""Aggregated statistics for a simulated metric."""
|
||||||
|
|
||||||
|
mean: float
|
||||||
|
std_dev: float
|
||||||
|
minimum: float
|
||||||
|
maximum: float
|
||||||
|
percentiles: Mapping[float, float]
|
||||||
|
sample_size: int
|
||||||
|
failed_runs: int
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class SimulationResult:
|
||||||
|
"""Monte Carlo output including per-metric summaries."""
|
||||||
|
|
||||||
|
iterations: int
|
||||||
|
summaries: Mapping[SimulationMetric, MetricSummary]
|
||||||
|
samples: Mapping[SimulationMetric, np.ndarray] | None = None
|
||||||
|
|
||||||
|
|
||||||
|
def run_monte_carlo(
|
||||||
|
cash_flows: Sequence[CashFlowSpec],
|
||||||
|
config: SimulationConfig,
|
||||||
|
*,
|
||||||
|
scenario_context: Mapping[str, Any] | None = None,
|
||||||
|
metadata: Mapping[str, Any] | None = None,
|
||||||
|
rng: Generator | None = None,
|
||||||
|
) -> SimulationResult:
|
||||||
|
"""Execute Monte Carlo simulation for the provided cash flows."""
|
||||||
|
|
||||||
|
if config.iterations <= 0:
|
||||||
|
raise ValueError("iterations must be greater than zero")
|
||||||
|
if config.compounds_per_year <= 0:
|
||||||
|
raise ValueError("compounds_per_year must be greater than zero")
|
||||||
|
for pct in config.percentiles:
|
||||||
|
if pct < 0.0 or pct > 100.0:
|
||||||
|
raise ValueError("percentiles must be within [0, 100]")
|
||||||
|
|
||||||
|
generator = rng or default_rng(config.seed)
|
||||||
|
|
||||||
|
metric_arrays: Dict[SimulationMetric, np.ndarray] = {
|
||||||
|
metric: np.empty(config.iterations, dtype=float)
|
||||||
|
for metric in config.metrics
|
||||||
|
}
|
||||||
|
|
||||||
|
for idx in range(config.iterations):
|
||||||
|
iteration_flows = [
|
||||||
|
_realise_cash_flow(
|
||||||
|
spec,
|
||||||
|
generator,
|
||||||
|
scenario_context=scenario_context,
|
||||||
|
metadata=metadata,
|
||||||
|
)
|
||||||
|
for spec in cash_flows
|
||||||
|
]
|
||||||
|
|
||||||
|
if SimulationMetric.NPV in metric_arrays:
|
||||||
|
metric_arrays[SimulationMetric.NPV][idx] = net_present_value(
|
||||||
|
config.discount_rate,
|
||||||
|
iteration_flows,
|
||||||
|
residual_value=config.residual_value,
|
||||||
|
residual_periods=config.residual_periods,
|
||||||
|
compounds_per_year=config.compounds_per_year,
|
||||||
|
)
|
||||||
|
if SimulationMetric.IRR in metric_arrays:
|
||||||
|
try:
|
||||||
|
metric_arrays[SimulationMetric.IRR][idx] = internal_rate_of_return(
|
||||||
|
iteration_flows,
|
||||||
|
compounds_per_year=config.compounds_per_year,
|
||||||
|
)
|
||||||
|
except (ValueError, ConvergenceError):
|
||||||
|
metric_arrays[SimulationMetric.IRR][idx] = np.nan
|
||||||
|
if SimulationMetric.PAYBACK in metric_arrays:
|
||||||
|
try:
|
||||||
|
metric_arrays[SimulationMetric.PAYBACK][idx] = payback_period(
|
||||||
|
iteration_flows,
|
||||||
|
compounds_per_year=config.compounds_per_year,
|
||||||
|
)
|
||||||
|
except (ValueError, PaybackNotReachedError):
|
||||||
|
metric_arrays[SimulationMetric.PAYBACK][idx] = np.nan
|
||||||
|
|
||||||
|
summaries = {
|
||||||
|
metric: _summarise(metric_arrays[metric], config.percentiles)
|
||||||
|
for metric in metric_arrays
|
||||||
|
}
|
||||||
|
|
||||||
|
samples = metric_arrays if config.return_samples else None
|
||||||
|
return SimulationResult(
|
||||||
|
iterations=config.iterations,
|
||||||
|
summaries=summaries,
|
||||||
|
samples=samples,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _realise_cash_flow(
|
||||||
|
spec: CashFlowSpec,
|
||||||
|
generator: Generator,
|
||||||
|
*,
|
||||||
|
scenario_context: Mapping[str, Any] | None,
|
||||||
|
metadata: Mapping[str, Any] | None,
|
||||||
|
) -> CashFlow:
|
||||||
|
if spec.distribution is None:
|
||||||
|
return spec.cash_flow
|
||||||
|
|
||||||
|
distribution = spec.distribution
|
||||||
|
base_amount = spec.cash_flow.amount
|
||||||
|
params = _resolve_parameters(
|
||||||
|
distribution,
|
||||||
|
base_amount,
|
||||||
|
scenario_context=scenario_context,
|
||||||
|
metadata=metadata,
|
||||||
|
)
|
||||||
|
sample = _sample_distribution(
|
||||||
|
distribution.type,
|
||||||
|
params,
|
||||||
|
generator,
|
||||||
|
)
|
||||||
|
return CashFlow(
|
||||||
|
amount=float(sample),
|
||||||
|
period_index=spec.cash_flow.period_index,
|
||||||
|
date=spec.cash_flow.date,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _resolve_parameters(
|
||||||
|
distribution: DistributionSpec,
|
||||||
|
base_amount: float,
|
||||||
|
*,
|
||||||
|
scenario_context: Mapping[str, Any] | None,
|
||||||
|
metadata: Mapping[str, Any] | None,
|
||||||
|
) -> Dict[str, Any]:
|
||||||
|
params = dict(distribution.parameters)
|
||||||
|
|
||||||
|
if distribution.source == DistributionSource.SCENARIO_FIELD:
|
||||||
|
if distribution.source_key is None:
|
||||||
|
raise DistributionConfigError(
|
||||||
|
"source_key is required for scenario_field sourcing")
|
||||||
|
if not scenario_context or distribution.source_key not in scenario_context:
|
||||||
|
raise DistributionConfigError(
|
||||||
|
f"scenario field '{distribution.source_key}' not found for distribution"
|
||||||
|
)
|
||||||
|
params.setdefault("mean", float(
|
||||||
|
scenario_context[distribution.source_key]))
|
||||||
|
elif distribution.source == DistributionSource.METADATA_KEY:
|
||||||
|
if distribution.source_key is None:
|
||||||
|
raise DistributionConfigError(
|
||||||
|
"source_key is required for metadata_key sourcing")
|
||||||
|
if not metadata or distribution.source_key not in metadata:
|
||||||
|
raise DistributionConfigError(
|
||||||
|
f"metadata key '{distribution.source_key}' not found for distribution"
|
||||||
|
)
|
||||||
|
params.setdefault("mean", float(metadata[distribution.source_key]))
|
||||||
|
else:
|
||||||
|
params.setdefault("mean", float(base_amount))
|
||||||
|
|
||||||
|
return params
|
||||||
|
|
||||||
|
|
||||||
|
def _sample_distribution(
|
||||||
|
distribution_type: DistributionType,
|
||||||
|
params: Mapping[str, Any],
|
||||||
|
generator: Generator,
|
||||||
|
) -> float:
|
||||||
|
if distribution_type is DistributionType.NORMAL:
|
||||||
|
return _sample_normal(params, generator)
|
||||||
|
if distribution_type is DistributionType.LOGNORMAL:
|
||||||
|
return _sample_lognormal(params, generator)
|
||||||
|
if distribution_type is DistributionType.TRIANGULAR:
|
||||||
|
return _sample_triangular(params, generator)
|
||||||
|
if distribution_type is DistributionType.DISCRETE:
|
||||||
|
return _sample_discrete(params, generator)
|
||||||
|
raise DistributionConfigError(
|
||||||
|
f"Unsupported distribution type: {distribution_type}")
|
||||||
|
|
||||||
|
|
||||||
|
def _sample_normal(params: Mapping[str, Any], generator: Generator) -> float:
|
||||||
|
if "std_dev" not in params:
|
||||||
|
raise DistributionConfigError("normal distribution requires 'std_dev'")
|
||||||
|
std_dev = float(params["std_dev"])
|
||||||
|
if std_dev < 0:
|
||||||
|
raise DistributionConfigError("std_dev must be non-negative")
|
||||||
|
mean = float(params.get("mean", 0.0))
|
||||||
|
if std_dev == 0:
|
||||||
|
return mean
|
||||||
|
return float(generator.normal(loc=mean, scale=std_dev))
|
||||||
|
|
||||||
|
|
||||||
|
def _sample_lognormal(params: Mapping[str, Any], generator: Generator) -> float:
|
||||||
|
if "sigma" not in params:
|
||||||
|
raise DistributionConfigError(
|
||||||
|
"lognormal distribution requires 'sigma'")
|
||||||
|
sigma = float(params["sigma"])
|
||||||
|
if sigma < 0:
|
||||||
|
raise DistributionConfigError("sigma must be non-negative")
|
||||||
|
if "mean" not in params:
|
||||||
|
raise DistributionConfigError(
|
||||||
|
"lognormal distribution requires 'mean' (mu in log space)")
|
||||||
|
mean = float(params["mean"])
|
||||||
|
return float(generator.lognormal(mean=mean, sigma=sigma))
|
||||||
|
|
||||||
|
|
||||||
|
def _sample_triangular(params: Mapping[str, Any], generator: Generator) -> float:
|
||||||
|
required = {"min", "mode", "max"}
|
||||||
|
if not required.issubset(params):
|
||||||
|
missing = ", ".join(sorted(required - params.keys()))
|
||||||
|
raise DistributionConfigError(
|
||||||
|
f"triangular distribution missing parameters: {missing}")
|
||||||
|
left = float(params["min"])
|
||||||
|
mode = float(params["mode"])
|
||||||
|
right = float(params["max"])
|
||||||
|
if not (left <= mode <= right):
|
||||||
|
raise DistributionConfigError(
|
||||||
|
"triangular distribution requires min <= mode <= max")
|
||||||
|
if left == right:
|
||||||
|
return mode
|
||||||
|
return float(generator.triangular(left=left, mode=mode, right=right))
|
||||||
|
|
||||||
|
|
||||||
|
def _sample_discrete(params: Mapping[str, Any], generator: Generator) -> float:
|
||||||
|
values = params.get("values")
|
||||||
|
probabilities = params.get("probabilities")
|
||||||
|
if not isinstance(values, Sequence) or not isinstance(probabilities, Sequence):
|
||||||
|
raise DistributionConfigError(
|
||||||
|
"discrete distribution requires 'values' and 'probabilities' sequences")
|
||||||
|
if len(values) != len(probabilities) or not values:
|
||||||
|
raise DistributionConfigError(
|
||||||
|
"values and probabilities must be non-empty and of equal length")
|
||||||
|
probs = np.array(probabilities, dtype=float)
|
||||||
|
if np.any(probs < 0):
|
||||||
|
raise DistributionConfigError("probabilities must be non-negative")
|
||||||
|
total = probs.sum()
|
||||||
|
if not np.isclose(total, 1.0):
|
||||||
|
raise DistributionConfigError("probabilities must sum to 1.0")
|
||||||
|
probs = probs / total
|
||||||
|
choices = np.array(values, dtype=float)
|
||||||
|
return float(generator.choice(choices, p=probs))
|
||||||
|
|
||||||
|
|
||||||
|
def _summarise(values: np.ndarray, percentiles: Sequence[float]) -> MetricSummary:
|
||||||
|
clean = values[~np.isnan(values)]
|
||||||
|
sample_size = clean.size
|
||||||
|
failed_runs = values.size - sample_size
|
||||||
|
|
||||||
|
if sample_size == 0:
|
||||||
|
percentile_map: Dict[float, float] = {
|
||||||
|
pct: float("nan") for pct in percentiles}
|
||||||
|
return MetricSummary(
|
||||||
|
mean=float("nan"),
|
||||||
|
std_dev=float("nan"),
|
||||||
|
minimum=float("nan"),
|
||||||
|
maximum=float("nan"),
|
||||||
|
percentiles=percentile_map,
|
||||||
|
sample_size=0,
|
||||||
|
failed_runs=failed_runs,
|
||||||
|
)
|
||||||
|
|
||||||
|
percentile_map = {
|
||||||
|
pct: float(np.percentile(clean, pct)) for pct in percentiles
|
||||||
|
}
|
||||||
|
return MetricSummary(
|
||||||
|
mean=float(np.mean(clean)),
|
||||||
|
std_dev=float(np.std(clean, ddof=1)) if sample_size > 1 else 0.0,
|
||||||
|
minimum=float(np.min(clean)),
|
||||||
|
maximum=float(np.max(clean)),
|
||||||
|
percentiles=percentile_map,
|
||||||
|
sample_size=sample_size,
|
||||||
|
failed_runs=failed_runs,
|
||||||
|
)
|
||||||
@@ -6,16 +6,21 @@ from typing import Callable, Sequence
|
|||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from config.database import SessionLocal
|
from config.database import SessionLocal
|
||||||
from models import Role, Scenario
|
from models import PricingSettings, Project, Role, Scenario
|
||||||
|
from services.pricing import PricingMetadata
|
||||||
from services.repositories import (
|
from services.repositories import (
|
||||||
FinancialInputRepository,
|
FinancialInputRepository,
|
||||||
|
PricingSettingsRepository,
|
||||||
|
PricingSettingsSeedResult,
|
||||||
ProjectRepository,
|
ProjectRepository,
|
||||||
RoleRepository,
|
RoleRepository,
|
||||||
ScenarioRepository,
|
ScenarioRepository,
|
||||||
SimulationParameterRepository,
|
SimulationParameterRepository,
|
||||||
UserRepository,
|
UserRepository,
|
||||||
ensure_admin_user as ensure_admin_user_record,
|
ensure_admin_user as ensure_admin_user_record,
|
||||||
|
ensure_default_pricing_settings,
|
||||||
ensure_default_roles,
|
ensure_default_roles,
|
||||||
|
pricing_settings_to_metadata,
|
||||||
)
|
)
|
||||||
from services.scenario_validation import ScenarioComparisonValidator
|
from services.scenario_validation import ScenarioComparisonValidator
|
||||||
|
|
||||||
@@ -33,6 +38,7 @@ class UnitOfWork(AbstractContextManager["UnitOfWork"]):
|
|||||||
self.simulation_parameters: SimulationParameterRepository | None = None
|
self.simulation_parameters: SimulationParameterRepository | 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
|
||||||
|
|
||||||
def __enter__(self) -> "UnitOfWork":
|
def __enter__(self) -> "UnitOfWork":
|
||||||
self.session = self._session_factory()
|
self.session = self._session_factory()
|
||||||
@@ -43,6 +49,7 @@ class UnitOfWork(AbstractContextManager["UnitOfWork"]):
|
|||||||
self.session)
|
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._scenario_validator = ScenarioComparisonValidator()
|
self._scenario_validator = ScenarioComparisonValidator()
|
||||||
return self
|
return self
|
||||||
|
|
||||||
@@ -60,6 +67,7 @@ class UnitOfWork(AbstractContextManager["UnitOfWork"]):
|
|||||||
self.simulation_parameters = None
|
self.simulation_parameters = None
|
||||||
self.users = None
|
self.users = None
|
||||||
self.roles = None
|
self.roles = None
|
||||||
|
self.pricing_settings = None
|
||||||
|
|
||||||
def flush(self) -> None:
|
def flush(self) -> None:
|
||||||
if not self.session:
|
if not self.session:
|
||||||
@@ -116,3 +124,45 @@ class UnitOfWork(AbstractContextManager["UnitOfWork"]):
|
|||||||
username=username,
|
username=username,
|
||||||
password=password,
|
password=password,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def ensure_default_pricing_settings(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
metadata: PricingMetadata,
|
||||||
|
slug: str = "default",
|
||||||
|
name: str | None = None,
|
||||||
|
description: str | None = None,
|
||||||
|
) -> PricingSettingsSeedResult:
|
||||||
|
if not self.pricing_settings:
|
||||||
|
raise RuntimeError("UnitOfWork session is not initialised")
|
||||||
|
return ensure_default_pricing_settings(
|
||||||
|
self.pricing_settings,
|
||||||
|
metadata=metadata,
|
||||||
|
slug=slug,
|
||||||
|
name=name,
|
||||||
|
description=description,
|
||||||
|
)
|
||||||
|
|
||||||
|
def get_pricing_metadata(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
slug: str = "default",
|
||||||
|
) -> PricingMetadata | None:
|
||||||
|
if not self.pricing_settings:
|
||||||
|
raise RuntimeError("UnitOfWork session is not initialised")
|
||||||
|
settings = self.pricing_settings.find_by_slug(
|
||||||
|
slug,
|
||||||
|
include_children=True,
|
||||||
|
)
|
||||||
|
if settings is None:
|
||||||
|
return None
|
||||||
|
return pricing_settings_to_metadata(settings)
|
||||||
|
|
||||||
|
def set_project_pricing_settings(
|
||||||
|
self,
|
||||||
|
project: Project,
|
||||||
|
pricing_settings: PricingSettings | None,
|
||||||
|
) -> Project:
|
||||||
|
if not self.projects:
|
||||||
|
raise RuntimeError("UnitOfWork session is not initialised")
|
||||||
|
return self.projects.set_pricing_settings(project, pricing_settings)
|
||||||
|
|||||||
@@ -1,3 +1,204 @@
|
|||||||
|
.report-overview {
|
||||||
|
margin-bottom: 2.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.report-grid {
|
||||||
|
display: grid;
|
||||||
|
gap: 1.5rem;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
|
||||||
|
}
|
||||||
|
|
||||||
|
.report-card {
|
||||||
|
background: var(--card);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
padding: 1.5rem;
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
box-shadow: 0 12px 30px rgba(4, 7, 14, 0.35);
|
||||||
|
}
|
||||||
|
|
||||||
|
.report-card h2 {
|
||||||
|
margin-top: 0;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.report-section + .report-section {
|
||||||
|
margin-top: 3rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-header {
|
||||||
|
margin-bottom: 1.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-header h2 {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-subtitle {
|
||||||
|
margin: 0.35rem 0 0;
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.metric-list {
|
||||||
|
list-style: none;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.metric-list.compact {
|
||||||
|
gap: 0.35rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.metric-list li {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: baseline;
|
||||||
|
font-size: 0.95rem;
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.metric-list strong {
|
||||||
|
font-size: 1.05rem;
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.metrics-table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
background: rgba(21, 27, 35, 0.6);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.metrics-table th,
|
||||||
|
.metrics-table td {
|
||||||
|
padding: 0.65rem 0.9rem;
|
||||||
|
text-align: left;
|
||||||
|
border-bottom: 1px solid rgba(255, 255, 255, 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
.metrics-table th {
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.metrics-table tr:last-child td,
|
||||||
|
.metrics-table tr:last-child th {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.definition-list {
|
||||||
|
margin: 0;
|
||||||
|
display: grid;
|
||||||
|
gap: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.definition-list div {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 140px 1fr;
|
||||||
|
gap: 0.5rem;
|
||||||
|
align-items: baseline;
|
||||||
|
}
|
||||||
|
|
||||||
|
.definition-list dt {
|
||||||
|
color: var(--muted);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.definition-list dd {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.scenario-card {
|
||||||
|
background: var(--card);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
padding: 1.5rem;
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
box-shadow: 0 16px 32px rgba(4, 7, 14, 0.42);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 1.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.scenario-card + .scenario-card {
|
||||||
|
margin-top: 1.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.scenario-card-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 1rem;
|
||||||
|
align-items: flex-start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.scenario-card h3 {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.scenario-meta {
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
|
||||||
|
.meta-label {
|
||||||
|
display: block;
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 0.8rem;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.meta-value {
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.scenario-grid {
|
||||||
|
display: grid;
|
||||||
|
gap: 1.25rem;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(260px, 1fr));
|
||||||
|
}
|
||||||
|
|
||||||
|
.scenario-panel {
|
||||||
|
background: rgba(15, 20, 27, 0.8);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
padding: 1.1rem;
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.05);
|
||||||
|
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.06);
|
||||||
|
}
|
||||||
|
|
||||||
|
.scenario-panel h4,
|
||||||
|
.scenario-panel h5 {
|
||||||
|
margin-top: 0;
|
||||||
|
margin-bottom: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.note-list {
|
||||||
|
padding-left: 1.1rem;
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.muted {
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-actions .button {
|
||||||
|
text-decoration: none;
|
||||||
|
background: transparent;
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
padding: 0.6rem 1rem;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
color: var(--text);
|
||||||
|
font-weight: 600;
|
||||||
|
transition: background 0.2s ease, border-color 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-actions .button:hover,
|
||||||
|
.page-actions .button:focus {
|
||||||
|
background: rgba(241, 178, 26, 0.14);
|
||||||
|
border-color: var(--brand);
|
||||||
|
}
|
||||||
:root {
|
:root {
|
||||||
--bg: #0b0f14;
|
--bg: #0b0f14;
|
||||||
--bg-2: #0f141b;
|
--bg-2: #0f141b;
|
||||||
|
|||||||
33
templates/partials/reports/filters_card.html
Normal file
33
templates/partials/reports/filters_card.html
Normal 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 %}
|
||||||
46
templates/partials/reports/monte_carlo_table.html
Normal file
46
templates/partials/reports/monte_carlo_table.html
Normal 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 %}
|
||||||
56
templates/partials/reports/options_card.html
Normal file
56
templates/partials/reports/options_card.html
Normal 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>
|
||||||
14
templates/partials/reports/scenario_actions.html
Normal file
14
templates/partials/reports/scenario_actions.html
Normal 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>
|
||||||
24
templates/partials/reports_header.html
Normal file
24
templates/partials/reports_header.html
Normal 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>
|
||||||
205
templates/reports/project_summary.html
Normal file
205
templates/reports/project_summary.html
Normal 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 %}
|
||||||
166
templates/reports/scenario_comparison.html
Normal file
166
templates/reports/scenario_comparison.html
Normal 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 %}
|
||||||
149
templates/reports/scenario_distribution.html
Normal file
149
templates/reports/scenario_distribution.html
Normal 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 %}
|
||||||
@@ -49,7 +49,11 @@
|
|||||||
|
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label for="currency">Currency</label>
|
<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>
|
||||||
|
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
|
|||||||
@@ -78,10 +78,8 @@ class TestScenarioLifecycle:
|
|||||||
json={"currency": "ca"},
|
json={"currency": "ca"},
|
||||||
)
|
)
|
||||||
assert invalid_update.status_code == 422
|
assert invalid_update.status_code == 422
|
||||||
assert (
|
assert "Invalid currency code" in invalid_update.json()[
|
||||||
invalid_update.json()["detail"][0]["msg"]
|
"detail"][0]["msg"]
|
||||||
== "Value error, Currency code must be a 3-letter ISO value"
|
|
||||||
)
|
|
||||||
|
|
||||||
# Scenario detail should still show the previous (valid) currency
|
# Scenario detail should still show the previous (valid) currency
|
||||||
scenario_detail = client.get(f"/scenarios/{scenario_id}/view")
|
scenario_detail = client.get(f"/scenarios/{scenario_id}/view")
|
||||||
|
|||||||
@@ -10,7 +10,15 @@ from sqlalchemy.orm import Session, sessionmaker
|
|||||||
|
|
||||||
from config.database import Base
|
from config.database import Base
|
||||||
from config.settings import AdminBootstrapSettings
|
from config.settings import AdminBootstrapSettings
|
||||||
from services.bootstrap import AdminBootstrapResult, RoleBootstrapResult, bootstrap_admin
|
from models import MiningOperationType, Project
|
||||||
|
from services.bootstrap import (
|
||||||
|
AdminBootstrapResult,
|
||||||
|
PricingBootstrapResult,
|
||||||
|
RoleBootstrapResult,
|
||||||
|
bootstrap_admin,
|
||||||
|
bootstrap_pricing_settings,
|
||||||
|
)
|
||||||
|
from services.pricing import PricingMetadata
|
||||||
from services.unit_of_work import UnitOfWork
|
from services.unit_of_work import UnitOfWork
|
||||||
|
|
||||||
|
|
||||||
@@ -114,3 +122,86 @@ def test_bootstrap_respects_force_reset(unit_of_work_factory: Callable[[], UnitO
|
|||||||
user = users_repo.get_by_email(rotated_settings.email)
|
user = users_repo.get_by_email(rotated_settings.email)
|
||||||
assert user is not None
|
assert user is not None
|
||||||
assert user.verify_password("rotated")
|
assert user.verify_password("rotated")
|
||||||
|
|
||||||
|
|
||||||
|
def test_bootstrap_pricing_creates_defaults(unit_of_work_factory: Callable[[], UnitOfWork]) -> None:
|
||||||
|
metadata = PricingMetadata(
|
||||||
|
default_payable_pct=95.0,
|
||||||
|
default_currency="CAD",
|
||||||
|
moisture_threshold_pct=3.0,
|
||||||
|
moisture_penalty_per_pct=1.25,
|
||||||
|
)
|
||||||
|
|
||||||
|
result = bootstrap_pricing_settings(
|
||||||
|
metadata=metadata,
|
||||||
|
unit_of_work_factory=unit_of_work_factory,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert isinstance(result, PricingBootstrapResult)
|
||||||
|
assert result.seed.created is True
|
||||||
|
assert result.projects_assigned == 0
|
||||||
|
|
||||||
|
with unit_of_work_factory() as uow:
|
||||||
|
settings_repo = uow.pricing_settings
|
||||||
|
assert settings_repo is not None
|
||||||
|
stored = settings_repo.get_by_slug("default")
|
||||||
|
assert stored.default_currency == "CAD"
|
||||||
|
assert float(stored.default_payable_pct) == pytest.approx(95.0)
|
||||||
|
assert float(stored.moisture_threshold_pct) == pytest.approx(3.0)
|
||||||
|
assert float(stored.moisture_penalty_per_pct) == pytest.approx(1.25)
|
||||||
|
|
||||||
|
|
||||||
|
def test_bootstrap_pricing_assigns_projects(unit_of_work_factory: Callable[[], UnitOfWork]) -> None:
|
||||||
|
metadata = PricingMetadata(
|
||||||
|
default_payable_pct=90.0,
|
||||||
|
default_currency="USD",
|
||||||
|
moisture_threshold_pct=5.0,
|
||||||
|
moisture_penalty_per_pct=0.5,
|
||||||
|
)
|
||||||
|
|
||||||
|
with unit_of_work_factory() as uow:
|
||||||
|
projects_repo = uow.projects
|
||||||
|
assert projects_repo is not None
|
||||||
|
project = Project(
|
||||||
|
name="Project Alpha",
|
||||||
|
operation_type=MiningOperationType.OPEN_PIT,
|
||||||
|
)
|
||||||
|
created = projects_repo.create(project)
|
||||||
|
project_id = created.id
|
||||||
|
|
||||||
|
result = bootstrap_pricing_settings(
|
||||||
|
metadata=metadata,
|
||||||
|
unit_of_work_factory=unit_of_work_factory,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result.projects_assigned == 1
|
||||||
|
assert result.seed.created is True
|
||||||
|
|
||||||
|
with unit_of_work_factory() as uow:
|
||||||
|
projects_repo = uow.projects
|
||||||
|
assert projects_repo is not None
|
||||||
|
stored = projects_repo.get(project_id, with_pricing=True)
|
||||||
|
assert stored.pricing_settings is not None
|
||||||
|
assert stored.pricing_settings.default_currency == "USD"
|
||||||
|
|
||||||
|
|
||||||
|
def test_bootstrap_pricing_is_idempotent(unit_of_work_factory: Callable[[], UnitOfWork]) -> None:
|
||||||
|
metadata = PricingMetadata(
|
||||||
|
default_payable_pct=92.5,
|
||||||
|
default_currency="EUR",
|
||||||
|
moisture_threshold_pct=4.5,
|
||||||
|
moisture_penalty_per_pct=0.75,
|
||||||
|
)
|
||||||
|
|
||||||
|
first = bootstrap_pricing_settings(
|
||||||
|
metadata=metadata,
|
||||||
|
unit_of_work_factory=unit_of_work_factory,
|
||||||
|
)
|
||||||
|
second = bootstrap_pricing_settings(
|
||||||
|
metadata=metadata,
|
||||||
|
unit_of_work_factory=unit_of_work_factory,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert first.seed.created is True
|
||||||
|
assert second.seed.created is False
|
||||||
|
assert second.projects_assigned == 0
|
||||||
|
|||||||
42
tests/test_currency.py
Normal file
42
tests/test_currency.py
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from services.currency import CurrencyValidationError, normalise_currency, require_currency
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"raw,expected",
|
||||||
|
[
|
||||||
|
("usd", "USD"),
|
||||||
|
(" Eur ", "EUR"),
|
||||||
|
("JPY", "JPY"),
|
||||||
|
(None, None),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_normalise_currency_valid_inputs(raw: str | None, expected: str | None) -> None:
|
||||||
|
assert normalise_currency(raw) == expected
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("raw", ["usd1", "us", "", "12", "X Y Z"])
|
||||||
|
def test_normalise_currency_invalid_inputs(raw: str) -> None:
|
||||||
|
with pytest.raises(CurrencyValidationError):
|
||||||
|
normalise_currency(raw)
|
||||||
|
|
||||||
|
|
||||||
|
def test_require_currency_with_value() -> None:
|
||||||
|
assert require_currency("gbp", default="usd") == "GBP"
|
||||||
|
|
||||||
|
|
||||||
|
def test_require_currency_with_default() -> None:
|
||||||
|
assert require_currency(None, default="cad") == "CAD"
|
||||||
|
|
||||||
|
|
||||||
|
def test_require_currency_missing_default() -> None:
|
||||||
|
with pytest.raises(CurrencyValidationError):
|
||||||
|
require_currency(None)
|
||||||
|
|
||||||
|
|
||||||
|
def test_require_currency_invalid_default() -> None:
|
||||||
|
with pytest.raises(CurrencyValidationError):
|
||||||
|
require_currency(None, default="invalid")
|
||||||
@@ -128,3 +128,17 @@ def test_scenario_export_excel(client: TestClient, unit_of_work_factory) -> None
|
|||||||
|
|
||||||
with ZipFile(BytesIO(response.content)) as archive:
|
with ZipFile(BytesIO(response.content)) as archive:
|
||||||
assert "xl/workbook.xml" in archive.namelist()
|
assert "xl/workbook.xml" in archive.namelist()
|
||||||
|
|
||||||
|
|
||||||
|
def test_scenario_export_rejects_invalid_currency_filter(client: TestClient) -> None:
|
||||||
|
response = client.post(
|
||||||
|
"/exports/scenarios",
|
||||||
|
json={
|
||||||
|
"format": "csv",
|
||||||
|
"filters": {"currencies": ["USD", "XX"]},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 422
|
||||||
|
detail = response.json()["detail"]
|
||||||
|
assert "Invalid currency code" in detail
|
||||||
|
|||||||
162
tests/test_financial.py
Normal file
162
tests/test_financial.py
Normal file
@@ -0,0 +1,162 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import date
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from services.financial import (
|
||||||
|
CashFlow,
|
||||||
|
PaybackNotReachedError,
|
||||||
|
internal_rate_of_return,
|
||||||
|
net_present_value,
|
||||||
|
normalize_cash_flows,
|
||||||
|
payback_period,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_normalize_cash_flows_with_dates() -> None:
|
||||||
|
base = date(2025, 1, 1)
|
||||||
|
period_length = 365.0 / 4
|
||||||
|
flows = [
|
||||||
|
CashFlow(amount=-1_000_000, date=base),
|
||||||
|
CashFlow(amount=350_000, date=date(2025, 4, 1)),
|
||||||
|
CashFlow(amount=420_000, date=date(2025, 7, 1)),
|
||||||
|
]
|
||||||
|
|
||||||
|
normalised = normalize_cash_flows(flows, compounds_per_year=4)
|
||||||
|
|
||||||
|
assert normalised[0] == (-1_000_000.0, 0.0)
|
||||||
|
expected_second = (date(2025, 4, 1) - base).days / period_length
|
||||||
|
expected_third = (date(2025, 7, 1) - base).days / period_length
|
||||||
|
|
||||||
|
assert normalised[1][1] == pytest.approx(expected_second, rel=1e-6)
|
||||||
|
assert normalised[2][1] == pytest.approx(expected_third, rel=1e-6)
|
||||||
|
|
||||||
|
|
||||||
|
def test_net_present_value_with_period_indices() -> None:
|
||||||
|
rate = 0.10
|
||||||
|
flows = [
|
||||||
|
CashFlow(amount=-1_000, period_index=0),
|
||||||
|
CashFlow(amount=500, period_index=1),
|
||||||
|
CashFlow(amount=500, period_index=2),
|
||||||
|
CashFlow(amount=500, period_index=3),
|
||||||
|
]
|
||||||
|
|
||||||
|
expected = -1_000 + sum(500 / (1 + rate) **
|
||||||
|
period for period in range(1, 4))
|
||||||
|
|
||||||
|
result = net_present_value(rate, flows)
|
||||||
|
|
||||||
|
assert result == pytest.approx(expected, rel=1e-9)
|
||||||
|
|
||||||
|
|
||||||
|
def test_net_present_value_with_residual_value() -> None:
|
||||||
|
rate = 0.08
|
||||||
|
flows = [
|
||||||
|
CashFlow(amount=-100_000, period_index=0),
|
||||||
|
CashFlow(amount=30_000, period_index=1),
|
||||||
|
CashFlow(amount=35_000, period_index=2),
|
||||||
|
]
|
||||||
|
|
||||||
|
expected = (
|
||||||
|
-100_000
|
||||||
|
+ 30_000 / (1 + rate)
|
||||||
|
+ 35_000 / (1 + rate) ** 2
|
||||||
|
+ 25_000 / (1 + rate) ** 3
|
||||||
|
)
|
||||||
|
|
||||||
|
result = net_present_value(rate, flows, residual_value=25_000)
|
||||||
|
|
||||||
|
assert result == pytest.approx(expected, rel=1e-9)
|
||||||
|
|
||||||
|
|
||||||
|
def test_internal_rate_of_return_simple_case() -> None:
|
||||||
|
flows = [
|
||||||
|
CashFlow(amount=-1_000, period_index=0),
|
||||||
|
CashFlow(amount=1_210, period_index=1),
|
||||||
|
]
|
||||||
|
|
||||||
|
irr = internal_rate_of_return(flows, guess=0.05)
|
||||||
|
|
||||||
|
assert irr == pytest.approx(0.21, rel=1e-9)
|
||||||
|
|
||||||
|
|
||||||
|
def test_internal_rate_of_return_multiple_sign_changes() -> None:
|
||||||
|
flows = [
|
||||||
|
CashFlow(amount=-500_000, period_index=0),
|
||||||
|
CashFlow(amount=250_000, period_index=1),
|
||||||
|
CashFlow(amount=-100_000, period_index=2),
|
||||||
|
CashFlow(amount=425_000, period_index=3),
|
||||||
|
]
|
||||||
|
|
||||||
|
irr = internal_rate_of_return(flows, guess=0.2)
|
||||||
|
|
||||||
|
npv = net_present_value(irr, flows)
|
||||||
|
|
||||||
|
assert npv == pytest.approx(0.0, abs=1e-6)
|
||||||
|
|
||||||
|
|
||||||
|
def test_internal_rate_of_return_requires_mixed_signs() -> None:
|
||||||
|
flows = [
|
||||||
|
CashFlow(amount=100_000, period_index=0),
|
||||||
|
CashFlow(amount=150_000, period_index=1),
|
||||||
|
]
|
||||||
|
|
||||||
|
with pytest.raises(ValueError):
|
||||||
|
internal_rate_of_return(flows)
|
||||||
|
|
||||||
|
|
||||||
|
def test_payback_period_exact_period() -> None:
|
||||||
|
flows = [
|
||||||
|
CashFlow(amount=-120_000, period_index=0),
|
||||||
|
CashFlow(amount=40_000, period_index=1),
|
||||||
|
CashFlow(amount=40_000, period_index=2),
|
||||||
|
CashFlow(amount=40_000, period_index=3),
|
||||||
|
]
|
||||||
|
|
||||||
|
period = payback_period(flows, allow_fractional=False)
|
||||||
|
|
||||||
|
assert period == pytest.approx(3.0)
|
||||||
|
|
||||||
|
|
||||||
|
def test_payback_period_fractional_period() -> None:
|
||||||
|
flows = [
|
||||||
|
CashFlow(amount=-100_000, period_index=0),
|
||||||
|
CashFlow(amount=80_000, period_index=1),
|
||||||
|
CashFlow(amount=30_000, period_index=2),
|
||||||
|
]
|
||||||
|
|
||||||
|
fractional = payback_period(flows)
|
||||||
|
whole = payback_period(flows, allow_fractional=False)
|
||||||
|
|
||||||
|
assert fractional == pytest.approx(1 + 20_000 / 30_000, rel=1e-9)
|
||||||
|
assert whole == pytest.approx(2.0)
|
||||||
|
|
||||||
|
|
||||||
|
def test_payback_period_raises_when_never_recovered() -> None:
|
||||||
|
flows = [
|
||||||
|
CashFlow(amount=-250_000, period_index=0),
|
||||||
|
CashFlow(amount=50_000, period_index=1),
|
||||||
|
CashFlow(amount=60_000, period_index=2),
|
||||||
|
CashFlow(amount=70_000, period_index=3),
|
||||||
|
]
|
||||||
|
|
||||||
|
with pytest.raises(PaybackNotReachedError):
|
||||||
|
payback_period(flows)
|
||||||
|
|
||||||
|
|
||||||
|
def test_payback_period_with_quarterly_compounding() -> None:
|
||||||
|
base = date(2025, 1, 1)
|
||||||
|
flows = [
|
||||||
|
CashFlow(amount=-120_000, date=base),
|
||||||
|
CashFlow(amount=35_000, date=date(2025, 4, 1)),
|
||||||
|
CashFlow(amount=35_000, date=date(2025, 7, 1)),
|
||||||
|
CashFlow(amount=50_000, date=date(2025, 10, 1)),
|
||||||
|
]
|
||||||
|
|
||||||
|
period = payback_period(flows, compounds_per_year=4)
|
||||||
|
|
||||||
|
period_length = 365.0 / 4
|
||||||
|
expected_period = (date(2025, 10, 1) - base).days / period_length
|
||||||
|
|
||||||
|
assert period == pytest.approx(expected_period, abs=1e-6)
|
||||||
@@ -68,3 +68,35 @@ def test_scenario_import_commit_invalid_token_returns_404(
|
|||||||
)
|
)
|
||||||
assert response.status_code == 404
|
assert response.status_code == 404
|
||||||
assert "Unknown scenario import token" in response.json()["detail"]
|
assert "Unknown scenario import token" in response.json()["detail"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_scenario_import_preview_rejects_invalid_currency(
|
||||||
|
client: TestClient,
|
||||||
|
unit_of_work_factory,
|
||||||
|
) -> None:
|
||||||
|
with unit_of_work_factory() as uow:
|
||||||
|
assert uow.projects is not None
|
||||||
|
project = Project(
|
||||||
|
name="Import Currency Project",
|
||||||
|
operation_type=MiningOperationType.OPEN_PIT,
|
||||||
|
)
|
||||||
|
uow.projects.create(project)
|
||||||
|
|
||||||
|
csv_content = (
|
||||||
|
"project_name,name,currency\n"
|
||||||
|
"Import Currency Project,Invalid Currency,US\n"
|
||||||
|
)
|
||||||
|
|
||||||
|
response = client.post(
|
||||||
|
"/imports/scenarios/preview",
|
||||||
|
files={"file": ("scenarios.csv", csv_content, "text/csv")},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
payload = response.json()
|
||||||
|
assert payload["summary"]["accepted"] == 0
|
||||||
|
assert payload["summary"]["errored"] == 1
|
||||||
|
assert payload["parser_errors"]
|
||||||
|
parser_error = payload["parser_errors"][0]
|
||||||
|
assert parser_error["field"] == "currency"
|
||||||
|
assert "Invalid currency code" in parser_error["message"]
|
||||||
|
|||||||
@@ -140,3 +140,22 @@ def test_scenario_import_handles_large_dataset() -> None:
|
|||||||
|
|
||||||
assert len(result.rows) == 500
|
assert len(result.rows) == 500
|
||||||
assert len(result.rows) == 500
|
assert len(result.rows) == 500
|
||||||
|
|
||||||
|
|
||||||
|
def test_scenario_import_rejects_invalid_currency() -> None:
|
||||||
|
csv_content = dedent(
|
||||||
|
"""
|
||||||
|
project_name,name,currency
|
||||||
|
Project A,Scenario Invalid,US
|
||||||
|
"""
|
||||||
|
).strip()
|
||||||
|
stream = BytesIO(csv_content.encode("utf-8"))
|
||||||
|
|
||||||
|
result = load_scenario_imports(stream, "scenarios.csv")
|
||||||
|
|
||||||
|
assert not result.rows
|
||||||
|
assert result.errors
|
||||||
|
error = result.errors[0]
|
||||||
|
assert error.row_number == 2
|
||||||
|
assert error.field == "currency"
|
||||||
|
assert "Invalid currency code" in error.message
|
||||||
|
|||||||
155
tests/test_pricing.py
Normal file
155
tests/test_pricing.py
Normal file
@@ -0,0 +1,155 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import math
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from services.pricing import (
|
||||||
|
PricingInput,
|
||||||
|
PricingMetadata,
|
||||||
|
PricingResult,
|
||||||
|
calculate_pricing,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_calculate_pricing_with_explicit_penalties() -> None:
|
||||||
|
pricing_input = PricingInput(
|
||||||
|
metal="copper",
|
||||||
|
ore_tonnage=100_000,
|
||||||
|
head_grade_pct=1.2,
|
||||||
|
recovery_pct=90,
|
||||||
|
payable_pct=96,
|
||||||
|
reference_price=8_500,
|
||||||
|
treatment_charge=100_000,
|
||||||
|
smelting_charge=0,
|
||||||
|
moisture_pct=10,
|
||||||
|
moisture_threshold_pct=8,
|
||||||
|
moisture_penalty_per_pct=3_000,
|
||||||
|
impurity_ppm={"As": 100},
|
||||||
|
impurity_thresholds={"As": 0},
|
||||||
|
impurity_penalty_per_ppm={"As": 2},
|
||||||
|
premiums=50_000,
|
||||||
|
fx_rate=1.0,
|
||||||
|
currency_code="usd",
|
||||||
|
)
|
||||||
|
|
||||||
|
result = calculate_pricing(pricing_input)
|
||||||
|
|
||||||
|
assert isinstance(result, PricingResult)
|
||||||
|
assert math.isclose(result.payable_metal_tonnes, 1_036.8, rel_tol=1e-6)
|
||||||
|
assert math.isclose(result.gross_revenue, 1_036.8 * 8_500, rel_tol=1e-6)
|
||||||
|
assert math.isclose(result.moisture_penalty, 6_000, rel_tol=1e-6)
|
||||||
|
assert math.isclose(result.impurity_penalty, 200, rel_tol=1e-6)
|
||||||
|
assert math.isclose(result.net_revenue, 8_756_600, rel_tol=1e-6)
|
||||||
|
assert result.treatment_smelt_charges == pytest.approx(100_000)
|
||||||
|
assert result.currency == "USD"
|
||||||
|
|
||||||
|
|
||||||
|
def test_calculate_pricing_with_metadata_defaults() -> None:
|
||||||
|
metadata = PricingMetadata(
|
||||||
|
default_payable_pct=95,
|
||||||
|
default_currency="EUR",
|
||||||
|
moisture_threshold_pct=7,
|
||||||
|
moisture_penalty_per_pct=2_000,
|
||||||
|
impurity_thresholds={"Pb": 50},
|
||||||
|
impurity_penalty_per_ppm={"Pb": 1.5},
|
||||||
|
)
|
||||||
|
|
||||||
|
pricing_input = PricingInput(
|
||||||
|
metal="lead",
|
||||||
|
ore_tonnage=50_000,
|
||||||
|
head_grade_pct=5,
|
||||||
|
recovery_pct=85,
|
||||||
|
payable_pct=None,
|
||||||
|
reference_price=2_000,
|
||||||
|
treatment_charge=30_000,
|
||||||
|
smelting_charge=20_000,
|
||||||
|
moisture_pct=9,
|
||||||
|
moisture_threshold_pct=None,
|
||||||
|
moisture_penalty_per_pct=None,
|
||||||
|
impurity_ppm={"Pb": 120},
|
||||||
|
premiums=12_000,
|
||||||
|
fx_rate=1.2,
|
||||||
|
currency_code=None,
|
||||||
|
)
|
||||||
|
|
||||||
|
result = calculate_pricing(pricing_input, metadata=metadata)
|
||||||
|
|
||||||
|
expected_payable = 50_000 * 0.05 * 0.85 * 0.95
|
||||||
|
assert math.isclose(result.payable_metal_tonnes,
|
||||||
|
expected_payable, rel_tol=1e-6)
|
||||||
|
assert result.moisture_penalty == pytest.approx((9 - 7) * 2_000)
|
||||||
|
assert result.impurity_penalty == pytest.approx((120 - 50) * 1.5)
|
||||||
|
assert result.treatment_smelt_charges == pytest.approx(50_000)
|
||||||
|
assert result.currency == "EUR"
|
||||||
|
assert result.net_revenue > 0
|
||||||
|
|
||||||
|
|
||||||
|
def test_calculate_pricing_currency_override() -> None:
|
||||||
|
pricing_input = PricingInput(
|
||||||
|
metal="gold",
|
||||||
|
ore_tonnage=10_000,
|
||||||
|
head_grade_pct=2.5,
|
||||||
|
recovery_pct=92,
|
||||||
|
payable_pct=98,
|
||||||
|
reference_price=60_000,
|
||||||
|
treatment_charge=40_000,
|
||||||
|
smelting_charge=10_000,
|
||||||
|
moisture_pct=5,
|
||||||
|
moisture_threshold_pct=7,
|
||||||
|
moisture_penalty_per_pct=1_000,
|
||||||
|
premiums=25_000,
|
||||||
|
fx_rate=1.0,
|
||||||
|
currency_code="cad",
|
||||||
|
)
|
||||||
|
|
||||||
|
metadata = PricingMetadata(default_currency="USD")
|
||||||
|
|
||||||
|
result = calculate_pricing(
|
||||||
|
pricing_input, metadata=metadata, currency="CAD")
|
||||||
|
|
||||||
|
assert result.currency == "CAD"
|
||||||
|
assert result.net_revenue > 0
|
||||||
|
|
||||||
|
|
||||||
|
def test_calculate_pricing_multiple_inputs_aggregate() -> None:
|
||||||
|
metadata = PricingMetadata(default_currency="USD")
|
||||||
|
inputs = [
|
||||||
|
PricingInput(
|
||||||
|
metal="copper",
|
||||||
|
ore_tonnage=10_000,
|
||||||
|
head_grade_pct=1.5,
|
||||||
|
recovery_pct=88,
|
||||||
|
payable_pct=95,
|
||||||
|
reference_price=8_000,
|
||||||
|
treatment_charge=20_000,
|
||||||
|
smelting_charge=5_000,
|
||||||
|
moisture_pct=7,
|
||||||
|
moisture_threshold_pct=8,
|
||||||
|
moisture_penalty_per_pct=1_000,
|
||||||
|
premiums=0,
|
||||||
|
fx_rate=1.0,
|
||||||
|
currency_code=None,
|
||||||
|
),
|
||||||
|
PricingInput(
|
||||||
|
metal="copper",
|
||||||
|
ore_tonnage=8_000,
|
||||||
|
head_grade_pct=1.1,
|
||||||
|
recovery_pct=90,
|
||||||
|
payable_pct=96,
|
||||||
|
reference_price=8_000,
|
||||||
|
treatment_charge=18_000,
|
||||||
|
smelting_charge=4_000,
|
||||||
|
moisture_pct=9,
|
||||||
|
moisture_threshold_pct=8,
|
||||||
|
moisture_penalty_per_pct=1_000,
|
||||||
|
premiums=0,
|
||||||
|
fx_rate=1.0,
|
||||||
|
currency_code="usd",
|
||||||
|
),
|
||||||
|
]
|
||||||
|
|
||||||
|
results = [calculate_pricing(i, metadata=metadata) for i in inputs]
|
||||||
|
|
||||||
|
assert all(result.currency == "USD" for result in results)
|
||||||
|
assert sum(result.net_revenue for result in results) > 0
|
||||||
209
tests/test_pricing_settings_repository.py
Normal file
209
tests/test_pricing_settings_repository.py
Normal file
@@ -0,0 +1,209 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from collections.abc import Iterator
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from sqlalchemy import create_engine
|
||||||
|
from sqlalchemy.orm import Session, sessionmaker
|
||||||
|
|
||||||
|
from config.database import Base
|
||||||
|
from models import PricingImpuritySettings, PricingMetalSettings, PricingSettings
|
||||||
|
from services.pricing import PricingMetadata
|
||||||
|
from services.repositories import (
|
||||||
|
PricingSettingsRepository,
|
||||||
|
ensure_default_pricing_settings,
|
||||||
|
)
|
||||||
|
from services.unit_of_work import UnitOfWork
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture()
|
||||||
|
def engine() -> Iterator:
|
||||||
|
engine = create_engine("sqlite:///:memory:", future=True)
|
||||||
|
Base.metadata.create_all(bind=engine)
|
||||||
|
try:
|
||||||
|
yield engine
|
||||||
|
finally:
|
||||||
|
Base.metadata.drop_all(bind=engine)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture()
|
||||||
|
def session(engine) -> Iterator[Session]:
|
||||||
|
TestingSession = sessionmaker(
|
||||||
|
bind=engine, expire_on_commit=False, future=True)
|
||||||
|
db = TestingSession()
|
||||||
|
try:
|
||||||
|
yield db
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
|
||||||
|
|
||||||
|
def test_pricing_settings_repository_crud(session: Session) -> None:
|
||||||
|
repo = PricingSettingsRepository(session)
|
||||||
|
|
||||||
|
settings = PricingSettings(
|
||||||
|
name="Contract A",
|
||||||
|
slug="Contract-A",
|
||||||
|
default_currency="usd",
|
||||||
|
default_payable_pct=95.0,
|
||||||
|
moisture_threshold_pct=7.5,
|
||||||
|
moisture_penalty_per_pct=1500.0,
|
||||||
|
)
|
||||||
|
repo.create(settings)
|
||||||
|
|
||||||
|
metal_override = PricingMetalSettings(
|
||||||
|
metal_code="Copper",
|
||||||
|
payable_pct=96.0,
|
||||||
|
moisture_threshold_pct=None,
|
||||||
|
moisture_penalty_per_pct=None,
|
||||||
|
)
|
||||||
|
repo.attach_metal_override(settings, metal_override)
|
||||||
|
|
||||||
|
impurity_override = PricingImpuritySettings(
|
||||||
|
impurity_code="as",
|
||||||
|
threshold_ppm=100.0,
|
||||||
|
penalty_per_ppm=3.5,
|
||||||
|
)
|
||||||
|
repo.attach_impurity_override(settings, impurity_override)
|
||||||
|
|
||||||
|
retrieved = repo.get_by_slug("CONTRACT-A", include_children=True)
|
||||||
|
assert retrieved.slug == "contract-a"
|
||||||
|
assert retrieved.default_currency == "USD"
|
||||||
|
assert len(retrieved.metal_overrides) == 1
|
||||||
|
assert retrieved.metal_overrides[0].metal_code == "copper"
|
||||||
|
assert len(retrieved.impurity_overrides) == 1
|
||||||
|
assert retrieved.impurity_overrides[0].impurity_code == "AS"
|
||||||
|
|
||||||
|
listed = repo.list(include_children=True)
|
||||||
|
assert len(listed) == 1
|
||||||
|
assert listed[0].id == settings.id
|
||||||
|
|
||||||
|
|
||||||
|
def test_ensure_default_pricing_settings_creates_and_updates(session: Session) -> None:
|
||||||
|
repo = PricingSettingsRepository(session)
|
||||||
|
|
||||||
|
metadata_initial = PricingMetadata(
|
||||||
|
default_payable_pct=100.0,
|
||||||
|
default_currency="USD",
|
||||||
|
moisture_threshold_pct=8.0,
|
||||||
|
moisture_penalty_per_pct=0.0,
|
||||||
|
impurity_thresholds={"As": 50.0},
|
||||||
|
impurity_penalty_per_ppm={"As": 2.0},
|
||||||
|
)
|
||||||
|
|
||||||
|
result_create = ensure_default_pricing_settings(
|
||||||
|
repo,
|
||||||
|
metadata=metadata_initial,
|
||||||
|
name="Seeded Pricing",
|
||||||
|
description="Seeded from defaults",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result_create.created is True
|
||||||
|
assert result_create.settings.slug == "default"
|
||||||
|
assert result_create.settings.default_currency == "USD"
|
||||||
|
assert len(result_create.settings.impurity_overrides) == 1
|
||||||
|
assert result_create.settings.impurity_overrides[0].penalty_per_ppm == 2.0
|
||||||
|
|
||||||
|
metadata_update = PricingMetadata(
|
||||||
|
default_payable_pct=97.0,
|
||||||
|
default_currency="EUR",
|
||||||
|
moisture_threshold_pct=6.5,
|
||||||
|
moisture_penalty_per_pct=250.0,
|
||||||
|
impurity_thresholds={"As": 45.0, "Pb": 12.0},
|
||||||
|
impurity_penalty_per_ppm={"As": 3.0, "Pb": 1.25},
|
||||||
|
)
|
||||||
|
|
||||||
|
result_update = ensure_default_pricing_settings(
|
||||||
|
repo,
|
||||||
|
metadata=metadata_update,
|
||||||
|
name="Seeded Pricing",
|
||||||
|
description="Seeded from defaults",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result_update.created is False
|
||||||
|
assert result_update.updated_fields > 0
|
||||||
|
assert result_update.impurity_upserts >= 1
|
||||||
|
|
||||||
|
updated = repo.get_by_slug("default", include_children=True)
|
||||||
|
assert updated.default_currency == "EUR"
|
||||||
|
as_override = {
|
||||||
|
item.impurity_code: item for item in updated.impurity_overrides}["AS"]
|
||||||
|
assert float(as_override.threshold_ppm) == 45.0
|
||||||
|
assert float(as_override.penalty_per_ppm) == 3.0
|
||||||
|
pb_override = {
|
||||||
|
item.impurity_code: item for item in updated.impurity_overrides}["PB"]
|
||||||
|
assert float(pb_override.threshold_ppm) == 12.0
|
||||||
|
|
||||||
|
|
||||||
|
def test_unit_of_work_exposes_pricing_settings(engine) -> None:
|
||||||
|
TestingSession = sessionmaker(
|
||||||
|
bind=engine, expire_on_commit=False, future=True)
|
||||||
|
metadata = PricingMetadata(
|
||||||
|
default_payable_pct=99.0,
|
||||||
|
default_currency="USD",
|
||||||
|
moisture_threshold_pct=7.0,
|
||||||
|
moisture_penalty_per_pct=125.0,
|
||||||
|
impurity_thresholds={"Zn": 80.0},
|
||||||
|
impurity_penalty_per_ppm={"Zn": 0.5},
|
||||||
|
)
|
||||||
|
|
||||||
|
with UnitOfWork(session_factory=TestingSession) as uow:
|
||||||
|
assert uow.pricing_settings is not None
|
||||||
|
result = uow.ensure_default_pricing_settings(
|
||||||
|
metadata=metadata,
|
||||||
|
slug="contract-core",
|
||||||
|
name="Contract Core",
|
||||||
|
)
|
||||||
|
assert result.settings.slug == "contract-core"
|
||||||
|
assert result.created is True
|
||||||
|
|
||||||
|
with UnitOfWork(session_factory=TestingSession) as uow:
|
||||||
|
assert uow.pricing_settings is not None
|
||||||
|
stored = uow.pricing_settings.get_by_slug(
|
||||||
|
"contract-core", include_children=True)
|
||||||
|
assert stored.default_payable_pct == 99.0
|
||||||
|
assert stored.impurity_overrides[0].impurity_code == "ZN"
|
||||||
|
|
||||||
|
|
||||||
|
def test_unit_of_work_get_pricing_metadata_returns_defaults(engine) -> None:
|
||||||
|
TestingSession = sessionmaker(
|
||||||
|
bind=engine, expire_on_commit=False, future=True)
|
||||||
|
seeded_metadata = PricingMetadata(
|
||||||
|
default_payable_pct=96.5,
|
||||||
|
default_currency="aud",
|
||||||
|
moisture_threshold_pct=6.25,
|
||||||
|
moisture_penalty_per_pct=210.0,
|
||||||
|
impurity_thresholds={"As": 45.0, "Pb": 15.0},
|
||||||
|
impurity_penalty_per_ppm={"As": 1.75, "Pb": 0.9},
|
||||||
|
)
|
||||||
|
|
||||||
|
with UnitOfWork(session_factory=TestingSession) as uow:
|
||||||
|
result = uow.ensure_default_pricing_settings(
|
||||||
|
metadata=seeded_metadata,
|
||||||
|
slug="default",
|
||||||
|
name="Default Contract",
|
||||||
|
description="Primary contract defaults",
|
||||||
|
)
|
||||||
|
assert result.created is True
|
||||||
|
|
||||||
|
with UnitOfWork(session_factory=TestingSession) as uow:
|
||||||
|
retrieved = uow.get_pricing_metadata()
|
||||||
|
|
||||||
|
assert retrieved is not None
|
||||||
|
assert retrieved.default_currency == "AUD"
|
||||||
|
assert retrieved.default_payable_pct == 96.5
|
||||||
|
assert retrieved.moisture_threshold_pct == 6.25
|
||||||
|
assert retrieved.moisture_penalty_per_pct == 210.0
|
||||||
|
assert retrieved.impurity_thresholds["AS"] == 45.0
|
||||||
|
assert retrieved.impurity_thresholds["PB"] == 15.0
|
||||||
|
assert retrieved.impurity_penalty_per_ppm["AS"] == 1.75
|
||||||
|
assert retrieved.impurity_penalty_per_ppm["PB"] == 0.9
|
||||||
|
|
||||||
|
|
||||||
|
def test_unit_of_work_get_pricing_metadata_returns_none_when_missing(engine) -> None:
|
||||||
|
TestingSession = sessionmaker(
|
||||||
|
bind=engine, expire_on_commit=False, future=True)
|
||||||
|
|
||||||
|
with UnitOfWork(session_factory=TestingSession) as uow:
|
||||||
|
missing = uow.get_pricing_metadata(slug="non-existent")
|
||||||
|
|
||||||
|
assert missing is None
|
||||||
@@ -13,6 +13,7 @@ from models import (
|
|||||||
FinancialCategory,
|
FinancialCategory,
|
||||||
FinancialInput,
|
FinancialInput,
|
||||||
MiningOperationType,
|
MiningOperationType,
|
||||||
|
PricingSettings,
|
||||||
Project,
|
Project,
|
||||||
Scenario,
|
Scenario,
|
||||||
ScenarioStatus,
|
ScenarioStatus,
|
||||||
@@ -147,6 +148,30 @@ def test_unit_of_work_commit_and_rollback(engine) -> None:
|
|||||||
projects = ProjectRepository(session).list()
|
projects = ProjectRepository(session).list()
|
||||||
assert len(projects) == 1
|
assert len(projects) == 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_unit_of_work_set_project_pricing_settings(engine) -> None:
|
||||||
|
TestingSession = sessionmaker(bind=engine, expire_on_commit=False, future=True)
|
||||||
|
with UnitOfWork(session_factory=TestingSession) as uow:
|
||||||
|
assert uow.projects is not None and uow.pricing_settings is not None
|
||||||
|
project = Project(name="Project Pricing", operation_type=MiningOperationType.OTHER)
|
||||||
|
uow.projects.create(project)
|
||||||
|
pricing_settings = PricingSettings(
|
||||||
|
name="Default Pricing",
|
||||||
|
slug="default",
|
||||||
|
default_currency="usd",
|
||||||
|
default_payable_pct=100.0,
|
||||||
|
moisture_threshold_pct=8.0,
|
||||||
|
moisture_penalty_per_pct=0.0,
|
||||||
|
)
|
||||||
|
uow.pricing_settings.create(pricing_settings)
|
||||||
|
uow.set_project_pricing_settings(project, pricing_settings)
|
||||||
|
|
||||||
|
with TestingSession() as session:
|
||||||
|
repo = ProjectRepository(session)
|
||||||
|
stored = repo.get(1, with_pricing=True)
|
||||||
|
assert stored.pricing_settings is not None
|
||||||
|
assert stored.pricing_settings.slug == "default"
|
||||||
|
|
||||||
# Rollback path
|
# Rollback path
|
||||||
with pytest.raises(RuntimeError):
|
with pytest.raises(RuntimeError):
|
||||||
with UnitOfWork(session_factory=TestingSession) as uow:
|
with UnitOfWork(session_factory=TestingSession) as uow:
|
||||||
@@ -302,6 +327,44 @@ def test_project_repository_filtered_for_export(session: Session) -> None:
|
|||||||
assert results[0].scenarios[0].name == "Alpha Scenario"
|
assert results[0].scenarios[0].name == "Alpha Scenario"
|
||||||
|
|
||||||
|
|
||||||
|
def test_project_repository_with_pricing_settings(session: Session) -> None:
|
||||||
|
repo = ProjectRepository(session)
|
||||||
|
settings = PricingSettings(
|
||||||
|
name="Contract Core",
|
||||||
|
slug="contract-core",
|
||||||
|
default_currency="usd",
|
||||||
|
default_payable_pct=95.0,
|
||||||
|
moisture_threshold_pct=7.5,
|
||||||
|
moisture_penalty_per_pct=100.0,
|
||||||
|
)
|
||||||
|
project = Project(
|
||||||
|
name="Project Pricing",
|
||||||
|
operation_type=MiningOperationType.OPEN_PIT,
|
||||||
|
pricing_settings=settings,
|
||||||
|
)
|
||||||
|
session.add(project)
|
||||||
|
session.flush()
|
||||||
|
|
||||||
|
fetched = repo.get(project.id, with_pricing=True)
|
||||||
|
assert fetched.pricing_settings is not None
|
||||||
|
assert fetched.pricing_settings.slug == "contract-core"
|
||||||
|
assert fetched.pricing_settings.default_currency == "USD"
|
||||||
|
|
||||||
|
listed = repo.list(with_pricing=True)
|
||||||
|
assert listed[0].pricing_settings is not None
|
||||||
|
|
||||||
|
repo.set_pricing_settings(project, None)
|
||||||
|
session.refresh(project)
|
||||||
|
assert project.pricing_settings is None
|
||||||
|
|
||||||
|
repo.set_pricing_settings(project, settings)
|
||||||
|
session.refresh(project)
|
||||||
|
assert project.pricing_settings is settings
|
||||||
|
|
||||||
|
export_results = repo.filtered_for_export(None, include_pricing=True)
|
||||||
|
assert export_results[0].pricing_settings is not None
|
||||||
|
|
||||||
|
|
||||||
def test_scenario_repository_filtered_for_export(session: Session) -> None:
|
def test_scenario_repository_filtered_for_export(session: Session) -> None:
|
||||||
repo = ScenarioRepository(session)
|
repo = ScenarioRepository(session)
|
||||||
|
|
||||||
|
|||||||
120
tests/test_scenario_pricing.py
Normal file
120
tests/test_scenario_pricing.py
Normal file
@@ -0,0 +1,120 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from models import MiningOperationType, Project, Scenario, ScenarioStatus
|
||||||
|
from services.pricing import PricingInput, PricingMetadata
|
||||||
|
from services.scenario_evaluation import ScenarioPricingConfig, ScenarioPricingEvaluator
|
||||||
|
|
||||||
|
|
||||||
|
def build_scenario() -> Scenario:
|
||||||
|
project = Project(name="Test Project",
|
||||||
|
operation_type=MiningOperationType.OPEN_PIT)
|
||||||
|
scenario = Scenario(
|
||||||
|
project=project,
|
||||||
|
project_id=1,
|
||||||
|
name="Scenario A",
|
||||||
|
status=ScenarioStatus.ACTIVE,
|
||||||
|
currency="USD",
|
||||||
|
)
|
||||||
|
scenario.id = 1 # simulate persisted entity
|
||||||
|
return scenario
|
||||||
|
|
||||||
|
|
||||||
|
def test_scenario_pricing_evaluator_uses_metadata_defaults() -> None:
|
||||||
|
scenario = build_scenario()
|
||||||
|
evaluator = ScenarioPricingEvaluator(
|
||||||
|
ScenarioPricingConfig(
|
||||||
|
metadata=PricingMetadata(
|
||||||
|
default_currency="USD", default_payable_pct=95)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
inputs = [
|
||||||
|
PricingInput(
|
||||||
|
metal="copper",
|
||||||
|
ore_tonnage=50_000,
|
||||||
|
head_grade_pct=1.0,
|
||||||
|
recovery_pct=90,
|
||||||
|
payable_pct=None,
|
||||||
|
reference_price=9_000,
|
||||||
|
treatment_charge=50_000,
|
||||||
|
smelting_charge=10_000,
|
||||||
|
moisture_pct=9,
|
||||||
|
moisture_threshold_pct=None,
|
||||||
|
moisture_penalty_per_pct=None,
|
||||||
|
impurity_ppm={"As": 120},
|
||||||
|
premiums=10_000,
|
||||||
|
fx_rate=1.0,
|
||||||
|
currency_code=None,
|
||||||
|
)
|
||||||
|
]
|
||||||
|
|
||||||
|
snapshot = evaluator.evaluate(scenario, inputs=inputs)
|
||||||
|
|
||||||
|
assert snapshot.scenario_id == scenario.id
|
||||||
|
assert len(snapshot.results) == 1
|
||||||
|
result = snapshot.results[0]
|
||||||
|
assert result.currency == "USD"
|
||||||
|
assert result.net_revenue > 0
|
||||||
|
|
||||||
|
|
||||||
|
def test_scenario_pricing_evaluator_override_metadata() -> None:
|
||||||
|
scenario = build_scenario()
|
||||||
|
evaluator = ScenarioPricingEvaluator(ScenarioPricingConfig())
|
||||||
|
metadata_override = PricingMetadata(
|
||||||
|
default_currency="CAD",
|
||||||
|
default_payable_pct=90,
|
||||||
|
moisture_threshold_pct=5,
|
||||||
|
moisture_penalty_per_pct=500,
|
||||||
|
)
|
||||||
|
|
||||||
|
inputs = [
|
||||||
|
PricingInput(
|
||||||
|
metal="copper",
|
||||||
|
ore_tonnage=20_000,
|
||||||
|
head_grade_pct=1.2,
|
||||||
|
recovery_pct=88,
|
||||||
|
payable_pct=None,
|
||||||
|
reference_price=8_200,
|
||||||
|
treatment_charge=15_000,
|
||||||
|
smelting_charge=6_000,
|
||||||
|
moisture_pct=6,
|
||||||
|
moisture_threshold_pct=None,
|
||||||
|
moisture_penalty_per_pct=None,
|
||||||
|
premiums=5_000,
|
||||||
|
fx_rate=1.0,
|
||||||
|
currency_code="cad",
|
||||||
|
),
|
||||||
|
PricingInput(
|
||||||
|
metal="gold",
|
||||||
|
ore_tonnage=5_000,
|
||||||
|
head_grade_pct=2.0,
|
||||||
|
recovery_pct=90,
|
||||||
|
payable_pct=None,
|
||||||
|
reference_price=60_000,
|
||||||
|
treatment_charge=10_000,
|
||||||
|
smelting_charge=5_000,
|
||||||
|
moisture_pct=4,
|
||||||
|
moisture_threshold_pct=None,
|
||||||
|
moisture_penalty_per_pct=None,
|
||||||
|
premiums=15_000,
|
||||||
|
fx_rate=1.0,
|
||||||
|
currency_code="cad",
|
||||||
|
),
|
||||||
|
]
|
||||||
|
|
||||||
|
snapshot = evaluator.evaluate(
|
||||||
|
scenario,
|
||||||
|
inputs=inputs,
|
||||||
|
metadata_override=metadata_override,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert len(snapshot.results) == 2
|
||||||
|
assert all(result.currency ==
|
||||||
|
scenario.currency for result in snapshot.results)
|
||||||
|
|
||||||
|
copper_result = snapshot.results[0]
|
||||||
|
expected_payable = 20_000 * 0.012 * 0.88 * 0.90
|
||||||
|
assert copper_result.payable_metal_tonnes == pytest.approx(
|
||||||
|
expected_payable)
|
||||||
|
assert sum(result.net_revenue for result in snapshot.results) > 0
|
||||||
@@ -278,3 +278,70 @@ class TestScenarioComparisonEndpoint:
|
|||||||
detail = response.json()["detail"]
|
detail = response.json()["detail"]
|
||||||
assert detail["code"] == "SCENARIO_PROJECT_MISMATCH"
|
assert detail["code"] == "SCENARIO_PROJECT_MISMATCH"
|
||||||
assert project_a_id != project_b_id
|
assert project_a_id != project_b_id
|
||||||
|
|
||||||
|
|
||||||
|
class TestScenarioApiCurrencyValidation:
|
||||||
|
def test_create_api_rejects_invalid_currency(
|
||||||
|
self,
|
||||||
|
api_client: TestClient,
|
||||||
|
session_factory: sessionmaker,
|
||||||
|
) -> None:
|
||||||
|
with UnitOfWork(session_factory=session_factory) as uow:
|
||||||
|
assert uow.projects is not None
|
||||||
|
assert uow.scenarios is not None
|
||||||
|
project = Project(
|
||||||
|
name="Currency Validation Project",
|
||||||
|
operation_type=MiningOperationType.OPEN_PIT,
|
||||||
|
)
|
||||||
|
uow.projects.create(project)
|
||||||
|
project_id = project.id
|
||||||
|
|
||||||
|
response = api_client.post(
|
||||||
|
f"/projects/{project_id}/scenarios",
|
||||||
|
json={
|
||||||
|
"name": "Invalid Currency Scenario",
|
||||||
|
"currency": "US",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 422
|
||||||
|
detail = response.json().get("detail", [])
|
||||||
|
assert any(
|
||||||
|
"Invalid currency code" in item.get("msg", "") for item in detail
|
||||||
|
), detail
|
||||||
|
|
||||||
|
with UnitOfWork(session_factory=session_factory) as uow:
|
||||||
|
assert uow.scenarios is not None
|
||||||
|
scenarios = uow.scenarios.list_for_project(project_id)
|
||||||
|
assert scenarios == []
|
||||||
|
|
||||||
|
def test_create_api_normalises_currency(
|
||||||
|
self,
|
||||||
|
api_client: TestClient,
|
||||||
|
session_factory: sessionmaker,
|
||||||
|
) -> None:
|
||||||
|
with UnitOfWork(session_factory=session_factory) as uow:
|
||||||
|
assert uow.projects is not None
|
||||||
|
assert uow.scenarios is not None
|
||||||
|
project = Project(
|
||||||
|
name="Currency Normalisation Project",
|
||||||
|
operation_type=MiningOperationType.OPEN_PIT,
|
||||||
|
)
|
||||||
|
uow.projects.create(project)
|
||||||
|
project_id = project.id
|
||||||
|
|
||||||
|
response = api_client.post(
|
||||||
|
f"/projects/{project_id}/scenarios",
|
||||||
|
json={
|
||||||
|
"name": "Normalised Currency Scenario",
|
||||||
|
"currency": "cad",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 201
|
||||||
|
|
||||||
|
with UnitOfWork(session_factory=session_factory) as uow:
|
||||||
|
assert uow.scenarios is not None
|
||||||
|
scenarios = uow.scenarios.list_for_project(project_id)
|
||||||
|
assert len(scenarios) == 1
|
||||||
|
assert scenarios[0].currency == "CAD"
|
||||||
|
|||||||
158
tests/test_simulation.py
Normal file
158
tests/test_simulation.py
Normal file
@@ -0,0 +1,158 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import math
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from services.financial import CashFlow, net_present_value
|
||||||
|
from services.simulation import (
|
||||||
|
CashFlowSpec,
|
||||||
|
DistributionConfigError,
|
||||||
|
DistributionSource,
|
||||||
|
DistributionSpec,
|
||||||
|
DistributionType,
|
||||||
|
SimulationConfig,
|
||||||
|
SimulationMetric,
|
||||||
|
run_monte_carlo,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_run_monte_carlo_deterministic_matches_financial_helpers() -> None:
|
||||||
|
base_flows = [
|
||||||
|
CashFlow(amount=-1000.0, period_index=0),
|
||||||
|
CashFlow(amount=600.0, period_index=1),
|
||||||
|
CashFlow(amount=600.0, period_index=2),
|
||||||
|
]
|
||||||
|
specs = [CashFlowSpec(cash_flow=flow) for flow in base_flows]
|
||||||
|
config = SimulationConfig(
|
||||||
|
iterations=10,
|
||||||
|
discount_rate=0.1,
|
||||||
|
percentiles=(50,),
|
||||||
|
seed=123,
|
||||||
|
)
|
||||||
|
|
||||||
|
result = run_monte_carlo(specs, config)
|
||||||
|
summary = result.summaries[SimulationMetric.NPV]
|
||||||
|
expected = net_present_value(0.1, base_flows)
|
||||||
|
|
||||||
|
assert summary.sample_size == config.iterations
|
||||||
|
assert summary.failed_runs == 0
|
||||||
|
assert summary.mean == pytest.approx(expected, rel=1e-6)
|
||||||
|
assert summary.std_dev == 0.0
|
||||||
|
assert summary.percentiles[50] == pytest.approx(expected, rel=1e-6)
|
||||||
|
|
||||||
|
|
||||||
|
def test_run_monte_carlo_normal_distribution_uses_seed_for_reproducibility() -> None:
|
||||||
|
base_flows = [
|
||||||
|
CashFlow(amount=-100.0, period_index=0),
|
||||||
|
CashFlow(amount=0.0, period_index=1),
|
||||||
|
CashFlow(amount=0.0, period_index=2),
|
||||||
|
]
|
||||||
|
revenue_flow = CashFlowSpec(
|
||||||
|
cash_flow=CashFlow(amount=120.0, period_index=1),
|
||||||
|
distribution=DistributionSpec(
|
||||||
|
type=DistributionType.NORMAL,
|
||||||
|
parameters={"mean": 120.0, "std_dev": 10.0},
|
||||||
|
),
|
||||||
|
)
|
||||||
|
specs = [CashFlowSpec(cash_flow=base_flows[0]), revenue_flow]
|
||||||
|
config = SimulationConfig(
|
||||||
|
iterations=1000,
|
||||||
|
discount_rate=0.0,
|
||||||
|
percentiles=(5.0, 50.0, 95.0),
|
||||||
|
seed=42,
|
||||||
|
)
|
||||||
|
|
||||||
|
result = run_monte_carlo(specs, config)
|
||||||
|
summary = result.summaries[SimulationMetric.NPV]
|
||||||
|
|
||||||
|
assert summary.sample_size == config.iterations
|
||||||
|
assert summary.failed_runs == 0
|
||||||
|
# With zero discount rate the expected mean NPV equals mean sampled value minus investment.
|
||||||
|
assert summary.mean == pytest.approx(20.0, abs=1.0)
|
||||||
|
assert summary.std_dev == pytest.approx(10.0, abs=1.0)
|
||||||
|
assert summary.percentiles[50.0] == pytest.approx(summary.mean, abs=1.0)
|
||||||
|
|
||||||
|
|
||||||
|
def test_run_monte_carlo_supports_scenario_field_source() -> None:
|
||||||
|
base_flow = CashFlow(amount=0.0, period_index=1)
|
||||||
|
spec = CashFlowSpec(
|
||||||
|
cash_flow=base_flow,
|
||||||
|
distribution=DistributionSpec(
|
||||||
|
type=DistributionType.NORMAL,
|
||||||
|
parameters={"std_dev": 0.0},
|
||||||
|
source=DistributionSource.SCENARIO_FIELD,
|
||||||
|
source_key="salvage_mean",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
config = SimulationConfig(iterations=1, discount_rate=0.0, seed=7)
|
||||||
|
|
||||||
|
result = run_monte_carlo(
|
||||||
|
[CashFlowSpec(cash_flow=CashFlow(
|
||||||
|
amount=-100.0, period_index=0)), spec],
|
||||||
|
config,
|
||||||
|
scenario_context={"salvage_mean": 150.0},
|
||||||
|
)
|
||||||
|
|
||||||
|
summary = result.summaries[SimulationMetric.NPV]
|
||||||
|
assert summary.sample_size == 1
|
||||||
|
assert summary.mean == pytest.approx(50.0)
|
||||||
|
|
||||||
|
|
||||||
|
def test_run_monte_carlo_records_failed_metrics_when_not_defined() -> None:
|
||||||
|
base_flows = [CashFlow(amount=100.0, period_index=0)]
|
||||||
|
specs = [CashFlowSpec(cash_flow=flow) for flow in base_flows]
|
||||||
|
config = SimulationConfig(
|
||||||
|
iterations=5,
|
||||||
|
discount_rate=0.1,
|
||||||
|
metrics=(SimulationMetric.IRR,),
|
||||||
|
seed=5,
|
||||||
|
)
|
||||||
|
|
||||||
|
result = run_monte_carlo(specs, config)
|
||||||
|
summary = result.summaries[SimulationMetric.IRR]
|
||||||
|
|
||||||
|
assert summary.sample_size == 0
|
||||||
|
assert summary.failed_runs == config.iterations
|
||||||
|
assert math.isnan(summary.mean)
|
||||||
|
|
||||||
|
|
||||||
|
def test_run_monte_carlo_distribution_missing_context_raises() -> None:
|
||||||
|
spec = DistributionSpec(
|
||||||
|
type=DistributionType.NORMAL,
|
||||||
|
parameters={"std_dev": 1.0},
|
||||||
|
source=DistributionSource.SCENARIO_FIELD,
|
||||||
|
source_key="unknown",
|
||||||
|
)
|
||||||
|
cash_flow_spec = CashFlowSpec(
|
||||||
|
cash_flow=CashFlow(amount=0.0, period_index=0),
|
||||||
|
distribution=spec,
|
||||||
|
)
|
||||||
|
config = SimulationConfig(iterations=1, discount_rate=0.0)
|
||||||
|
|
||||||
|
with pytest.raises(DistributionConfigError):
|
||||||
|
run_monte_carlo([cash_flow_spec], config, scenario_context={})
|
||||||
|
|
||||||
|
|
||||||
|
def test_run_monte_carlo_can_return_samples() -> None:
|
||||||
|
base_flow = CashFlow(amount=50.0, period_index=1)
|
||||||
|
specs = [
|
||||||
|
CashFlowSpec(cash_flow=CashFlow(amount=-40.0, period_index=0)),
|
||||||
|
CashFlowSpec(cash_flow=base_flow),
|
||||||
|
]
|
||||||
|
config = SimulationConfig(
|
||||||
|
iterations=3,
|
||||||
|
discount_rate=0.0,
|
||||||
|
metrics=(SimulationMetric.NPV,),
|
||||||
|
return_samples=True,
|
||||||
|
seed=11,
|
||||||
|
)
|
||||||
|
|
||||||
|
result = run_monte_carlo(specs, config)
|
||||||
|
|
||||||
|
assert result.samples is not None
|
||||||
|
assert SimulationMetric.NPV in result.samples
|
||||||
|
samples = result.samples[SimulationMetric.NPV]
|
||||||
|
assert isinstance(samples, np.ndarray)
|
||||||
|
assert samples.shape == (config.iterations,)
|
||||||
Reference in New Issue
Block a user