""" Event Order API — create event orders, add recipes × quantities, generate aggregated shopping lists, and optionally create purchase orders. """ import logging from datetime import date from decimal import Decimal from typing import Optional from collections import defaultdict from fastapi import APIRouter, Depends, HTTPException, Query from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy import select, func, delete from sqlalchemy.orm import selectinload from pydantic import BaseModel from database import get_db from models.user import User from models.event_order import EventOrder, EventOrderItem from models.recipe import Recipe, RecipeIngredient, RecipeSubRecipe from models.ingredient import Ingredient, IngredientSource, IngredientCategory from models.supplier import Supplier from auth import get_current_user, require_cap from api.ingredients import convert_to_standard, UNIT_CONVERSIONS logger = logging.getLogger(__name__) router = APIRouter() # ── Pydantic schemas ───────────────────────────────────────────────────────── class EventOrderCreate(BaseModel): name: str event_date: Optional[date] = None notes: Optional[str] = None class EventOrderUpdate(BaseModel): name: Optional[str] = None event_date: Optional[date] = None notes: Optional[str] = None status: Optional[str] = None class EventOrderItemAdd(BaseModel): recipe_id: int quantity: int notes: Optional[str] = None sort_order: int = 0 class EventOrderItemUpdate(BaseModel): quantity: Optional[int] = None notes: Optional[str] = None sort_order: Optional[int] = None class BulkItemEntry(BaseModel): recipe_id: int quantity: int notes: Optional[str] = None class EventOrderBulkAdd(BaseModel): items: list[BulkItemEntry] class EventOrderResponse(BaseModel): id: int name: str event_date: Optional[str] = None notes: Optional[str] = None status: str item_count: int = 0 estimated_cost: Optional[float] = None created_at: str = "" updated_at: str = "" class EventOrderItemResponse(BaseModel): id: int recipe_id: int recipe_name: str = "" recipe_type: str = "" batch_portions: int = 1 quantity: int cost_per_portion: Optional[float] = None subtotal: Optional[float] = None notes: Optional[str] = None sort_order: int = 0 # ── Event Order CRUD ───────────────────────────────────────────────────────── @router.get("") async def list_event_orders( status: Optional[str] = Query(None), user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): query = ( select(EventOrder) .options(selectinload(EventOrder.items)) .where(EventOrder.kitchen_id == user.kitchen_id) ) if status: query = query.where(EventOrder.status == status) result = await db.execute(query.order_by(EventOrder.event_date.desc().nullslast(), EventOrder.created_at.desc())) orders = result.scalars().all() responses = [] for o in orders: responses.append(EventOrderResponse( id=o.id, name=o.name, event_date=str(o.event_date) if o.event_date else None, notes=o.notes, status=o.status, item_count=len(o.items) if o.items else 0, created_at=str(o.created_at) if o.created_at else "", updated_at=str(o.updated_at) if o.updated_at else "", )) return responses @router.post("") async def create_event_order( data: EventOrderCreate, user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): order = EventOrder( kitchen_id=user.kitchen_id, name=data.name, event_date=data.event_date, notes=data.notes, created_by=user.id, ) db.add(order) await db.commit() await db.refresh(order) return {"id": order.id, "name": order.name} @router.get("/{order_id}") async def get_event_order( order_id: int, user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): result = await db.execute( select(EventOrder) .options(selectinload(EventOrder.items).selectinload(EventOrderItem.recipe)) .where(EventOrder.id == order_id, EventOrder.kitchen_id == user.kitchen_id) ) order = result.scalar_one_or_none() if not order: raise HTTPException(404, "Event order not found") items = [] for item in sorted(order.items, key=lambda x: x.sort_order): recipe = item.recipe # Get cost per portion from latest snapshot from models.recipe import RecipeCostSnapshot snap_result = await db.execute( select(RecipeCostSnapshot) .where(RecipeCostSnapshot.recipe_id == item.recipe_id) .order_by(RecipeCostSnapshot.snapshot_date.desc()) .limit(1) ) snap = snap_result.scalar_one_or_none() cpp = float(snap.cost_per_portion) if snap else None subtotal = cpp * item.quantity if cpp else None items.append(EventOrderItemResponse( id=item.id, recipe_id=item.recipe_id, recipe_name=recipe.name if recipe else "", recipe_type=recipe.recipe_type if recipe else "", batch_portions=recipe.batch_portions if recipe else 1, quantity=item.quantity, cost_per_portion=cpp, subtotal=round(subtotal, 2) if subtotal else None, notes=item.notes, sort_order=item.sort_order, )) return { "id": order.id, "name": order.name, "event_date": str(order.event_date) if order.event_date else None, "notes": order.notes, "status": order.status, "items": [i.model_dump() for i in items], "created_at": str(order.created_at) if order.created_at else "", "updated_at": str(order.updated_at) if order.updated_at else "", } @router.patch("/{order_id}") async def update_event_order( order_id: int, data: EventOrderUpdate, user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): result = await db.execute( select(EventOrder).where(EventOrder.id == order_id, EventOrder.kitchen_id == user.kitchen_id) ) order = result.scalar_one_or_none() if not order: raise HTTPException(404, "Event order not found") if data.name is not None: order.name = data.name if data.event_date is not None: order.event_date = data.event_date if data.notes is not None: order.notes = data.notes if data.status is not None: order.status = data.status await db.commit() return {"ok": True} @router.delete("/{order_id}") async def delete_event_order( order_id: int, user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): result = await db.execute( select(EventOrder).where(EventOrder.id == order_id, EventOrder.kitchen_id == user.kitchen_id) ) order = result.scalar_one_or_none() if not order: raise HTTPException(404, "Event order not found") if order.status != "DRAFT": raise HTTPException(400, "Only DRAFT orders can be deleted") await db.delete(order) await db.commit() return {"ok": True} # ── Event Order Items ──────────────────────────────────────────────────────── @router.post("/{order_id}/items") async def add_item( order_id: int, data: EventOrderItemAdd, user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): order = await _get_order(order_id, user.kitchen_id, db) recipe = await db.execute( select(Recipe).where(Recipe.id == data.recipe_id, Recipe.kitchen_id == user.kitchen_id) ) if not recipe.scalar_one_or_none(): raise HTTPException(404, "Recipe not found") item = EventOrderItem( event_order_id=order_id, recipe_id=data.recipe_id, quantity=data.quantity, notes=data.notes, sort_order=data.sort_order, ) db.add(item) await db.commit() await db.refresh(item) return {"id": item.id} @router.post("/{order_id}/items/bulk") async def add_items_bulk( order_id: int, data: EventOrderBulkAdd, user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): """Add multiple recipes to an event order at once (e.g. from a menu).""" order = await _get_order(order_id, user.kitchen_id, db) # Get existing recipe_ids on this order to detect duplicates existing_result = await db.execute( select(EventOrderItem.recipe_id).where(EventOrderItem.event_order_id == order_id) ) existing_ids = {r[0] for r in existing_result.all()} # Get max sort_order max_sort_result = await db.execute( select(func.coalesce(func.max(EventOrderItem.sort_order), -1)) .where(EventOrderItem.event_order_id == order_id) ) next_sort = (max_sort_result.scalar() or 0) + 1 # Validate all recipe_ids belong to this kitchen recipe_ids = [entry.recipe_id for entry in data.items] valid_result = await db.execute( select(Recipe.id).where(Recipe.id.in_(recipe_ids), Recipe.kitchen_id == user.kitchen_id) ) valid_ids = {r[0] for r in valid_result.all()} added = 0 skipped = 0 for entry in data.items: if entry.recipe_id not in valid_ids: skipped += 1 continue if entry.recipe_id in existing_ids: skipped += 1 continue item = EventOrderItem( event_order_id=order_id, recipe_id=entry.recipe_id, quantity=entry.quantity, notes=entry.notes, sort_order=next_sort, ) db.add(item) existing_ids.add(entry.recipe_id) next_sort += 1 added += 1 await db.commit() return {"added": added, "skipped": skipped} @router.patch("/items/{item_id}") async def update_item( item_id: int, data: EventOrderItemUpdate, user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): result = await db.execute(select(EventOrderItem).where(EventOrderItem.id == item_id)) item = result.scalar_one_or_none() if not item: raise HTTPException(404, "Item not found") if data.quantity is not None: item.quantity = data.quantity if data.notes is not None: item.notes = data.notes if data.sort_order is not None: item.sort_order = data.sort_order await db.commit() return {"ok": True} @router.delete("/items/{item_id}") async def delete_item( item_id: int, user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): result = await db.execute(select(EventOrderItem).where(EventOrderItem.id == item_id)) item = result.scalar_one_or_none() if not item: raise HTTPException(404, "Item not found") await db.delete(item) await db.commit() return {"ok": True} # ── Shopping List ──────────────────────────────────────────────────────────── async def _collect_ingredients_for_recipe( recipe_id: int, multiplier: float, db: AsyncSession, depth: int = 0, ) -> dict[int, float]: """Recursively collect ingredient quantities for a recipe × multiplier. Returns {ingredient_id: total_quantity_in_standard_unit}.""" if depth > 5: return {} result: dict[int, float] = defaultdict(float) # Direct ingredients (yield-adjusted: divide by yield to get required purchase qty) ri_result = await db.execute( select(RecipeIngredient) .options(selectinload(RecipeIngredient.ingredient)) .where(RecipeIngredient.recipe_id == recipe_id) ) _bases = {"g": 1.0, "kg": 1000.0, "ml": 1.0, "ltr": 1000.0} for ri in ri_result.scalars().all(): yld = float(ri.yield_percent) if ri.yield_percent else 100.0 raw_qty = float(ri.quantity) * multiplier # Convert from display unit to standard unit if needed if ri.unit and ri.ingredient and ri.unit != ri.ingredient.standard_unit: from_base = _bases.get(ri.unit) to_base = _bases.get(ri.ingredient.standard_unit) if from_base and to_base: raw_qty = raw_qty * from_base / to_base adjusted_qty = raw_qty / (yld / 100) if yld > 0 else raw_qty result[ri.ingredient_id] += adjusted_qty # Sub-recipe ingredients sr_result = await db.execute( select(RecipeSubRecipe).where(RecipeSubRecipe.parent_recipe_id == recipe_id) ) for sr in sr_result.scalars().all(): child_result = await db.execute( select(Recipe).where(Recipe.id == sr.child_recipe_id) ) child_recipe = child_result.scalar_one_or_none() if not child_recipe: continue # Use unified output qty (handles both portioned and bulk) child_output_qty = float(child_recipe.batch_yield_qty) if child_recipe.batch_output_type == "bulk" and child_recipe.batch_yield_qty else (child_recipe.batch_portions or 1) child_output_unit = child_recipe.batch_yield_unit if child_recipe.batch_output_type == "bulk" and child_recipe.batch_yield_unit else "portion" # Convert portions_needed to child output unit if different unit was used needed = float(sr.portions_needed) needed_unit = sr.portions_needed_unit or child_output_unit if needed_unit != child_output_unit: _bases = {"g": 1.0, "kg": 1000.0, "ml": 1.0, "ltr": 1000.0} if needed_unit in _bases and child_output_unit in _bases: needed = needed * _bases[needed_unit] / _bases[child_output_unit] child_multiplier = multiplier * (needed / child_output_qty) child_ings = await _collect_ingredients_for_recipe(sr.child_recipe_id, child_multiplier, db, depth + 1) for ing_id, qty in child_ings.items(): result[ing_id] += qty return dict(result) @router.get("/{order_id}/shopping-list") async def get_shopping_list( order_id: int, group_by_supplier: bool = Query(False), user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): """Aggregated ingredient shopping list across all event order items.""" order = await _get_order(order_id, user.kitchen_id, db) # Get all items items_result = await db.execute( select(EventOrderItem) .options(selectinload(EventOrderItem.recipe)) .where(EventOrderItem.event_order_id == order_id) ) items = items_result.scalars().all() # Aggregate ingredient quantities across all recipes total_ingredients: dict[int, float] = defaultdict(float) recipe_breakdown: dict[int, list] = defaultdict(list) # ingredient_id -> [{recipe, qty}] for item in items: recipe = item.recipe if not recipe: continue # For dish: multiplier = quantity (servings) / output_qty # For component: multiplier = quantity (batches) if recipe.recipe_type == "component": multiplier = float(item.quantity) # each item.quantity = number of batches else: # Dishes are always portioned with batch_portions=1 multiplier = float(item.quantity) / (recipe.batch_portions or 1) ings = await _collect_ingredients_for_recipe(recipe.id, multiplier, db) for ing_id, qty in ings.items(): total_ingredients[ing_id] += qty recipe_breakdown[ing_id].append({ "recipe_name": recipe.name, "quantity": round(qty, 3), }) if not total_ingredients: return {"items": [], "by_supplier": {}} # Load ingredient details and sources ing_ids = list(total_ingredients.keys()) ing_result = await db.execute( select(Ingredient) .options( selectinload(Ingredient.category), selectinload(Ingredient.sources).selectinload(IngredientSource.supplier), ) .where(Ingredient.id.in_(ing_ids)) ) ingredients = {ing.id: ing for ing in ing_result.scalars().all()} shopping_items = [] by_supplier: dict[str, list] = defaultdict(list) for ing_id, total_qty in sorted(total_ingredients.items(), key=lambda x: x[0]): ing = ingredients.get(ing_id) if not ing: continue # Quantity already yield-adjusted during collection adjusted_qty = total_qty # Source info sources = [] for src in (ing.sources or []): pack_total = None suggested_packs = None cost_per_pack = None if src.pack_quantity and src.unit_size and src.unit_size_type: pack_in_std = convert_to_standard( Decimal(str(src.pack_quantity)) * src.unit_size, src.unit_size_type, ing.standard_unit, ) if pack_in_std and float(pack_in_std) > 0: pack_total = float(pack_in_std) suggested_packs = int(adjusted_qty / pack_total) + (1 if adjusted_qty % pack_total > 0 else 0) if src.latest_unit_price: cost_per_pack = float(src.latest_unit_price) source_info = { "supplier_id": src.supplier_id, "supplier_name": src.supplier.name if src.supplier else "", "product_code": src.product_code, "pack_description": f"{src.pack_quantity}×{src.unit_size}{src.unit_size_type}" if src.pack_quantity and src.unit_size else None, "pack_total_std_unit": pack_total, "suggested_packs": suggested_packs, "cost_per_pack": cost_per_pack, "subtotal": round(cost_per_pack * suggested_packs, 2) if cost_per_pack and suggested_packs else None, } sources.append(source_info) if group_by_supplier: supplier_name = src.supplier.name if src.supplier else "Unknown" by_supplier[supplier_name].append({ "ingredient_name": ing.name, "quantity_needed": round(adjusted_qty, 3), "unit": ing.standard_unit, **source_info, }) item_data = { "ingredient_id": ing.id, "ingredient_name": ing.name, "category": ing.category.name if ing.category else "Other", "total_quantity": round(total_qty, 3), "adjusted_quantity": round(adjusted_qty, 3), "unit": ing.standard_unit, "sources": sources, "recipe_breakdown": recipe_breakdown.get(ing_id, []), } shopping_items.append(item_data) # Sort by category shopping_items.sort(key=lambda x: (x["category"], x["ingredient_name"])) return {"items": shopping_items, "by_supplier": dict(by_supplier) if group_by_supplier else {}} # ── Generate Purchase Orders ───────────────────────────────────────────────── @router.post("/{order_id}/generate-po") async def generate_purchase_orders( order_id: int, user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): """Generate purchase orders from shopping list, grouped by supplier.""" from models.purchase_order import PurchaseOrder, PurchaseOrderLineItem order = await _get_order(order_id, user.kitchen_id, db) # Get shopping list grouped by supplier items_result = await db.execute( select(EventOrderItem) .options(selectinload(EventOrderItem.recipe)) .where(EventOrderItem.event_order_id == order_id) ) items = items_result.scalars().all() # Aggregate ingredient quantities total_ingredients: dict[int, float] = defaultdict(float) for item in items: recipe = item.recipe if not recipe: continue if recipe.recipe_type == "component": multiplier = float(item.quantity) else: multiplier = float(item.quantity) / (recipe.batch_portions or 1) ings = await _collect_ingredients_for_recipe(recipe.id, multiplier, db) for ing_id, qty in ings.items(): total_ingredients[ing_id] += qty if not total_ingredients: raise HTTPException(400, "No ingredients to order") # Load ingredients with sources from sqlalchemy.orm import selectinload as si ing_result = await db.execute( select(Ingredient) .options( selectinload(Ingredient.sources).selectinload(IngredientSource.supplier), ) .where(Ingredient.id.in_(list(total_ingredients.keys()))) ) ingredients = {ing.id: ing for ing in ing_result.scalars().all()} # Group by supplier: {supplier_id: [{ingredient, qty, source}]} supplier_lines: dict[int, list] = defaultdict(list) unmapped = [] for ing_id, total_qty in total_ingredients.items(): ing = ingredients.get(ing_id) if not ing: continue # Quantity already yield-adjusted during collection adjusted_qty = total_qty # Pick the most recent source (by latest_invoice_date) best_source = None for src in (ing.sources or []): if best_source is None or (src.latest_invoice_date and ( not best_source.latest_invoice_date or src.latest_invoice_date > best_source.latest_invoice_date )): best_source = src if best_source and best_source.supplier_id: pack_total = None suggested_packs = 1 if best_source.pack_quantity and best_source.unit_size and best_source.unit_size_type: pack_in_std = convert_to_standard( Decimal(str(best_source.pack_quantity)) * best_source.unit_size, best_source.unit_size_type, ing.standard_unit, ) if pack_in_std and float(pack_in_std) > 0: pack_total = float(pack_in_std) suggested_packs = int(adjusted_qty / pack_total) + (1 if adjusted_qty % pack_total > 0 else 0) supplier_lines[best_source.supplier_id].append({ "ingredient": ing, "source": best_source, "quantity": max(suggested_packs, 1), "unit_price": float(best_source.latest_unit_price) if best_source.latest_unit_price else 0, }) else: unmapped.append(ing.name) # Create one PO per supplier created_pos = [] for supplier_id, lines in supplier_lines.items(): total = sum(l["quantity"] * l["unit_price"] for l in lines) po = PurchaseOrder( kitchen_id=user.kitchen_id, supplier_id=supplier_id, order_date=order.event_date or date.today(), order_type="itemised", status="DRAFT", total_amount=Decimal(str(round(total, 2))), notes=f"Auto-generated from event order: {order.name}", created_by=user.id, ) db.add(po) await db.flush() for idx, line in enumerate(lines): line_total = round(line["quantity"] * line["unit_price"], 2) po_line = PurchaseOrderLineItem( purchase_order_id=po.id, kitchen_id=user.kitchen_id, product_code=line["source"].product_code, description=line["ingredient"].name, unit=line["ingredient"].standard_unit, unit_price=Decimal(str(line["unit_price"])), quantity=Decimal(str(line["quantity"])), total=Decimal(str(line_total)), line_number=idx + 1, source="event_order", ) db.add(po_line) created_pos.append({"id": po.id, "supplier_id": supplier_id}) await db.commit() return { "created": len(created_pos), "purchase_orders": created_pos, "unmapped_ingredients": unmapped, } # ── Helpers ────────────────────────────────────────────────────────────────── async def _get_order(order_id: int, kitchen_id: int, db: AsyncSession) -> EventOrder: result = await db.execute( select(EventOrder).where(EventOrder.id == order_id, EventOrder.kitchen_id == kitchen_id) ) order = result.scalar_one_or_none() if not order: raise HTTPException(404, "Event order not found") return order