from fastapi import APIRouter, Depends, HTTPException, status from sqlalchemy.orm import Session from typing import Optional from datetime import datetime, timezone from database import get_db from models.shift import WaiterShift, ShiftBreak from models.business_day import BusinessDay from models.order import OrderItem from models.settings import PosSettings from models.user import User from schemas.shift import StartShiftRequest, EndShiftRequest from routers.deps import get_current_user, require_manager router = APIRouter() def _dt(dt): """Serialize a naive-UTC datetime to ISO string with Z so JS parses it as UTC.""" if dt is None: return None return (dt.isoformat() + "Z") if dt.tzinfo is None else dt.isoformat() def _get_setting(db: Session, key: str, default: str = "true") -> str: s = db.query(PosSettings).filter(PosSettings.key == key).first() return s.value if s else default def compute_shift_total(shift_id: int, db: Session) -> float: items = db.query(OrderItem).filter( OrderItem.paid_in_shift_id == shift_id, OrderItem.status == "paid", ).all() return round(sum(i.unit_price * i.quantity for i in items), 2) def _enrich_shift(shift: WaiterShift, db: Session) -> dict: w = shift.waiter wname = (w.full_name or w.username) if w else f"#{shift.waiter_id}" total = compute_shift_total(shift.id, db) if shift.ended_at is None else (shift.total_collected or 0.0) return { "id": shift.id, "waiter_id": shift.waiter_id, "waiter_name": wname, "business_day_id": shift.business_day_id, "started_at": _dt(shift.started_at), "ended_at": _dt(shift.ended_at), "starting_cash": shift.starting_cash, "total_collected": total, "net_to_deliver": round(total + (shift.starting_cash or 0.0), 2), "is_active": shift.ended_at is None, "notes": shift.notes, "breaks": [ {"id": b.id, "shift_id": b.shift_id, "started_at": _dt(b.started_at), "ended_at": _dt(b.ended_at)} for b in shift.breaks ], } @router.get("/my") def my_shift(db: Session = Depends(get_db), user: User = Depends(get_current_user)): shift = db.query(WaiterShift).filter( WaiterShift.waiter_id == user.id, WaiterShift.ended_at == None, ).first() return _enrich_shift(shift, db) if shift else None @router.post("/start", status_code=status.HTTP_201_CREATED) def start_shift( body: StartShiftRequest, db: Session = Depends(get_db), user: User = Depends(get_current_user), ): target_id = body.waiter_id if target_id and target_id != user.id: if user.role not in ("manager", "sysadmin"): raise HTTPException(status_code=403, detail="Only managers can start shifts for other waiters") target = db.query(User).filter(User.id == target_id, User.is_active == True).first() if not target: raise HTTPException(status_code=404, detail="Waiter not found") else: target_id = user.id if user.role == "waiter" and _get_setting(db, "shifts.waiter_self_start") != "true": raise HTTPException(status_code=403, detail="Shift start requires manager confirmation") active_day = db.query(BusinessDay).filter(BusinessDay.status == "open").first() if not active_day: raise HTTPException(status_code=400, detail="No open business day — manager must open the restaurant first") existing = db.query(WaiterShift).filter( WaiterShift.waiter_id == target_id, WaiterShift.ended_at == None, ).first() if existing: raise HTTPException(status_code=400, detail="Waiter already has an active shift") shift = WaiterShift( waiter_id=target_id, business_day_id=active_day.id, starting_cash=body.starting_cash, ) db.add(shift) db.commit() db.refresh(shift) return _enrich_shift(shift, db) @router.post("/end") def end_shift( body: EndShiftRequest, db: Session = Depends(get_db), user: User = Depends(get_current_user), ): if user.role == "waiter" and _get_setting(db, "shifts.waiter_self_end") != "true": raise HTTPException(status_code=403, detail="Shift end requires manager confirmation") shift = db.query(WaiterShift).filter( WaiterShift.waiter_id == user.id, WaiterShift.ended_at == None, ).first() if not shift: raise HTTPException(status_code=404, detail="No active shift found") now = datetime.now(timezone.utc) shift.total_collected = compute_shift_total(shift.id, db) shift.ended_at = now if body.notes: shift.notes = body.notes open_break = db.query(ShiftBreak).filter( ShiftBreak.shift_id == shift.id, ShiftBreak.ended_at == None ).first() if open_break: open_break.ended_at = now db.commit() db.refresh(shift) return _enrich_shift(shift, db) @router.post("/manager/start", status_code=status.HTTP_201_CREATED) def manager_start_shift( body: StartShiftRequest, db: Session = Depends(get_db), user: User = Depends(require_manager), ): if not body.waiter_id: raise HTTPException(status_code=400, detail="waiter_id is required") target = db.query(User).filter(User.id == body.waiter_id, User.is_active == True).first() if not target: raise HTTPException(status_code=404, detail="Waiter not found") active_day = db.query(BusinessDay).filter(BusinessDay.status == "open").first() if not active_day: raise HTTPException(status_code=400, detail="No open business day") existing = db.query(WaiterShift).filter( WaiterShift.waiter_id == body.waiter_id, WaiterShift.ended_at == None, ).first() if existing: raise HTTPException(status_code=400, detail="Waiter already has an active shift") shift = WaiterShift( waiter_id=body.waiter_id, business_day_id=active_day.id, starting_cash=body.starting_cash, ) db.add(shift) db.commit() db.refresh(shift) return _enrich_shift(shift, db) @router.post("/manager/end/{shift_id}") def manager_end_shift( shift_id: int, body: EndShiftRequest, db: Session = Depends(get_db), user: User = Depends(require_manager), ): shift = db.query(WaiterShift).filter( WaiterShift.id == shift_id, WaiterShift.ended_at == None, ).first() if not shift: raise HTTPException(status_code=404, detail="Active shift not found") now = datetime.now(timezone.utc) shift.total_collected = compute_shift_total(shift.id, db) shift.ended_at = now if body.notes: shift.notes = body.notes open_break = db.query(ShiftBreak).filter( ShiftBreak.shift_id == shift.id, ShiftBreak.ended_at == None ).first() if open_break: open_break.ended_at = now db.commit() db.refresh(shift) return _enrich_shift(shift, db) @router.post("/{shift_id}/break/start") def start_break( shift_id: int, db: Session = Depends(get_db), user: User = Depends(get_current_user), ): shift = db.query(WaiterShift).filter(WaiterShift.id == shift_id).first() if not shift: raise HTTPException(status_code=404, detail="Shift not found") if shift.waiter_id != user.id and user.role not in ("manager", "sysadmin"): raise HTTPException(status_code=403, detail="Access denied") if shift.ended_at: raise HTTPException(status_code=400, detail="Shift already ended") open_break = db.query(ShiftBreak).filter( ShiftBreak.shift_id == shift_id, ShiftBreak.ended_at == None ).first() if open_break: raise HTTPException(status_code=400, detail="Break already in progress") b = ShiftBreak(shift_id=shift_id) db.add(b) db.commit() db.refresh(b) return {"id": b.id, "shift_id": b.shift_id, "started_at": _dt(b.started_at), "ended_at": _dt(b.ended_at)} @router.post("/{shift_id}/break/end") def end_break( shift_id: int, db: Session = Depends(get_db), user: User = Depends(get_current_user), ): shift = db.query(WaiterShift).filter(WaiterShift.id == shift_id).first() if not shift: raise HTTPException(status_code=404, detail="Shift not found") if shift.waiter_id != user.id and user.role not in ("manager", "sysadmin"): raise HTTPException(status_code=403, detail="Access denied") open_break = db.query(ShiftBreak).filter( ShiftBreak.shift_id == shift_id, ShiftBreak.ended_at == None ).first() if not open_break: raise HTTPException(status_code=404, detail="No active break found") open_break.ended_at = datetime.now(timezone.utc) db.commit() db.refresh(open_break) return {"id": open_break.id, "shift_id": open_break.shift_id, "started_at": _dt(open_break.started_at), "ended_at": _dt(open_break.ended_at)} @router.get("/") def list_shifts( waiter_id: Optional[int] = None, business_day_id: Optional[int] = None, active_only: bool = False, db: Session = Depends(get_db), user: User = Depends(require_manager), ): q = db.query(WaiterShift) if waiter_id: q = q.filter(WaiterShift.waiter_id == waiter_id) if business_day_id: q = q.filter(WaiterShift.business_day_id == business_day_id) if active_only: q = q.filter(WaiterShift.ended_at == None) shifts = q.order_by(WaiterShift.started_at.desc()).all() return {"shifts": [_enrich_shift(s, db) for s in shifts]} @router.get("/{shift_id}") def get_shift( shift_id: int, db: Session = Depends(get_db), user: User = Depends(require_manager), ): shift = db.query(WaiterShift).filter(WaiterShift.id == shift_id).first() if not shift: raise HTTPException(status_code=404, detail="Shift not found") return _enrich_shift(shift, db) @router.delete("/{shift_id}", status_code=status.HTTP_204_NO_CONTENT) def delete_shift( shift_id: int, db: Session = Depends(get_db), user: User = Depends(require_manager), ): """Permanently delete a shift, its breaks, and all orders opened during that shift.""" from models.order import Order, OrderItem, OrderAuditLog, OrderWaiter import json as _json shift = db.query(WaiterShift).filter(WaiterShift.id == shift_id).first() if not shift: raise HTTPException(status_code=404, detail="Shift not found") if shift.ended_at is None: raise HTTPException(status_code=400, detail="Cannot delete an active shift — end it first") # Find all orders opened during this shift's time window by this waiter orders = db.query(Order).filter( Order.opened_by == shift.waiter_id, Order.opened_at >= shift.started_at, Order.opened_at <= shift.ended_at, ).all() for order in orders: db.query(OrderAuditLog).filter(OrderAuditLog.order_id == order.id).delete(synchronize_session=False) db.query(OrderWaiter).filter(OrderWaiter.order_id == order.id).delete(synchronize_session=False) db.query(OrderItem).filter(OrderItem.order_id == order.id).delete(synchronize_session=False) db.delete(order) db.delete(shift) db.commit() @router.get("/{shift_id}/summary") def get_shift_summary( shift_id: int, db: Session = Depends(get_db), user: User = Depends(require_manager), ): """Full shift summary with complete per-item waiter attribution. Returns all items that either: - were paid in this shift (paid_in_shift_id == shift_id), OR - were added by this waiter (added_by == waiter_id) and are active/unpaid or paid in another shift. Each item includes added_by_name, paid_by_name, timestamps, and order context. Orders include opener_name and closer_name. """ from models.order import Order from models.table import Table from sqlalchemy.orm import joinedload shift = db.query(WaiterShift).filter(WaiterShift.id == shift_id).first() if not shift: raise HTTPException(status_code=404, detail="Shift not found") waiter_id = shift.waiter_id # Collect all relevant items: paid in this shift OR added by this waiter paid_items = db.query(OrderItem).options( joinedload(OrderItem.product), joinedload(OrderItem.order), ).filter( OrderItem.paid_in_shift_id == shift_id, OrderItem.status == "paid", ).all() ordered_items = db.query(OrderItem).options( joinedload(OrderItem.product), joinedload(OrderItem.order), ).filter( OrderItem.added_by == waiter_id, OrderItem.added_at >= shift.started_at, OrderItem.added_at <= shift.ended_at, (OrderItem.paid_in_shift_id != shift_id) | (OrderItem.paid_in_shift_id == None), ).all() # Deduplicate: union by item id, paid_items takes precedence items_by_id: dict[int, OrderItem] = {} for item in ordered_items: items_by_id[item.id] = item for item in paid_items: items_by_id[item.id] = item all_items = list(items_by_id.values()) # Build lookup maps all_waiter_ids = set() for item in all_items: all_waiter_ids.add(item.added_by) if item.paid_by: all_waiter_ids.add(item.paid_by) all_order_ids = {item.order_id for item in all_items} # Load orders with opener/closer orders_db: dict[int, Order] = {} if all_order_ids: for o in db.query(Order).filter(Order.id.in_(all_order_ids)).all(): orders_db[o.id] = o if o.opened_by: all_waiter_ids.add(o.opened_by) if o.closed_by: all_waiter_ids.add(o.closed_by) # Load table names table_ids = {o.table_id for o in orders_db.values() if o.table_id} tables_map: dict[int, str] = {} if table_ids: for t in db.query(Table).filter(Table.id.in_(table_ids)).all(): prefix = (t.group.prefix if t.group and t.group.prefix else "") if t.group else "" tables_map[t.id] = t.label if t.label else f"{prefix}{t.number}" # Load waiter names waiters_map: dict[int, str] = {} if all_waiter_ids: for w in db.query(User).filter(User.id.in_(all_waiter_ids)).all(): waiters_map[w.id] = w.full_name or w.username def _wname(wid): if wid is None: return None return waiters_map.get(wid, f"#{wid}") # Build orders dict orders_out: dict[int, dict] = {} for oid, o in orders_db.items(): tid = o.table_id orders_out[oid] = { "order_id": oid, "table_id": tid, "table_name": tables_map.get(tid) if tid else None, "opened_at": _dt(o.opened_at), "closed_at": _dt(o.closed_at), "opener_name": _wname(o.opened_by), "closer_name": _wname(o.closed_by), "status": o.status, "items": [], } # Attach items to their orders for item in all_items: oid = item.order_id if oid not in orders_out: continue orders_out[oid]["items"].append({ "id": item.id, "product_name": item.product.name if item.product else f"#{item.product_id}", "quantity": item.quantity, "unit_price": float(item.unit_price), "subtotal": round(float(item.unit_price) * item.quantity, 2), "status": item.status, "added_by_id": item.added_by, "added_by_name": _wname(item.added_by), "added_at": _dt(item.added_at), "paid_by_id": item.paid_by, "paid_by_name": _wname(item.paid_by), "paid_at": _dt(item.paid_at), "paid_in_shift_id": item.paid_in_shift_id, "payment_method": item.payment_method, }) # Remove orders with no items (shouldn't happen but safety net) populated_orders = [o for o in orders_out.values() if o["items"]] # Compute duration started = shift.started_at ended = shift.ended_at duration_minutes = None if started and ended: duration_minutes = int((ended - started).total_seconds() / 60) elif started: from datetime import timezone as tz now = datetime.now(tz.utc) s = started if started.tzinfo else started.replace(tzinfo=tz.utc) duration_minutes = int((now - s).total_seconds() / 60) enriched = _enrich_shift(shift, db) enriched["orders"] = populated_orders enriched["duration_minutes"] = duration_minutes return enriched