from fastapi import APIRouter, Depends, HTTPException, Query from sqlalchemy import case, func, or_, select from sqlalchemy.exc import IntegrityError from sqlalchemy.orm import Session, joinedload, selectinload from app.api.deps import AuthSession, get_auth_session from app.db.session import get_db from app.models.mix import Mix, MixIngredient from app.models.product import Product, ProductIngredient from app.models.raw_material import RawMaterial from app.schemas.editor import ( EditorIngredientCreate, EditorIngredientRow, EditorIngredientUpdate, EditorMixFormulaRead, EditorMixCreate, EditorMixFormulaReplace, EditorMixIngredientCreate, EditorMixIngredientUpdate, EditorMixRow, EditorMixUpdate, EditorProductFormulaRead, EditorProductIngredientCreate, EditorProductIngredientUpdate, EditorProductRow, EditorProductUpdate, EditorResolvedMixFormula, ) from app.services.client_access_service import has_access_level from app.services.costing_engine import calculate_raw_material_cost, get_active_price from app.services.mix_calculator_service import resolve_editor_mix_formula, resolve_representative_product router = APIRouter(prefix="/api/editor", tags=["editor"]) def _serialize_row(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_client_name": product.mix.client_name if product.mix else "", "mix_name": product.mix.name if product.mix else "", "sale_type": product.sale_type, "unit_of_measure": product.unit_of_measure, "visible": product.visible, "product_notes": product.notes, "mix_notes": product.mix.notes if product.mix else None, } def _serialize_product_formula(product: Product) -> dict: ingredients = [ { "id": ingredient.id, "raw_material_id": ingredient.raw_material_id, "raw_material_name": ingredient.raw_material.name if ingredient.raw_material else f"Raw material {ingredient.raw_material_id}", "quantity_kg": ingredient.quantity_kg, "sort_order": ingredient.sort_order, "notes": ingredient.notes, } for ingredient in sorted(product.ingredients, key=lambda item: (item.sort_order, item.raw_material.name if item.raw_material else "")) ] return { "id": product.id, "tenant_id": product.tenant_id, "client_name": product.client_name, "name": product.name, "mix_id": product.mix_id, "mix_name": product.mix.name if product.mix else "", "ingredients": ingredients, "total_kg": round(sum(ingredient["quantity_kg"] for ingredient in ingredients), 4), } def _serialize_mix_row(mix: Mix, *, visible_count: int, product_count: int) -> dict: return { "id": mix.id, "tenant_id": mix.tenant_id, "client_name": mix.client_name, "name": mix.name, "visible": visible_count > 0, "product_count": product_count, "visible_product_count": visible_count, "notes": mix.notes, } def _mix_product_counts(db: Session, tenant_id: str) -> dict[int, tuple[int, int]]: """Per-mix (total products, visible products) used to drive the Status column.""" rows = db.execute( select( Product.mix_id, func.count(), func.sum(case((Product.visible, 1), else_=0)), ) .where(Product.tenant_id == tenant_id) .group_by(Product.mix_id) ).all() return {mix_id: (int(total), int(visible or 0)) for mix_id, total, visible in rows} def _serialize_mix_formula(mix: Mix) -> dict: ingredients = [ { "id": ingredient.id, "raw_material_id": ingredient.raw_material_id, "raw_material_name": ingredient.raw_material.name if ingredient.raw_material else f"Raw material {ingredient.raw_material_id}", "quantity_kg": ingredient.quantity_kg, "notes": ingredient.notes, } for ingredient in sorted( mix.ingredients, key=lambda item: item.raw_material.name if item.raw_material else "", ) ] return { "id": mix.id, "tenant_id": mix.tenant_id, "client_name": mix.client_name, "name": mix.name, "ingredients": ingredients, "total_kg": round(sum(ingredient["quantity_kg"] for ingredient in ingredients), 4), } def _load_editor_mix_formula(db: Session, *, mix_id: int, tenant_id: str) -> Mix | None: return db.scalar( select(Mix) .where(Mix.id == mix_id, Mix.tenant_id == tenant_id) .options(selectinload(Mix.ingredients).selectinload(MixIngredient.raw_material)) ) def _load_editor_product_formula(db: Session, *, product_id: int, tenant_id: str) -> Product | None: return db.scalar( select(Product) .where(Product.id == product_id, Product.tenant_id == tenant_id) .options( joinedload(Product.mix), selectinload(Product.ingredients).selectinload(ProductIngredient.raw_material), ) ) def _require_editor_session( session: AuthSession = Depends(get_auth_session), db: Session = Depends(get_db), ) -> AuthSession: if session.role == "internal": permissions = session.module_permissions or {} if not has_access_level(permissions.get("client_access"), "manage"): raise HTTPException(status_code=403, detail="Lean access is required") if not has_access_level(permissions.get("products"), "edit"): raise HTTPException(status_code=403, detail="products edit access is required") if not has_access_level(permissions.get("mix_master"), "edit"): raise HTTPException(status_code=403, detail="mix_master edit access is required") if not session.tenant_id: raise HTTPException(status_code=403, detail="Internal user context is missing") return session raise HTTPException(status_code=403, detail="Lean access is required") @router.get("/products", response_model=list[EditorProductRow]) def list_editor_products( q: str | None = Query(default=None, max_length=255), client_name: str | None = Query(default=None, max_length=255), limit: int = Query(default=500, ge=1, le=1000), session: AuthSession = Depends(_require_editor_session), db: Session = Depends(get_db), ): statement = ( select(Product) .where(Product.tenant_id == session.tenant_id) .options(joinedload(Product.mix)) .join(Product.mix) .order_by(Product.client_name, Product.name, Product.id) .limit(limit) ) if client_name: statement = statement.where(Product.client_name == client_name) if q: term = f"%{q.strip()}%" statement = statement.where( or_( Product.client_name.ilike(term), Product.name.ilike(term), Product.item_id.ilike(term), Product.unit_of_measure.ilike(term), Mix.name.ilike(term), ) ) return [_serialize_row(product) for product in db.scalars(statement).all()] @router.patch("/products/{product_id}", response_model=EditorProductRow) def update_editor_product( product_id: int, payload: EditorProductUpdate, session: AuthSession = Depends(_require_editor_session), db: Session = Depends(get_db), ): product = db.scalar( select(Product) .where(Product.id == product_id, Product.tenant_id == session.tenant_id) .options(joinedload(Product.mix)) ) if product is None: raise HTTPException(status_code=404, detail="Product not found") if payload.mix_id is not None: mix = db.scalar(select(Mix).where(Mix.id == payload.mix_id, Mix.tenant_id == session.tenant_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(product, field, value) db.commit() db.refresh(product) return _serialize_row(product) @router.get("/mixes", response_model=list[EditorMixRow]) def list_editor_mixes( q: str | None = Query(default=None, max_length=255), client_name: str | None = Query(default=None, max_length=255), limit: int = Query(default=500, ge=1, le=1000), session: AuthSession = Depends(_require_editor_session), db: Session = Depends(get_db), ): statement = select(Mix).where(Mix.tenant_id == session.tenant_id) if client_name: statement = statement.where(Mix.client_name == client_name) if q: term = f"%{q.strip()}%" statement = statement.where(or_(Mix.client_name.ilike(term), Mix.name.ilike(term))) statement = statement.order_by(Mix.client_name, Mix.name, Mix.id).limit(limit) counts = _mix_product_counts(db, session.tenant_id or "") mixes = db.scalars(statement).all() return [ _serialize_mix_row(mix, visible_count=counts.get(mix.id, (0, 0))[1], product_count=counts.get(mix.id, (0, 0))[0]) for mix in mixes ] @router.post("/mixes", response_model=EditorMixRow, status_code=201) def create_editor_mix( payload: EditorMixCreate, session: AuthSession = Depends(_require_editor_session), db: Session = Depends(get_db), ): mix = Mix( tenant_id=session.tenant_id or "", client_name=payload.client_name.strip(), name=payload.name.strip(), notes=payload.notes, ) db.add(mix) db.commit() db.refresh(mix) # A brand-new mix has no products yet, so it reads as Inactive (no visible products). return _serialize_mix_row(mix, visible_count=0, product_count=0) @router.patch("/mixes/{mix_id}", response_model=EditorMixRow) def update_editor_mix( mix_id: int, payload: EditorMixUpdate, session: AuthSession = Depends(_require_editor_session), db: Session = Depends(get_db), ): mix = db.scalar(select(Mix).where(Mix.id == mix_id, Mix.tenant_id == session.tenant_id)) if mix is None: raise HTTPException(status_code=404, detail="Mix not found") updates = payload.model_dump(exclude_unset=True) # `visible` is a virtual field: it fans out to the visibility of every product # under the mix rather than mapping to a mix column. visible = updates.pop("visible", None) for field, value in updates.items(): setattr(mix, field, value) if visible is not None: for product in db.scalars( select(Product).where(Product.tenant_id == session.tenant_id, Product.mix_id == mix_id) ).all(): product.visible = visible db.commit() counts = _mix_product_counts(db, session.tenant_id or "") total, visible_count = counts.get(mix_id, (0, 0)) return _serialize_mix_row(mix, visible_count=visible_count, product_count=total) @router.get("/mixes/{mix_id}/ingredients", response_model=EditorMixFormulaRead) def get_editor_mix_ingredients( mix_id: int, session: AuthSession = Depends(_require_editor_session), db: Session = Depends(get_db), ): mix = _load_editor_mix_formula(db, mix_id=mix_id, tenant_id=session.tenant_id or "") if mix is None: raise HTTPException(status_code=404, detail="Mix not found") return _serialize_mix_formula(mix) @router.post("/mixes/{mix_id}/ingredients", response_model=EditorMixFormulaRead, status_code=201) def add_editor_mix_ingredient( mix_id: int, payload: EditorMixIngredientCreate, session: AuthSession = Depends(_require_editor_session), db: Session = Depends(get_db), ): mix = _load_editor_mix_formula(db, mix_id=mix_id, tenant_id=session.tenant_id or "") if mix is None: raise HTTPException(status_code=404, detail="Mix not found") if db.scalar(select(RawMaterial.id).where(RawMaterial.id == payload.raw_material_id, RawMaterial.tenant_id == session.tenant_id)) is None: raise HTTPException(status_code=404, detail="Raw material not found") db.add( MixIngredient( tenant_id=session.tenant_id or "", mix_id=mix_id, raw_material_id=payload.raw_material_id, quantity_kg=payload.quantity_kg, notes=payload.notes, ) ) try: db.commit() except IntegrityError as exc: db.rollback() raise HTTPException(status_code=400, detail="Raw material is already on this mix") from exc mix = _load_editor_mix_formula(db, mix_id=mix_id, tenant_id=session.tenant_id or "") return _serialize_mix_formula(mix) @router.patch("/mixes/{mix_id}/ingredients/{ingredient_id}", response_model=EditorMixFormulaRead) def update_editor_mix_ingredient( mix_id: int, ingredient_id: int, payload: EditorMixIngredientUpdate, session: AuthSession = Depends(_require_editor_session), db: Session = Depends(get_db), ): ingredient = db.scalar( select(MixIngredient) .join(Mix) .where( MixIngredient.id == ingredient_id, MixIngredient.mix_id == mix_id, Mix.tenant_id == session.tenant_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() mix = _load_editor_mix_formula(db, mix_id=mix_id, tenant_id=session.tenant_id or "") return _serialize_mix_formula(mix) @router.delete("/mixes/{mix_id}/ingredients/{ingredient_id}", response_model=EditorMixFormulaRead) def delete_editor_mix_ingredient( mix_id: int, ingredient_id: int, session: AuthSession = Depends(_require_editor_session), db: Session = Depends(get_db), ): ingredient = db.scalar( select(MixIngredient) .join(Mix) .where( MixIngredient.id == ingredient_id, MixIngredient.mix_id == mix_id, Mix.tenant_id == session.tenant_id, ) ) if ingredient is None: raise HTTPException(status_code=404, detail="Ingredient not found") db.delete(ingredient) db.commit() mix = _load_editor_mix_formula(db, mix_id=mix_id, tenant_id=session.tenant_id or "") return _serialize_mix_formula(mix) @router.get("/mixes/{mix_id}/formula", response_model=EditorResolvedMixFormula) def get_editor_mix_resolved_formula( mix_id: int, session: AuthSession = Depends(_require_editor_session), db: Session = Depends(get_db), ): """The mix formula as the Mix Calculator reads it (product-first resolution). This is what the Mix Editor displays, so the two surfaces show identical ingredients and quantities. See `resolve_editor_mix_formula`. """ tenant_id = session.tenant_id or "" mix = _load_editor_mix_formula(db, mix_id=mix_id, tenant_id=tenant_id) if mix is None: raise HTTPException(status_code=404, detail="Mix not found") return resolve_editor_mix_formula(db, tenant_id=tenant_id, mix=mix) @router.put("/mixes/{mix_id}/formula", response_model=EditorResolvedMixFormula) def replace_editor_mix_formula( mix_id: int, payload: EditorMixFormulaReplace, session: AuthSession = Depends(_require_editor_session), db: Session = Depends(get_db), ): """Replace a mix's whole formula in one save. Writes back to the *same source* the Mix Calculator reads: the representative product's own formula (`ProductIngredient`) when it has one, otherwise the shared mix master (`MixIngredient`). Either way the calculator immediately reflects the edit. """ tenant_id = session.tenant_id or "" mix = _load_editor_mix_formula(db, mix_id=mix_id, tenant_id=tenant_id) if mix is None: raise HTTPException(status_code=404, detail="Mix not found") raw_ids = [row.raw_material_id for row in payload.rows] if len(set(raw_ids)) != len(raw_ids): raise HTTPException(status_code=400, detail="Each raw material can only appear once in a mix") existing_ids = set( db.scalars( select(RawMaterial.id).where(RawMaterial.tenant_id == tenant_id, RawMaterial.id.in_(raw_ids)) ).all() ) missing = [raw_id for raw_id in raw_ids if raw_id not in existing_ids] if missing: raise HTTPException(status_code=404, detail="Raw material not found") product = resolve_representative_product(db, tenant_id=tenant_id, mix_id=mix_id) if product is not None and product.ingredients: # Replace the representative product's own formula. for ingredient in list(product.ingredients): db.delete(ingredient) db.flush() for sort_order, row in enumerate(payload.rows, start=1): db.add( ProductIngredient( tenant_id=tenant_id, product_id=product.id, raw_material_id=row.raw_material_id, quantity_kg=row.quantity_kg, sort_order=sort_order, notes=row.notes, ) ) else: # No product-specific formula in play: edit the shared mix master, which # is what the calculator falls back to for this mix. for ingredient in list(mix.ingredients): db.delete(ingredient) db.flush() for row in payload.rows: db.add( MixIngredient( tenant_id=tenant_id, mix_id=mix.id, raw_material_id=row.raw_material_id, quantity_kg=row.quantity_kg, notes=row.notes, ) ) db.commit() mix = _load_editor_mix_formula(db, mix_id=mix_id, tenant_id=tenant_id) return resolve_editor_mix_formula(db, tenant_id=tenant_id, mix=mix) @router.get("/products/{product_id}/ingredients", response_model=EditorProductFormulaRead) def get_editor_product_ingredients( product_id: int, session: AuthSession = Depends(_require_editor_session), db: Session = Depends(get_db), ): product = _load_editor_product_formula(db, product_id=product_id, tenant_id=session.tenant_id or "") if product is None: raise HTTPException(status_code=404, detail="Product not found") return _serialize_product_formula(product) @router.post("/products/{product_id}/ingredients", response_model=EditorProductFormulaRead, status_code=201) def add_editor_product_ingredient( product_id: int, payload: EditorProductIngredientCreate, session: AuthSession = Depends(_require_editor_session), db: Session = Depends(get_db), ): product = _load_editor_product_formula(db, product_id=product_id, tenant_id=session.tenant_id or "") if product is None: raise HTTPException(status_code=404, detail="Product not found") if db.scalar(select(RawMaterial.id).where(RawMaterial.id == payload.raw_material_id, RawMaterial.tenant_id == session.tenant_id)) is None: raise HTTPException(status_code=404, detail="Raw material not found") next_sort_order = ( db.scalar( select(func.coalesce(func.max(ProductIngredient.sort_order), 0)).where(ProductIngredient.product_id == product_id) ) or 0 ) + 1 db.add( ProductIngredient( tenant_id=session.tenant_id or "", product_id=product_id, raw_material_id=payload.raw_material_id, quantity_kg=payload.quantity_kg, sort_order=next_sort_order, notes=payload.notes, ) ) try: db.commit() except IntegrityError as exc: db.rollback() raise HTTPException(status_code=400, detail="Raw material is already on this product") from exc product = _load_editor_product_formula(db, product_id=product_id, tenant_id=session.tenant_id or "") return _serialize_product_formula(product) @router.patch("/products/{product_id}/ingredients/{ingredient_id}", response_model=EditorProductFormulaRead) def update_editor_product_ingredient( product_id: int, ingredient_id: int, payload: EditorProductIngredientUpdate, session: AuthSession = Depends(_require_editor_session), db: Session = Depends(get_db), ): ingredient = db.scalar( select(ProductIngredient) .join(Product) .where( ProductIngredient.id == ingredient_id, ProductIngredient.product_id == product_id, Product.tenant_id == session.tenant_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() product = _load_editor_product_formula(db, product_id=product_id, tenant_id=session.tenant_id or "") return _serialize_product_formula(product) @router.delete("/products/{product_id}/ingredients/{ingredient_id}", response_model=EditorProductFormulaRead) def delete_editor_product_ingredient( product_id: int, ingredient_id: int, session: AuthSession = Depends(_require_editor_session), db: Session = Depends(get_db), ): ingredient = db.scalar( select(ProductIngredient) .join(Product) .where( ProductIngredient.id == ingredient_id, ProductIngredient.product_id == product_id, Product.tenant_id == session.tenant_id, ) ) if ingredient is None: raise HTTPException(status_code=404, detail="Ingredient not found") db.delete(ingredient) db.commit() product = _load_editor_product_formula(db, product_id=product_id, tenant_id=session.tenant_id or "") return _serialize_product_formula(product) # --- Ingredients (raw materials) catalogue ----------------------------------- # # The mix editor consumes raw materials as the ingredients dropdown; this gives # Lean admins a sibling editor to curate that catalogue — the ingredients that # ultimately get used inside mixes. Same auth/tenant model as the mix editor. def _serialize_ingredient(material: RawMaterial, usage_count: int) -> dict: active_price = get_active_price(material) cost_per_kg = ( calculate_raw_material_cost(material, active_price).cost_per_kg if active_price is not None else None ) return { "id": material.id, "name": material.name, "supplier": material.supplier, "unit_of_measure": material.unit_of_measure, "kg_per_unit": material.kg_per_unit, "status": material.status, "rounding_decimals": material.rounding_decimals, "notes": material.notes, "cost_per_kg": cost_per_kg, "usage_count": usage_count, "created_at": material.created_at, } def _ingredient_usage_counts(db: Session, tenant_id: str) -> dict[int, int]: """How many product/mix formula rows reference each raw material.""" rows = db.execute( select(ProductIngredient.raw_material_id, func.count()) .where(ProductIngredient.tenant_id == tenant_id) .group_by(ProductIngredient.raw_material_id) ).all() return {raw_material_id: count for raw_material_id, count in rows} @router.get("/ingredients", response_model=list[EditorIngredientRow]) def list_editor_ingredients( session: AuthSession = Depends(_require_editor_session), db: Session = Depends(get_db), ): tenant_id = session.tenant_id or "" materials = db.scalars( select(RawMaterial) .where(RawMaterial.tenant_id == tenant_id) .options(selectinload(RawMaterial.price_versions)) .order_by(RawMaterial.name) ).all() usage = _ingredient_usage_counts(db, tenant_id) return [_serialize_ingredient(material, usage.get(material.id, 0)) for material in materials] @router.post("/ingredients", response_model=EditorIngredientRow, status_code=201) def create_editor_ingredient( payload: EditorIngredientCreate, session: AuthSession = Depends(_require_editor_session), db: Session = Depends(get_db), ): material = RawMaterial( tenant_id=session.tenant_id or "", name=payload.name.strip(), supplier=(payload.supplier or "").strip() or None, unit_of_measure=payload.unit_of_measure.strip(), kg_per_unit=payload.kg_per_unit, status=payload.status.strip() or "active", rounding_decimals=payload.rounding_decimals, notes=payload.notes, ) db.add(material) try: db.commit() except IntegrityError as exc: db.rollback() raise HTTPException(status_code=409, detail="An ingredient with that name already exists") from exc db.refresh(material) return _serialize_ingredient(material, 0) @router.patch("/ingredients/{ingredient_id}", response_model=EditorIngredientRow) def update_editor_ingredient( ingredient_id: int, payload: EditorIngredientUpdate, session: AuthSession = Depends(_require_editor_session), db: Session = Depends(get_db), ): tenant_id = session.tenant_id or "" material = db.scalar( select(RawMaterial) .where(RawMaterial.id == ingredient_id, RawMaterial.tenant_id == tenant_id) .options(selectinload(RawMaterial.price_versions)) ) if material is None: raise HTTPException(status_code=404, detail="Ingredient not found") updates = payload.model_dump(exclude_unset=True) if "name" in updates and updates["name"] is not None: updates["name"] = updates["name"].strip() if "supplier" in updates: updates["supplier"] = (updates["supplier"] or "").strip() or None if "unit_of_measure" in updates and updates["unit_of_measure"] is not None: updates["unit_of_measure"] = updates["unit_of_measure"].strip() for field, value in updates.items(): setattr(material, field, value) try: db.commit() except IntegrityError as exc: db.rollback() raise HTTPException(status_code=409, detail="An ingredient with that name already exists") from exc db.refresh(material) usage = _ingredient_usage_counts(db, tenant_id) return _serialize_ingredient(material, usage.get(material.id, 0))