feat: Add currency and unit support across models, routes, and templates; enhance UI for consumption, costs, and production

This commit is contained in:
2025-10-21 09:53:04 +02:00
parent 139ae04538
commit fcea39deb0
18 changed files with 354 additions and 31 deletions

View File

@@ -10,8 +10,12 @@ class Capex(Base):
scenario_id = Column(Integer, ForeignKey("scenario.id"), nullable=False) scenario_id = Column(Integer, ForeignKey("scenario.id"), nullable=False)
amount = Column(Float, nullable=False) amount = Column(Float, nullable=False)
description = Column(String, nullable=True) description = Column(String, nullable=True)
currency_code = Column(String(3), nullable=False, default="USD")
scenario = relationship("Scenario", back_populates="capex_items") scenario = relationship("Scenario", back_populates="capex_items")
def __repr__(self): def __repr__(self):
return f"<Capex id={self.id} scenario_id={self.scenario_id} amount={self.amount}>" return (
f"<Capex id={self.id} scenario_id={self.scenario_id} "
f"amount={self.amount} currency={self.currency_code}>"
)

View File

@@ -10,8 +10,13 @@ class Consumption(Base):
scenario_id = Column(Integer, ForeignKey("scenario.id"), nullable=False) scenario_id = Column(Integer, ForeignKey("scenario.id"), nullable=False)
amount = Column(Float, nullable=False) amount = Column(Float, nullable=False)
description = Column(String, nullable=True) description = Column(String, nullable=True)
unit_name = Column(String(64), nullable=True)
unit_symbol = Column(String(16), nullable=True)
scenario = relationship("Scenario", back_populates="consumption_items") scenario = relationship("Scenario", back_populates="consumption_items")
def __repr__(self): def __repr__(self):
return f"<Consumption id={self.id} scenario_id={self.scenario_id} amount={self.amount}>" return (
f"<Consumption id={self.id} scenario_id={self.scenario_id} "
f"amount={self.amount} unit={self.unit_symbol or self.unit_name}>"
)

View File

@@ -10,8 +10,12 @@ class Opex(Base):
scenario_id = Column(Integer, ForeignKey("scenario.id"), nullable=False) scenario_id = Column(Integer, ForeignKey("scenario.id"), nullable=False)
amount = Column(Float, nullable=False) amount = Column(Float, nullable=False)
description = Column(String, nullable=True) description = Column(String, nullable=True)
currency_code = Column(String(3), nullable=False, default="USD")
scenario = relationship("Scenario", back_populates="opex_items") scenario = relationship("Scenario", back_populates="opex_items")
def __repr__(self): def __repr__(self):
return f"<Opex id={self.id} scenario_id={self.scenario_id} amount={self.amount}>" return (
f"<Opex id={self.id} scenario_id={self.scenario_id} "
f"amount={self.amount} currency={self.currency_code}>"
)

View File

@@ -10,9 +10,14 @@ class ProductionOutput(Base):
scenario_id = Column(Integer, ForeignKey("scenario.id"), nullable=False) scenario_id = Column(Integer, ForeignKey("scenario.id"), nullable=False)
amount = Column(Float, nullable=False) amount = Column(Float, nullable=False)
description = Column(String, nullable=True) description = Column(String, nullable=True)
unit_name = Column(String(64), nullable=True)
unit_symbol = Column(String(16), nullable=True)
scenario = relationship( scenario = relationship(
"Scenario", back_populates="production_output_items") "Scenario", back_populates="production_output_items")
def __repr__(self): def __repr__(self):
return f"<ProductionOutput id={self.id} scenario_id={self.scenario_id} amount={self.amount}>" return (
f"<ProductionOutput id={self.id} scenario_id={self.scenario_id} "
f"amount={self.amount} unit={self.unit_symbol or self.unit_name}>"
)

View File

@@ -1,7 +1,7 @@
from typing import List, Optional from typing import List, Optional
from fastapi import APIRouter, Depends, status from fastapi import APIRouter, Depends, status
from pydantic import BaseModel, ConfigDict, PositiveFloat from pydantic import BaseModel, ConfigDict, PositiveFloat, field_validator
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from models.consumption import Consumption from models.consumption import Consumption
@@ -9,10 +9,22 @@ from routes.dependencies import get_db
router = APIRouter(prefix="/api/consumption", tags=["Consumption"]) router = APIRouter(prefix="/api/consumption", tags=["Consumption"])
class ConsumptionBase(BaseModel): class ConsumptionBase(BaseModel):
scenario_id: int scenario_id: int
amount: PositiveFloat amount: PositiveFloat
description: Optional[str] = None description: Optional[str] = None
unit_name: Optional[str] = None
unit_symbol: Optional[str] = None
@field_validator("unit_name", "unit_symbol")
@classmethod
def _normalize_text(cls, value: Optional[str]) -> Optional[str]:
if value is None:
return None
stripped = value.strip()
return stripped or None
class ConsumptionCreate(ConsumptionBase): class ConsumptionCreate(ConsumptionBase):

View File

@@ -1,7 +1,7 @@
from typing import List, Optional from typing import List, Optional
from fastapi import APIRouter, Depends from fastapi import APIRouter, Depends
from pydantic import BaseModel, ConfigDict from pydantic import BaseModel, ConfigDict, field_validator
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from models.capex import Capex from models.capex import Capex
@@ -9,28 +9,36 @@ from models.opex import Opex
from routes.dependencies import get_db from routes.dependencies import get_db
router = APIRouter(prefix="/api/costs", tags=["Costs"]) router = APIRouter(prefix="/api/costs", tags=["Costs"])
# Pydantic schemas for Capex # Pydantic schemas for CAPEX and OPEX
class CapexCreate(BaseModel): class _CostBase(BaseModel):
scenario_id: int scenario_id: int
amount: float amount: float
description: Optional[str] = None description: Optional[str] = None
currency_code: str = "USD"
@field_validator("currency_code")
@classmethod
def _normalize_currency(cls, value: Optional[str]) -> str:
code = (value or "USD").strip().upper()
return code[:3] if len(code) > 3 else code
class CapexRead(CapexCreate): class CapexCreate(_CostBase):
pass
class CapexRead(_CostBase):
id: int id: int
model_config = ConfigDict(from_attributes=True) model_config = ConfigDict(from_attributes=True)
# Pydantic schemas for Opex class OpexCreate(_CostBase):
class OpexCreate(BaseModel): pass
scenario_id: int
amount: float
description: Optional[str] = None
class OpexRead(OpexCreate): class OpexRead(_CostBase):
id: int id: int
model_config = ConfigDict(from_attributes=True) model_config = ConfigDict(from_attributes=True)

View File

@@ -1,7 +1,7 @@
from typing import List, Optional from typing import List, Optional
from fastapi import APIRouter, Depends, status from fastapi import APIRouter, Depends, status
from pydantic import BaseModel, ConfigDict, PositiveFloat from pydantic import BaseModel, ConfigDict, PositiveFloat, field_validator
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from models.production_output import ProductionOutput from models.production_output import ProductionOutput
@@ -9,10 +9,22 @@ from routes.dependencies import get_db
router = APIRouter(prefix="/api/production", tags=["Production"]) router = APIRouter(prefix="/api/production", tags=["Production"])
class ProductionOutputBase(BaseModel): class ProductionOutputBase(BaseModel):
scenario_id: int scenario_id: int
amount: PositiveFloat amount: PositiveFloat
description: Optional[str] = None description: Optional[str] = None
unit_name: Optional[str] = None
unit_symbol: Optional[str] = None
@field_validator("unit_name", "unit_symbol")
@classmethod
def _normalize_text(cls, value: Optional[str]) -> Optional[str]:
if value is None:
return None
stripped = value.strip()
return stripped or None
class ProductionOutputCreate(ProductionOutputBase): class ProductionOutputCreate(ProductionOutputBase):

View File

@@ -19,6 +19,24 @@ from models.simulation_result import SimulationResult
from routes.dependencies import get_db from routes.dependencies import get_db
from services.reporting import generate_report from services.reporting import generate_report
CURRENCY_CHOICES: list[Dict[str, Any]] = [
{"id": "USD", "name": "US Dollar (USD)"},
{"id": "EUR", "name": "Euro (EUR)"},
{"id": "GBP", "name": "British Pound (GBP)"},
{"id": "CAD", "name": "Canadian Dollar (CAD)"},
{"id": "AUD", "name": "Australian Dollar (AUD)"},
]
MEASUREMENT_UNITS: list[Dict[str, Any]] = [
{"id": "tonnes", "name": "Tonnes", "symbol": "t"},
{"id": "kilograms", "name": "Kilograms", "symbol": "kg"},
{"id": "pounds", "name": "Pounds", "symbol": "lb"},
{"id": "liters", "name": "Liters", "symbol": "L"},
{"id": "cubic_meters", "name": "Cubic Meters", "symbol": "m3"},
{"id": "kilowatt_hours", "name": "Kilowatt Hours", "symbol": "kWh"},
]
router = APIRouter() router = APIRouter()
# Set up Jinja2 templates directory # Set up Jinja2 templates directory
@@ -96,6 +114,7 @@ def _load_costs(db: Session) -> Dict[str, Any]:
"scenario_id": int(getattr(capex, "scenario_id")), "scenario_id": int(getattr(capex, "scenario_id")),
"amount": float(getattr(capex, "amount", 0.0)), "amount": float(getattr(capex, "amount", 0.0)),
"description": getattr(capex, "description", "") or "", "description": getattr(capex, "description", "") or "",
"currency_code": getattr(capex, "currency_code", "USD") or "USD",
} }
) )
@@ -111,6 +130,7 @@ def _load_costs(db: Session) -> Dict[str, Any]:
"scenario_id": int(getattr(opex, "scenario_id")), "scenario_id": int(getattr(opex, "scenario_id")),
"amount": float(getattr(opex, "amount", 0.0)), "amount": float(getattr(opex, "amount", 0.0)),
"description": getattr(opex, "description", "") or "", "description": getattr(opex, "description", "") or "",
"currency_code": getattr(opex, "currency_code", "USD") or "USD",
} }
) )
@@ -131,12 +151,16 @@ def _load_consumption(db: Session) -> Dict[str, Any]:
scenario_id = int(getattr(record, "scenario_id")) scenario_id = int(getattr(record, "scenario_id"))
amount_value = float(getattr(record, "amount", 0.0)) amount_value = float(getattr(record, "amount", 0.0))
description = getattr(record, "description", "") or "" description = getattr(record, "description", "") or ""
unit_name = getattr(record, "unit_name", None)
unit_symbol = getattr(record, "unit_symbol", None)
grouped[scenario_id].append( grouped[scenario_id].append(
{ {
"id": record_id, "id": record_id,
"scenario_id": scenario_id, "scenario_id": scenario_id,
"amount": amount_value, "amount": amount_value,
"description": description, "description": description,
"unit_name": unit_name,
"unit_symbol": unit_symbol,
} }
) )
return {"consumption_by_scenario": dict(grouped)} return {"consumption_by_scenario": dict(grouped)}
@@ -153,12 +177,16 @@ def _load_production(db: Session) -> Dict[str, Any]:
scenario_id = int(getattr(record, "scenario_id")) scenario_id = int(getattr(record, "scenario_id"))
amount_value = float(getattr(record, "amount", 0.0)) amount_value = float(getattr(record, "amount", 0.0))
description = getattr(record, "description", "") or "" description = getattr(record, "description", "") or ""
unit_name = getattr(record, "unit_name", None)
unit_symbol = getattr(record, "unit_symbol", None)
grouped[scenario_id].append( grouped[scenario_id].append(
{ {
"id": record_id, "id": record_id,
"scenario_id": scenario_id, "scenario_id": scenario_id,
"amount": amount_value, "amount": amount_value,
"description": description, "description": description,
"unit_name": unit_name,
"unit_symbol": unit_symbol,
} }
) )
return {"production_by_scenario": dict(grouped)} return {"production_by_scenario": dict(grouped)}
@@ -543,6 +571,7 @@ async def costs_view(request: Request, db: Session = Depends(get_db)):
context: Dict[str, Any] = {} context: Dict[str, Any] = {}
context.update(_load_scenarios(db)) context.update(_load_scenarios(db))
context.update(_load_costs(db)) context.update(_load_costs(db))
context["currency_options"] = CURRENCY_CHOICES
return _render(request, "costs.html", context) return _render(request, "costs.html", context)
@@ -552,6 +581,7 @@ async def consumption_view(request: Request, db: Session = Depends(get_db)):
context: Dict[str, Any] = {} context: Dict[str, Any] = {}
context.update(_load_scenarios(db)) context.update(_load_scenarios(db))
context.update(_load_consumption(db)) context.update(_load_consumption(db))
context["unit_options"] = MEASUREMENT_UNITS
return _render(request, "consumption.html", context) return _render(request, "consumption.html", context)
@@ -561,6 +591,7 @@ async def production_view(request: Request, db: Session = Depends(get_db)):
context: Dict[str, Any] = {} context: Dict[str, Any] = {}
context.update(_load_scenarios(db)) context.update(_load_scenarios(db))
context.update(_load_production(db)) context.update(_load_production(db))
context["unit_options"] = MEASUREMENT_UNITS
return _render(request, "production.html", context) return _render(request, "production.html", context)

View File

@@ -1,6 +1,6 @@
document.addEventListener("DOMContentLoaded", () => { document.addEventListener("DOMContentLoaded", () => {
const dataElement = document.getElementById("consumption-data"); const dataElement = document.getElementById("consumption-data");
let data = { scenarios: [], consumption: {} }; let data = { scenarios: [], consumption: {}, unit_options: [] };
if (dataElement) { if (dataElement) {
try { try {
@@ -12,6 +12,9 @@ document.addEventListener("DOMContentLoaded", () => {
parsed.consumption && typeof parsed.consumption === "object" parsed.consumption && typeof parsed.consumption === "object"
? parsed.consumption ? parsed.consumption
: {}, : {},
unit_options: Array.isArray(parsed.unit_options)
? parsed.unit_options
: [],
}; };
} }
} catch (error) { } catch (error) {
@@ -26,6 +29,10 @@ document.addEventListener("DOMContentLoaded", () => {
const emptyState = document.getElementById("consumption-empty"); const emptyState = document.getElementById("consumption-empty");
const form = document.getElementById("consumption-form"); const form = document.getElementById("consumption-form");
const feedbackEl = document.getElementById("consumption-feedback"); const feedbackEl = document.getElementById("consumption-feedback");
const unitSelect = document.getElementById("consumption-form-unit");
const unitSymbolInput = document.getElementById(
"consumption-form-unit-symbol"
);
const showFeedback = (message, type = "success") => { const showFeedback = (message, type = "success") => {
if (!feedbackEl) { if (!feedbackEl) {
@@ -50,6 +57,16 @@ document.addEventListener("DOMContentLoaded", () => {
maximumFractionDigits: 2, maximumFractionDigits: 2,
}); });
const formatMeasurement = (amount, symbol, name) => {
if (symbol) {
return `${formatAmount(amount)} ${symbol}`;
}
if (name) {
return `${formatAmount(amount)} ${name}`;
}
return formatAmount(amount);
};
const renderConsumptionRows = (scenarioId) => { const renderConsumptionRows = (scenarioId) => {
if (!tableBody || !tableWrapper || !emptyState) { if (!tableBody || !tableWrapper || !emptyState) {
return; return;
@@ -73,7 +90,11 @@ document.addEventListener("DOMContentLoaded", () => {
records.forEach((record) => { records.forEach((record) => {
const row = document.createElement("tr"); const row = document.createElement("tr");
row.innerHTML = ` row.innerHTML = `
<td>${formatAmount(record.amount)}</td> <td>${formatMeasurement(
record.amount,
record.unit_symbol,
record.unit_name
)}</td>
<td>${record.description || "—"}</td> <td>${record.description || "—"}</td>
`; `;
tableBody.appendChild(row); tableBody.appendChild(row);
@@ -107,10 +128,14 @@ document.addEventListener("DOMContentLoaded", () => {
const formData = new FormData(form); const formData = new FormData(form);
const scenarioId = formData.get("scenario_id"); const scenarioId = formData.get("scenario_id");
const unitName = formData.get("unit_name");
const unitSymbol = formData.get("unit_symbol");
const payload = { const payload = {
scenario_id: scenarioId ? Number(scenarioId) : null, scenario_id: scenarioId ? Number(scenarioId) : null,
amount: Number(formData.get("amount")), amount: Number(formData.get("amount")),
description: formData.get("description") || null, description: formData.get("description") || null,
unit_name: unitName ? String(unitName) : null,
unit_symbol: unitSymbol ? String(unitSymbol) : null,
}; };
try { try {
@@ -136,6 +161,7 @@ document.addEventListener("DOMContentLoaded", () => {
consumptionByScenario[mapKey].push(result); consumptionByScenario[mapKey].push(result);
form.reset(); form.reset();
syncUnitSelection();
showFeedback("Consumption record saved.", "success"); showFeedback("Consumption record saved.", "success");
if (filterSelect && filterSelect.value === String(result.scenario_id)) { if (filterSelect && filterSelect.value === String(result.scenario_id)) {
@@ -150,6 +176,29 @@ document.addEventListener("DOMContentLoaded", () => {
form.addEventListener("submit", submitConsumption); form.addEventListener("submit", submitConsumption);
} }
const syncUnitSelection = () => {
if (!unitSelect || !unitSymbolInput) {
return;
}
if (!unitSelect.value && unitSelect.options.length > 0) {
const firstOption = Array.from(unitSelect.options).find(
(option) => option.value
);
if (firstOption) {
firstOption.selected = true;
}
}
const selectedOption = unitSelect.options[unitSelect.selectedIndex];
unitSymbolInput.value = selectedOption
? selectedOption.getAttribute("data-symbol") || ""
: "";
};
if (unitSelect) {
unitSelect.addEventListener("change", syncUnitSelection);
syncUnitSelection();
}
if (filterSelect && filterSelect.value) { if (filterSelect && filterSelect.value) {
renderConsumptionRows(filterSelect.value); renderConsumptionRows(filterSelect.value);
} }

View File

@@ -2,6 +2,7 @@ document.addEventListener("DOMContentLoaded", () => {
const dataElement = document.getElementById("costs-payload"); const dataElement = document.getElementById("costs-payload");
let capexByScenario = {}; let capexByScenario = {};
let opexByScenario = {}; let opexByScenario = {};
let currencyOptions = [];
if (dataElement) { if (dataElement) {
try { try {
@@ -13,6 +14,9 @@ document.addEventListener("DOMContentLoaded", () => {
if (parsed.opex && typeof parsed.opex === "object") { if (parsed.opex && typeof parsed.opex === "object") {
opexByScenario = parsed.opex; opexByScenario = parsed.opex;
} }
if (Array.isArray(parsed.currency_options)) {
currencyOptions = parsed.currency_options;
}
} }
} catch (error) { } catch (error) {
console.error("Unable to parse cost data", error); console.error("Unable to parse cost data", error);
@@ -34,6 +38,8 @@ document.addEventListener("DOMContentLoaded", () => {
const opexFeedback = document.getElementById("opex-feedback"); const opexFeedback = document.getElementById("opex-feedback");
const capexFormScenario = document.getElementById("capex-form-scenario"); const capexFormScenario = document.getElementById("capex-form-scenario");
const opexFormScenario = document.getElementById("opex-form-scenario"); const opexFormScenario = document.getElementById("opex-form-scenario");
const capexCurrencySelect = document.getElementById("capex-form-currency");
const opexCurrencySelect = document.getElementById("opex-form-currency");
const showFeedback = (element, message, type = "success") => { const showFeedback = (element, message, type = "success") => {
if (!element) { if (!element) {
@@ -58,9 +64,44 @@ document.addEventListener("DOMContentLoaded", () => {
maximumFractionDigits: 2, maximumFractionDigits: 2,
}); });
const formatCurrencyAmount = (value, currencyCode) => {
if (!currencyCode) {
return formatAmount(value);
}
try {
return new Intl.NumberFormat(undefined, {
style: "currency",
currency: currencyCode,
minimumFractionDigits: 2,
maximumFractionDigits: 2,
}).format(Number(value));
} catch (error) {
return `${currencyCode} ${formatAmount(value)}`;
}
};
const sumAmount = (records) => const sumAmount = (records) =>
records.reduce((total, record) => total + Number(record.amount || 0), 0); records.reduce((total, record) => total + Number(record.amount || 0), 0);
const describeTotal = (records) => {
if (!records || records.length === 0) {
return "—";
}
const total = sumAmount(records);
const currencyCodes = Array.from(
new Set(
records
.map((record) => (record.currency_code || "").trim().toUpperCase())
.filter(Boolean)
)
);
if (currencyCodes.length === 1) {
return formatCurrencyAmount(total, currencyCodes[0]);
}
return `${formatAmount(total)} (mixed)`;
};
const renderCostTables = (scenarioId) => { const renderCostTables = (scenarioId) => {
if ( if (
!capexTableBody || !capexTableBody ||
@@ -86,7 +127,7 @@ document.addEventListener("DOMContentLoaded", () => {
capexRecords.forEach((record) => { capexRecords.forEach((record) => {
const row = document.createElement("tr"); const row = document.createElement("tr");
row.innerHTML = ` row.innerHTML = `
<td>${formatAmount(record.amount)}</td> <td>${formatCurrencyAmount(record.amount, record.currency_code)}</td>
<td>${record.description || "—"}</td> <td>${record.description || "—"}</td>
`; `;
capexTableBody.appendChild(row); capexTableBody.appendChild(row);
@@ -100,15 +141,15 @@ document.addEventListener("DOMContentLoaded", () => {
opexRecords.forEach((record) => { opexRecords.forEach((record) => {
const row = document.createElement("tr"); const row = document.createElement("tr");
row.innerHTML = ` row.innerHTML = `
<td>${formatAmount(record.amount)}</td> <td>${formatCurrencyAmount(record.amount, record.currency_code)}</td>
<td>${record.description || "—"}</td> <td>${record.description || "—"}</td>
`; `;
opexTableBody.appendChild(row); opexTableBody.appendChild(row);
}); });
} }
capexTotal.textContent = formatAmount(sumAmount(capexRecords)); capexTotal.textContent = describeTotal(capexRecords);
opexTotal.textContent = formatAmount(sumAmount(opexRecords)); opexTotal.textContent = describeTotal(opexRecords);
}; };
const toggleCostView = (show) => { const toggleCostView = (show) => {
@@ -153,6 +194,18 @@ document.addEventListener("DOMContentLoaded", () => {
} }
}; };
const ensureCurrencySelection = (selectElement) => {
if (!selectElement || selectElement.value) {
return;
}
const firstOption = selectElement.querySelector(
"option[value]:not([value=''])"
);
if (firstOption && firstOption.value) {
selectElement.value = firstOption.value;
}
};
if (filterSelect) { if (filterSelect) {
filterSelect.addEventListener("change", (event) => { filterSelect.addEventListener("change", (event) => {
const value = event.target.value; const value = event.target.value;
@@ -173,10 +226,12 @@ document.addEventListener("DOMContentLoaded", () => {
const formData = new FormData(event.target); const formData = new FormData(event.target);
const scenarioId = formData.get("scenario_id"); const scenarioId = formData.get("scenario_id");
const currencyCode = formData.get("currency_code");
const payload = { const payload = {
scenario_id: scenarioId ? Number(scenarioId) : null, scenario_id: scenarioId ? Number(scenarioId) : null,
amount: Number(formData.get("amount")), amount: Number(formData.get("amount")),
description: formData.get("description") || null, description: formData.get("description") || null,
currency_code: currencyCode ? String(currencyCode).toUpperCase() : null,
}; };
if (!payload.scenario_id) { if (!payload.scenario_id) {
@@ -184,6 +239,11 @@ document.addEventListener("DOMContentLoaded", () => {
return; return;
} }
if (!payload.currency_code) {
showFeedback(feedbackEl, "Choose a currency before submitting.", "error");
return;
}
try { try {
const response = await fetch(targetUrl, { const response = await fetch(targetUrl, {
method: "POST", method: "POST",
@@ -206,6 +266,7 @@ document.addEventListener("DOMContentLoaded", () => {
storageMap[mapKey].push(result); storageMap[mapKey].push(result);
event.target.reset(); event.target.reset();
ensureCurrencySelection(event.target.querySelector("select[name='currency_code']"));
showFeedback(feedbackEl, "Entry saved successfully.", "success"); showFeedback(feedbackEl, "Entry saved successfully.", "success");
if (filterSelect && filterSelect.value === mapKey) { if (filterSelect && filterSelect.value === mapKey) {
@@ -221,12 +282,14 @@ document.addEventListener("DOMContentLoaded", () => {
}; };
if (capexForm) { if (capexForm) {
ensureCurrencySelection(capexCurrencySelect);
capexForm.addEventListener("submit", (event) => capexForm.addEventListener("submit", (event) =>
submitCostEntry(event, "/api/costs/capex", capexByScenario, capexFeedback) submitCostEntry(event, "/api/costs/capex", capexByScenario, capexFeedback)
); );
} }
if (opexForm) { if (opexForm) {
ensureCurrencySelection(opexCurrencySelect);
opexForm.addEventListener("submit", (event) => opexForm.addEventListener("submit", (event) =>
submitCostEntry(event, "/api/costs/opex", opexByScenario, opexFeedback) submitCostEntry(event, "/api/costs/opex", opexByScenario, opexFeedback)
); );

View File

@@ -1,6 +1,6 @@
document.addEventListener("DOMContentLoaded", () => { document.addEventListener("DOMContentLoaded", () => {
const dataElement = document.getElementById("production-data"); const dataElement = document.getElementById("production-data");
let data = { scenarios: [], production: {} }; let data = { scenarios: [], production: {}, unit_options: [] };
if (dataElement) { if (dataElement) {
try { try {
@@ -12,6 +12,9 @@ document.addEventListener("DOMContentLoaded", () => {
parsed.production && typeof parsed.production === "object" parsed.production && typeof parsed.production === "object"
? parsed.production ? parsed.production
: {}, : {},
unit_options: Array.isArray(parsed.unit_options)
? parsed.unit_options
: [],
}; };
} }
} catch (error) { } catch (error) {
@@ -26,6 +29,8 @@ document.addEventListener("DOMContentLoaded", () => {
const emptyState = document.getElementById("production-empty"); const emptyState = document.getElementById("production-empty");
const form = document.getElementById("production-form"); const form = document.getElementById("production-form");
const feedbackEl = document.getElementById("production-feedback"); const feedbackEl = document.getElementById("production-feedback");
const unitSelect = document.getElementById("production-form-unit");
const unitSymbolInput = document.getElementById("production-form-unit-symbol");
const showFeedback = (message, type = "success") => { const showFeedback = (message, type = "success") => {
if (!feedbackEl) { if (!feedbackEl) {
@@ -50,6 +55,16 @@ document.addEventListener("DOMContentLoaded", () => {
maximumFractionDigits: 2, maximumFractionDigits: 2,
}); });
const formatMeasurement = (amount, symbol, name) => {
if (symbol) {
return `${formatAmount(amount)} ${symbol}`;
}
if (name) {
return `${formatAmount(amount)} ${name}`;
}
return formatAmount(amount);
};
const renderProductionRows = (scenarioId) => { const renderProductionRows = (scenarioId) => {
if (!tableBody || !tableWrapper || !emptyState) { if (!tableBody || !tableWrapper || !emptyState) {
return; return;
@@ -74,7 +89,11 @@ document.addEventListener("DOMContentLoaded", () => {
records.forEach((record) => { records.forEach((record) => {
const row = document.createElement("tr"); const row = document.createElement("tr");
row.innerHTML = ` row.innerHTML = `
<td>${formatAmount(record.amount)}</td> <td>${formatMeasurement(
record.amount,
record.unit_symbol,
record.unit_name
)}</td>
<td>${record.description || "—"}</td> <td>${record.description || "—"}</td>
`; `;
tableBody.appendChild(row); tableBody.appendChild(row);
@@ -108,10 +127,14 @@ document.addEventListener("DOMContentLoaded", () => {
const formData = new FormData(form); const formData = new FormData(form);
const scenarioId = formData.get("scenario_id"); const scenarioId = formData.get("scenario_id");
const unitName = formData.get("unit_name");
const unitSymbol = formData.get("unit_symbol");
const payload = { const payload = {
scenario_id: scenarioId ? Number(scenarioId) : null, scenario_id: scenarioId ? Number(scenarioId) : null,
amount: Number(formData.get("amount")), amount: Number(formData.get("amount")),
description: formData.get("description") || null, description: formData.get("description") || null,
unit_name: unitName ? String(unitName) : null,
unit_symbol: unitSymbol ? String(unitSymbol) : null,
}; };
try { try {
@@ -137,6 +160,7 @@ document.addEventListener("DOMContentLoaded", () => {
productionByScenario[mapKey].push(result); productionByScenario[mapKey].push(result);
form.reset(); form.reset();
syncUnitSelection();
showFeedback("Production output saved.", "success"); showFeedback("Production output saved.", "success");
if (filterSelect && filterSelect.value === String(result.scenario_id)) { if (filterSelect && filterSelect.value === String(result.scenario_id)) {
@@ -151,6 +175,29 @@ document.addEventListener("DOMContentLoaded", () => {
form.addEventListener("submit", submitProduction); form.addEventListener("submit", submitProduction);
} }
const syncUnitSelection = () => {
if (!unitSelect || !unitSymbolInput) {
return;
}
if (!unitSelect.value && unitSelect.options.length > 0) {
const firstOption = Array.from(unitSelect.options).find(
(option) => option.value
);
if (firstOption) {
firstOption.selected = true;
}
}
const selectedOption = unitSelect.options[unitSelect.selectedIndex];
unitSymbolInput.value = selectedOption
? selectedOption.getAttribute("data-symbol") || ""
: "";
};
if (unitSelect) {
unitSelect.addEventListener("change", syncUnitSelection);
syncUnitSelection();
}
if (filterSelect && filterSelect.value) { if (filterSelect && filterSelect.value) {
renderProductionRows(filterSelect.value); renderProductionRows(filterSelect.value);
} }

View File

@@ -28,6 +28,26 @@ title %}Consumption · CalMiner{% endblock %} {% block content %}
{{ select_field( "Scenario", "consumption-form-scenario", {{ select_field( "Scenario", "consumption-form-scenario",
name="scenario_id", options=scenarios, required=True, placeholder="Select a name="scenario_id", options=scenarios, required=True, placeholder="Select a
scenario", placeholder_disabled=True ) }} scenario", placeholder_disabled=True ) }}
<label for="consumption-form-unit">
Unit
<select
id="consumption-form-unit"
name="unit_name"
required
>
<option value="" disabled selected>Select unit</option>
{% for unit in unit_options %}
<option value="{{ unit.name }}" data-symbol="{{ unit.symbol }}">
{{ unit.name }} ({{ unit.symbol }})
</option>
{% endfor %}
</select>
</label>
<input
id="consumption-form-unit-symbol"
type="hidden"
name="unit_symbol"
/>
<label for="consumption-form-amount"> <label for="consumption-form-amount">
Amount Amount
<input <input
@@ -58,7 +78,7 @@ title %}Consumption · CalMiner{% endblock %} {% block content %}
{% endblock %} {% block scripts %} {{ super() }} {% endblock %} {% block scripts %} {{ super() }}
<script id="consumption-data" type="application/json"> <script id="consumption-data" type="application/json">
{{ {"scenarios": scenarios, "consumption": consumption_by_scenario} | tojson }} {{ {"scenarios": scenarios, "consumption": consumption_by_scenario, "unit_options": unit_options} | tojson }}
</script> </script>
<script src="/static/js/consumption.js"></script> <script src="/static/js/consumption.js"></script>
{% endblock %} {% endblock %}

View File

@@ -57,6 +57,17 @@ title %}Costs · CalMiner{% endblock %} {% block content %}
{{ select_field( "Scenario", "capex-form-scenario", name="scenario_id", {{ select_field( "Scenario", "capex-form-scenario", name="scenario_id",
options=scenarios, required=True, placeholder="Select a scenario", options=scenarios, required=True, placeholder="Select a scenario",
placeholder_disabled=True ) }} placeholder_disabled=True ) }}
{{ select_field(
"Currency",
"capex-form-currency",
name="currency_code",
options=currency_options,
required=True,
placeholder="Select currency",
placeholder_disabled=True,
value_attr="id",
label_attr="name"
) }}
<label for="capex-form-amount"> <label for="capex-form-amount">
Amount Amount
<input <input
@@ -90,6 +101,17 @@ title %}Costs · CalMiner{% endblock %} {% block content %}
{{ select_field( "Scenario", "opex-form-scenario", name="scenario_id", {{ select_field( "Scenario", "opex-form-scenario", name="scenario_id",
options=scenarios, required=True, placeholder="Select a scenario", options=scenarios, required=True, placeholder="Select a scenario",
placeholder_disabled=True ) }} placeholder_disabled=True ) }}
{{ select_field(
"Currency",
"opex-form-currency",
name="currency_code",
options=currency_options,
required=True,
placeholder="Select currency",
placeholder_disabled=True,
value_attr="id",
label_attr="name"
) }}
<label for="opex-form-amount"> <label for="opex-form-amount">
Amount Amount
<input <input
@@ -117,7 +139,7 @@ title %}Costs · CalMiner{% endblock %} {% block content %}
{% endblock %} {% block scripts %} {{ super() }} {% endblock %} {% block scripts %} {{ super() }}
<script id="costs-payload" type="application/json"> <script id="costs-payload" type="application/json">
{{ {"capex": capex_by_scenario, "opex": opex_by_scenario} | tojson }} {{ {"capex": capex_by_scenario, "opex": opex_by_scenario, "currency_options": currency_options} | tojson }}
</script> </script>
<script src="/static/js/costs.js"></script> <script src="/static/js/costs.js"></script>
{% endblock %} {% endblock %}

View File

@@ -46,6 +46,18 @@
{% endfor %} {% endfor %}
</select> </select>
</label> </label>
<label for="production-form-unit">
Unit
<select id="production-form-unit" name="unit_name" required>
<option value="" disabled selected>Select unit</option>
{% for unit in unit_options %}
<option value="{{ unit.name }}" data-symbol="{{ unit.symbol }}">
{{ unit.name }} ({{ unit.symbol }})
</option>
{% endfor %}
</select>
</label>
<input id="production-form-unit-symbol" type="hidden" name="unit_symbol" />
<label for="production-form-amount"> <label for="production-form-amount">
Amount Amount
<input <input
@@ -75,7 +87,7 @@
{% endblock %} {% block scripts %} {{ super() }} {% endblock %} {% block scripts %} {{ super() }}
<script id="production-data" type="application/json"> <script id="production-data" type="application/json">
{{ {"scenarios": scenarios, "production": production_by_scenario} | tojson }} {{ {"scenarios": scenarios, "production": production_by_scenario, "unit_options": unit_options} | tojson }}
</script> </script>
<script src="/static/js/production.js"></script> <script src="/static/js/production.js"></script>
{% endblock %} {% endblock %}

View File

@@ -111,21 +111,27 @@ def seeded_ui_data(db_session: Session) -> Generator[Dict[str, Any], None, None]
scenario_id=scenario.id, scenario_id=scenario.id,
amount=1_000_000.0, amount=1_000_000.0,
description="Drill purchase", description="Drill purchase",
currency_code="USD",
) )
opex = Opex( opex = Opex(
scenario_id=scenario.id, scenario_id=scenario.id,
amount=250_000.0, amount=250_000.0,
description="Fuel spend", description="Fuel spend",
currency_code="USD",
) )
consumption = Consumption( consumption = Consumption(
scenario_id=scenario.id, scenario_id=scenario.id,
amount=1_200.0, amount=1_200.0,
description="Diesel (L)", description="Diesel (L)",
unit_name="Liters",
unit_symbol="L",
) )
production = ProductionOutput( production = ProductionOutput(
scenario_id=scenario.id, scenario_id=scenario.id,
amount=800.0, amount=800.0,
description="Ore (tonnes)", description="Ore (tonnes)",
unit_name="Tonnes",
unit_symbol="t",
) )
equipment = Equipment( equipment = Equipment(
scenario_id=scenario.id, scenario_id=scenario.id,

View File

@@ -25,6 +25,8 @@ def test_create_consumption(client: TestClient) -> None:
"scenario_id": scenario_id, "scenario_id": scenario_id,
"amount": 125.5, "amount": 125.5,
"description": "Fuel usage baseline", "description": "Fuel usage baseline",
"unit_name": "Liters",
"unit_symbol": "L",
} }
response = client.post("/api/consumption/", json=payload) response = client.post("/api/consumption/", json=payload)
@@ -34,6 +36,7 @@ def test_create_consumption(client: TestClient) -> None:
assert body["scenario_id"] == scenario_id assert body["scenario_id"] == scenario_id
assert body["amount"] == pytest.approx(125.5) assert body["amount"] == pytest.approx(125.5)
assert body["description"] == "Fuel usage baseline" assert body["description"] == "Fuel usage baseline"
assert body["unit_symbol"] == "L"
def test_list_consumption_returns_created_items(client: TestClient) -> None: def test_list_consumption_returns_created_items(client: TestClient) -> None:
@@ -46,6 +49,8 @@ def test_list_consumption_returns_created_items(client: TestClient) -> None:
"scenario_id": scenario_id, "scenario_id": scenario_id,
"amount": amount, "amount": amount,
"description": f"Consumption {amount}", "description": f"Consumption {amount}",
"unit_name": "Tonnes",
"unit_symbol": "t",
}, },
) )
assert response.status_code == 201 assert response.status_code == 201

View File

@@ -7,6 +7,7 @@ from main import app
def setup_module(module): def setup_module(module):
Base.metadata.drop_all(bind=engine)
Base.metadata.create_all(bind=engine) Base.metadata.create_all(bind=engine)
@@ -34,12 +35,14 @@ def test_create_and_list_capex_and_opex():
"scenario_id": sid, "scenario_id": sid,
"amount": 1000.0, "amount": 1000.0,
"description": "Initial capex", "description": "Initial capex",
"currency_code": "USD",
} }
resp2 = client.post("/api/costs/capex", json=capex_payload) resp2 = client.post("/api/costs/capex", json=capex_payload)
assert resp2.status_code == 200 assert resp2.status_code == 200
capex = resp2.json() capex = resp2.json()
assert capex["scenario_id"] == sid assert capex["scenario_id"] == sid
assert capex["amount"] == 1000.0 assert capex["amount"] == 1000.0
assert capex["currency_code"] == "USD"
resp3 = client.get("/api/costs/capex") resp3 = client.get("/api/costs/capex")
assert resp3.status_code == 200 assert resp3.status_code == 200
@@ -51,12 +54,14 @@ def test_create_and_list_capex_and_opex():
"scenario_id": sid, "scenario_id": sid,
"amount": 500.0, "amount": 500.0,
"description": "Recurring opex", "description": "Recurring opex",
"currency_code": "USD",
} }
resp4 = client.post("/api/costs/opex", json=opex_payload) resp4 = client.post("/api/costs/opex", json=opex_payload)
assert resp4.status_code == 200 assert resp4.status_code == 200
opex = resp4.json() opex = resp4.json()
assert opex["scenario_id"] == sid assert opex["scenario_id"] == sid
assert opex["amount"] == 500.0 assert opex["amount"] == 500.0
assert opex["currency_code"] == "USD"
resp5 = client.get("/api/costs/opex") resp5 = client.get("/api/costs/opex")
assert resp5.status_code == 200 assert resp5.status_code == 200
@@ -71,8 +76,12 @@ def test_multiple_capex_entries():
for amount in amounts: for amount in amounts:
resp = client.post( resp = client.post(
"/api/costs/capex", "/api/costs/capex",
json={"scenario_id": sid, "amount": amount, json={
"description": f"Capex {amount}"}, "scenario_id": sid,
"amount": amount,
"description": f"Capex {amount}",
"currency_code": "EUR",
},
) )
assert resp.status_code == 200 assert resp.status_code == 200
@@ -91,8 +100,12 @@ def test_multiple_opex_entries():
for amount in amounts: for amount in amounts:
resp = client.post( resp = client.post(
"/api/costs/opex", "/api/costs/opex",
json={"scenario_id": sid, "amount": amount, json={
"description": f"Opex {amount}"}, "scenario_id": sid,
"amount": amount,
"description": f"Opex {amount}",
"currency_code": "CAD",
},
) )
assert resp.status_code == 200 assert resp.status_code == 200

View File

@@ -25,6 +25,8 @@ def test_create_production_record(client: TestClient) -> None:
"scenario_id": scenario_id, "scenario_id": scenario_id,
"amount": 475.25, "amount": 475.25,
"description": "Daily output", "description": "Daily output",
"unit_name": "Tonnes",
"unit_symbol": "t",
} }
response = client.post("/api/production/", json=payload) response = client.post("/api/production/", json=payload)
@@ -33,6 +35,7 @@ def test_create_production_record(client: TestClient) -> None:
assert created["scenario_id"] == scenario_id assert created["scenario_id"] == scenario_id
assert created["amount"] == pytest.approx(475.25) assert created["amount"] == pytest.approx(475.25)
assert created["description"] == "Daily output" assert created["description"] == "Daily output"
assert created["unit_symbol"] == "t"
def test_list_production_filters_by_scenario(client: TestClient) -> None: def test_list_production_filters_by_scenario(client: TestClient) -> None:
@@ -46,6 +49,8 @@ def test_list_production_filters_by_scenario(client: TestClient) -> None:
"scenario_id": scenario_id, "scenario_id": scenario_id,
"amount": amount, "amount": amount,
"description": f"Output {amount}", "description": f"Output {amount}",
"unit_name": "Kilograms",
"unit_symbol": "kg",
}, },
) )
assert response.status_code == 201 assert response.status_code == 201