import json from datetime import datetime, timezone 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 class TransferOrderRequest(BaseModel): target_table_id: int class MergeOrderRequest(BaseModel): target_order_id: int class SplitItemRequest(BaseModel): quantity: int # how many to split off into a new item row class PrintSynopsisRequest(BaseModel): printer_id: int class MoveItemsRequest(BaseModel): item_ids: List[int] target_order_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, print_order_synopsis 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] class ActiveOrderSlim(BaseModel): id: int table_id: int status: str waiter_ids: List[int] model_config = {"from_attributes": True} @router.get("/active", response_model=List[ActiveOrderSlim]) def list_active_orders(db: Session = Depends(get_db), user: User = Depends(get_current_user)): """All currently open/partially-paid/paid orders (lightweight). Accessible to all staff.""" orders = db.query(Order).filter(Order.status.in_(["open", "partially_paid", "paid"])).all() return [ ActiveOrderSlim( id=o.id, table_id=o.table_id, status=o.status, waiter_ids=[w.waiter_id for w in o.waiters], ) for o in orders ] @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)): from models.business_day import BusinessDay from models.shift import WaiterShift active_day = db.query(BusinessDay).filter(BusinessDay.status == "open").first() if not active_day: raise HTTPException(status_code=403, detail="Restaurant is not open — manager must open the business day first") if user.role == "waiter": active_shift = db.query(WaiterShift).filter( WaiterShift.waiter_id == user.id, WaiterShift.ended_at == None, ).first() if not active_shift: raise HTTPException(status_code=403, detail="You do not have an active shift") existing = db.query(Order).filter( Order.table_id == body.table_id, Order.status.in_(["open", "partially_paid", "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, business_day_id=active_day.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", "paid"): raise HTTPException(status_code=400, detail="Order is not open") # Adding items to a fully-paid order reopens it — partially_paid since prior items were paid if order.status == "paid": order.status = "partially_paid" 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.post("/{order_id}/retry-print", response_model=AddItemsResponse) def retry_print( 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") unprinted_ids = [item.id for item in order.items if not item.printed and item.status == "active"] if not unprinted_ids: return {"order": order, "print_results": []} print_results = route_and_print_sync(order_id, unprinted_ids, db) db.refresh(order) 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") from models.shift import WaiterShift items = db.query(OrderItem).filter( OrderItem.id.in_(body.item_ids), OrderItem.order_id == order_id, OrderItem.status == "active", ).all() now = datetime.now(timezone.utc) active_shift = db.query(WaiterShift).filter( WaiterShift.waiter_id == user.id, WaiterShift.ended_at == None, ).first() 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 item.paid_in_shift_id = active_shift.id if active_shift else None total_paid += item.unit_price * item.quantity db.flush() # write item status changes before counting, since autoflush=False 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.now(timezone.utc) 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.now(timezone.utc) 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(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") 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"} # ─── Transfer order to a different table ───────────────────────────────────── @router.post("/{order_id}/transfer") def transfer_order( order_id: int, body: TransferOrderRequest, 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", "paid"): raise HTTPException(status_code=400, detail="Order is not active") target_table = db.query(Table).filter(Table.id == body.target_table_id, Table.is_active == True).first() if not target_table: raise HTTPException(status_code=404, detail="Target table not found") if body.target_table_id == order.table_id: raise HTTPException(status_code=400, detail="Table is already assigned to this order") conflict = db.query(Order).filter( Order.table_id == body.target_table_id, Order.status.in_(["open", "partially_paid", "paid"]), ).first() if conflict: raise HTTPException(status_code=400, detail="Target table already has an active order") old_table_id = order.table_id order.table_id = body.target_table_id _audit(db, order_id, "TABLE_TRANSFER", waiter_id=user.id, note=f"Transferred from table {old_table_id} to table {body.target_table_id}") db.commit() db.refresh(order) return order # ─── Merge another order into this one ─────────────────────────────────────── @router.post("/{order_id}/merge") def merge_order( order_id: int, body: MergeOrderRequest, db: Session = Depends(get_db), user: User = Depends(get_current_user), ): """ Merge source order (order_id) INTO target order (body.target_order_id). All items (paid + active) from the source are reassigned to the target. Source waiters are added to the target if not already there. Source order is cancelled with audit note. """ source = db.query(Order).filter(Order.id == order_id).first() if not source: raise HTTPException(status_code=404, detail="Source order not found") if not _can_access_order(source, user, db): raise HTTPException(status_code=403, detail="Access denied") if source.status not in ("open", "partially_paid", "paid"): raise HTTPException(status_code=400, detail="Source order is not active") target = db.query(Order).filter(Order.id == body.target_order_id).first() if not target: raise HTTPException(status_code=404, detail="Target order not found") if not _can_access_order(target, user, db): raise HTTPException(status_code=403, detail="Access denied to target order") if target.status not in ("open", "partially_paid", "paid"): raise HTTPException(status_code=400, detail="Target order is not active") if source.id == target.id: raise HTTPException(status_code=400, detail="Cannot merge an order with itself") # Move all items to target order moved_item_ids = [] for item in source.items: item.order_id = target.id moved_item_ids.append(item.id) # Copy source waiters to target (no duplicates) existing_waiter_ids = {w.waiter_id for w in target.waiters} for ow in source.waiters: if ow.waiter_id not in existing_waiter_ids: db.add(OrderWaiter(order_id=target.id, waiter_id=ow.waiter_id)) # Recompute target status after flush db.flush() active_remaining = db.query(OrderItem).filter( OrderItem.order_id == target.id, OrderItem.status == "active" ).count() paid_exists = db.query(OrderItem).filter( OrderItem.order_id == target.id, OrderItem.status == "paid" ).count() if active_remaining > 0: target.status = "partially_paid" if paid_exists > 0 else "open" else: target.status = "paid" # Cancel source order source.status = "cancelled" source.closed_at = datetime.now(timezone.utc) source.closed_by = user.id _audit(db, source.id, "ORDER_CANCELLED", waiter_id=user.id, note=f"Merged into order #{target.id} (table {target.table_id})") _audit(db, target.id, "ITEMS_ADDED", waiter_id=user.id, item_ids=moved_item_ids, note=f"Items merged from order #{source.id} (table {source.table_id})") db.commit() db.refresh(target) return target # ─── Split a stacked item into two rows ────────────────────────────────────── @router.post("/{order_id}/items/{item_id}/split", response_model=List[OrderItemOut]) def split_item( order_id: int, item_id: int, body: SplitItemRequest, db: Session = Depends(get_db), user: User = Depends(get_current_user), ): """ Split qty units off item_id into a new item row. Both rows share all properties (product, price, options, notes). Only active items can be split. """ 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 item.status != "active": raise HTTPException(status_code=400, detail="Only active items can be split") if body.quantity <= 0 or body.quantity >= item.quantity: raise HTTPException( status_code=400, detail=f"Split quantity must be between 1 and {item.quantity - 1}" ) order = db.query(Order).filter(Order.id == order_id).first() if not _can_access_order(order, user, db): raise HTTPException(status_code=403, detail="Access denied") # Reduce original item item.quantity -= body.quantity # Create split-off item new_item = OrderItem( order_id=order_id, product_id=item.product_id, added_by=item.added_by, quantity=body.quantity, unit_price=item.unit_price, selected_options=item.selected_options, removed_ingredients=item.removed_ingredients, notes=item.notes, status="active", printed=item.printed, ) db.add(new_item) db.commit() db.refresh(item) db.refresh(new_item) return [item, new_item] # ─── Move selected items to another order ──────────────────────────────────── @router.post("/{order_id}/move-items") def move_items( order_id: int, body: MoveItemsRequest, db: Session = Depends(get_db), user: User = Depends(get_current_user), ): """Move specific active items from this order to another open order.""" source = db.query(Order).filter(Order.id == order_id).first() if not source: raise HTTPException(status_code=404, detail="Source order not found") if not _can_access_order(source, user, db): raise HTTPException(status_code=403, detail="Access denied") if source.status not in ("open", "partially_paid"): raise HTTPException(status_code=400, detail="Source order is not active") target = db.query(Order).filter(Order.id == body.target_order_id).first() if not target: raise HTTPException(status_code=404, detail="Target order not found") if not _can_access_order(target, user, db): raise HTTPException(status_code=403, detail="Access denied to target order") if target.status not in ("open", "partially_paid"): raise HTTPException(status_code=400, detail="Target order is not active") if source.id == target.id: raise HTTPException(status_code=400, detail="Source and target orders are the same") items = db.query(OrderItem).filter( OrderItem.id.in_(body.item_ids), OrderItem.order_id == order_id, OrderItem.status == "active", ).all() if not items: raise HTTPException(status_code=400, detail="No active items found to move") moved_ids = [] for item in items: item.order_id = target.id moved_ids.append(item.id) # Recompute source status db.flush() src_active = db.query(OrderItem).filter(OrderItem.order_id == source.id, OrderItem.status == "active").count() src_paid = db.query(OrderItem).filter(OrderItem.order_id == source.id, OrderItem.status == "paid").count() if src_active == 0 and src_paid == 0: source.status = "open" elif src_active == 0: source.status = "paid" else: source.status = "partially_paid" if src_paid > 0 else "open" # Recompute target status tgt_active = db.query(OrderItem).filter(OrderItem.order_id == target.id, OrderItem.status == "active").count() tgt_paid = db.query(OrderItem).filter(OrderItem.order_id == target.id, OrderItem.status == "paid").count() target.status = "partially_paid" if (tgt_active > 0 and tgt_paid > 0) else ("paid" if tgt_active == 0 else "open") _audit(db, source.id, "ITEMS_MOVED_OUT", waiter_id=user.id, item_ids=moved_ids, note=f"Moved to order #{target.id} (table {target.table_id})") _audit(db, target.id, "ITEMS_MOVED_IN", waiter_id=user.id, item_ids=moved_ids, note=f"Moved from order #{source.id} (table {source.table_id})") db.commit() db.refresh(source) return {"moved_item_ids": moved_ids, "source_status": source.status, "target_status": target.status} # ─── Print order synopsis ───────────────────────────────────────────────────── @router.post("/{order_id}/print-synopsis") def print_synopsis( order_id: int, body: PrintSynopsisRequest, background_tasks: BackgroundTasks, db: Session = Depends(get_db), user: User = Depends(get_current_user), ): 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") if not _can_access_order(order, user, db): raise HTTPException(status_code=403, detail="Access denied") 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.nickname or 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, }) total = sum(i["total"] for i in items_data) paid_total = sum(i["total"] for i in items_data if i["status"] == "paid") synopsis = { "order_id": order.id, "table_name": table_name, "waiter_name": waiter_name, "opened_at": order.opened_at.strftime("%d/%m/%Y %H:%M"), "items": items_data, "total": total, "paid_total": paid_total, "remaining": total - paid_total, } background_tasks.add_task(print_order_synopsis, printer.ip_address, printer.port, synopsis) return {"status": "printing"}