from __future__ import annotations from datetime import date from sqlalchemy import func, select from sqlalchemy.orm import Session, joinedload, selectinload from app.api.deps import AuthSession from app.models.mix import Mix, MixIngredient from app.models.mix_calculator import MixCalculatorSession, MixCalculatorSessionLine from app.models.product import Product, ProductIngredient from app.schemas.mix_calculator import MixCalculatorSessionCreate, MixCalculatorSessionUpdate from app.services.costing_engine import extract_unit_quantity_kg def can_view_all_mix_calculator_sessions(session: AuthSession) -> bool: return session.client_role in {"superadmin", "admin"} def _build_session_access_query(session: AuthSession): query = select(MixCalculatorSession).where(MixCalculatorSession.tenant_id == session.tenant_id) if can_view_all_mix_calculator_sessions(session): return query return query.where(MixCalculatorSession.prepared_by_user_id == session.user_id) def _load_product_for_calculation(db: Session, tenant_id: str, product_id: int) -> Product | None: return db.scalar( select(Product) .where(Product.id == product_id, Product.tenant_id == tenant_id, Product.visible.is_(True)) .options( selectinload(Product.ingredients).selectinload(ProductIngredient.raw_material), selectinload(Product.mix).selectinload(Mix.ingredients).selectinload(MixIngredient.raw_material), ) ) def _resolved_formula_rows(product: Product) -> tuple[list[dict], float]: if product.ingredients: rows = [ { "raw_material_id": ingredient.raw_material_id, "raw_material_name": ingredient.raw_material.name, "quantity_kg": ingredient.quantity_kg, "unit": ingredient.raw_material.unit_of_measure, "rounding_decimals": ingredient.raw_material.rounding_decimals, "sort_order": ingredient.sort_order, } for ingredient in product.ingredients if ingredient.raw_material is not None ] elif product.mix is not None: rows = [ { "raw_material_id": ingredient.raw_material_id, "raw_material_name": ingredient.raw_material.name if ingredient.raw_material is not None else f"Raw material {ingredient.raw_material_id}", "quantity_kg": ingredient.quantity_kg, "unit": ingredient.raw_material.unit_of_measure if ingredient.raw_material is not None else "kg", "rounding_decimals": ingredient.raw_material.rounding_decimals if ingredient.raw_material is not None else 2, "sort_order": index, } for index, ingredient in enumerate(product.mix.ingredients, start=1) ] else: rows = [] rows.sort(key=lambda row: (row["sort_order"], row["raw_material_name"])) return rows, round(sum(row["quantity_kg"] for row in rows), 4) def _fractional_bag_warning(batch_size_kg: float, total_bags: float, unit_of_measure: str) -> str | None: rounded_bags = round(total_bags) if abs(total_bags - rounded_bags) < 1e-9: return None return ( f"Batch size {batch_size_kg:g}kg produces {total_bags:.2f} bags for {unit_of_measure}. " "This is not a whole-bag quantity." ) def _mix_calculator_label(product: Product) -> str: return product.mix.name if product.mix else product.name def _mix_calculator_option_rank(product: Product) -> tuple[int, int, float, int]: unit_label = (product.unit_of_measure or "").lower() unit_size = extract_unit_quantity_kg(product.unit_of_measure) return ( 0 if abs(unit_size - 20) < 1e-9 and "bag" in unit_label and "bulka" not in unit_label else 1, 0 if "bulka" not in unit_label else 1, unit_size if unit_size > 0 else 999999, product.id, ) def resolve_representative_product(db: Session, *, tenant_id: str, mix_id: int) -> Product | None: """The single product the Mix Calculator surfaces for a given mix. The calculator lists one representative product per (client, mix) and reads its formula. The Mix Editor reuses this so it edits exactly what the calculator shows. Preference order mirrors `build_mix_calculator_options`: visible products that already have a product-specific formula, ranked by `_mix_calculator_option_rank`; then any visible product; then any product. """ products = db.scalars( select(Product) .where(Product.tenant_id == tenant_id, Product.mix_id == mix_id) .options( selectinload(Product.ingredients).selectinload(ProductIngredient.raw_material), selectinload(Product.mix).selectinload(Mix.ingredients).selectinload(MixIngredient.raw_material), ) ).all() if not products: return None with_formula = [product for product in products if product.visible and product.ingredients] pool = with_formula or [product for product in products if product.visible] or list(products) return min(pool, key=_mix_calculator_option_rank) def resolve_editor_mix_formula(db: Session, *, tenant_id: str, mix: Mix) -> dict: """Resolve a mix's formula the way the calculator does, for the editor. Returns the resolved ingredient rows (with each row's share of the total as `mix_percentage`), the total kg, and where the formula lives: `source='product'` (a representative product's own formula) or `source='mix'` (the shared mix master fallback). `product_id` names the product that owns the formula when `source='product'`. The save path writes back to that same source. """ product = resolve_representative_product(db, tenant_id=tenant_id, mix_id=mix.id) if product is not None and product.ingredients: rows, total_kg = _resolved_formula_rows(product) # Carry each ingredient's note through so saving doesn't wipe it. notes_by_raw_material = { ingredient.raw_material_id: ingredient.notes for ingredient in product.ingredients } for row in rows: row["notes"] = notes_by_raw_material.get(row["raw_material_id"]) source = "product" product_id = product.id else: # No product-specific formula: the calculator reads the shared mix master, # so the editor shows and edits that. rows = [ { "raw_material_id": ingredient.raw_material_id, "raw_material_name": ingredient.raw_material.name if ingredient.raw_material is not None else f"Raw material {ingredient.raw_material_id}", "quantity_kg": ingredient.quantity_kg, "unit": ingredient.raw_material.unit_of_measure if ingredient.raw_material is not None else "kg", "sort_order": index, "notes": ingredient.notes, } for index, ingredient in enumerate( sorted(mix.ingredients, key=lambda item: item.raw_material.name if item.raw_material else ""), start=1, ) ] total_kg = round(sum(row["quantity_kg"] for row in rows), 4) source = "mix" product_id = product.id if product is not None else None ingredients = [ { "raw_material_id": row["raw_material_id"], "raw_material_name": row["raw_material_name"], "quantity_kg": round(row["quantity_kg"], 4), "mix_percentage": round((row["quantity_kg"] / total_kg) * 100, 4) if total_kg > 0 else 0.0, "unit": row["unit"], "notes": row.get("notes"), } for row in rows ] return { "id": mix.id, "tenant_id": mix.tenant_id, "client_name": mix.client_name, "name": mix.name, "source": source, "product_id": product_id, "ingredients": ingredients, "total_kg": total_kg, } def calculate_mix_calculator_preview( db: Session, *, tenant_id: str, payload: MixCalculatorSessionCreate | MixCalculatorSessionUpdate | dict, ): values = payload if isinstance(payload, dict) else payload.model_dump(exclude_unset=False) product = _load_product_for_calculation(db, tenant_id, int(values["product_id"])) if product is None: raise ValueError("Product not found") if product.client_name != values["client_name"]: raise ValueError("Selected product does not belong to the chosen client") formula_rows, source_total_kg = _resolved_formula_rows(product) if source_total_kg <= 0: raise ValueError("Product has no source kilograms to scale") batch_size_kg = float(values["batch_size_kg"]) scale_factor = batch_size_kg / source_total_kg unit_size_kg = extract_unit_quantity_kg(product.unit_of_measure) total_bags = round(batch_size_kg / unit_size_kg, 4) if unit_size_kg > 0 else 0.0 warnings: list[str] = [] bag_warning = _fractional_bag_warning(batch_size_kg, total_bags, product.unit_of_measure) if bag_warning: warnings.append(bag_warning) lines = [] for index, ingredient in enumerate(formula_rows, start=1): mix_percentage = round((ingredient["quantity_kg"] / source_total_kg) * 100, 4) required_kg = round(ingredient["quantity_kg"] * scale_factor, 4) lines.append( { "raw_material_id": ingredient["raw_material_id"], "raw_material_name": ingredient["raw_material_name"], "required_kg": required_kg, "mix_percentage": mix_percentage, "unit": ingredient["unit"], "rounding_decimals": ingredient.get("rounding_decimals", 2), "sort_order": ingredient["sort_order"] or index, } ) mix_label = _mix_calculator_label(product) return { "client_name": product.client_name, "product_id": product.id, # The source workbook labels this as Product, but for the calculator # it is the mix/formula being produced. "product_name": mix_label, "mix_id": product.mix_id, "mix_name": mix_label, "mix_date": values["mix_date"], "batch_size_kg": round(batch_size_kg, 4), "total_bags": total_bags, "total_kg": round(batch_size_kg, 4), "product_unit_of_measure": product.unit_of_measure, "product_unit_size_kg": round(unit_size_kg, 4), "prepared_by_name": values["prepared_by_name"], "status": values.get("status") or "saved", "notes": values.get("notes"), "warnings": warnings, "lines": lines, } def build_mix_calculator_options(db: Session, *, tenant_id: str) -> dict: # Prefer product-specific formulas where present; fall back to the shared # mix master for legacy rows that have not been migrated yet. product_totals_rows = db.execute( select(ProductIngredient.product_id, func.coalesce(func.sum(ProductIngredient.quantity_kg), 0.0)) .join(Product, Product.id == ProductIngredient.product_id) .where(Product.tenant_id == tenant_id) .group_by(ProductIngredient.product_id) ).all() product_totals: dict[int, float] = {product_id: round(total or 0.0, 4) for product_id, total in product_totals_rows} mix_totals_rows = db.execute( select(MixIngredient.mix_id, func.coalesce(func.sum(MixIngredient.quantity_kg), 0.0)) .join(Mix, Mix.id == MixIngredient.mix_id) .where(Mix.tenant_id == tenant_id) .group_by(MixIngredient.mix_id) ).all() mix_totals: dict[int, float] = {mix_id: round(total or 0.0, 4) for mix_id, total in mix_totals_rows} product_ids_with_formulas = select(ProductIngredient.product_id).where(ProductIngredient.tenant_id == tenant_id) products = db.scalars( select(Product) .where( Product.tenant_id == tenant_id, Product.visible.is_(True), Product.id.in_(product_ids_with_formulas), ) .options(joinedload(Product.mix)) .order_by(Product.client_name, Product.name) ).all() representative_products: dict[tuple[str, str], Product] = {} for product in products: mix_label = _mix_calculator_label(product) key = (product.client_name, mix_label) current = representative_products.get(key) if current is None: representative_products[key] = product continue if _mix_calculator_option_rank(product) < _mix_calculator_option_rank(current): representative_products[key] = product products = sorted( representative_products.values(), key=lambda product: (product.client_name, _mix_calculator_label(product), product.id), ) clients = sorted({product.client_name for product in products}) product_rows = [ { "product_id": product.id, "client_name": product.client_name, "product_name": _mix_calculator_label(product), "mix_id": product.mix_id, "mix_name": _mix_calculator_label(product), "unit_of_measure": product.unit_of_measure, "unit_size_kg": round(extract_unit_quantity_kg(product.unit_of_measure), 4), "mix_total_kg": product_totals.get(product.id, mix_totals.get(product.mix_id, 0.0)), } for product in products ] return {"clients": clients, "products": product_rows} def serialize_mix_calculator_session(session_record: MixCalculatorSession, auth_session: AuthSession) -> dict: total_bags = round(session_record.total_bags, 4) warnings: list[str] = [] bag_warning = _fractional_bag_warning(session_record.batch_size_kg, total_bags, session_record.product_unit_of_measure) if bag_warning: warnings.append(bag_warning) return { "id": session_record.id, "tenant_id": session_record.tenant_id, "session_number": session_record.session_number, "client_name": session_record.client_name, "product_id": session_record.product_id, "product_name": session_record.product_name, "mix_id": session_record.mix_id, "mix_name": session_record.mix_name, "mix_date": session_record.mix_date, "batch_size_kg": round(session_record.batch_size_kg, 4), "total_bags": total_bags, "total_kg": round(session_record.total_kg, 4), "product_unit_of_measure": session_record.product_unit_of_measure, "product_unit_size_kg": round(session_record.product_unit_size_kg, 4), "prepared_by_user_id": session_record.prepared_by_user_id, "prepared_by_name": session_record.prepared_by_name, "created_by": session_record.created_by, "status": session_record.status, "notes": session_record.notes, "created_at": session_record.created_at, "updated_at": session_record.updated_at, "warnings": warnings, "is_owner": session_record.prepared_by_user_id == auth_session.user_id, "lines": [ { "id": line.id, "raw_material_id": line.raw_material_id, "raw_material_name": line.raw_material_name, "required_kg": round(line.required_kg, 4), "mix_percentage": round(line.mix_percentage, 4), "unit": line.unit, "rounding_decimals": line.rounding_decimals, "sort_order": line.sort_order, } for line in session_record.lines ], } def list_mix_calculator_sessions(db: Session, *, auth_session: AuthSession, limit: int = 100) -> list[dict]: sessions = db.scalars( _build_session_access_query(auth_session) .options(selectinload(MixCalculatorSession.lines)) .order_by(MixCalculatorSession.created_at.desc(), MixCalculatorSession.id.desc()) .limit(limit) ).all() return [serialize_mix_calculator_session(session_record, auth_session) for session_record in sessions] def get_mix_calculator_session(db: Session, *, auth_session: AuthSession, session_id: int) -> MixCalculatorSession | None: return db.scalar( _build_session_access_query(auth_session) .where(MixCalculatorSession.id == session_id) .options(selectinload(MixCalculatorSession.lines)) ) def _next_session_number(db: Session, *, tenant_id: str, mix_date: date) -> str: prefix = f"HPP-{mix_date.strftime('%Y%m%d')}-" existing = db.scalars( select(MixCalculatorSession.session_number) .where( MixCalculatorSession.tenant_id == tenant_id, MixCalculatorSession.mix_date == mix_date, MixCalculatorSession.session_number.like(f"{prefix}%"), ) ).all() sequence = 1 if existing: sequence = max(int(value.rsplit("-", 1)[-1]) for value in existing) + 1 return f"{prefix}{sequence:04d}" def create_mix_calculator_session(db: Session, *, auth_session: AuthSession, payload: MixCalculatorSessionCreate) -> dict: preview = calculate_mix_calculator_preview(db, tenant_id=auth_session.tenant_id or "", payload=payload) session_record = MixCalculatorSession( tenant_id=auth_session.tenant_id or "default", session_number=_next_session_number(db, tenant_id=auth_session.tenant_id or "default", mix_date=payload.mix_date), client_name=preview["client_name"], product_id=preview["product_id"], product_name=preview["product_name"], mix_id=preview["mix_id"], mix_name=preview["mix_name"], mix_date=preview["mix_date"], batch_size_kg=preview["batch_size_kg"], total_bags=preview["total_bags"], total_kg=preview["total_kg"], product_unit_of_measure=preview["product_unit_of_measure"], product_unit_size_kg=preview["product_unit_size_kg"], prepared_by_user_id=auth_session.user_id, prepared_by_name=preview["prepared_by_name"], created_by=auth_session.email, status=preview["status"], notes=preview["notes"], ) session_record.lines = [ MixCalculatorSessionLine( tenant_id=auth_session.tenant_id or "default", raw_material_id=line["raw_material_id"], raw_material_name=line["raw_material_name"], required_kg=line["required_kg"], mix_percentage=line["mix_percentage"], unit=line["unit"], rounding_decimals=line.get("rounding_decimals", 2), sort_order=line["sort_order"], ) for line in preview["lines"] ] db.add(session_record) db.commit() db.refresh(session_record) db.refresh(session_record, attribute_names=["lines"]) return serialize_mix_calculator_session(session_record, auth_session) def update_mix_calculator_session( db: Session, *, auth_session: AuthSession, session_record: MixCalculatorSession, payload: MixCalculatorSessionUpdate, ) -> dict: merged_values = { "mix_date": session_record.mix_date, "client_name": session_record.client_name, "product_id": session_record.product_id, "batch_size_kg": session_record.batch_size_kg, "prepared_by_name": session_record.prepared_by_name, "status": session_record.status, "notes": session_record.notes, } merged_values.update(payload.model_dump(exclude_unset=True)) preview = calculate_mix_calculator_preview(db, tenant_id=auth_session.tenant_id or "", payload=merged_values) session_record.client_name = preview["client_name"] session_record.product_id = preview["product_id"] session_record.product_name = preview["product_name"] session_record.mix_id = preview["mix_id"] session_record.mix_name = preview["mix_name"] session_record.mix_date = preview["mix_date"] session_record.batch_size_kg = preview["batch_size_kg"] session_record.total_bags = preview["total_bags"] session_record.total_kg = preview["total_kg"] session_record.product_unit_of_measure = preview["product_unit_of_measure"] session_record.product_unit_size_kg = preview["product_unit_size_kg"] session_record.prepared_by_name = preview["prepared_by_name"] session_record.status = preview["status"] session_record.notes = preview["notes"] session_record.lines.clear() session_record.lines.extend( [ MixCalculatorSessionLine( tenant_id=auth_session.tenant_id or "default", raw_material_id=line["raw_material_id"], raw_material_name=line["raw_material_name"], required_kg=line["required_kg"], mix_percentage=line["mix_percentage"], unit=line["unit"], sort_order=line["sort_order"], ) for line in preview["lines"] ] ) db.commit() db.refresh(session_record) db.refresh(session_record, attribute_names=["lines"]) return serialize_mix_calculator_session(session_record, auth_session)