import json from datetime import datetime from fastapi import APIRouter, Depends, HTTPException, status, BackgroundTasks from sqlalchemy.orm import Session from typing import List, Optional from database import get_db from models.order import Order, OrderItem, OrderWaiter, OrderAuditLog from models.user import User, WaiterZone from models.table import Table from models.product import Product from schemas.order import OrderCreate, OrderOut, OrderItemOut, AddItemsRequest, AddItemsResponse, PayItemsRequest, AssignWaiterRequest, OrderWaiterOut from pydantic import BaseModel class PrintOrderRequest(BaseModel): printer_id: int from routers.deps import get_current_user, require_manager from services.printer_service import route_and_print, route_and_print_sync, print_order_receipt router = APIRouter() def _can_access_order(order: Order, user: User, db: Session) -> bool: """Zone-based access: any waiter whose zone covers the order's table group may act on it.""" if user.role in ("manager", "sysadmin"): return True zones = db.query(WaiterZone).filter(WaiterZone.waiter_id == user.id).all() if not zones: return False if any(z.group_id is None for z in zones): return True table = db.query(Table).filter(Table.id == order.table_id).first() if not table: return False allowed_group_ids = {z.group_id for z in zones} return table.group_id in allowed_group_ids def _audit(db: Session, order_id: int, event_type: str, waiter_id: int = None, item_ids: list = None, amount: float = None, payment_method: str = None, note: str = None): db.add(OrderAuditLog( order_id=order_id, event_type=event_type, waiter_id=waiter_id, item_ids=json.dumps(item_ids) if item_ids is not None else None, amount=amount, payment_method=payment_method, note=note, )) @router.get("/", response_model=List[OrderOut]) def list_orders( order_status: Optional[str] = None, waiter_id: Optional[int] = None, db: Session = Depends(get_db), user: User = Depends(require_manager), ): q = db.query(Order) if order_status: q = q.filter(Order.status == order_status) if waiter_id: q = q.join(OrderWaiter).filter(OrderWaiter.waiter_id == waiter_id) return q.all() @router.get("/my", response_model=List[OrderOut]) def my_orders(db: Session = Depends(get_db), user: User = Depends(get_current_user)): direct = db.query(Order).join(OrderWaiter).filter( OrderWaiter.waiter_id == user.id, Order.status.in_(["open", "partially_paid"]), ).all() # Also orders where user is opener but not explicitly assigned also_opened = db.query(Order).filter( Order.opened_by == user.id, Order.status.in_(["open", "partially_paid"]), ).all() seen = {o.id for o in direct} return direct + [o for o in also_opened if o.id not in seen] @router.get("/{order_id}", response_model=OrderOut) def get_order(order_id: int, db: Session = Depends(get_db), user: User = Depends(get_current_user)): order = db.query(Order).filter(Order.id == order_id).first() if not order: raise HTTPException(status_code=404, detail="Order not found") if not _can_access_order(order, user, db): raise HTTPException(status_code=403, detail="Access denied") return order @router.post("/", response_model=OrderOut, status_code=status.HTTP_201_CREATED) def open_order(body: OrderCreate, db: Session = Depends(get_db), user: User = Depends(get_current_user)): existing = db.query(Order).filter( Order.table_id == body.table_id, Order.status.in_(["open", "partially_paid"]), ).first() if existing: raise HTTPException(status_code=400, detail="Table already has an open order") order = Order(table_id=body.table_id, opened_by=user.id) db.add(order) db.flush() db.add(OrderWaiter(order_id=order.id, waiter_id=user.id)) _audit(db, order.id, "ORDER_OPENED", waiter_id=user.id) db.commit() db.refresh(order) return order @router.post("/{order_id}/items", response_model=AddItemsResponse) def add_items( order_id: int, body: AddItemsRequest, db: Session = Depends(get_db), user: User = Depends(get_current_user), ): order = db.query(Order).filter(Order.id == order_id).first() if not order: raise HTTPException(status_code=404, detail="Order not found") if not _can_access_order(order, user, db): raise HTTPException(status_code=403, detail="Access denied") if order.status not in ("open", "partially_paid"): raise HTTPException(status_code=400, detail="Order is not open") new_item_ids = [] for item_in in body.items: product = db.query(Product).filter(Product.id == item_in.product_id).first() if not product or not product.is_available: raise HTTPException(status_code=400, detail=f"Product {item_in.product_id} not available") extra_cost = sum( (o.price_delta or o.extra_cost or 0.0) for o in (item_in.selected_options or []) ) item = OrderItem( order_id=order_id, product_id=item_in.product_id, added_by=user.id, quantity=item_in.quantity, unit_price=product.base_price + extra_cost, selected_options=json.dumps([o.model_dump() for o in item_in.selected_options]) if item_in.selected_options else None, removed_ingredients=json.dumps(item_in.removed_ingredients) if item_in.removed_ingredients else None, notes=item_in.notes, ) db.add(item) db.flush() new_item_ids.append(item.id) _audit(db, order_id, "ITEMS_ADDED", waiter_id=user.id, item_ids=new_item_ids) db.commit() db.refresh(order) print_results = route_and_print_sync(order_id, new_item_ids, db) return {"order": order, "print_results": print_results} @router.put("/{order_id}/items/{item_id}", response_model=OrderItemOut) def edit_item(order_id: int, item_id: int, notes: Optional[str] = None, db: Session = Depends(get_db), user: User = Depends(require_manager)): item = db.query(OrderItem).filter(OrderItem.id == item_id, OrderItem.order_id == order_id).first() if not item: raise HTTPException(status_code=404, detail="Item not found") if notes is not None: item.notes = notes db.commit() db.refresh(item) return item @router.delete("/{order_id}/items/{item_id}", status_code=status.HTTP_204_NO_CONTENT) def cancel_item(order_id: int, item_id: int, db: Session = Depends(get_db), user: User = Depends(require_manager)): item = db.query(OrderItem).filter(OrderItem.id == item_id, OrderItem.order_id == order_id).first() if not item: raise HTTPException(status_code=404, detail="Item not found") item.status = "cancelled" _audit(db, order_id, "ITEM_CANCELLED", waiter_id=user.id, item_ids=[item_id]) db.commit() @router.post("/{order_id}/pay") def pay_items(order_id: int, body: PayItemsRequest, db: Session = Depends(get_db), user: User = Depends(get_current_user)): order = db.query(Order).filter(Order.id == order_id).first() if not order: raise HTTPException(status_code=404, detail="Order not found") if not _can_access_order(order, user, db): raise HTTPException(status_code=403, detail="Access denied") items = db.query(OrderItem).filter( OrderItem.id.in_(body.item_ids), OrderItem.order_id == order_id, OrderItem.status == "active", ).all() now = datetime.utcnow() total_paid = 0.0 for item in items: item.status = "paid" item.paid_by = user.id item.paid_at = now item.payment_method = body.payment_method total_paid += item.unit_price * item.quantity active_remaining = db.query(OrderItem).filter( OrderItem.order_id == order_id, OrderItem.status == "active" ).count() order.status = "paid" if active_remaining == 0 else "partially_paid" paid_ids = [i.id for i in items] _audit(db, order_id, "PAYMENT", waiter_id=user.id, item_ids=paid_ids, amount=total_paid, payment_method=body.payment_method) db.commit() return {"status": order.status, "paid_item_ids": paid_ids} @router.post("/{order_id}/close") def close_order(order_id: int, db: Session = Depends(get_db), user: User = Depends(get_current_user)): order = db.query(Order).filter(Order.id == order_id).first() if not order: raise HTTPException(status_code=404, detail="Order not found") if not _can_access_order(order, user, db): raise HTTPException(status_code=403, detail="Access denied") if order.status not in ("paid", "open", "partially_paid"): raise HTTPException(status_code=400, detail="Cannot close order in current status") order.status = "closed" order.closed_at = datetime.utcnow() order.closed_by = user.id _audit(db, order_id, "ORDER_CLOSED", waiter_id=user.id) db.commit() return {"status": "closed"} @router.delete("/{order_id}", status_code=status.HTTP_204_NO_CONTENT) def cancel_order(order_id: int, db: Session = Depends(get_db), user: User = Depends(require_manager)): order = db.query(Order).filter(Order.id == order_id).first() if not order: raise HTTPException(status_code=404, detail="Order not found") order.status = "cancelled" order.closed_at = datetime.utcnow() order.closed_by = user.id _audit(db, order_id, "ORDER_CANCELLED", waiter_id=user.id) db.commit() @router.put("/{order_id}/assign-waiter") def assign_waiter(order_id: int, body: AssignWaiterRequest, db: Session = Depends(get_db), user: User = Depends(require_manager)): order = db.query(Order).filter(Order.id == order_id).first() if not order: raise HTTPException(status_code=404, detail="Order not found") existing = db.query(OrderWaiter).filter( OrderWaiter.order_id == order_id, OrderWaiter.waiter_id == body.waiter_id ).first() if existing: raise HTTPException(status_code=400, detail="Waiter already assigned") db.add(OrderWaiter(order_id=order_id, waiter_id=body.waiter_id)) db.commit() return {"status": "assigned"} @router.delete("/{order_id}/waiters/{waiter_id}", status_code=status.HTTP_204_NO_CONTENT) def remove_waiter(order_id: int, waiter_id: int, db: Session = Depends(get_db), user: User = Depends(require_manager)): assignment = db.query(OrderWaiter).filter( OrderWaiter.order_id == order_id, OrderWaiter.waiter_id == waiter_id ).first() if not assignment: raise HTTPException(status_code=404, detail="Assignment not found") db.delete(assignment) db.commit() @router.post("/{order_id}/print") def print_order( order_id: int, body: PrintOrderRequest, background_tasks: BackgroundTasks, db: Session = Depends(get_db), user: User = Depends(require_manager), ): from models.printer import Printer order = db.query(Order).filter(Order.id == order_id).first() if not order: raise HTTPException(status_code=404, detail="Order not found") printer = db.query(Printer).filter(Printer.id == body.printer_id, Printer.is_active == True).first() if not printer: raise HTTPException(status_code=404, detail="Printer not found or inactive") table = db.query(Table).filter(Table.id == order.table_id).first() table_name = (table.label or f"T{table.number}") if table else f"#{order.table_id}" opener = db.query(User).filter(User.id == order.opened_by).first() waiter_name = opener.username if opener else f"#{order.opened_by}" items_data = [] for item in order.items: if item.status == "cancelled": continue product_name = item.product.name if item.product else f"#{item.product_id}" items_data.append({ "name": product_name, "quantity": item.quantity, "unit_price": item.unit_price, "total": item.unit_price * item.quantity, "status": item.status, }) grand_total = sum(i["total"] for i in items_data) receipt = { "order_id": order.id, "table_name": table_name, "waiter_name": waiter_name, "opened_at": order.opened_at.strftime("%d/%m/%Y %H:%M"), "closed_at": order.closed_at.strftime("%d/%m/%Y %H:%M") if order.closed_at else None, "status": order.status, "items": items_data, "total": grand_total, "notes": order.notes, } background_tasks.add_task(print_order_receipt, printer.ip_address, printer.port, receipt) return {"status": "printing"}