Files
data-entry-app/backend/app/api/product_costing.py
T

248 lines
10 KiB
Python
Raw Normal View History

2026-06-09 21:28:53 +12:00
from fastapi import APIRouter, Depends, HTTPException, Query, status
from sqlalchemy import or_, select
from sqlalchemy.orm import Session
from app.api.deps import AuthSession, require_client_module_access
from app.db.session import get_db
from app.models.product_costing import (
ProductCostBagInput,
ProductCostBaseInput,
ProductCostClientInput,
ProductCostFreightInput,
ProductCostItem,
ProductCostProcessInput,
)
from app.schemas.product_costing import (
ProductCostInputsRead,
ProductCostInputsUpdate,
ProductCostItemCreate,
ProductCostItemRead,
ProductCostItemUpdate,
ProductCostRecalculateAllRead,
)
from app.services.product_costing_service import (
BAG_INPUTS,
FREIGHT_INPUTS,
PROCESS_NAMES,
recalculate_all_product_cost_items,
recalculate_product_cost_item,
serialize_product_cost_item,
)
router = APIRouter(prefix="/api/product-costing", tags=["product-costing"])
def _load_item(db: Session, tenant_id: str, item_id: int) -> ProductCostItem | None:
return db.scalar(select(ProductCostItem).where(ProductCostItem.id == item_id, ProductCostItem.tenant_id == tenant_id))
def _ensure_inputs(db: Session, tenant_id: str) -> ProductCostBaseInput:
base = db.scalar(select(ProductCostBaseInput).where(ProductCostBaseInput.tenant_id == tenant_id))
if base is None:
base = ProductCostBaseInput(tenant_id=tenant_id)
db.add(base)
db.flush()
for process_name in PROCESS_NAMES:
if db.scalar(select(ProductCostProcessInput.id).where(ProductCostProcessInput.tenant_id == tenant_id, ProductCostProcessInput.process_name == process_name)) is None:
db.add(ProductCostProcessInput(tenant_id=tenant_id, process_name=process_name, cost_per_kg=0.0))
for key, label in BAG_INPUTS.items():
if db.scalar(select(ProductCostBagInput.id).where(ProductCostBagInput.tenant_id == tenant_id, ProductCostBagInput.input_key == key)) is None:
db.add(ProductCostBagInput(tenant_id=tenant_id, input_key=key, label=label, cost=0.0))
for key, label in FREIGHT_INPUTS.items():
if db.scalar(select(ProductCostFreightInput.id).where(ProductCostFreightInput.tenant_id == tenant_id, ProductCostFreightInput.input_key == key)) is None:
db.add(ProductCostFreightInput(tenant_id=tenant_id, input_key=key, label=label, cost=0.0))
db.flush()
return base
def _serialize_inputs(db: Session, tenant_id: str) -> dict:
base = _ensure_inputs(db, tenant_id)
return {
"base": {
"grading_per_tonne": base.grading_per_tonne,
"grading_per_kg": base.grading_per_kg,
"cracking_per_tonne": base.cracking_per_tonne,
"cracking_per_kg": base.cracking_per_kg,
},
"processes": [
{"key": row.process_name, "label": row.process_name, "cost": row.cost_per_kg}
for row in db.scalars(select(ProductCostProcessInput).where(ProductCostProcessInput.tenant_id == tenant_id).order_by(ProductCostProcessInput.process_name)).all()
],
"clients": [
{
"client_category": row.client_category,
"distributor_margin": row.distributor_margin,
"wholesale_margin": row.wholesale_margin,
}
for row in db.scalars(select(ProductCostClientInput).where(ProductCostClientInput.tenant_id == tenant_id).order_by(ProductCostClientInput.client_category)).all()
],
"bags": [
{"key": row.input_key, "label": row.label, "cost": row.cost}
for row in db.scalars(select(ProductCostBagInput).where(ProductCostBagInput.tenant_id == tenant_id).order_by(ProductCostBagInput.input_key)).all()
],
"freight": [
{"key": row.input_key, "label": row.label, "cost": row.cost}
for row in db.scalars(select(ProductCostFreightInput).where(ProductCostFreightInput.tenant_id == tenant_id).order_by(ProductCostFreightInput.input_key)).all()
],
}
@router.get("/items", response_model=list[ProductCostItemRead])
def list_product_cost_items(
q: str | None = Query(default=None),
client_category: str | None = Query(default=None),
limit: int = Query(default=250, ge=1, le=1000),
session: AuthSession = Depends(require_client_module_access("products")),
db: Session = Depends(get_db),
):
statement = select(ProductCostItem).where(ProductCostItem.tenant_id == session.tenant_id)
if client_category:
statement = statement.where(ProductCostItem.client_category == client_category)
if q:
term = f"%{q}%"
statement = statement.where(
or_(
ProductCostItem.client_category.ilike(term),
ProductCostItem.item_id.ilike(term),
ProductCostItem.product_name.ilike(term),
ProductCostItem.mix_product_name.ilike(term),
)
)
items = db.scalars(statement.order_by(ProductCostItem.client_category, ProductCostItem.product_name).limit(limit)).all()
return [serialize_product_cost_item(item) for item in items]
@router.post("/items", response_model=ProductCostItemRead, status_code=status.HTTP_201_CREATED)
def create_product_cost_item(
payload: ProductCostItemCreate,
session: AuthSession = Depends(require_client_module_access("products", "edit")),
db: Session = Depends(get_db),
):
item = ProductCostItem(tenant_id=session.tenant_id or "default", **payload.model_dump())
db.add(item)
db.flush()
recalculate_product_cost_item(db, item)
db.commit()
db.refresh(item)
return serialize_product_cost_item(item)
@router.get("/items/{item_id}", response_model=ProductCostItemRead)
def get_product_cost_item(
item_id: int,
session: AuthSession = Depends(require_client_module_access("products")),
db: Session = Depends(get_db),
):
item = _load_item(db, session.tenant_id or "default", item_id)
if item is None:
raise HTTPException(status_code=404, detail="Product cost item not found")
return serialize_product_cost_item(item)
@router.patch("/items/{item_id}", response_model=ProductCostItemRead)
def update_product_cost_item(
item_id: int,
payload: ProductCostItemUpdate,
session: AuthSession = Depends(require_client_module_access("products", "edit")),
db: Session = Depends(get_db),
):
item = _load_item(db, session.tenant_id or "default", item_id)
if item is None:
raise HTTPException(status_code=404, detail="Product cost item not found")
for field, value in payload.model_dump(exclude_unset=True).items():
setattr(item, field, value)
recalculate_product_cost_item(db, item)
db.commit()
db.refresh(item)
return serialize_product_cost_item(item)
@router.post("/items/{item_id}/recalculate", response_model=ProductCostItemRead)
def recalculate_one(
item_id: int,
session: AuthSession = Depends(require_client_module_access("products", "edit")),
db: Session = Depends(get_db),
):
item = _load_item(db, session.tenant_id or "default", item_id)
if item is None:
raise HTTPException(status_code=404, detail="Product cost item not found")
recalculate_product_cost_item(db, item)
db.commit()
db.refresh(item)
return serialize_product_cost_item(item)
@router.post("/recalculate-all", response_model=ProductCostRecalculateAllRead)
def recalculate_all(
session: AuthSession = Depends(require_client_module_access("products", "edit")),
db: Session = Depends(get_db),
):
count = recalculate_all_product_cost_items(db, session.tenant_id or "default")
db.commit()
return {"recalculated": count}
@router.get("/inputs", response_model=ProductCostInputsRead)
def get_product_cost_inputs(
session: AuthSession = Depends(require_client_module_access("products")),
db: Session = Depends(get_db),
):
return _serialize_inputs(db, session.tenant_id or "default")
@router.patch("/inputs", response_model=ProductCostInputsRead)
def update_product_cost_inputs(
payload: ProductCostInputsUpdate,
session: AuthSession = Depends(require_client_module_access("products", "edit")),
db: Session = Depends(get_db),
):
tenant_id = session.tenant_id or "default"
base = _ensure_inputs(db, tenant_id)
if payload.base is not None:
for field, value in payload.base.model_dump().items():
setattr(base, field, value)
if payload.processes is not None:
existing = {row.process_name: row for row in db.scalars(select(ProductCostProcessInput).where(ProductCostProcessInput.tenant_id == tenant_id)).all()}
for row in payload.processes:
target = existing.get(row.key)
if target is None:
db.add(ProductCostProcessInput(tenant_id=tenant_id, process_name=row.key, cost_per_kg=row.cost))
else:
target.cost_per_kg = row.cost
if payload.clients is not None:
existing = {row.client_category: row for row in db.scalars(select(ProductCostClientInput).where(ProductCostClientInput.tenant_id == tenant_id)).all()}
for row in payload.clients:
target = existing.get(row.client_category)
if target is None:
db.add(ProductCostClientInput(tenant_id=tenant_id, client_category=row.client_category, distributor_margin=row.distributor_margin, wholesale_margin=row.wholesale_margin))
else:
target.distributor_margin = row.distributor_margin
target.wholesale_margin = row.wholesale_margin
if payload.bags is not None:
existing = {row.input_key: row for row in db.scalars(select(ProductCostBagInput).where(ProductCostBagInput.tenant_id == tenant_id)).all()}
for row in payload.bags:
target = existing.get(row.key)
if target is None:
db.add(ProductCostBagInput(tenant_id=tenant_id, input_key=row.key, label=row.label, cost=row.cost))
else:
target.label = row.label
target.cost = row.cost
if payload.freight is not None:
existing = {row.input_key: row for row in db.scalars(select(ProductCostFreightInput).where(ProductCostFreightInput.tenant_id == tenant_id)).all()}
for row in payload.freight:
target = existing.get(row.key)
if target is None:
db.add(ProductCostFreightInput(tenant_id=tenant_id, input_key=row.key, label=row.label, cost=row.cost))
else:
target.label = row.label
target.cost = row.cost
db.flush()
recalculate_all_product_cost_items(db, tenant_id)
db.commit()
return _serialize_inputs(db, tenant_id)