feat: Phase 2B — staff payroll / hourly rate

- Users: add hourly_rate (Float, nullable) — manager-only, not shown to waiter
- WaiterShift: add hourly_rate_snapshot — copied from user.hourly_rate at shift open, never updated
- Shift start (both /start and /manager/start): snapshot rate at shift creation
- compute_shift_pay(): calculates worked hours (minus breaks) × rate_snapshot; returns None if unset
- _enrich_shift(): now includes hourly_rate_snapshot, duration_hours, shift_pay
- shifts_report in reports.py: delegates to _enrich_shift so all shift endpoints are consistent
- Shifts CSV export: adds duration_hours, hourly_rate, shift_pay columns
- StaffTab: hourly_rate field in waiter edit modal (Μισθοδοσία section, col 3)
- ShiftsOverview report: Αμοιβή/ώρα + Αποδοχές columns; untracked shows 'Δεν παρακολουθείται'

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-06-07 20:55:24 +03:00
parent d0dfd63415
commit e5b8ef32be
8 changed files with 101 additions and 29 deletions

View File

@@ -872,7 +872,7 @@ def shifts_report(
db: Session = Depends(get_db),
user: User = Depends(require_manager),
):
from routers.shifts import compute_shift_total
from routers.shifts import _enrich_shift
q = db.query(WaiterShift)
if waiter_id:
@@ -887,28 +887,7 @@ def shifts_report(
q = q.filter(WaiterShift.ended_at == None)
shifts = q.order_by(WaiterShift.started_at.desc()).all()
waiters_db = {u.id: u for u in db.query(User).all()}
result = []
for shift in shifts:
w = waiters_db.get(shift.waiter_id)
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)
result.append({
"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,
})
return {"shifts": result}
return {"shifts": [_enrich_shift(s, db) for s in shifts]}
# ---------------------------------------------------------------------------
@@ -1513,6 +1492,9 @@ def shifts_export(
"waiter": s["waiter_name"],
"started_at": s["started_at"],
"ended_at": s["ended_at"] or "",
"duration_hours": s.get("duration_hours", ""),
"hourly_rate": s.get("hourly_rate_snapshot", ""),
"shift_pay": s.get("shift_pay", ""),
"starting_cash": s["starting_cash"],
"total_collected": s["total_collected"],
"net_to_deliver": s["net_to_deliver"],

View File

@@ -35,10 +35,46 @@ def compute_shift_total(shift_id: int, db: Session) -> float:
return round(sum(i.unit_price * i.quantity for i in items), 2)
def compute_shift_pay(shift: WaiterShift) -> dict:
"""Return duration_hours and shift_pay. shift_pay is None if no rate snapshot."""
now = datetime.now(timezone.utc)
start = shift.started_at
if start and start.tzinfo is None:
start = start.replace(tzinfo=timezone.utc)
end = shift.ended_at
if end and end.tzinfo is None:
end = end.replace(tzinfo=timezone.utc)
reference = end or now
total_seconds = (reference - start).total_seconds() if start else 0
# Subtract completed breaks
break_seconds = 0
for b in shift.breaks:
bs = b.started_at
be = b.ended_at
if bs and be:
if bs.tzinfo is None:
bs = bs.replace(tzinfo=timezone.utc)
if be.tzinfo is None:
be = be.replace(tzinfo=timezone.utc)
break_seconds += (be - bs).total_seconds()
worked_seconds = max(0, total_seconds - break_seconds)
duration_hours = round(worked_seconds / 3600, 4)
shift_pay = None
if shift.hourly_rate_snapshot is not None:
shift_pay = round(duration_hours * shift.hourly_rate_snapshot, 2)
return {"duration_hours": round(duration_hours, 2), "shift_pay": shift_pay}
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)
pay_data = compute_shift_pay(shift)
return {
"id": shift.id,
"waiter_id": shift.waiter_id,
@@ -51,6 +87,9 @@ def _enrich_shift(shift: WaiterShift, db: Session) -> dict:
"net_to_deliver": round(total + (shift.starting_cash or 0.0), 2),
"is_active": shift.ended_at is None,
"notes": shift.notes,
"hourly_rate_snapshot": shift.hourly_rate_snapshot,
"duration_hours": pay_data["duration_hours"],
"shift_pay": pay_data["shift_pay"],
"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
@@ -97,10 +136,12 @@ def start_shift(
if existing:
raise HTTPException(status_code=400, detail="Waiter already has an active shift")
target_user = db.query(User).filter(User.id == target_id).first()
shift = WaiterShift(
waiter_id=target_id,
business_day_id=active_day.id,
starting_cash=body.starting_cash,
hourly_rate_snapshot=target_user.hourly_rate if target_user else None,
)
db.add(shift)
db.commit()
@@ -169,6 +210,7 @@ def manager_start_shift(
waiter_id=body.waiter_id,
business_day_id=active_day.id,
starting_cash=body.starting_cash,
hourly_rate_snapshot=target.hourly_rate,
)
db.add(shift)
db.commit()