v1.2 scaffold
This commit is contained in:
@@ -0,0 +1 @@
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
|
||||
@@ -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",
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
|
||||
@@ -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()
|
||||
@@ -0,0 +1 @@
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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")),
|
||||
)
|
||||
@@ -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",
|
||||
]
|
||||
|
||||
@@ -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)
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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]
|
||||
@@ -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()
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user