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:
@@ -266,6 +266,9 @@ def _run_migrations():
|
||||
"ALTER TABLE products ADD COLUMN cost_simple REAL",
|
||||
"ALTER TABLE products ADD COLUMN cost_breakdown TEXT",
|
||||
"ALTER TABLE order_items ADD COLUMN unit_cost REAL",
|
||||
# Phase 2B — staff payroll
|
||||
"ALTER TABLE users ADD COLUMN hourly_rate REAL",
|
||||
"ALTER TABLE waiter_shifts ADD COLUMN hourly_rate_snapshot REAL",
|
||||
]
|
||||
for sql in migrations:
|
||||
try:
|
||||
|
||||
@@ -19,6 +19,8 @@ class WaiterShift(Base):
|
||||
starting_cash = Column(Float, nullable=True)
|
||||
total_collected = Column(Float, nullable=True) # snapshot written at shift end
|
||||
notes = Column(Text, nullable=True)
|
||||
# Phase 2B — payroll snapshot (copied from user.hourly_rate at shift start, never updated)
|
||||
hourly_rate_snapshot = Column(Float, nullable=True)
|
||||
|
||||
waiter = relationship("User", foreign_keys=[waiter_id])
|
||||
business_day = relationship("BusinessDay", back_populates="shifts")
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
from sqlalchemy import Column, Integer, String, Boolean, DateTime, ForeignKey
|
||||
from sqlalchemy import Column, Integer, String, Boolean, DateTime, Float, ForeignKey
|
||||
from sqlalchemy.orm import relationship
|
||||
from datetime import datetime, timezone
|
||||
from database import Base
|
||||
@@ -24,6 +24,8 @@ class User(Base):
|
||||
note = Column(String, nullable=True)
|
||||
avatar_url = Column(String, nullable=True)
|
||||
created_at = Column(DateTime(timezone=True), default=_utcnow)
|
||||
# Phase 2B — payroll
|
||||
hourly_rate = Column(Float, nullable=True)
|
||||
|
||||
orders_opened = relationship("Order", foreign_keys="Order.opened_by", back_populates="opener")
|
||||
orders_closed = relationship("Order", foreign_keys="Order.closed_by", back_populates="closer")
|
||||
|
||||
@@ -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"],
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -14,6 +14,8 @@ class UserBase(BaseModel):
|
||||
note: Optional[str] = None
|
||||
avatar_url: Optional[str] = None
|
||||
email: Optional[str] = None
|
||||
# Phase 2B — payroll
|
||||
hourly_rate: Optional[float] = None
|
||||
|
||||
|
||||
class UserCreate(UserBase):
|
||||
@@ -29,6 +31,8 @@ class UserUpdate(BaseModel):
|
||||
mobile_phone: Optional[str] = None
|
||||
email: Optional[str] = None
|
||||
note: Optional[str] = None
|
||||
# Phase 2B — payroll
|
||||
hourly_rate: Optional[float] = None
|
||||
|
||||
|
||||
class WaiterZoneOut(BaseModel):
|
||||
|
||||
Reference in New Issue
Block a user