Files
xenia-pos-local/local_backend/routers/schedule.py
bonamin 34ae328b0d feat: bump client-services (accumulated feature work + deploy fixes)
Snapshot of in-progress work across local_backend, manager_dashboard,
and waiter_pwa (pricing, chat, fiscal, prep zones, recovery codes, CRM,
inventory, permissions), plus the nginx/docker-compose deploy fixes for
the Unraid + NPM reverse-proxy setup.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-19 10:00:14 +03:00

188 lines
6.5 KiB
Python

from datetime import date, datetime, timedelta, timezone
from fastapi import APIRouter, Depends, HTTPException, status, Query
from sqlalchemy.orm import Session
from typing import List, Optional
from database import get_db
from models.schedule import ScheduledShift
from models.shift import WaiterShift
from models.user import User
from schemas.schedule import ScheduledShiftCreate, ScheduledShiftUpdate, ScheduledShiftOut
from routers.deps import require_manager
router = APIRouter()
def _duration_hours(start: str, end: str) -> float:
"""Compute hours between two HH:MM strings. Handles overnight spans."""
sh, sm = int(start[:2]), int(start[3:5])
eh, em = int(end[:2]), int(end[3:5])
mins = (eh * 60 + em) - (sh * 60 + sm)
if mins <= 0:
mins += 24 * 60 # overnight
return round(mins / 60, 2)
def _enrich(s: ScheduledShift) -> dict:
u = s.user
user_name = (u.full_name or u.username) if u else f"#{s.user_id}"
hourly_rate = u.hourly_rate if u else None
dur = _duration_hours(s.start_time, s.end_time)
estimated_pay = round(dur * hourly_rate, 2) if hourly_rate else None
return {
"id": s.id,
"user_id": s.user_id,
"user_name": user_name,
"hourly_rate": hourly_rate,
"scheduled_date": s.scheduled_date,
"start_time": s.start_time,
"end_time": s.end_time,
"notes": s.notes,
"created_by_id": s.created_by_id,
"created_at": s.created_at,
"duration_hours": dur,
"estimated_pay": estimated_pay,
}
@router.get("/", response_model=List[ScheduledShiftOut])
def list_schedule(
from_date: Optional[date] = Query(default=None, alias="from"),
to_date: Optional[date] = Query(default=None, alias="to"),
user_id: Optional[int] = None,
db: Session = Depends(get_db),
user: User = Depends(require_manager),
):
q = db.query(ScheduledShift)
if from_date:
q = q.filter(ScheduledShift.scheduled_date >= from_date)
if to_date:
q = q.filter(ScheduledShift.scheduled_date <= to_date)
if user_id:
q = q.filter(ScheduledShift.user_id == user_id)
shifts = q.order_by(ScheduledShift.scheduled_date, ScheduledShift.start_time).all()
return [_enrich(s) for s in shifts]
@router.post("/", response_model=ScheduledShiftOut, status_code=status.HTTP_201_CREATED)
def create_scheduled_shift(
body: ScheduledShiftCreate,
db: Session = Depends(get_db),
user: User = Depends(require_manager),
):
target = db.query(User).filter(User.id == body.user_id, User.is_active == True).first()
if not target:
raise HTTPException(status_code=404, detail="User not found")
s = ScheduledShift(
user_id=body.user_id,
scheduled_date=body.scheduled_date,
start_time=body.start_time,
end_time=body.end_time,
notes=body.notes,
created_by_id=user.id,
)
db.add(s)
db.commit()
db.refresh(s)
return _enrich(s)
@router.put("/{shift_id}", response_model=ScheduledShiftOut)
def update_scheduled_shift(
shift_id: int,
body: ScheduledShiftUpdate,
db: Session = Depends(get_db),
user: User = Depends(require_manager),
):
s = db.query(ScheduledShift).filter(ScheduledShift.id == shift_id).first()
if not s:
raise HTTPException(status_code=404, detail="Scheduled shift not found")
for field, value in body.model_dump(exclude_none=True).items():
setattr(s, field, value)
db.commit()
db.refresh(s)
return _enrich(s)
@router.delete("/{shift_id}", status_code=status.HTTP_204_NO_CONTENT)
def delete_scheduled_shift(
shift_id: int,
db: Session = Depends(get_db),
user: User = Depends(require_manager),
):
s = db.query(ScheduledShift).filter(ScheduledShift.id == shift_id).first()
if not s:
raise HTTPException(status_code=404, detail="Scheduled shift not found")
db.delete(s)
db.commit()
@router.get("/week")
def week_schedule(
week_start: Optional[date] = Query(default=None),
db: Session = Depends(get_db),
user: User = Depends(require_manager),
):
"""Current (or specified) week: scheduled shifts + actual WaiterShifts for comparison."""
if week_start is None:
today = date.today()
week_start = today - timedelta(days=today.weekday()) # Monday
week_end = week_start + timedelta(days=6) # Sunday
scheduled = db.query(ScheduledShift).filter(
ScheduledShift.scheduled_date >= week_start,
ScheduledShift.scheduled_date <= week_end,
).order_by(ScheduledShift.scheduled_date, ScheduledShift.start_time).all()
# Actual shifts that started within the week
week_start_dt = datetime.combine(week_start, datetime.min.time()).replace(tzinfo=timezone.utc)
week_end_dt = datetime.combine(week_end, datetime.max.time()).replace(tzinfo=timezone.utc)
actuals = db.query(WaiterShift).filter(
WaiterShift.started_at >= week_start_dt,
WaiterShift.started_at <= week_end_dt,
).all()
waiters = {u.id: u for u in db.query(User).filter(User.perm_access_waiter_app == True, User.is_active == True).all()}
def _dt(dt):
if not dt:
return None
return (dt.isoformat() + "Z") if dt.tzinfo is None else dt.isoformat()
# Build actual shifts lookup: (user_id, date_str) → list of actual shifts
actuals_map: dict = {}
for a in actuals:
d = a.started_at.date() if a.started_at else None
if d:
key = (a.waiter_id, d.isoformat())
if key not in actuals_map:
actuals_map[key] = []
actuals_map[key].append({
"id": a.id,
"started_at": _dt(a.started_at),
"ended_at": _dt(a.ended_at),
"is_active": a.ended_at is None,
})
# Estimate total weekly labor cost
total_estimated_pay = 0.0
scheduled_out = []
for s in scheduled:
enriched = _enrich(s)
key = (s.user_id, s.scheduled_date.isoformat())
enriched["actual_shifts"] = actuals_map.get(key, [])
scheduled_out.append(enriched)
if enriched["estimated_pay"]:
total_estimated_pay += enriched["estimated_pay"]
return {
"week_start": week_start.isoformat(),
"week_end": week_end.isoformat(),
"scheduled": scheduled_out,
"total_estimated_pay": round(total_estimated_pay, 2),
"waiters": [
{"id": u.id, "name": u.full_name or u.username, "hourly_rate": u.hourly_rate}
for u in sorted(waiters.values(), key=lambda x: x.full_name or x.username)
],
}