v0.1.12
This commit is contained in:
@@ -0,0 +1,247 @@
|
||||
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)
|
||||
Reference in New Issue
Block a user