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)