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.get("/{shift_id}/summary") def get_shift_summary( shift_id: int, db: Session = Depends(get_db), user: User = Depends(require_manager), ): """Full shift summary: enriched shift data + paid items grouped by order.""" from models.order import Order 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") from models.table import Table items = db.query(OrderItem).options( joinedload(OrderItem.product), joinedload(OrderItem.order), ).filter( OrderItem.paid_in_shift_id == shift_id, OrderItem.status == "paid", ).all() # Build table_id -> display name map for all referenced tables table_ids = {item.order.table_id for item in items if item.order and item.order.table_id} tables_map: dict[int, str] = {} if table_ids: tbl_rows = db.query(Table).filter(Table.id.in_(table_ids)).all() for t in tbl_rows: 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}" orders_seen = {} for item in items: oid = item.order_id if oid not in orders_seen: o = item.order tid = o.table_id if o else None orders_seen[oid] = { "order_id": oid, "table_id": tid, "table_name": tables_map.get(tid) if tid else None, "opened_at": _dt(o.opened_at) if o else None, "items": [], } orders_seen[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), "paid_at": _dt(item.paid_at), }) # Compute hours worked 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 datetime, timezone as tz duration_minutes = int((datetime.now(tz.utc) - started.replace(tzinfo=tz.utc) if started.tzinfo is None else datetime.now(tz.utc) - started).total_seconds() / 60) enriched = _enrich_shift(shift, db) enriched["orders"] = list(orders_seen.values()) enriched["duration_minutes"] = duration_minutes return enriched