v1.2 scaffold
This commit is contained in:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user