v1.2 scaffold

This commit is contained in:
2026-04-25 20:43:37 +12:00
parent 658cda8c35
commit bc211ffcc8
58 changed files with 5104 additions and 0 deletions
+1
View File
@@ -0,0 +1 @@
+1
View File
@@ -0,0 +1 @@
+28
View File
@@ -0,0 +1,28 @@
from fastapi import APIRouter, HTTPException, status
from pydantic import BaseModel
from app.core.config import settings
router = APIRouter(prefix="/api/auth", tags=["auth"])
class LoginRequest(BaseModel):
email: str
password: str
class LoginResponse(BaseModel):
name: str
email: str
role: str
@router.post("/login", response_model=LoginResponse)
def login(payload: LoginRequest):
if payload.email.strip().lower() != settings.operator_email.lower() or payload.password != settings.operator_password:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid email or password")
return {
"name": settings.operator_name,
"email": settings.operator_email,
"role": "operator",
}
+101
View File
@@ -0,0 +1,101 @@
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy import select
from sqlalchemy.orm import Session
from app.db.session import get_db
from app.models.mix import Mix, MixIngredient
from app.models.raw_material import RawMaterial
from app.schemas.mix import MixCreate, MixIngredientCreate, MixIngredientUpdate, MixRead, MixUpdate
from app.services.costing_engine import calculate_mix_cost
router = APIRouter(prefix="/api/mixes", tags=["mixes"])
@router.get("", response_model=list[MixRead])
def list_mixes(db: Session = Depends(get_db)):
mixes = db.scalars(select(Mix).order_by(Mix.name)).all()
return [calculate_mix_cost(db, mix.id) for mix in mixes]
@router.post("", response_model=MixRead, status_code=status.HTTP_201_CREATED)
def create_mix(payload: MixCreate, db: Session = Depends(get_db)):
mix = Mix(
client_name=payload.client_name,
name=payload.name,
status=payload.status,
version=payload.version,
notes=payload.notes,
)
db.add(mix)
db.flush()
for ingredient in payload.ingredients:
if db.scalar(select(RawMaterial).where(RawMaterial.id == ingredient.raw_material_id)) is None:
raise HTTPException(status_code=404, detail=f"Raw material {ingredient.raw_material_id} not found")
db.add(
MixIngredient(
mix_id=mix.id,
raw_material_id=ingredient.raw_material_id,
quantity_kg=ingredient.quantity_kg,
notes=ingredient.notes,
)
)
db.commit()
return calculate_mix_cost(db, mix.id)
@router.get("/{mix_id}", response_model=MixRead)
def get_mix(mix_id: int, db: Session = Depends(get_db)):
if db.scalar(select(Mix.id).where(Mix.id == mix_id)) is None:
raise HTTPException(status_code=404, detail="Mix not found")
return calculate_mix_cost(db, mix_id)
@router.patch("/{mix_id}", response_model=MixRead)
def update_mix(mix_id: int, payload: MixUpdate, db: Session = Depends(get_db)):
mix = db.scalar(select(Mix).where(Mix.id == mix_id))
if mix is None:
raise HTTPException(status_code=404, detail="Mix not found")
for field, value in payload.model_dump(exclude_unset=True).items():
setattr(mix, field, value)
db.commit()
return calculate_mix_cost(db, mix_id)
@router.post("/{mix_id}/ingredients", response_model=MixRead, status_code=status.HTTP_201_CREATED)
def add_mix_ingredient(mix_id: int, payload: MixIngredientCreate, db: Session = Depends(get_db)):
if db.scalar(select(Mix.id).where(Mix.id == mix_id)) is None:
raise HTTPException(status_code=404, detail="Mix not found")
if db.scalar(select(RawMaterial.id).where(RawMaterial.id == payload.raw_material_id)) is None:
raise HTTPException(status_code=404, detail="Raw material not found")
db.add(MixIngredient(mix_id=mix_id, raw_material_id=payload.raw_material_id, quantity_kg=payload.quantity_kg, notes=payload.notes))
db.commit()
return calculate_mix_cost(db, mix_id)
@router.patch("/{mix_id}/ingredients/{ingredient_id}", response_model=MixRead)
def update_mix_ingredient(mix_id: int, ingredient_id: int, payload: MixIngredientUpdate, db: Session = Depends(get_db)):
ingredient = db.scalar(select(MixIngredient).where(MixIngredient.id == ingredient_id, MixIngredient.mix_id == mix_id))
if ingredient is None:
raise HTTPException(status_code=404, detail="Ingredient not found")
for field, value in payload.model_dump(exclude_unset=True).items():
setattr(ingredient, field, value)
db.commit()
return calculate_mix_cost(db, mix_id)
@router.delete("/{mix_id}/ingredients/{ingredient_id}", response_model=MixRead)
def delete_mix_ingredient(mix_id: int, ingredient_id: int, db: Session = Depends(get_db)):
ingredient = db.scalar(select(MixIngredient).where(MixIngredient.id == ingredient_id, MixIngredient.mix_id == mix_id))
if ingredient is None:
raise HTTPException(status_code=404, detail="Ingredient not found")
db.delete(ingredient)
db.commit()
return calculate_mix_cost(db, mix_id)
@router.get("/{mix_id}/cost-breakdown", response_model=MixRead)
def get_mix_cost_breakdown(mix_id: int, db: Session = Depends(get_db)):
if db.scalar(select(Mix.id).where(Mix.id == mix_id)) is None:
raise HTTPException(status_code=404, detail="Mix not found")
return calculate_mix_cost(db, mix_id)
+64
View File
@@ -0,0 +1,64 @@
from fastapi import APIRouter, Depends
from sqlalchemy import select
from sqlalchemy.orm import Session
from app.db.session import get_db
from app.models.mix import Mix
from app.models.product import Product
from app.models.raw_material import RawMaterial
from app.models.scenario import Scenario
from app.services.costing_engine import calculate_mix_cost, calculate_product_cost, serialize_raw_material
router = APIRouter(prefix="/api/powerbi", tags=["powerbi"])
@router.get("/raw-material-costs")
def raw_material_costs(db: Session = Depends(get_db)):
materials = db.scalars(select(RawMaterial).order_by(RawMaterial.name)).all()
return [serialize_raw_material(material) for material in materials]
@router.get("/mix-costs")
def mix_costs(db: Session = Depends(get_db)):
mixes = db.scalars(select(Mix).order_by(Mix.name)).all()
return [calculate_mix_cost(db, mix.id) for mix in mixes]
@router.get("/product-costs")
def product_costs(db: Session = Depends(get_db)):
products = db.scalars(select(Product).order_by(Product.name)).all()
return [calculate_product_cost(db, product.id) for product in products]
@router.get("/scenario-results")
def scenario_results(db: Session = Depends(get_db)):
scenarios = db.scalars(select(Scenario).order_by(Scenario.created_at.desc())).all()
return [
{
"scenario_id": scenario.id,
"scenario_name": scenario.name,
"status": scenario.status,
"description": scenario.description,
"overrides": scenario.overrides,
}
for scenario in scenarios
]
@router.get("/data-quality-issues")
def data_quality_issues(db: Session = Depends(get_db)):
issues: list[dict] = []
for mix in db.scalars(select(Mix)).all():
result = calculate_mix_cost(db, mix.id)
for warning in result["warnings"]:
issues.append({"entity_type": "mix", "entity_id": mix.id, "entity_name": mix.name, "warning": warning})
for product in db.scalars(select(Product)).all():
result = calculate_product_cost(db, product.id)
for warning in result["warnings"]:
issues.append({"entity_type": "product", "entity_id": product.id, "entity_name": product.name, "warning": warning})
for material in db.scalars(select(RawMaterial)).all():
serialized = serialize_raw_material(material)
if serialized["current_price"] is None:
issues.append({"entity_type": "raw_material", "entity_id": material.id, "entity_name": material.name, "warning": "No active price"})
return issues
+88
View File
@@ -0,0 +1,88 @@
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy import select
from sqlalchemy.orm import Session
from app.db.session import get_db
from app.models.mix import Mix
from app.models.product import Product
from app.schemas.product import ProductCostBreakdown, ProductCreate, ProductRead, ProductUpdate
from app.services.costing_engine import calculate_product_cost
router = APIRouter(prefix="/api/products", tags=["products"])
def _serialize_product(product: Product) -> dict:
return {
"id": product.id,
"tenant_id": product.tenant_id,
"client_name": product.client_name,
"item_id": product.item_id,
"name": product.name,
"mix_id": product.mix_id,
"mix_name": product.mix.name if product.mix else "",
"sale_type": product.sale_type,
"own_bag": product.own_bag,
"unit_of_measure": product.unit_of_measure,
"items_per_pallet": product.items_per_pallet,
"bagging_process": product.bagging_process,
"distributor_margin": product.distributor_margin,
"wholesale_margin": product.wholesale_margin,
"notes": product.notes,
"created_at": product.created_at,
}
@router.get("", response_model=list[ProductRead])
def list_products(db: Session = Depends(get_db)):
products = db.scalars(select(Product).order_by(Product.name)).all()
return [_serialize_product(product) for product in products]
@router.post("", response_model=ProductRead, status_code=status.HTTP_201_CREATED)
def create_product(payload: ProductCreate, db: Session = Depends(get_db)):
if db.scalar(select(Mix.id).where(Mix.id == payload.mix_id)) is None:
raise HTTPException(status_code=404, detail="Mix not found")
product = Product(**payload.model_dump())
db.add(product)
db.commit()
db.refresh(product)
return _serialize_product(product)
@router.get("/{product_id}", response_model=ProductRead)
def get_product(product_id: int, db: Session = Depends(get_db)):
product = db.scalar(select(Product).where(Product.id == product_id))
if product is None:
raise HTTPException(status_code=404, detail="Product not found")
return _serialize_product(product)
@router.patch("/{product_id}", response_model=ProductRead)
def update_product(product_id: int, payload: ProductUpdate, db: Session = Depends(get_db)):
product = db.scalar(select(Product).where(Product.id == product_id))
if product is None:
raise HTTPException(status_code=404, detail="Product not found")
if payload.mix_id is not None and db.scalar(select(Mix.id).where(Mix.id == payload.mix_id)) is None:
raise HTTPException(status_code=404, detail="Mix not found")
for field, value in payload.model_dump(exclude_unset=True).items():
setattr(product, field, value)
db.commit()
db.refresh(product)
return _serialize_product(product)
@router.get("/{product_id}/cost-breakdown", response_model=ProductCostBreakdown)
def get_product_cost_breakdown(product_id: int, db: Session = Depends(get_db)):
try:
return calculate_product_cost(db, product_id)
except ValueError as exc:
raise HTTPException(status_code=404, detail=str(exc)) from exc
@router.get("/{product_id}/price-output", response_model=ProductCostBreakdown)
def get_product_price_output(product_id: int, db: Session = Depends(get_db)):
try:
return calculate_product_cost(db, product_id)
except ValueError as exc:
raise HTTPException(status_code=404, detail=str(exc)) from exc
+122
View File
@@ -0,0 +1,122 @@
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy import select
from sqlalchemy.orm import Session, selectinload
from app.db.session import get_db
from app.models.raw_material import RawMaterial, RawMaterialPriceVersion
from app.schemas.raw_material import (
RawMaterialCreate,
RawMaterialPriceVersionCreate,
RawMaterialPriceVersionRead,
RawMaterialRead,
RawMaterialUpdate,
)
from app.services.costing_engine import calculate_raw_material_cost, serialize_raw_material
router = APIRouter(prefix="/api/raw-materials", tags=["raw-materials"])
def _serialize_price(material: RawMaterial, price: RawMaterialPriceVersion) -> dict:
price_comp = calculate_raw_material_cost(material, price)
return {
"id": price.id,
"market_value": price.market_value,
"waste_percentage": price.waste_percentage,
"effective_date": price.effective_date,
"status": price.status,
"notes": price.notes,
"created_at": price.created_at,
"loss_cost": price_comp.loss_cost,
"cost_per_unit": price_comp.cost_per_unit,
"cost_per_kg": price_comp.cost_per_kg,
}
@router.get("", response_model=list[RawMaterialRead])
def list_raw_materials(db: Session = Depends(get_db)):
materials = db.scalars(select(RawMaterial).options(selectinload(RawMaterial.price_versions)).order_by(RawMaterial.name)).all()
return [serialize_raw_material(material) for material in materials]
@router.post("", response_model=RawMaterialRead, status_code=status.HTTP_201_CREATED)
def create_raw_material(payload: RawMaterialCreate, db: Session = Depends(get_db)):
material = RawMaterial(
name=payload.name,
supplier=payload.supplier,
unit_of_measure=payload.unit_of_measure,
kg_per_unit=payload.kg_per_unit,
status=payload.status,
notes=payload.notes,
)
material.price_versions.append(
RawMaterialPriceVersion(
market_value=payload.initial_price.market_value,
waste_percentage=payload.initial_price.waste_percentage,
effective_date=payload.initial_price.effective_date,
status=payload.initial_price.status,
notes=payload.initial_price.notes,
)
)
db.add(material)
db.commit()
db.refresh(material)
return serialize_raw_material(material)
@router.get("/{raw_material_id}", response_model=RawMaterialRead)
def get_raw_material(raw_material_id: int, db: Session = Depends(get_db)):
material = db.scalar(
select(RawMaterial).where(RawMaterial.id == raw_material_id).options(selectinload(RawMaterial.price_versions))
)
if material is None:
raise HTTPException(status_code=404, detail="Raw material not found")
return serialize_raw_material(material)
@router.patch("/{raw_material_id}", response_model=RawMaterialRead)
def update_raw_material(raw_material_id: int, payload: RawMaterialUpdate, db: Session = Depends(get_db)):
material = db.scalar(
select(RawMaterial).where(RawMaterial.id == raw_material_id).options(selectinload(RawMaterial.price_versions))
)
if material is None:
raise HTTPException(status_code=404, detail="Raw material not found")
for field, value in payload.model_dump(exclude_unset=True).items():
setattr(material, field, value)
db.commit()
db.refresh(material)
return serialize_raw_material(material)
@router.post("/{raw_material_id}/prices", response_model=RawMaterialPriceVersionRead, status_code=status.HTTP_201_CREATED)
def add_price_version(raw_material_id: int, payload: RawMaterialPriceVersionCreate, db: Session = Depends(get_db)):
material = db.scalar(select(RawMaterial).where(RawMaterial.id == raw_material_id))
if material is None:
raise HTTPException(status_code=404, detail="Raw material not found")
price = RawMaterialPriceVersion(
raw_material_id=raw_material_id,
market_value=payload.market_value,
waste_percentage=payload.waste_percentage,
effective_date=payload.effective_date,
status=payload.status,
notes=payload.notes,
)
db.add(price)
db.commit()
db.refresh(price)
return _serialize_price(material, price)
@router.get("/{raw_material_id}/price-history", response_model=list[RawMaterialPriceVersionRead])
def get_price_history(raw_material_id: int, db: Session = Depends(get_db)):
material = db.scalar(select(RawMaterial).where(RawMaterial.id == raw_material_id))
if material is None:
raise HTTPException(status_code=404, detail="Raw material not found")
prices = db.scalars(
select(RawMaterialPriceVersion)
.where(RawMaterialPriceVersion.raw_material_id == raw_material_id)
.order_by(RawMaterialPriceVersion.effective_date.desc())
).all()
items = []
for price in prices:
items.append(_serialize_price(material, price))
return items
+84
View File
@@ -0,0 +1,84 @@
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy import select
from sqlalchemy.orm import Session
from app.db.session import get_db
from app.models.scenario import CostingResult, Scenario
from app.schemas.scenario import ScenarioCreate, ScenarioRead, ScenarioRunResponse
from app.services.scenario_engine import run_scenario
router = APIRouter(prefix="/api/scenarios", tags=["scenarios"])
@router.get("", response_model=list[ScenarioRead])
def list_scenarios(db: Session = Depends(get_db)):
return db.scalars(select(Scenario).order_by(Scenario.created_at.desc())).all()
@router.post("", response_model=ScenarioRead, status_code=status.HTTP_201_CREATED)
def create_scenario(payload: ScenarioCreate, db: Session = Depends(get_db)):
scenario = Scenario(name=payload.name, description=payload.description, overrides=payload.overrides)
db.add(scenario)
db.commit()
db.refresh(scenario)
return scenario
@router.get("/{scenario_id}", response_model=ScenarioRead)
def get_scenario(scenario_id: int, db: Session = Depends(get_db)):
scenario = db.scalar(select(Scenario).where(Scenario.id == scenario_id))
if scenario is None:
raise HTTPException(status_code=404, detail="Scenario not found")
return scenario
@router.post("/{scenario_id}/run", response_model=ScenarioRunResponse)
def run_scenario_endpoint(scenario_id: int, db: Session = Depends(get_db)):
scenario = db.scalar(select(Scenario).where(Scenario.id == scenario_id))
if scenario is None:
raise HTTPException(status_code=404, detail="Scenario not found")
results = run_scenario(db, scenario)
db.refresh(scenario)
return {"scenario": scenario, "results": results}
@router.get("/{scenario_id}/results")
def get_scenario_results(scenario_id: int, db: Session = Depends(get_db)):
scenario = db.scalar(select(Scenario).where(Scenario.id == scenario_id))
if scenario is None:
raise HTTPException(status_code=404, detail="Scenario not found")
results = db.scalars(select(CostingResult).where(CostingResult.scenario_id == scenario_id)).all()
return [
{
"product_id": result.product_id,
"finished_product_delivered": result.finished_product_delivered,
"distributor_price": result.distributor_price,
"wholesale_price": result.wholesale_price,
"warnings": result.warnings,
"details": result.details,
}
for result in results
]
@router.post("/{scenario_id}/approve", response_model=ScenarioRead)
def approve_scenario(scenario_id: int, db: Session = Depends(get_db)):
scenario = db.scalar(select(Scenario).where(Scenario.id == scenario_id))
if scenario is None:
raise HTTPException(status_code=404, detail="Scenario not found")
scenario.status = "approved"
db.commit()
db.refresh(scenario)
return scenario
@router.post("/{scenario_id}/reject", response_model=ScenarioRead)
def reject_scenario(scenario_id: int, db: Session = Depends(get_db)):
scenario = db.scalar(select(Scenario).where(Scenario.id == scenario_id))
if scenario is None:
raise HTTPException(status_code=404, detail="Scenario not found")
scenario.status = "rejected"
db.commit()
db.refresh(scenario)
return scenario
+1
View File
@@ -0,0 +1 @@
+24
View File
@@ -0,0 +1,24 @@
import os
from dataclasses import dataclass
@dataclass(frozen=True)
class Settings:
app_name: str
database_url: str
operator_name: str
operator_email: str
operator_password: str
@classmethod
def from_env(cls) -> "Settings":
return cls(
app_name=os.getenv("APP_NAME", "Data Entry App API"),
database_url=os.getenv("DATABASE_URL", "sqlite:///./data_entry_app.db"),
operator_name=os.getenv("OPERATOR_NAME", "Operations Manager"),
operator_email=os.getenv("OPERATOR_EMAIL", "operator@example.com"),
operator_password=os.getenv("OPERATOR_PASSWORD", "changeme"),
)
settings = Settings.from_env()
+1
View File
@@ -0,0 +1 @@
+23
View File
@@ -0,0 +1,23 @@
from sqlalchemy import create_engine
from sqlalchemy.orm import DeclarativeBase, sessionmaker
from app.core.config import settings
class Base(DeclarativeBase):
pass
connect_args = {"check_same_thread": False} if settings.database_url.startswith("sqlite") else {}
engine = create_engine(settings.database_url, connect_args=connect_args)
SessionLocal = sessionmaker(bind=engine, autoflush=False, autocommit=False, expire_on_commit=False)
def get_db():
db = SessionLocal()
try:
yield db
finally:
db.close()
+81
View File
@@ -0,0 +1,81 @@
import os
import sys
from contextlib import asynccontextmanager
from pathlib import Path
if __package__ in {None, ""}:
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
import uvicorn
from app.api.auth import router as auth_router
from app.api.mixes import router as mixes_router
from app.api.powerbi import router as powerbi_router
from app.api.products import router as products_router
from app.api.raw_materials import router as raw_materials_router
from app.api.scenarios import router as scenarios_router
from app.core.config import settings
from app.db.session import Base, engine
from app.seed import seed_if_empty
@asynccontextmanager
async def lifespan(_: FastAPI):
Base.metadata.create_all(bind=engine)
seed_if_empty()
yield
app = FastAPI(title=settings.app_name, lifespan=lifespan)
app.add_middleware(
CORSMiddleware,
allow_origins=["*"],
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
app.include_router(auth_router)
app.include_router(raw_materials_router)
app.include_router(mixes_router)
app.include_router(products_router)
app.include_router(scenarios_router)
app.include_router(powerbi_router)
@app.get("/")
def root():
return {
"app": settings.app_name,
"message": "Use the operator frontend to sign in, manage raw materials, and review downstream mix and product costs.",
"workflow": [
"Sign in as an operator",
"Update raw material prices or add new materials",
"Review mix master recalculations",
"Confirm finished product pricing outputs",
],
"endpoints": {
"login": "/api/auth/login",
"raw_materials": "/api/raw-materials",
"mixes": "/api/mixes",
"products": "/api/products",
"scenarios": "/api/scenarios",
"docs": "/docs",
},
}
@app.get("/health")
def healthcheck():
return {"status": "ok"}
if __name__ == "__main__":
uvicorn.run(
app,
host=os.getenv("HOST", "0.0.0.0"),
port=int(os.getenv("PORT", "8000")),
)
+19
View File
@@ -0,0 +1,19 @@
from app.models.assumption import FreightCostRule, PackagingCostRule, ProcessCostRule
from app.models.mix import Mix, MixIngredient
from app.models.product import Product
from app.models.raw_material import RawMaterial, RawMaterialPriceVersion
from app.models.scenario import CostingResult, Scenario
__all__ = [
"CostingResult",
"FreightCostRule",
"Mix",
"MixIngredient",
"PackagingCostRule",
"ProcessCostRule",
"Product",
"RawMaterial",
"RawMaterialPriceVersion",
"Scenario",
]
+38
View File
@@ -0,0 +1,38 @@
from datetime import datetime
from sqlalchemy import Boolean, DateTime, Float, String
from sqlalchemy.orm import Mapped, mapped_column
from app.db.session import Base
class ProcessCostRule(Base):
__tablename__ = "process_cost_rules"
id: Mapped[int] = mapped_column(primary_key=True)
process_name: Mapped[str] = mapped_column(String(64), unique=True)
grading_cost: Mapped[float] = mapped_column(Float, default=0.0)
bagging_cost: Mapped[float] = mapped_column(Float, default=0.0)
cracking_cost: Mapped[float] = mapped_column(Float, default=0.0)
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
class PackagingCostRule(Base):
__tablename__ = "packaging_cost_rules"
id: Mapped[int] = mapped_column(primary_key=True)
sale_type: Mapped[str] = mapped_column(String(64))
unit_of_measure: Mapped[str] = mapped_column(String(64))
own_bag: Mapped[bool] = mapped_column(Boolean, default=False)
bag_cost: Mapped[float] = mapped_column(Float, default=0.0)
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
class FreightCostRule(Base):
__tablename__ = "freight_cost_rules"
id: Mapped[int] = mapped_column(primary_key=True)
sale_type: Mapped[str] = mapped_column(String(64))
unit_of_measure: Mapped[str] = mapped_column(String(64))
cost_per_unit: Mapped[float] = mapped_column(Float, default=0.0)
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
+46
View File
@@ -0,0 +1,46 @@
from __future__ import annotations
from datetime import datetime
from sqlalchemy import DateTime, Float, ForeignKey, String, Text, UniqueConstraint
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.db.session import Base
class Mix(Base):
__tablename__ = "mixes"
id: Mapped[int] = mapped_column(primary_key=True)
tenant_id: Mapped[str] = mapped_column(String(64), default="default")
client_name: Mapped[str] = mapped_column(String(255))
name: Mapped[str] = mapped_column(String(255), index=True)
status: Mapped[str] = mapped_column(String(32), default="draft")
version: Mapped[int] = mapped_column(default=1)
notes: Mapped[str | None] = mapped_column(Text, nullable=True)
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
ingredients: Mapped[list["MixIngredient"]] = relationship(
back_populates="mix",
cascade="all, delete-orphan",
)
products: Mapped[list["Product"]] = relationship(back_populates="mix")
class MixIngredient(Base):
__tablename__ = "mix_ingredients"
__table_args__ = (UniqueConstraint("mix_id", "raw_material_id", name="uq_mix_ingredient"),)
id: Mapped[int] = mapped_column(primary_key=True)
mix_id: Mapped[int] = mapped_column(ForeignKey("mixes.id"), index=True)
raw_material_id: Mapped[int] = mapped_column(ForeignKey("raw_materials.id"), index=True)
quantity_kg: Mapped[float] = mapped_column(Float)
notes: Mapped[str | None] = mapped_column(Text, nullable=True)
mix: Mapped[Mix] = relationship(back_populates="ingredients")
raw_material: Mapped["RawMaterial"] = relationship()
from app.models.product import Product # noqa: E402
from app.models.raw_material import RawMaterial # noqa: E402
+34
View File
@@ -0,0 +1,34 @@
from __future__ import annotations
from datetime import datetime
from sqlalchemy import Boolean, DateTime, Float, ForeignKey, Integer, String, Text
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.db.session import Base
class Product(Base):
__tablename__ = "products"
id: Mapped[int] = mapped_column(primary_key=True)
tenant_id: Mapped[str] = mapped_column(String(64), default="default")
client_name: Mapped[str] = mapped_column(String(255))
item_id: Mapped[str | None] = mapped_column(String(128), nullable=True)
name: Mapped[str] = mapped_column(String(255), index=True)
mix_id: Mapped[int] = mapped_column(ForeignKey("mixes.id"))
sale_type: Mapped[str] = mapped_column(String(64), default="standard")
own_bag: Mapped[bool] = mapped_column(Boolean, default=False)
unit_of_measure: Mapped[str] = mapped_column(String(64), default="20kg bag")
items_per_pallet: Mapped[int] = mapped_column(Integer, default=50)
bagging_process: Mapped[str | None] = mapped_column(String(64), nullable=True)
distributor_margin: Mapped[float | None] = mapped_column(Float, nullable=True)
wholesale_margin: Mapped[float | None] = mapped_column(Float, nullable=True)
notes: Mapped[str | None] = mapped_column(Text, nullable=True)
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
mix: Mapped["Mix"] = relationship(back_populates="products")
from app.models.mix import Mix # noqa: E402
+44
View File
@@ -0,0 +1,44 @@
from __future__ import annotations
from datetime import date, datetime
from sqlalchemy import Date, DateTime, Float, ForeignKey, String, Text
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.db.session import Base
class RawMaterial(Base):
__tablename__ = "raw_materials"
id: Mapped[int] = mapped_column(primary_key=True)
tenant_id: Mapped[str] = mapped_column(String(64), default="default")
name: Mapped[str] = mapped_column(String(255), unique=True, index=True)
supplier: Mapped[str | None] = mapped_column(String(255), nullable=True)
unit_of_measure: Mapped[str] = mapped_column(String(64))
kg_per_unit: Mapped[float] = mapped_column(Float)
status: Mapped[str] = mapped_column(String(32), default="active")
notes: Mapped[str | None] = mapped_column(Text, nullable=True)
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
price_versions: Mapped[list["RawMaterialPriceVersion"]] = relationship(
back_populates="raw_material",
cascade="all, delete-orphan",
order_by="desc(RawMaterialPriceVersion.effective_date)",
)
class RawMaterialPriceVersion(Base):
__tablename__ = "raw_material_price_versions"
id: Mapped[int] = mapped_column(primary_key=True)
raw_material_id: Mapped[int] = mapped_column(ForeignKey("raw_materials.id"), index=True)
market_value: Mapped[float] = mapped_column(Float)
waste_percentage: Mapped[float] = mapped_column(Float, default=0.0)
effective_date: Mapped[date] = mapped_column(Date, default=date.today)
status: Mapped[str] = mapped_column(String(32), default="active")
notes: Mapped[str | None] = mapped_column(Text, nullable=True)
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
raw_material: Mapped[RawMaterial] = relationship(back_populates="price_versions")
+41
View File
@@ -0,0 +1,41 @@
from __future__ import annotations
from datetime import datetime
from sqlalchemy import DateTime, Float, ForeignKey, JSON, String
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.db.session import Base
class Scenario(Base):
__tablename__ = "scenarios"
id: Mapped[int] = mapped_column(primary_key=True)
name: Mapped[str] = mapped_column(String(255), unique=True)
status: Mapped[str] = mapped_column(String(32), default="draft")
description: Mapped[str | None] = mapped_column(String(500), nullable=True)
overrides: Mapped[dict] = mapped_column(JSON, default=dict)
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
results: Mapped[list["CostingResult"]] = relationship(
back_populates="scenario",
cascade="all, delete-orphan",
)
class CostingResult(Base):
__tablename__ = "costing_results"
id: Mapped[int] = mapped_column(primary_key=True)
scenario_id: Mapped[int] = mapped_column(ForeignKey("scenarios.id"), index=True)
product_id: Mapped[int] = mapped_column(ForeignKey("products.id"), index=True)
finished_product_delivered: Mapped[float] = mapped_column(Float)
distributor_price: Mapped[float | None] = mapped_column(Float, nullable=True)
wholesale_price: Mapped[float | None] = mapped_column(Float, nullable=True)
warnings: Mapped[list[str]] = mapped_column(JSON, default=list)
details: Mapped[dict] = mapped_column(JSON, default=dict)
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
scenario: Mapped[Scenario] = relationship(back_populates="results")
+1
View File
@@ -0,0 +1 @@
+60
View File
@@ -0,0 +1,60 @@
from datetime import datetime
from pydantic import BaseModel, ConfigDict, Field
class MixIngredientCreate(BaseModel):
raw_material_id: int
quantity_kg: float = Field(gt=0)
notes: str | None = None
class MixIngredientUpdate(BaseModel):
quantity_kg: float | None = Field(default=None, gt=0)
notes: str | None = None
class MixIngredientRead(BaseModel):
id: int
raw_material_id: int
raw_material_name: str
quantity_kg: float
cost_per_kg: float | None
line_cost: float | None
notes: str | None
model_config = ConfigDict(from_attributes=True)
class MixCreate(BaseModel):
client_name: str
name: str
status: str = "draft"
version: int = 1
notes: str | None = None
ingredients: list[MixIngredientCreate]
class MixUpdate(BaseModel):
client_name: str | None = None
name: str | None = None
status: str | None = None
version: int | None = None
notes: str | None = None
class MixRead(BaseModel):
id: int
tenant_id: str
client_name: str
name: str
status: str
version: int
notes: str | None
created_at: datetime
ingredients: list[MixIngredientRead]
total_mix_kg: float
total_mix_cost: float
mix_cost_per_kg: float | None
warnings: list[str]
model_config = ConfigDict(from_attributes=True)
+70
View File
@@ -0,0 +1,70 @@
from datetime import datetime
from pydantic import BaseModel, ConfigDict, Field
class ProductCreate(BaseModel):
client_name: str
item_id: str | None = None
name: str
mix_id: int
sale_type: str = "standard"
own_bag: bool = False
unit_of_measure: str = "20kg bag"
items_per_pallet: int = Field(default=50, gt=0)
bagging_process: str | None = None
distributor_margin: float | None = Field(default=None, gt=0, lt=1)
wholesale_margin: float | None = Field(default=None, gt=0, lt=1)
notes: str | None = None
class ProductUpdate(BaseModel):
client_name: str | None = None
item_id: str | None = None
name: str | None = None
mix_id: int | None = None
sale_type: str | None = None
own_bag: bool | None = None
unit_of_measure: str | None = None
items_per_pallet: int | None = Field(default=None, gt=0)
bagging_process: str | None = None
distributor_margin: float | None = Field(default=None, gt=0, lt=1)
wholesale_margin: float | None = Field(default=None, gt=0, lt=1)
notes: str | None = None
class ProductRead(BaseModel):
id: int
tenant_id: str
client_name: str
item_id: str | None
name: str
mix_id: int
mix_name: str
sale_type: str
own_bag: bool
unit_of_measure: str
items_per_pallet: int
bagging_process: str | None
distributor_margin: float | None
wholesale_margin: float | None
notes: str | None
created_at: datetime
model_config = ConfigDict(from_attributes=True)
class ProductCostBreakdown(BaseModel):
product_id: int
product_name: str
cleaned_product_cost: float
grading_cost: float
bagging_cost: float
cracking_cost: float
bag_cost: float
freight_cost: float
finished_product_delivered: float
distributor_price: float | None
wholesale_price: float | None
warnings: list[str]
inputs: dict[str, object]
+53
View File
@@ -0,0 +1,53 @@
from datetime import date, datetime
from pydantic import BaseModel, ConfigDict, Field
class RawMaterialPriceVersionCreate(BaseModel):
market_value: float = Field(gt=0)
waste_percentage: float = Field(ge=0, default=0.0)
effective_date: date
status: str = "active"
notes: str | None = None
class RawMaterialPriceVersionRead(RawMaterialPriceVersionCreate):
id: int
created_at: datetime
loss_cost: float
cost_per_unit: float
cost_per_kg: float
model_config = ConfigDict(from_attributes=True)
class RawMaterialCreate(BaseModel):
name: str
supplier: str | None = None
unit_of_measure: str
kg_per_unit: float = Field(gt=0)
status: str = "active"
notes: str | None = None
initial_price: RawMaterialPriceVersionCreate
class RawMaterialUpdate(BaseModel):
supplier: str | None = None
unit_of_measure: str | None = None
kg_per_unit: float | None = Field(default=None, gt=0)
status: str | None = None
notes: str | None = None
class RawMaterialRead(BaseModel):
id: int
tenant_id: str
name: str
supplier: str | None
unit_of_measure: str
kg_per_unit: float
status: str
notes: str | None
created_at: datetime
current_price: RawMaterialPriceVersionRead | None
model_config = ConfigDict(from_attributes=True)
+26
View File
@@ -0,0 +1,26 @@
from datetime import datetime
from pydantic import BaseModel, ConfigDict, Field
from app.schemas.product import ProductCostBreakdown
class ScenarioCreate(BaseModel):
name: str
description: str | None = None
overrides: dict = Field(default_factory=dict)
class ScenarioRead(BaseModel):
id: int
name: str
status: str
description: str | None
overrides: dict
created_at: datetime
model_config = ConfigDict(from_attributes=True)
class ScenarioRunResponse(BaseModel):
scenario: ScenarioRead
results: list[ProductCostBreakdown]
+76
View File
@@ -0,0 +1,76 @@
from datetime import date
from sqlalchemy import select
from app.db.session import Base, SessionLocal, engine
from app.models.assumption import FreightCostRule, PackagingCostRule, ProcessCostRule
from app.models.mix import Mix, MixIngredient
from app.models.product import Product
from app.models.raw_material import RawMaterial, RawMaterialPriceVersion
def seed_if_empty():
Base.metadata.create_all(bind=engine)
with SessionLocal() as db:
existing = db.scalar(select(RawMaterial.id))
if existing is not None:
return
maize = RawMaterial(name="Maize", supplier="Example Supplier", unit_of_measure="tonne", kg_per_unit=1000, status="active")
barley = RawMaterial(name="Barley", supplier="Example Supplier", unit_of_measure="tonne", kg_per_unit=1000, status="active")
acid_buf = RawMaterial(name="Acid Buf", supplier="Example Supplier", unit_of_measure="bag", kg_per_unit=25, status="active")
maize.price_versions.append(RawMaterialPriceVersion(market_value=520, waste_percentage=0.02, effective_date=date(2026, 4, 1)))
barley.price_versions.append(RawMaterialPriceVersion(market_value=470, waste_percentage=0.015, effective_date=date(2026, 4, 1)))
acid_buf.price_versions.append(RawMaterialPriceVersion(market_value=39, waste_percentage=0.0, effective_date=date(2026, 4, 1)))
db.add_all([maize, barley, acid_buf])
db.flush()
db.add_all(
[
ProcessCostRule(process_name="standard_bagging", grading_cost=0.055, bagging_cost=0.04, cracking_cost=0.0),
ProcessCostRule(process_name="bulk_loadout", grading_cost=0.03, bagging_cost=0.0, cracking_cost=0.0),
PackagingCostRule(sale_type="standard", unit_of_measure="20kg bag", own_bag=False, bag_cost=0.63),
PackagingCostRule(sale_type="bulka", unit_of_measure="550kg bulka", own_bag=False, bag_cost=7.5),
FreightCostRule(sale_type="standard", unit_of_measure="20kg bag", cost_per_unit=1.45),
FreightCostRule(sale_type="bulka", unit_of_measure="550kg bulka", cost_per_unit=18.0),
]
)
db.flush()
mix = Mix(client_name="Specialty Feeds", name="Pigeon Mix", status="active", version=1, notes="Seed recipe for MVP")
db.add(mix)
db.flush()
db.add_all(
[
MixIngredient(mix_id=mix.id, raw_material_id=maize.id, quantity_kg=180),
MixIngredient(mix_id=mix.id, raw_material_id=barley.id, quantity_kg=95),
MixIngredient(mix_id=mix.id, raw_material_id=acid_buf.id, quantity_kg=5),
]
)
db.flush()
db.add(
Product(
client_name="Specialty Feeds",
item_id="SKU-001",
name="Specialty Pigeon Breeder 20kg",
mix_id=mix.id,
sale_type="standard",
own_bag=False,
unit_of_measure="20kg bag",
items_per_pallet=50,
bagging_process="standard_bagging",
distributor_margin=0.225,
wholesale_margin=0.18,
notes="Reference product for formula parity work",
)
)
db.commit()
if __name__ == "__main__":
seed_if_empty()
+1
View File
@@ -0,0 +1 @@
+277
View File
@@ -0,0 +1,277 @@
from __future__ import annotations
from dataclasses import asdict, dataclass
import re
from sqlalchemy import select
from sqlalchemy.orm import Session, selectinload
from app.models.assumption import FreightCostRule, PackagingCostRule, ProcessCostRule
from app.models.mix import Mix, MixIngredient
from app.models.product import Product
from app.models.raw_material import RawMaterial, RawMaterialPriceVersion
@dataclass
class PriceComputation:
loss_cost: float
cost_per_unit: float
cost_per_kg: float
def calculate_raw_material_cost(raw_material: RawMaterial, price: RawMaterialPriceVersion) -> PriceComputation:
loss_cost = price.market_value * price.waste_percentage
cost_per_unit = price.market_value + loss_cost
cost_per_kg = cost_per_unit / raw_material.kg_per_unit
return PriceComputation(loss_cost=round(loss_cost, 4), cost_per_unit=round(cost_per_unit, 4), cost_per_kg=round(cost_per_kg, 4))
def get_active_price(raw_material: RawMaterial) -> RawMaterialPriceVersion | None:
active_prices = [price for price in raw_material.price_versions if price.status == "active"]
if not active_prices:
return None
active_prices.sort(key=lambda item: item.effective_date, reverse=True)
return active_prices[0]
def calculate_mix_cost(db: Session, mix_id: int, overrides: dict | None = None) -> dict:
overrides = overrides or {}
mix = db.scalar(
select(Mix)
.where(Mix.id == mix_id)
.options(selectinload(Mix.ingredients).selectinload(MixIngredient.raw_material).selectinload(RawMaterial.price_versions))
)
if mix is None:
raise ValueError(f"Mix {mix_id} not found")
total_mix_kg = 0.0
total_mix_cost = 0.0
warnings: list[str] = []
lines: list[dict] = []
for ingredient in mix.ingredients:
raw_material = ingredient.raw_material
active_price = get_active_price(raw_material)
if active_price is None:
warnings.append(f"{raw_material.name} has no active price")
lines.append(
{
"id": ingredient.id,
"raw_material_id": raw_material.id,
"raw_material_name": raw_material.name,
"quantity_kg": ingredient.quantity_kg,
"cost_per_kg": None,
"line_cost": None,
"notes": ingredient.notes,
}
)
total_mix_kg += ingredient.quantity_kg
continue
market_value = overrides.get("raw_material_market_values", {}).get(str(raw_material.id), active_price.market_value)
waste_percentage = overrides.get("raw_material_waste_percentages", {}).get(str(raw_material.id), active_price.waste_percentage)
price_stub = RawMaterialPriceVersion(
raw_material_id=raw_material.id,
market_value=market_value,
waste_percentage=waste_percentage,
effective_date=active_price.effective_date,
status=active_price.status,
)
price_comp = calculate_raw_material_cost(raw_material, price_stub)
line_cost = round(ingredient.quantity_kg * price_comp.cost_per_kg, 4)
total_mix_kg += ingredient.quantity_kg
total_mix_cost += line_cost
lines.append(
{
"id": ingredient.id,
"raw_material_id": raw_material.id,
"raw_material_name": raw_material.name,
"quantity_kg": ingredient.quantity_kg,
"cost_per_kg": price_comp.cost_per_kg,
"line_cost": line_cost,
"notes": ingredient.notes,
}
)
if total_mix_kg == 0:
warnings.append("Mix total kg is zero")
mix_cost_per_kg = None
else:
mix_cost_per_kg = round(total_mix_cost / total_mix_kg, 4)
if not mix.ingredients:
warnings.append("Mix has no ingredients")
return {
"id": mix.id,
"tenant_id": mix.tenant_id,
"client_name": mix.client_name,
"name": mix.name,
"status": mix.status,
"version": mix.version,
"notes": mix.notes,
"created_at": mix.created_at,
"ingredients": lines,
"total_mix_kg": round(total_mix_kg, 4),
"total_mix_cost": round(total_mix_cost, 4),
"mix_cost_per_kg": mix_cost_per_kg,
"warnings": warnings,
}
def _get_process_costs(db: Session, process_name: str | None, overrides: dict) -> tuple[float, float, float, list[str]]:
if not process_name:
return 0.0, 0.0, 0.0, ["Missing bagging process"]
rule = db.scalar(select(ProcessCostRule).where(ProcessCostRule.process_name == process_name))
if rule is None:
return 0.0, 0.0, 0.0, [f"Process rule not found for {process_name}"]
override_costs = overrides.get("process_costs", {}).get(process_name, {})
grading = override_costs.get("grading_cost", rule.grading_cost)
bagging = override_costs.get("bagging_cost", rule.bagging_cost)
cracking = override_costs.get("cracking_cost", rule.cracking_cost)
return grading, bagging, cracking, []
def _get_packaging_cost(db: Session, product: Product, overrides: dict) -> tuple[float, list[str]]:
if product.own_bag:
return 0.0, []
rule = db.scalar(
select(PackagingCostRule).where(
PackagingCostRule.sale_type == product.sale_type,
PackagingCostRule.unit_of_measure == product.unit_of_measure,
PackagingCostRule.own_bag == product.own_bag,
)
)
if rule is None:
return 0.0, ["Packaging rule not found"]
return overrides.get("packaging_costs", {}).get(str(rule.id), rule.bag_cost), []
def _get_freight_cost(db: Session, product: Product, overrides: dict) -> tuple[float, list[str]]:
rule = db.scalar(
select(FreightCostRule).where(
FreightCostRule.sale_type == product.sale_type,
FreightCostRule.unit_of_measure == product.unit_of_measure,
)
)
if rule is None:
return 0.0, ["Freight rule not found"]
return overrides.get("freight_costs", {}).get(str(rule.id), rule.cost_per_unit), []
def _apply_margin(cost: float, margin: float | None) -> float | None:
if margin is None:
return None
if margin >= 1:
raise ValueError("Margin must be lower than 1")
return round(cost / (1 - margin), 4)
def _extract_unit_quantity_kg(unit_of_measure: str) -> float:
normalized = unit_of_measure.strip().lower()
if normalized == "tonne":
return 1000.0
if normalized == "kg":
return 1.0
match = re.search(r"(\d+(?:\.\d+)?)\s*kg", normalized)
if match:
return float(match.group(1))
return 1.0
def calculate_product_cost(db: Session, product_id: int, overrides: dict | None = None) -> dict:
overrides = overrides or {}
product = db.scalar(select(Product).where(Product.id == product_id).options(selectinload(Product.mix)))
if product is None:
raise ValueError(f"Product {product_id} not found")
mix_result = calculate_mix_cost(db, product.mix_id, overrides=overrides)
warnings = list(mix_result["warnings"])
sale_unit_kg = _extract_unit_quantity_kg(product.unit_of_measure)
mix_cost_per_kg = mix_result["mix_cost_per_kg"] or 0.0
cleaned_product_cost = round(mix_cost_per_kg * sale_unit_kg, 4)
grading_cost, bagging_cost, cracking_cost, process_warnings = _get_process_costs(db, product.bagging_process, overrides)
warnings.extend(process_warnings)
grading_cost = round(grading_cost * sale_unit_kg, 4)
bagging_cost = round(bagging_cost * sale_unit_kg, 4)
cracking_cost = round(cracking_cost * sale_unit_kg, 4)
bag_cost, packaging_warnings = _get_packaging_cost(db, product, overrides)
warnings.extend(packaging_warnings)
freight_cost, freight_warnings = _get_freight_cost(db, product, overrides)
warnings.extend(freight_warnings)
finished_product_delivered = round(
cleaned_product_cost + grading_cost + bagging_cost + cracking_cost + bag_cost + freight_cost,
4,
)
distributor_margin = overrides.get("product_margins", {}).get(str(product.id), {}).get("distributor_margin", product.distributor_margin)
wholesale_margin = overrides.get("product_margins", {}).get(str(product.id), {}).get("wholesale_margin", product.wholesale_margin)
return {
"product_id": product.id,
"product_name": product.name,
"cleaned_product_cost": round(cleaned_product_cost, 4),
"grading_cost": round(grading_cost, 4),
"bagging_cost": round(bagging_cost, 4),
"cracking_cost": round(cracking_cost, 4),
"bag_cost": round(bag_cost, 4),
"freight_cost": round(freight_cost, 4),
"finished_product_delivered": finished_product_delivered,
"distributor_price": _apply_margin(finished_product_delivered, distributor_margin),
"wholesale_price": _apply_margin(finished_product_delivered, wholesale_margin),
"warnings": warnings,
"inputs": {
"mix": {
"mix_id": product.mix_id,
"mix_name": product.mix.name,
"total_mix_kg": mix_result["total_mix_kg"],
"total_mix_cost": mix_result["total_mix_cost"],
"mix_cost_per_kg": mix_result["mix_cost_per_kg"],
"sale_unit_kg": sale_unit_kg,
},
"product": {
"sale_type": product.sale_type,
"own_bag": product.own_bag,
"unit_of_measure": product.unit_of_measure,
"items_per_pallet": product.items_per_pallet,
"bagging_process": product.bagging_process,
"distributor_margin": distributor_margin,
"wholesale_margin": wholesale_margin,
},
},
}
def serialize_raw_material(raw_material: RawMaterial) -> dict:
active_price = get_active_price(raw_material)
current_price = None
if active_price is not None:
price_comp = calculate_raw_material_cost(raw_material, active_price)
current_price = {
"id": active_price.id,
"market_value": active_price.market_value,
"waste_percentage": active_price.waste_percentage,
"effective_date": active_price.effective_date,
"status": active_price.status,
"notes": active_price.notes,
"created_at": active_price.created_at,
**asdict(price_comp),
}
return {
"id": raw_material.id,
"tenant_id": raw_material.tenant_id,
"name": raw_material.name,
"supplier": raw_material.supplier,
"unit_of_measure": raw_material.unit_of_measure,
"kg_per_unit": raw_material.kg_per_unit,
"status": raw_material.status,
"notes": raw_material.notes,
"created_at": raw_material.created_at,
"current_price": current_price,
}
+32
View File
@@ -0,0 +1,32 @@
from sqlalchemy import delete, select
from sqlalchemy.orm import Session
from app.models.product import Product
from app.models.scenario import CostingResult, Scenario
from app.services.costing_engine import calculate_product_cost
def run_scenario(db: Session, scenario: Scenario) -> list[dict]:
db.execute(delete(CostingResult).where(CostingResult.scenario_id == scenario.id))
products = db.scalars(select(Product).order_by(Product.name)).all()
results: list[dict] = []
for product in products:
breakdown = calculate_product_cost(db, product.id, overrides=scenario.overrides or {})
db.add(
CostingResult(
scenario_id=scenario.id,
product_id=product.id,
finished_product_delivered=breakdown["finished_product_delivered"],
distributor_price=breakdown["distributor_price"],
wholesale_price=breakdown["wholesale_price"],
warnings=breakdown["warnings"],
details=breakdown,
)
)
results.append(breakdown)
scenario.status = "reviewed"
db.commit()
return results