248 lines
10 KiB
Python
248 lines
10 KiB
Python
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)
|