348 lines
12 KiB
Python
348 lines
12 KiB
Python
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")
|
|
|
|
items = db.query(OrderItem).options(
|
|
joinedload(OrderItem.product),
|
|
joinedload(OrderItem.order),
|
|
).filter(
|
|
OrderItem.paid_in_shift_id == shift_id,
|
|
OrderItem.status == "paid",
|
|
).all()
|
|
|
|
orders_seen = {}
|
|
for item in items:
|
|
oid = item.order_id
|
|
if oid not in orders_seen:
|
|
o = item.order
|
|
orders_seen[oid] = {
|
|
"order_id": oid,
|
|
"table_id": o.table_id if o 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
|