Backend overhaul: new models, routers, schemas for shifts, business day, flags, messages, settings
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -5,6 +5,11 @@ from sqlalchemy.orm import Session
|
||||
from database import get_db
|
||||
from models.user import User
|
||||
from schemas.auth import LoginRequest, TokenResponse
|
||||
from pydantic import BaseModel as _PydanticBase
|
||||
|
||||
class LoginByIdRequest(_PydanticBase):
|
||||
waiter_id: int
|
||||
pin: str
|
||||
from schemas.user import UserOut
|
||||
from routers.deps import get_current_user, make_token, decode_token, blacklist_token
|
||||
|
||||
@@ -20,6 +25,16 @@ def login(body: LoginRequest, db: Session = Depends(get_db)):
|
||||
return TokenResponse(access_token=token, user=UserOut.model_validate(user))
|
||||
|
||||
|
||||
@router.post("/login-by-id", response_model=TokenResponse)
|
||||
def login_by_id(body: LoginByIdRequest, db: Session = Depends(get_db)):
|
||||
"""Login using waiter id + PIN (used by the waiter-picker login screen)."""
|
||||
user = db.query(User).filter(User.id == body.waiter_id, User.is_active == True).first()
|
||||
if not user or not bcrypt.checkpw(body.pin.encode(), user.pin_hash.encode()):
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Λανθασμένο PIN")
|
||||
token = make_token(user)
|
||||
return TokenResponse(access_token=token, user=UserOut.model_validate(user))
|
||||
|
||||
|
||||
@router.post("/refresh", response_model=TokenResponse)
|
||||
def refresh(token: str, db: Session = Depends(get_db)):
|
||||
payload = decode_token(token)
|
||||
@@ -40,3 +55,37 @@ def logout(token: str):
|
||||
@router.get("/me", response_model=UserOut)
|
||||
def me(user: User = Depends(get_current_user)):
|
||||
return user
|
||||
|
||||
|
||||
# ─── Public waiter list (login screen — no auth required) ────────────────────
|
||||
|
||||
from pydantic import BaseModel as _BaseModel
|
||||
|
||||
class PublicWaiterOut(_BaseModel):
|
||||
id: int
|
||||
full_name: str | None
|
||||
nickname: str | None
|
||||
avatar_url: str | None
|
||||
on_shift: bool
|
||||
model_config = {"from_attributes": True}
|
||||
|
||||
|
||||
@router.get("/waiters", response_model=list[PublicWaiterOut])
|
||||
def public_waiter_list(db: Session = Depends(get_db)):
|
||||
"""Public endpoint — returns active waiters with on-shift flag. No auth required."""
|
||||
from models.shift import WaiterShift
|
||||
waiters = db.query(User).filter(User.role == "waiter", User.is_active == True).all()
|
||||
on_shift_ids = {
|
||||
row.waiter_id
|
||||
for row in db.query(WaiterShift).filter(WaiterShift.ended_at == None).all()
|
||||
}
|
||||
return [
|
||||
PublicWaiterOut(
|
||||
id=w.id,
|
||||
full_name=w.full_name,
|
||||
nickname=w.nickname,
|
||||
avatar_url=w.avatar_url,
|
||||
on_shift=w.id in on_shift_ids,
|
||||
)
|
||||
for w in waiters
|
||||
]
|
||||
|
||||
162
local_backend/routers/business_day.py
Normal file
162
local_backend/routers/business_day.py
Normal file
@@ -0,0 +1,162 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from sqlalchemy import func
|
||||
from sqlalchemy.orm import Session
|
||||
from typing import Optional
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from database import get_db
|
||||
from models.business_day import BusinessDay
|
||||
from models.order import Order, OrderItem, OrderAuditLog
|
||||
from models.shift import WaiterShift
|
||||
from models.flag import TableFlagAssignment
|
||||
from models.message import StaffMessage, StaffMessageAck
|
||||
from schemas.business_day import BusinessDayOut, OpenBusinessDayRequest, CloseBusinessDayRequest
|
||||
from routers.deps import get_current_user, require_manager
|
||||
from models.user import User
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
def _dt(dt):
|
||||
if dt is None:
|
||||
return None
|
||||
return (dt.isoformat() + "Z") if dt.tzinfo is None else dt.isoformat()
|
||||
|
||||
|
||||
@router.get("/current", response_model=Optional[BusinessDayOut])
|
||||
def get_current_business_day(
|
||||
db: Session = Depends(get_db),
|
||||
user: User = Depends(get_current_user),
|
||||
):
|
||||
return db.query(BusinessDay).filter(BusinessDay.status == "open").first()
|
||||
|
||||
|
||||
@router.post("/open", response_model=BusinessDayOut, status_code=status.HTTP_201_CREATED)
|
||||
def open_business_day(
|
||||
body: OpenBusinessDayRequest,
|
||||
db: Session = Depends(get_db),
|
||||
user: User = Depends(require_manager),
|
||||
):
|
||||
existing = db.query(BusinessDay).filter(BusinessDay.status == "open").first()
|
||||
if existing:
|
||||
raise HTTPException(status_code=400, detail="A business day is already open")
|
||||
day = BusinessDay(opened_by_id=user.id, notes=body.notes)
|
||||
db.add(day)
|
||||
db.commit()
|
||||
db.refresh(day)
|
||||
return day
|
||||
|
||||
|
||||
@router.post("/close", response_model=BusinessDayOut)
|
||||
def close_business_day(
|
||||
body: CloseBusinessDayRequest,
|
||||
db: Session = Depends(get_db),
|
||||
user: User = Depends(require_manager),
|
||||
):
|
||||
day = db.query(BusinessDay).filter(BusinessDay.status == "open").first()
|
||||
if not day:
|
||||
raise HTTPException(status_code=404, detail="No open business day")
|
||||
|
||||
open_orders = db.query(Order).filter(
|
||||
Order.business_day_id == day.id,
|
||||
Order.status.in_(["open", "partially_paid"]),
|
||||
).all()
|
||||
|
||||
if open_orders and not body.force:
|
||||
# Count orders that have at least one active (unpaid) item — covers both
|
||||
# "open" (fully unpaid) and "partially_paid" (partially unpaid) orders.
|
||||
with_pending = sum(
|
||||
1 for o in open_orders
|
||||
if db.query(OrderItem).filter(
|
||||
OrderItem.order_id == o.id,
|
||||
OrderItem.status == "active",
|
||||
).first() is not None
|
||||
)
|
||||
raise HTTPException(
|
||||
status_code=409,
|
||||
detail={
|
||||
"message": f"{len(open_orders)} table(s) still open, {with_pending} with unpaid items.",
|
||||
"open_orders": len(open_orders),
|
||||
"partially_paid": with_pending,
|
||||
},
|
||||
)
|
||||
|
||||
now = datetime.now(timezone.utc)
|
||||
|
||||
# Close all non-terminal orders for this business day (open, partially_paid, paid)
|
||||
all_unclosed = db.query(Order).filter(
|
||||
Order.business_day_id == day.id,
|
||||
Order.status.in_(["open", "partially_paid", "paid"]),
|
||||
).all()
|
||||
for order in all_unclosed:
|
||||
was_unpaid = order.status in ("open", "partially_paid")
|
||||
order.status = "closed"
|
||||
order.closed_at = now
|
||||
order.closed_by = user.id
|
||||
if was_unpaid:
|
||||
db.add(OrderAuditLog(
|
||||
order_id=order.id,
|
||||
event_type="ORDER_CLOSED",
|
||||
waiter_id=user.id,
|
||||
note="Force-closed at end of business day",
|
||||
))
|
||||
|
||||
active_shifts = db.query(WaiterShift).filter(
|
||||
WaiterShift.business_day_id == day.id,
|
||||
WaiterShift.ended_at == None,
|
||||
).all()
|
||||
for shift in active_shifts:
|
||||
items = db.query(OrderItem).filter(
|
||||
OrderItem.paid_in_shift_id == shift.id,
|
||||
OrderItem.status == "paid",
|
||||
).all()
|
||||
shift.total_collected = sum(i.unit_price * i.quantity for i in items)
|
||||
shift.ended_at = now
|
||||
|
||||
# Clear all table flags and staff messages — fresh slate for the next day
|
||||
db.query(TableFlagAssignment).delete(synchronize_session=False)
|
||||
db.query(StaffMessageAck).delete(synchronize_session=False)
|
||||
db.query(StaffMessage).delete(synchronize_session=False)
|
||||
|
||||
day.status = "closed"
|
||||
day.closed_at = now
|
||||
day.closed_by_id = user.id
|
||||
if body.notes:
|
||||
day.notes = body.notes
|
||||
|
||||
db.commit()
|
||||
db.refresh(day)
|
||||
return day
|
||||
|
||||
|
||||
@router.get("/history")
|
||||
def business_day_history(
|
||||
db: Session = Depends(get_db),
|
||||
user: User = Depends(require_manager),
|
||||
):
|
||||
days = db.query(BusinessDay).order_by(BusinessDay.opened_at.desc()).all()
|
||||
result = []
|
||||
for day in days:
|
||||
order_count = db.query(Order).filter(Order.business_day_id == day.id).count()
|
||||
revenue = (
|
||||
db.query(func.sum(OrderItem.unit_price * OrderItem.quantity))
|
||||
.join(Order)
|
||||
.filter(Order.business_day_id == day.id, OrderItem.status == "paid")
|
||||
.scalar() or 0.0
|
||||
)
|
||||
w_opener = day.opener
|
||||
w_closer = day.closer
|
||||
result.append({
|
||||
"id": day.id,
|
||||
"status": day.status,
|
||||
"opened_at": _dt(day.opened_at),
|
||||
"closed_at": _dt(day.closed_at),
|
||||
"opened_by_id": day.opened_by_id,
|
||||
"opened_by_name": (w_opener.full_name or w_opener.username) if w_opener else None,
|
||||
"closed_by_id": day.closed_by_id,
|
||||
"closed_by_name": (w_closer.full_name or w_closer.username) if w_closer else None,
|
||||
"notes": day.notes,
|
||||
"order_count": order_count,
|
||||
"revenue": round(revenue, 2),
|
||||
})
|
||||
return {"business_days": result}
|
||||
141
local_backend/routers/flags.py
Normal file
141
local_backend/routers/flags.py
Normal file
@@ -0,0 +1,141 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from sqlalchemy.orm import Session
|
||||
from typing import List
|
||||
|
||||
from database import get_db
|
||||
from models.flag import TableFlagDef, TableFlagAssignment
|
||||
from schemas.flag import FlagDefCreate, FlagDefUpdate, FlagDefOut, FlagAssignmentOut, SetTableFlagsRequest
|
||||
from routers.deps import get_current_user, require_manager
|
||||
from models.user import User
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
# ─── Flag definitions (manager only) ─────────────────────────────────────────
|
||||
|
||||
@router.get("/defs", response_model=List[FlagDefOut])
|
||||
def list_flag_defs(
|
||||
include_inactive: bool = False,
|
||||
db: Session = Depends(get_db),
|
||||
user: User = Depends(get_current_user),
|
||||
):
|
||||
q = db.query(TableFlagDef)
|
||||
if not include_inactive:
|
||||
q = q.filter(TableFlagDef.is_active == True)
|
||||
return q.order_by(TableFlagDef.sort_order, TableFlagDef.id).all()
|
||||
|
||||
|
||||
@router.post("/defs", response_model=FlagDefOut, status_code=status.HTTP_201_CREATED)
|
||||
def create_flag_def(
|
||||
body: FlagDefCreate,
|
||||
db: Session = Depends(get_db),
|
||||
user: User = Depends(require_manager),
|
||||
):
|
||||
flag = TableFlagDef(**body.model_dump())
|
||||
db.add(flag)
|
||||
db.commit()
|
||||
db.refresh(flag)
|
||||
return flag
|
||||
|
||||
|
||||
@router.put("/defs/{flag_id}", response_model=FlagDefOut)
|
||||
def update_flag_def(
|
||||
flag_id: int,
|
||||
body: FlagDefUpdate,
|
||||
db: Session = Depends(get_db),
|
||||
user: User = Depends(require_manager),
|
||||
):
|
||||
flag = db.query(TableFlagDef).filter(TableFlagDef.id == flag_id).first()
|
||||
if not flag:
|
||||
raise HTTPException(status_code=404, detail="Flag not found")
|
||||
for k, v in body.model_dump(exclude_unset=True).items():
|
||||
setattr(flag, k, v)
|
||||
db.commit()
|
||||
db.refresh(flag)
|
||||
return flag
|
||||
|
||||
|
||||
@router.delete("/defs/{flag_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||
def delete_flag_def(
|
||||
flag_id: int,
|
||||
db: Session = Depends(get_db),
|
||||
user: User = Depends(require_manager),
|
||||
):
|
||||
flag = db.query(TableFlagDef).filter(TableFlagDef.id == flag_id).first()
|
||||
if not flag:
|
||||
raise HTTPException(status_code=404, detail="Flag not found")
|
||||
# Soft delete — keeps existing assignments readable
|
||||
flag.is_active = False
|
||||
db.commit()
|
||||
|
||||
|
||||
# ─── All assignments (bulk endpoint for manager views) ───────────────────────
|
||||
|
||||
@router.get("/assignments", response_model=List[FlagAssignmentOut])
|
||||
def get_all_assignments(
|
||||
db: Session = Depends(get_db),
|
||||
user: User = Depends(get_current_user),
|
||||
):
|
||||
"""All active flag assignments across all tables (for manager dashboard bulk load)."""
|
||||
return db.query(TableFlagAssignment).all()
|
||||
|
||||
|
||||
# ─── Table flag assignments ───────────────────────────────────────────────────
|
||||
|
||||
@router.get("/table/{table_id}", response_model=List[FlagAssignmentOut])
|
||||
def get_table_flags(
|
||||
table_id: int,
|
||||
db: Session = Depends(get_db),
|
||||
user: User = Depends(get_current_user),
|
||||
):
|
||||
return db.query(TableFlagAssignment).filter(
|
||||
TableFlagAssignment.table_id == table_id
|
||||
).all()
|
||||
|
||||
|
||||
@router.put("/table/{table_id}", response_model=List[FlagAssignmentOut])
|
||||
def set_table_flags(
|
||||
table_id: int,
|
||||
body: SetTableFlagsRequest,
|
||||
db: Session = Depends(get_db),
|
||||
user: User = Depends(get_current_user),
|
||||
):
|
||||
"""Replace all flags on a table with the given set of flag_ids."""
|
||||
# Validate all flag_ids exist and are active
|
||||
if body.flag_ids:
|
||||
valid = db.query(TableFlagDef).filter(
|
||||
TableFlagDef.id.in_(body.flag_ids),
|
||||
TableFlagDef.is_active == True,
|
||||
).count()
|
||||
if valid != len(body.flag_ids):
|
||||
raise HTTPException(status_code=400, detail="One or more flag IDs are invalid")
|
||||
|
||||
# Delete existing assignments for this table
|
||||
db.query(TableFlagAssignment).filter(
|
||||
TableFlagAssignment.table_id == table_id
|
||||
).delete(synchronize_session=False)
|
||||
|
||||
# Insert new assignments
|
||||
for flag_id in body.flag_ids:
|
||||
db.add(TableFlagAssignment(
|
||||
table_id=table_id,
|
||||
flag_id=flag_id,
|
||||
assigned_by=user.id,
|
||||
))
|
||||
|
||||
db.commit()
|
||||
return db.query(TableFlagAssignment).filter(
|
||||
TableFlagAssignment.table_id == table_id
|
||||
).all()
|
||||
|
||||
|
||||
@router.delete("/table/{table_id}/all", status_code=status.HTTP_204_NO_CONTENT)
|
||||
def clear_table_flags(
|
||||
table_id: int,
|
||||
db: Session = Depends(get_db),
|
||||
user: User = Depends(get_current_user),
|
||||
):
|
||||
db.query(TableFlagAssignment).filter(
|
||||
TableFlagAssignment.table_id == table_id
|
||||
).delete(synchronize_session=False)
|
||||
db.commit()
|
||||
199
local_backend/routers/messages.py
Normal file
199
local_backend/routers/messages.py
Normal file
@@ -0,0 +1,199 @@
|
||||
import json
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from sqlalchemy.orm import Session, joinedload
|
||||
from typing import List
|
||||
|
||||
from database import get_db
|
||||
from models.message import StaffMessage, StaffMessageAck, QuickMessageTemplate
|
||||
from models.user import User
|
||||
from schemas.message import (
|
||||
SendMessageRequest, StaffMessageOut,
|
||||
QuickTemplateCreate, QuickTemplateUpdate, QuickTemplateOut,
|
||||
)
|
||||
from routers.deps import get_current_user, require_manager
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
def _load_msg(db: Session, msg_id: int) -> StaffMessage:
|
||||
"""Reload a message with sender and acks eagerly loaded."""
|
||||
return db.query(StaffMessage).options(
|
||||
joinedload(StaffMessage.sender),
|
||||
joinedload(StaffMessage.acks),
|
||||
).filter(StaffMessage.id == msg_id).one()
|
||||
|
||||
|
||||
def _message_out(msg: StaffMessage) -> StaffMessageOut:
|
||||
sender_name = None
|
||||
try:
|
||||
sender_name = msg.sender.username if msg.sender else None
|
||||
except Exception:
|
||||
pass
|
||||
return StaffMessageOut(
|
||||
id=msg.id,
|
||||
sender_id=msg.sender_id,
|
||||
sender_name=sender_name,
|
||||
body=msg.body,
|
||||
target_waiter_ids=msg.target_waiter_ids,
|
||||
table_ids=msg.table_ids,
|
||||
created_at=msg.created_at,
|
||||
acked_by=[ack.waiter_id for ack in msg.acks],
|
||||
)
|
||||
|
||||
|
||||
# ─── Quick templates ──────────────────────────────────────────────────────────
|
||||
|
||||
@router.get("/templates", response_model=List[QuickTemplateOut])
|
||||
def list_templates(
|
||||
db: Session = Depends(get_db),
|
||||
user: User = Depends(get_current_user),
|
||||
):
|
||||
return db.query(QuickMessageTemplate).filter(
|
||||
QuickMessageTemplate.is_active == True
|
||||
).order_by(QuickMessageTemplate.sort_order, QuickMessageTemplate.id).all()
|
||||
|
||||
|
||||
@router.post("/templates", response_model=QuickTemplateOut, status_code=status.HTTP_201_CREATED)
|
||||
def create_template(
|
||||
body: QuickTemplateCreate,
|
||||
db: Session = Depends(get_db),
|
||||
user: User = Depends(require_manager),
|
||||
):
|
||||
t = QuickMessageTemplate(**body.model_dump())
|
||||
db.add(t)
|
||||
db.commit()
|
||||
db.refresh(t)
|
||||
return t
|
||||
|
||||
|
||||
@router.put("/templates/{template_id}", response_model=QuickTemplateOut)
|
||||
def update_template(
|
||||
template_id: int,
|
||||
body: QuickTemplateUpdate,
|
||||
db: Session = Depends(get_db),
|
||||
user: User = Depends(require_manager),
|
||||
):
|
||||
t = db.query(QuickMessageTemplate).filter(QuickMessageTemplate.id == template_id).first()
|
||||
if not t:
|
||||
raise HTTPException(status_code=404, detail="Template not found")
|
||||
for k, v in body.model_dump(exclude_unset=True).items():
|
||||
setattr(t, k, v)
|
||||
db.commit()
|
||||
db.refresh(t)
|
||||
return t
|
||||
|
||||
|
||||
@router.delete("/templates/{template_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||
def delete_template(
|
||||
template_id: int,
|
||||
db: Session = Depends(get_db),
|
||||
user: User = Depends(require_manager),
|
||||
):
|
||||
t = db.query(QuickMessageTemplate).filter(QuickMessageTemplate.id == template_id).first()
|
||||
if not t:
|
||||
raise HTTPException(status_code=404, detail="Template not found")
|
||||
t.is_active = False
|
||||
db.commit()
|
||||
|
||||
|
||||
# ─── Staff messages ───────────────────────────────────────────────────────────
|
||||
|
||||
@router.post("/send", response_model=StaffMessageOut, status_code=status.HTTP_201_CREATED)
|
||||
def send_message(
|
||||
body: SendMessageRequest,
|
||||
db: Session = Depends(get_db),
|
||||
user: User = Depends(require_manager),
|
||||
):
|
||||
msg = StaffMessage(
|
||||
sender_id=user.id,
|
||||
body=body.body,
|
||||
target_waiter_ids=json.dumps(body.target_waiter_ids),
|
||||
table_ids=json.dumps(body.table_ids or []),
|
||||
)
|
||||
db.add(msg)
|
||||
db.commit()
|
||||
msg = _load_msg(db, msg.id)
|
||||
return _message_out(msg)
|
||||
|
||||
|
||||
@router.get("/unread", response_model=List[StaffMessageOut])
|
||||
def get_unread_messages(
|
||||
db: Session = Depends(get_db),
|
||||
user: User = Depends(get_current_user),
|
||||
):
|
||||
"""
|
||||
Returns messages targeting this waiter that they haven't acked yet.
|
||||
A message targets a waiter if their ID is in target_waiter_ids,
|
||||
OR if target_waiter_ids is empty (broadcast to all).
|
||||
"""
|
||||
all_msgs = db.query(StaffMessage).options(
|
||||
joinedload(StaffMessage.sender),
|
||||
joinedload(StaffMessage.acks),
|
||||
).order_by(StaffMessage.created_at.desc()).limit(200).all()
|
||||
acked_ids = {
|
||||
ack.message_id
|
||||
for ack in db.query(StaffMessageAck).filter(StaffMessageAck.waiter_id == user.id).all()
|
||||
}
|
||||
|
||||
result = []
|
||||
for msg in all_msgs:
|
||||
if msg.id in acked_ids:
|
||||
continue
|
||||
targets = json.loads(msg.target_waiter_ids or "[]")
|
||||
# Empty list = broadcast to all
|
||||
if not targets or user.id in targets:
|
||||
result.append(_message_out(msg))
|
||||
|
||||
return result
|
||||
|
||||
|
||||
@router.get("/recent", response_model=List[StaffMessageOut])
|
||||
def get_recent_messages(
|
||||
limit: int = 10,
|
||||
db: Session = Depends(get_db),
|
||||
user: User = Depends(get_current_user),
|
||||
):
|
||||
"""Last N messages targeting this user (for notification history drawer)."""
|
||||
all_msgs = db.query(StaffMessage).options(
|
||||
joinedload(StaffMessage.sender),
|
||||
joinedload(StaffMessage.acks),
|
||||
).order_by(StaffMessage.created_at.desc()).limit(200).all()
|
||||
result = []
|
||||
for msg in all_msgs:
|
||||
targets = json.loads(msg.target_waiter_ids or "[]")
|
||||
if not targets or user.id in targets:
|
||||
result.append(_message_out(msg))
|
||||
if len(result) >= limit:
|
||||
break
|
||||
return result
|
||||
|
||||
|
||||
@router.post("/{message_id}/ack", status_code=status.HTTP_204_NO_CONTENT)
|
||||
def ack_message(
|
||||
message_id: int,
|
||||
db: Session = Depends(get_db),
|
||||
user: User = Depends(get_current_user),
|
||||
):
|
||||
msg = db.query(StaffMessage).filter(StaffMessage.id == message_id).first()
|
||||
if not msg:
|
||||
raise HTTPException(status_code=404, detail="Message not found")
|
||||
existing = db.query(StaffMessageAck).filter(
|
||||
StaffMessageAck.message_id == message_id,
|
||||
StaffMessageAck.waiter_id == user.id,
|
||||
).first()
|
||||
if not existing:
|
||||
db.add(StaffMessageAck(message_id=message_id, waiter_id=user.id))
|
||||
db.commit()
|
||||
|
||||
|
||||
@router.get("/all", response_model=List[StaffMessageOut])
|
||||
def list_all_messages(
|
||||
limit: int = 50,
|
||||
db: Session = Depends(get_db),
|
||||
user: User = Depends(require_manager),
|
||||
):
|
||||
msgs = db.query(StaffMessage).options(
|
||||
joinedload(StaffMessage.sender),
|
||||
joinedload(StaffMessage.acks),
|
||||
).order_by(StaffMessage.created_at.desc()).limit(limit).all()
|
||||
return [_message_out(m) for m in msgs]
|
||||
@@ -1,5 +1,5 @@
|
||||
import json
|
||||
from datetime import datetime
|
||||
from datetime import datetime, timezone
|
||||
from fastapi import APIRouter, Depends, HTTPException, status, BackgroundTasks
|
||||
from sqlalchemy.orm import Session
|
||||
from typing import List, Optional
|
||||
@@ -14,8 +14,25 @@ from pydantic import BaseModel
|
||||
|
||||
class PrintOrderRequest(BaseModel):
|
||||
printer_id: int
|
||||
|
||||
class TransferOrderRequest(BaseModel):
|
||||
target_table_id: int
|
||||
|
||||
class MergeOrderRequest(BaseModel):
|
||||
target_order_id: int
|
||||
|
||||
class SplitItemRequest(BaseModel):
|
||||
quantity: int # how many to split off into a new item row
|
||||
|
||||
class PrintSynopsisRequest(BaseModel):
|
||||
printer_id: int
|
||||
|
||||
class MoveItemsRequest(BaseModel):
|
||||
item_ids: List[int]
|
||||
target_order_id: int
|
||||
|
||||
from routers.deps import get_current_user, require_manager
|
||||
from services.printer_service import route_and_print, route_and_print_sync, print_order_receipt
|
||||
from services.printer_service import route_and_print, route_and_print_sync, print_order_receipt, print_order_synopsis
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
@@ -79,6 +96,29 @@ def my_orders(db: Session = Depends(get_db), user: User = Depends(get_current_us
|
||||
return direct + [o for o in also_opened if o.id not in seen]
|
||||
|
||||
|
||||
class ActiveOrderSlim(BaseModel):
|
||||
id: int
|
||||
table_id: int
|
||||
status: str
|
||||
waiter_ids: List[int]
|
||||
model_config = {"from_attributes": True}
|
||||
|
||||
|
||||
@router.get("/active", response_model=List[ActiveOrderSlim])
|
||||
def list_active_orders(db: Session = Depends(get_db), user: User = Depends(get_current_user)):
|
||||
"""All currently open/partially-paid/paid orders (lightweight). Accessible to all staff."""
|
||||
orders = db.query(Order).filter(Order.status.in_(["open", "partially_paid", "paid"])).all()
|
||||
return [
|
||||
ActiveOrderSlim(
|
||||
id=o.id,
|
||||
table_id=o.table_id,
|
||||
status=o.status,
|
||||
waiter_ids=[w.waiter_id for w in o.waiters],
|
||||
)
|
||||
for o in orders
|
||||
]
|
||||
|
||||
|
||||
@router.get("/{order_id}", response_model=OrderOut)
|
||||
def get_order(order_id: int, db: Session = Depends(get_db), user: User = Depends(get_current_user)):
|
||||
order = db.query(Order).filter(Order.id == order_id).first()
|
||||
@@ -91,13 +131,28 @@ def get_order(order_id: int, db: Session = Depends(get_db), user: User = Depends
|
||||
|
||||
@router.post("/", response_model=OrderOut, status_code=status.HTTP_201_CREATED)
|
||||
def open_order(body: OrderCreate, db: Session = Depends(get_db), user: User = Depends(get_current_user)):
|
||||
from models.business_day import BusinessDay
|
||||
from models.shift import WaiterShift
|
||||
|
||||
active_day = db.query(BusinessDay).filter(BusinessDay.status == "open").first()
|
||||
if not active_day:
|
||||
raise HTTPException(status_code=403, detail="Restaurant is not open — manager must open the business day first")
|
||||
|
||||
if user.role == "waiter":
|
||||
active_shift = db.query(WaiterShift).filter(
|
||||
WaiterShift.waiter_id == user.id,
|
||||
WaiterShift.ended_at == None,
|
||||
).first()
|
||||
if not active_shift:
|
||||
raise HTTPException(status_code=403, detail="You do not have an active shift")
|
||||
|
||||
existing = db.query(Order).filter(
|
||||
Order.table_id == body.table_id,
|
||||
Order.status.in_(["open", "partially_paid"]),
|
||||
Order.status.in_(["open", "partially_paid", "paid"]),
|
||||
).first()
|
||||
if existing:
|
||||
raise HTTPException(status_code=400, detail="Table already has an open order")
|
||||
order = Order(table_id=body.table_id, opened_by=user.id)
|
||||
order = Order(table_id=body.table_id, opened_by=user.id, business_day_id=active_day.id)
|
||||
db.add(order)
|
||||
db.flush()
|
||||
db.add(OrderWaiter(order_id=order.id, waiter_id=user.id))
|
||||
@@ -119,9 +174,13 @@ def add_items(
|
||||
raise HTTPException(status_code=404, detail="Order not found")
|
||||
if not _can_access_order(order, user, db):
|
||||
raise HTTPException(status_code=403, detail="Access denied")
|
||||
if order.status not in ("open", "partially_paid"):
|
||||
if order.status not in ("open", "partially_paid", "paid"):
|
||||
raise HTTPException(status_code=400, detail="Order is not open")
|
||||
|
||||
# Adding items to a fully-paid order reopens it — partially_paid since prior items were paid
|
||||
if order.status == "paid":
|
||||
order.status = "partially_paid"
|
||||
|
||||
new_item_ids = []
|
||||
for item_in in body.items:
|
||||
product = db.query(Product).filter(Product.id == item_in.product_id).first()
|
||||
@@ -154,6 +213,27 @@ def add_items(
|
||||
return {"order": order, "print_results": print_results}
|
||||
|
||||
|
||||
@router.post("/{order_id}/retry-print", response_model=AddItemsResponse)
|
||||
def retry_print(
|
||||
order_id: int,
|
||||
db: Session = Depends(get_db),
|
||||
user: User = Depends(get_current_user),
|
||||
):
|
||||
order = db.query(Order).filter(Order.id == order_id).first()
|
||||
if not order:
|
||||
raise HTTPException(status_code=404, detail="Order not found")
|
||||
if not _can_access_order(order, user, db):
|
||||
raise HTTPException(status_code=403, detail="Access denied")
|
||||
|
||||
unprinted_ids = [item.id for item in order.items if not item.printed and item.status == "active"]
|
||||
if not unprinted_ids:
|
||||
return {"order": order, "print_results": []}
|
||||
|
||||
print_results = route_and_print_sync(order_id, unprinted_ids, db)
|
||||
db.refresh(order)
|
||||
return {"order": order, "print_results": print_results}
|
||||
|
||||
|
||||
@router.put("/{order_id}/items/{item_id}", response_model=OrderItemOut)
|
||||
def edit_item(order_id: int, item_id: int, notes: Optional[str] = None, db: Session = Depends(get_db), user: User = Depends(require_manager)):
|
||||
item = db.query(OrderItem).filter(OrderItem.id == item_id, OrderItem.order_id == order_id).first()
|
||||
@@ -184,20 +264,28 @@ def pay_items(order_id: int, body: PayItemsRequest, db: Session = Depends(get_db
|
||||
if not _can_access_order(order, user, db):
|
||||
raise HTTPException(status_code=403, detail="Access denied")
|
||||
|
||||
from models.shift import WaiterShift
|
||||
|
||||
items = db.query(OrderItem).filter(
|
||||
OrderItem.id.in_(body.item_ids),
|
||||
OrderItem.order_id == order_id,
|
||||
OrderItem.status == "active",
|
||||
).all()
|
||||
now = datetime.utcnow()
|
||||
now = datetime.now(timezone.utc)
|
||||
active_shift = db.query(WaiterShift).filter(
|
||||
WaiterShift.waiter_id == user.id,
|
||||
WaiterShift.ended_at == None,
|
||||
).first()
|
||||
total_paid = 0.0
|
||||
for item in items:
|
||||
item.status = "paid"
|
||||
item.paid_by = user.id
|
||||
item.paid_at = now
|
||||
item.payment_method = body.payment_method
|
||||
item.paid_in_shift_id = active_shift.id if active_shift else None
|
||||
total_paid += item.unit_price * item.quantity
|
||||
|
||||
db.flush() # write item status changes before counting, since autoflush=False
|
||||
active_remaining = db.query(OrderItem).filter(
|
||||
OrderItem.order_id == order_id, OrderItem.status == "active"
|
||||
).count()
|
||||
@@ -220,7 +308,7 @@ def close_order(order_id: int, db: Session = Depends(get_db), user: User = Depen
|
||||
if order.status not in ("paid", "open", "partially_paid"):
|
||||
raise HTTPException(status_code=400, detail="Cannot close order in current status")
|
||||
order.status = "closed"
|
||||
order.closed_at = datetime.utcnow()
|
||||
order.closed_at = datetime.now(timezone.utc)
|
||||
order.closed_by = user.id
|
||||
_audit(db, order_id, "ORDER_CLOSED", waiter_id=user.id)
|
||||
db.commit()
|
||||
@@ -233,14 +321,14 @@ def cancel_order(order_id: int, db: Session = Depends(get_db), user: User = Depe
|
||||
if not order:
|
||||
raise HTTPException(status_code=404, detail="Order not found")
|
||||
order.status = "cancelled"
|
||||
order.closed_at = datetime.utcnow()
|
||||
order.closed_at = datetime.now(timezone.utc)
|
||||
order.closed_by = user.id
|
||||
_audit(db, order_id, "ORDER_CANCELLED", waiter_id=user.id)
|
||||
db.commit()
|
||||
|
||||
|
||||
@router.put("/{order_id}/assign-waiter")
|
||||
def assign_waiter(order_id: int, body: AssignWaiterRequest, db: Session = Depends(get_db), user: User = Depends(require_manager)):
|
||||
def assign_waiter(order_id: int, body: AssignWaiterRequest, db: Session = Depends(get_db), user: User = Depends(get_current_user)):
|
||||
order = db.query(Order).filter(Order.id == order_id).first()
|
||||
if not order:
|
||||
raise HTTPException(status_code=404, detail="Order not found")
|
||||
@@ -318,3 +406,295 @@ def print_order(
|
||||
|
||||
background_tasks.add_task(print_order_receipt, printer.ip_address, printer.port, receipt)
|
||||
return {"status": "printing"}
|
||||
|
||||
|
||||
# ─── Transfer order to a different table ─────────────────────────────────────
|
||||
|
||||
@router.post("/{order_id}/transfer")
|
||||
def transfer_order(
|
||||
order_id: int,
|
||||
body: TransferOrderRequest,
|
||||
db: Session = Depends(get_db),
|
||||
user: User = Depends(get_current_user),
|
||||
):
|
||||
order = db.query(Order).filter(Order.id == order_id).first()
|
||||
if not order:
|
||||
raise HTTPException(status_code=404, detail="Order not found")
|
||||
if not _can_access_order(order, user, db):
|
||||
raise HTTPException(status_code=403, detail="Access denied")
|
||||
if order.status not in ("open", "partially_paid", "paid"):
|
||||
raise HTTPException(status_code=400, detail="Order is not active")
|
||||
|
||||
target_table = db.query(Table).filter(Table.id == body.target_table_id, Table.is_active == True).first()
|
||||
if not target_table:
|
||||
raise HTTPException(status_code=404, detail="Target table not found")
|
||||
if body.target_table_id == order.table_id:
|
||||
raise HTTPException(status_code=400, detail="Table is already assigned to this order")
|
||||
|
||||
conflict = db.query(Order).filter(
|
||||
Order.table_id == body.target_table_id,
|
||||
Order.status.in_(["open", "partially_paid", "paid"]),
|
||||
).first()
|
||||
if conflict:
|
||||
raise HTTPException(status_code=400, detail="Target table already has an active order")
|
||||
|
||||
old_table_id = order.table_id
|
||||
order.table_id = body.target_table_id
|
||||
_audit(db, order_id, "TABLE_TRANSFER", waiter_id=user.id,
|
||||
note=f"Transferred from table {old_table_id} to table {body.target_table_id}")
|
||||
db.commit()
|
||||
db.refresh(order)
|
||||
return order
|
||||
|
||||
|
||||
# ─── Merge another order into this one ───────────────────────────────────────
|
||||
|
||||
@router.post("/{order_id}/merge")
|
||||
def merge_order(
|
||||
order_id: int,
|
||||
body: MergeOrderRequest,
|
||||
db: Session = Depends(get_db),
|
||||
user: User = Depends(get_current_user),
|
||||
):
|
||||
"""
|
||||
Merge source order (order_id) INTO target order (body.target_order_id).
|
||||
All items (paid + active) from the source are reassigned to the target.
|
||||
Source waiters are added to the target if not already there.
|
||||
Source order is cancelled with audit note.
|
||||
"""
|
||||
source = db.query(Order).filter(Order.id == order_id).first()
|
||||
if not source:
|
||||
raise HTTPException(status_code=404, detail="Source order not found")
|
||||
if not _can_access_order(source, user, db):
|
||||
raise HTTPException(status_code=403, detail="Access denied")
|
||||
if source.status not in ("open", "partially_paid", "paid"):
|
||||
raise HTTPException(status_code=400, detail="Source order is not active")
|
||||
|
||||
target = db.query(Order).filter(Order.id == body.target_order_id).first()
|
||||
if not target:
|
||||
raise HTTPException(status_code=404, detail="Target order not found")
|
||||
if not _can_access_order(target, user, db):
|
||||
raise HTTPException(status_code=403, detail="Access denied to target order")
|
||||
if target.status not in ("open", "partially_paid", "paid"):
|
||||
raise HTTPException(status_code=400, detail="Target order is not active")
|
||||
if source.id == target.id:
|
||||
raise HTTPException(status_code=400, detail="Cannot merge an order with itself")
|
||||
|
||||
# Move all items to target order
|
||||
moved_item_ids = []
|
||||
for item in source.items:
|
||||
item.order_id = target.id
|
||||
moved_item_ids.append(item.id)
|
||||
|
||||
# Copy source waiters to target (no duplicates)
|
||||
existing_waiter_ids = {w.waiter_id for w in target.waiters}
|
||||
for ow in source.waiters:
|
||||
if ow.waiter_id not in existing_waiter_ids:
|
||||
db.add(OrderWaiter(order_id=target.id, waiter_id=ow.waiter_id))
|
||||
|
||||
# Recompute target status after flush
|
||||
db.flush()
|
||||
active_remaining = db.query(OrderItem).filter(
|
||||
OrderItem.order_id == target.id, OrderItem.status == "active"
|
||||
).count()
|
||||
paid_exists = db.query(OrderItem).filter(
|
||||
OrderItem.order_id == target.id, OrderItem.status == "paid"
|
||||
).count()
|
||||
if active_remaining > 0:
|
||||
target.status = "partially_paid" if paid_exists > 0 else "open"
|
||||
else:
|
||||
target.status = "paid"
|
||||
|
||||
# Cancel source order
|
||||
source.status = "cancelled"
|
||||
source.closed_at = datetime.now(timezone.utc)
|
||||
source.closed_by = user.id
|
||||
|
||||
_audit(db, source.id, "ORDER_CANCELLED", waiter_id=user.id,
|
||||
note=f"Merged into order #{target.id} (table {target.table_id})")
|
||||
_audit(db, target.id, "ITEMS_ADDED", waiter_id=user.id, item_ids=moved_item_ids,
|
||||
note=f"Items merged from order #{source.id} (table {source.table_id})")
|
||||
|
||||
db.commit()
|
||||
db.refresh(target)
|
||||
return target
|
||||
|
||||
|
||||
# ─── Split a stacked item into two rows ──────────────────────────────────────
|
||||
|
||||
@router.post("/{order_id}/items/{item_id}/split", response_model=List[OrderItemOut])
|
||||
def split_item(
|
||||
order_id: int,
|
||||
item_id: int,
|
||||
body: SplitItemRequest,
|
||||
db: Session = Depends(get_db),
|
||||
user: User = Depends(get_current_user),
|
||||
):
|
||||
"""
|
||||
Split qty units off item_id into a new item row.
|
||||
Both rows share all properties (product, price, options, notes).
|
||||
Only active items can be split.
|
||||
"""
|
||||
item = db.query(OrderItem).filter(
|
||||
OrderItem.id == item_id, OrderItem.order_id == order_id
|
||||
).first()
|
||||
if not item:
|
||||
raise HTTPException(status_code=404, detail="Item not found")
|
||||
if item.status != "active":
|
||||
raise HTTPException(status_code=400, detail="Only active items can be split")
|
||||
if body.quantity <= 0 or body.quantity >= item.quantity:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"Split quantity must be between 1 and {item.quantity - 1}"
|
||||
)
|
||||
|
||||
order = db.query(Order).filter(Order.id == order_id).first()
|
||||
if not _can_access_order(order, user, db):
|
||||
raise HTTPException(status_code=403, detail="Access denied")
|
||||
|
||||
# Reduce original item
|
||||
item.quantity -= body.quantity
|
||||
|
||||
# Create split-off item
|
||||
new_item = OrderItem(
|
||||
order_id=order_id,
|
||||
product_id=item.product_id,
|
||||
added_by=item.added_by,
|
||||
quantity=body.quantity,
|
||||
unit_price=item.unit_price,
|
||||
selected_options=item.selected_options,
|
||||
removed_ingredients=item.removed_ingredients,
|
||||
notes=item.notes,
|
||||
status="active",
|
||||
printed=item.printed,
|
||||
)
|
||||
db.add(new_item)
|
||||
db.commit()
|
||||
db.refresh(item)
|
||||
db.refresh(new_item)
|
||||
return [item, new_item]
|
||||
|
||||
|
||||
# ─── Move selected items to another order ────────────────────────────────────
|
||||
|
||||
@router.post("/{order_id}/move-items")
|
||||
def move_items(
|
||||
order_id: int,
|
||||
body: MoveItemsRequest,
|
||||
db: Session = Depends(get_db),
|
||||
user: User = Depends(get_current_user),
|
||||
):
|
||||
"""Move specific active items from this order to another open order."""
|
||||
source = db.query(Order).filter(Order.id == order_id).first()
|
||||
if not source:
|
||||
raise HTTPException(status_code=404, detail="Source order not found")
|
||||
if not _can_access_order(source, user, db):
|
||||
raise HTTPException(status_code=403, detail="Access denied")
|
||||
if source.status not in ("open", "partially_paid"):
|
||||
raise HTTPException(status_code=400, detail="Source order is not active")
|
||||
|
||||
target = db.query(Order).filter(Order.id == body.target_order_id).first()
|
||||
if not target:
|
||||
raise HTTPException(status_code=404, detail="Target order not found")
|
||||
if not _can_access_order(target, user, db):
|
||||
raise HTTPException(status_code=403, detail="Access denied to target order")
|
||||
if target.status not in ("open", "partially_paid"):
|
||||
raise HTTPException(status_code=400, detail="Target order is not active")
|
||||
if source.id == target.id:
|
||||
raise HTTPException(status_code=400, detail="Source and target orders are the same")
|
||||
|
||||
items = db.query(OrderItem).filter(
|
||||
OrderItem.id.in_(body.item_ids),
|
||||
OrderItem.order_id == order_id,
|
||||
OrderItem.status == "active",
|
||||
).all()
|
||||
if not items:
|
||||
raise HTTPException(status_code=400, detail="No active items found to move")
|
||||
|
||||
moved_ids = []
|
||||
for item in items:
|
||||
item.order_id = target.id
|
||||
moved_ids.append(item.id)
|
||||
|
||||
# Recompute source status
|
||||
db.flush()
|
||||
src_active = db.query(OrderItem).filter(OrderItem.order_id == source.id, OrderItem.status == "active").count()
|
||||
src_paid = db.query(OrderItem).filter(OrderItem.order_id == source.id, OrderItem.status == "paid").count()
|
||||
if src_active == 0 and src_paid == 0:
|
||||
source.status = "open"
|
||||
elif src_active == 0:
|
||||
source.status = "paid"
|
||||
else:
|
||||
source.status = "partially_paid" if src_paid > 0 else "open"
|
||||
|
||||
# Recompute target status
|
||||
tgt_active = db.query(OrderItem).filter(OrderItem.order_id == target.id, OrderItem.status == "active").count()
|
||||
tgt_paid = db.query(OrderItem).filter(OrderItem.order_id == target.id, OrderItem.status == "paid").count()
|
||||
target.status = "partially_paid" if (tgt_active > 0 and tgt_paid > 0) else ("paid" if tgt_active == 0 else "open")
|
||||
|
||||
_audit(db, source.id, "ITEMS_MOVED_OUT", waiter_id=user.id, item_ids=moved_ids,
|
||||
note=f"Moved to order #{target.id} (table {target.table_id})")
|
||||
_audit(db, target.id, "ITEMS_MOVED_IN", waiter_id=user.id, item_ids=moved_ids,
|
||||
note=f"Moved from order #{source.id} (table {source.table_id})")
|
||||
|
||||
db.commit()
|
||||
db.refresh(source)
|
||||
return {"moved_item_ids": moved_ids, "source_status": source.status, "target_status": target.status}
|
||||
|
||||
|
||||
# ─── Print order synopsis ─────────────────────────────────────────────────────
|
||||
|
||||
@router.post("/{order_id}/print-synopsis")
|
||||
def print_synopsis(
|
||||
order_id: int,
|
||||
body: PrintSynopsisRequest,
|
||||
background_tasks: BackgroundTasks,
|
||||
db: Session = Depends(get_db),
|
||||
user: User = Depends(get_current_user),
|
||||
):
|
||||
from models.printer import Printer
|
||||
|
||||
order = db.query(Order).filter(Order.id == order_id).first()
|
||||
if not order:
|
||||
raise HTTPException(status_code=404, detail="Order not found")
|
||||
if not _can_access_order(order, user, db):
|
||||
raise HTTPException(status_code=403, detail="Access denied")
|
||||
|
||||
printer = db.query(Printer).filter(Printer.id == body.printer_id, Printer.is_active == True).first()
|
||||
if not printer:
|
||||
raise HTTPException(status_code=404, detail="Printer not found or inactive")
|
||||
|
||||
table = db.query(Table).filter(Table.id == order.table_id).first()
|
||||
table_name = (table.label or f"T{table.number}") if table else f"#{order.table_id}"
|
||||
opener = db.query(User).filter(User.id == order.opened_by).first()
|
||||
waiter_name = (opener.nickname or opener.username) if opener else f"#{order.opened_by}"
|
||||
|
||||
items_data = []
|
||||
for item in order.items:
|
||||
if item.status == "cancelled":
|
||||
continue
|
||||
product_name = item.product.name if item.product else f"#{item.product_id}"
|
||||
items_data.append({
|
||||
"name": product_name,
|
||||
"quantity": item.quantity,
|
||||
"unit_price": item.unit_price,
|
||||
"total": item.unit_price * item.quantity,
|
||||
"status": item.status,
|
||||
})
|
||||
|
||||
total = sum(i["total"] for i in items_data)
|
||||
paid_total = sum(i["total"] for i in items_data if i["status"] == "paid")
|
||||
|
||||
synopsis = {
|
||||
"order_id": order.id,
|
||||
"table_name": table_name,
|
||||
"waiter_name": waiter_name,
|
||||
"opened_at": order.opened_at.strftime("%d/%m/%Y %H:%M"),
|
||||
"items": items_data,
|
||||
"total": total,
|
||||
"paid_total": paid_total,
|
||||
"remaining": total - paid_total,
|
||||
}
|
||||
|
||||
background_tasks.add_task(print_order_synopsis, printer.ip_address, printer.port, synopsis)
|
||||
return {"status": "printing"}
|
||||
|
||||
@@ -6,13 +6,14 @@ from sqlalchemy.orm import Session
|
||||
from typing import List
|
||||
|
||||
from database import get_db
|
||||
from models.product import Product, Category, ProductOption, ProductIngredient, ProductPreferenceSet, ProductPreferenceChoice
|
||||
from models.product import Product, Category, ProductOption, ProductQuickOption, ProductIngredient, ProductPreferenceSet, ProductPreferenceChoice
|
||||
from models.order import OrderItem
|
||||
from models.user import User
|
||||
from schemas.product import (
|
||||
ProductCreate, ProductUpdate, ProductOut, ProductReorderItem,
|
||||
CategoryCreate, CategoryUpdate, CategoryOut, CategoryReorderItem,
|
||||
PreferenceSetCreate,
|
||||
SubcategoryReorderItem, ParentGeneralReorderItem,
|
||||
PreferenceSetCreate, ProductQuickOptionCreate,
|
||||
)
|
||||
from routers.deps import get_current_user, require_manager
|
||||
|
||||
@@ -21,6 +22,22 @@ router = APIRouter()
|
||||
IMAGE_DIR = "/app/data/product_images"
|
||||
|
||||
|
||||
def _replace_quick_options(db, product, quick_options):
|
||||
for qo in product.quick_options:
|
||||
db.delete(qo)
|
||||
db.flush()
|
||||
for i, qo in enumerate(quick_options):
|
||||
db.add(ProductQuickOption(
|
||||
product_id=product.id,
|
||||
name=qo.name,
|
||||
price=qo.price,
|
||||
allow_multiple=qo.allow_multiple,
|
||||
sort_order=qo.sort_order if qo.sort_order else i,
|
||||
is_favorite=qo.is_favorite,
|
||||
favorite_sort_order=qo.favorite_sort_order,
|
||||
))
|
||||
|
||||
|
||||
def _replace_options(db, product, options):
|
||||
for opt in product.options:
|
||||
db.delete(opt)
|
||||
@@ -31,7 +48,10 @@ def _replace_options(db, product, options):
|
||||
product_id=product.id,
|
||||
name=opt.name,
|
||||
extra_cost=opt.extra_cost,
|
||||
allow_multiple=opt.allow_multiple,
|
||||
sub_choices=sub_json,
|
||||
is_favorite=opt.is_favorite,
|
||||
favorite_sort_order=opt.favorite_sort_order,
|
||||
))
|
||||
|
||||
|
||||
@@ -53,6 +73,8 @@ def _replace_preference_sets(db, product, sets: List[PreferenceSetCreate]):
|
||||
product_id=product.id,
|
||||
name=ps.name,
|
||||
shared_subset=shared_json,
|
||||
is_favorite=ps.is_favorite,
|
||||
favorite_sort_order=ps.favorite_sort_order,
|
||||
)
|
||||
db.add(new_set)
|
||||
db.flush()
|
||||
@@ -82,8 +104,15 @@ def list_categories(db: Session = Depends(get_db), user: User = Depends(get_curr
|
||||
|
||||
@router.post("/categories", response_model=CategoryOut, status_code=status.HTTP_201_CREATED)
|
||||
def create_category(body: CategoryCreate, db: Session = Depends(get_db), user: User = Depends(require_manager)):
|
||||
max_order = db.query(Category).count()
|
||||
cat = Category(name=body.name, color=body.color, sort_order=max_order)
|
||||
# sort_order is among siblings (same parent_id level)
|
||||
sibling_count = db.query(Category).filter(Category.parent_id == body.parent_id).count()
|
||||
cat = Category(
|
||||
name=body.name,
|
||||
color=body.color,
|
||||
sort_order=sibling_count,
|
||||
parent_id=body.parent_id,
|
||||
general_sort_order=body.general_sort_order,
|
||||
)
|
||||
db.add(cat)
|
||||
db.commit()
|
||||
db.refresh(cat)
|
||||
@@ -99,6 +128,26 @@ def reorder_categories(items: List[CategoryReorderItem], db: Session = Depends(g
|
||||
db.commit()
|
||||
|
||||
|
||||
@router.put("/categories/reorder-subcategories", status_code=status.HTTP_204_NO_CONTENT)
|
||||
def reorder_subcategories(items: List[SubcategoryReorderItem], db: Session = Depends(get_db), user: User = Depends(require_manager)):
|
||||
"""Reorder sub-categories within their parent (sort_order among siblings)."""
|
||||
for item in items:
|
||||
cat = db.query(Category).filter(Category.id == item.id).first()
|
||||
if cat:
|
||||
cat.sort_order = item.sort_order
|
||||
db.commit()
|
||||
|
||||
|
||||
@router.put("/categories/reorder-general", status_code=status.HTTP_204_NO_CONTENT)
|
||||
def reorder_general(items: List[ParentGeneralReorderItem], db: Session = Depends(get_db), user: User = Depends(require_manager)):
|
||||
"""Update general_sort_order on parent categories (position of the General group)."""
|
||||
for item in items:
|
||||
cat = db.query(Category).filter(Category.id == item.id).first()
|
||||
if cat:
|
||||
cat.general_sort_order = item.general_sort_order
|
||||
db.commit()
|
||||
|
||||
|
||||
@router.put("/categories/{category_id}", response_model=CategoryOut)
|
||||
def update_category(category_id: int, body: CategoryUpdate, db: Session = Depends(get_db), user: User = Depends(require_manager)):
|
||||
cat = db.query(Category).filter(Category.id == category_id).first()
|
||||
@@ -126,7 +175,8 @@ def delete_category(category_id: int, db: Session = Depends(get_db), user: User
|
||||
def list_products(all: bool = False, db: Session = Depends(get_db), user: User = Depends(get_current_user)):
|
||||
q = db.query(Product)
|
||||
if not all or user.role not in ("manager", "sysadmin"):
|
||||
q = q.filter(Product.is_available == True)
|
||||
# Waiters only see active, available products
|
||||
q = q.filter(Product.is_available == True, Product.lifecycle_status == "active")
|
||||
return q.order_by(Product.sort_order, Product.id).all()
|
||||
|
||||
|
||||
@@ -141,15 +191,33 @@ def reorder_products(items: List[ProductReorderItem], db: Session = Depends(get_
|
||||
|
||||
@router.post("/", response_model=ProductOut, status_code=status.HTTP_201_CREATED)
|
||||
def create_product(body: ProductCreate, db: Session = Depends(get_db), user: User = Depends(require_manager)):
|
||||
data = body.model_dump(exclude={"options", "ingredients", "preference_sets"})
|
||||
data = body.model_dump(exclude={"quick_options", "options", "ingredients", "preference_sets"})
|
||||
if data.get("sort_order") == 0:
|
||||
data["sort_order"] = db.query(Product).count()
|
||||
product = Product(**data)
|
||||
db.add(product)
|
||||
db.flush()
|
||||
for i, qo in enumerate(body.quick_options):
|
||||
db.add(ProductQuickOption(
|
||||
product_id=product.id,
|
||||
name=qo.name,
|
||||
price=qo.price,
|
||||
allow_multiple=qo.allow_multiple,
|
||||
sort_order=qo.sort_order if qo.sort_order else i,
|
||||
is_favorite=qo.is_favorite,
|
||||
favorite_sort_order=qo.favorite_sort_order,
|
||||
))
|
||||
for opt in body.options:
|
||||
sub_json = json.dumps([s.model_dump() for s in opt.sub_choices]) if opt.sub_choices else None
|
||||
db.add(ProductOption(product_id=product.id, name=opt.name, extra_cost=opt.extra_cost, sub_choices=sub_json))
|
||||
db.add(ProductOption(
|
||||
product_id=product.id,
|
||||
name=opt.name,
|
||||
extra_cost=opt.extra_cost,
|
||||
allow_multiple=opt.allow_multiple,
|
||||
sub_choices=sub_json,
|
||||
is_favorite=opt.is_favorite,
|
||||
favorite_sort_order=opt.favorite_sort_order,
|
||||
))
|
||||
for ing in body.ingredients:
|
||||
db.add(ProductIngredient(product_id=product.id, **ing.model_dump()))
|
||||
_replace_preference_sets(db, product, body.preference_sets)
|
||||
@@ -163,8 +231,10 @@ def update_product(product_id: int, body: ProductUpdate, db: Session = Depends(g
|
||||
product = db.query(Product).filter(Product.id == product_id).first()
|
||||
if not product:
|
||||
raise HTTPException(status_code=404, detail="Product not found")
|
||||
for field, value in body.model_dump(exclude_none=True, exclude={"options", "ingredients", "preference_sets"}).items():
|
||||
for field, value in body.model_dump(exclude_none=True, exclude={"quick_options", "options", "ingredients", "preference_sets"}).items():
|
||||
setattr(product, field, value)
|
||||
if body.quick_options is not None:
|
||||
_replace_quick_options(db, product, body.quick_options)
|
||||
if body.options is not None:
|
||||
_replace_options(db, product, body.options)
|
||||
if body.ingredients is not None:
|
||||
@@ -216,9 +286,14 @@ def delete_product(product_id: int, hard: bool = False, db: Session = Depends(ge
|
||||
if has_orders:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="Cannot permanently delete a product that appears in past orders. Deactivate it instead."
|
||||
detail="Cannot permanently delete a product that appears in past orders. Archive it instead."
|
||||
)
|
||||
db.delete(product)
|
||||
else:
|
||||
product.is_available = False
|
||||
# If product has order history, archive it; otherwise hard delete
|
||||
has_orders = db.query(OrderItem).filter(OrderItem.product_id == product_id).first()
|
||||
if has_orders:
|
||||
product.lifecycle_status = "archived"
|
||||
else:
|
||||
db.delete(product)
|
||||
db.commit()
|
||||
|
||||
@@ -12,6 +12,7 @@ from models.order import Order, OrderItem, OrderWaiter, PrintLog
|
||||
from models.user import User
|
||||
from models.table import Table
|
||||
from models.printer import Printer
|
||||
from models.shift import WaiterShift
|
||||
from schemas.order import OrderOut
|
||||
from schemas.table import TableOut
|
||||
from routers.deps import require_manager
|
||||
@@ -20,6 +21,12 @@ from services.printer_service import print_waiter_report, print_printer_report,
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
def _dt(dt):
|
||||
if dt is None:
|
||||
return None
|
||||
return (dt.isoformat() + "Z") if dt.tzinfo is None else dt.isoformat()
|
||||
|
||||
|
||||
@router.get("/shift")
|
||||
def shift_summary(
|
||||
from_dt: Optional[str] = Query(default=None, alias="from"),
|
||||
@@ -438,3 +445,213 @@ def print_printer_totals(
|
||||
|
||||
background_tasks.add_task(print_printer_report, printer.ip_address, printer.port, report, body.mode)
|
||||
return {"status": "printing"}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Shift history report
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@router.get("/shifts")
|
||||
def shifts_report(
|
||||
waiter_id: Optional[int] = None,
|
||||
business_day_id: Optional[int] = None,
|
||||
from_dt: Optional[str] = Query(default=None, alias="from"),
|
||||
to_dt: Optional[str] = Query(default=None, alias="to"),
|
||||
active_only: bool = False,
|
||||
db: Session = Depends(get_db),
|
||||
user: User = Depends(require_manager),
|
||||
):
|
||||
from routers.shifts import compute_shift_total
|
||||
|
||||
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 from_dt:
|
||||
q = q.filter(WaiterShift.started_at >= datetime.fromisoformat(from_dt))
|
||||
if to_dt:
|
||||
q = q.filter(WaiterShift.started_at <= datetime.fromisoformat(to_dt))
|
||||
if active_only:
|
||||
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}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Product performance analytics
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@router.get("/products/performance")
|
||||
def product_performance(
|
||||
from_dt: Optional[str] = Query(default=None, alias="from"),
|
||||
to_dt: Optional[str] = Query(default=None, alias="to"),
|
||||
business_day_id: Optional[int] = None,
|
||||
category_id: Optional[int] = None,
|
||||
db: Session = Depends(get_db),
|
||||
user: User = Depends(require_manager),
|
||||
):
|
||||
from models.product import Product
|
||||
|
||||
q = db.query(OrderItem).filter(OrderItem.status.in_(["active", "paid"]))
|
||||
if from_dt:
|
||||
q = q.filter(OrderItem.added_at >= datetime.fromisoformat(from_dt))
|
||||
if to_dt:
|
||||
q = q.filter(OrderItem.added_at <= datetime.fromisoformat(to_dt))
|
||||
if business_day_id:
|
||||
q = q.join(Order).filter(Order.business_day_id == business_day_id)
|
||||
|
||||
items = q.all()
|
||||
products_db = {p.id: p for p in db.query(Product).all()}
|
||||
|
||||
summary: dict = {}
|
||||
for item in items:
|
||||
pid = item.product_id
|
||||
product = products_db.get(pid)
|
||||
if category_id and (not product or product.category_id != category_id):
|
||||
continue
|
||||
if pid not in summary:
|
||||
summary[pid] = {
|
||||
"product_id": pid,
|
||||
"product_name": product.name if product else f"#{pid}",
|
||||
"category_id": product.category_id if product else None,
|
||||
"qty_sold": 0,
|
||||
"revenue": 0.0,
|
||||
"order_ids": set(),
|
||||
}
|
||||
summary[pid]["qty_sold"] += item.quantity
|
||||
summary[pid]["revenue"] += item.unit_price * item.quantity
|
||||
summary[pid]["order_ids"].add(item.order_id)
|
||||
|
||||
result = []
|
||||
for entry in summary.values():
|
||||
entry["order_count"] = len(entry.pop("order_ids"))
|
||||
entry["revenue"] = round(entry["revenue"], 2)
|
||||
result.append(entry)
|
||||
|
||||
result.sort(key=lambda x: x["qty_sold"], reverse=True)
|
||||
return {"products": result}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Table performance analytics
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@router.get("/tables/performance")
|
||||
def table_performance(
|
||||
from_dt: Optional[str] = Query(default=None, alias="from"),
|
||||
to_dt: Optional[str] = Query(default=None, alias="to"),
|
||||
business_day_id: Optional[int] = None,
|
||||
db: Session = Depends(get_db),
|
||||
user: User = Depends(require_manager),
|
||||
):
|
||||
q = db.query(Order).filter(Order.status.in_(["closed", "paid"]))
|
||||
if from_dt:
|
||||
q = q.filter(Order.opened_at >= datetime.fromisoformat(from_dt))
|
||||
if to_dt:
|
||||
q = q.filter(Order.opened_at <= datetime.fromisoformat(to_dt))
|
||||
if business_day_id:
|
||||
q = q.filter(Order.business_day_id == business_day_id)
|
||||
orders = q.all()
|
||||
|
||||
tables_db = {t.id: t for t in db.query(Table).all()}
|
||||
|
||||
summary: dict = {}
|
||||
for order in orders:
|
||||
tid = order.table_id
|
||||
if tid not in summary:
|
||||
t = tables_db.get(tid)
|
||||
summary[tid] = {
|
||||
"table_id": tid,
|
||||
"table_name": (t.label or f"T{t.number}") if t else f"#{tid}",
|
||||
"order_count": 0,
|
||||
"revenue": 0.0,
|
||||
"durations": [],
|
||||
}
|
||||
summary[tid]["order_count"] += 1
|
||||
summary[tid]["revenue"] += sum(
|
||||
i.unit_price * i.quantity for i in order.items if i.status in ("active", "paid")
|
||||
)
|
||||
if order.closed_at and order.opened_at:
|
||||
summary[tid]["durations"].append(
|
||||
(order.closed_at - order.opened_at).total_seconds() / 60
|
||||
)
|
||||
|
||||
result = []
|
||||
for entry in summary.values():
|
||||
durations = entry.pop("durations")
|
||||
entry["avg_duration_minutes"] = round(sum(durations) / len(durations), 1) if durations else None
|
||||
entry["revenue"] = round(entry["revenue"], 2)
|
||||
result.append(entry)
|
||||
|
||||
result.sort(key=lambda x: x["revenue"], reverse=True)
|
||||
return {"tables": result}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Traffic analysis (hour-of-day / day-of-week)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@router.get("/traffic")
|
||||
def traffic_analysis(
|
||||
from_dt: Optional[str] = Query(default=None, alias="from"),
|
||||
to_dt: Optional[str] = Query(default=None, alias="to"),
|
||||
business_day_id: Optional[int] = None,
|
||||
db: Session = Depends(get_db),
|
||||
user: User = Depends(require_manager),
|
||||
):
|
||||
q = db.query(Order)
|
||||
if from_dt:
|
||||
q = q.filter(Order.opened_at >= datetime.fromisoformat(from_dt))
|
||||
if to_dt:
|
||||
q = q.filter(Order.opened_at <= datetime.fromisoformat(to_dt))
|
||||
if business_day_id:
|
||||
q = q.filter(Order.business_day_id == business_day_id)
|
||||
orders = q.all()
|
||||
|
||||
by_hour = {h: {"hour": h, "orders": 0, "revenue": 0.0} for h in range(24)}
|
||||
day_labels = ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"]
|
||||
by_weekday = {d: {"day": d, "label": day_labels[d], "orders": 0, "revenue": 0.0} for d in range(7)}
|
||||
|
||||
for order in orders:
|
||||
revenue = sum(
|
||||
i.unit_price * i.quantity for i in order.items if i.status in ("active", "paid")
|
||||
)
|
||||
h = order.opened_at.hour
|
||||
d = order.opened_at.weekday()
|
||||
by_hour[h]["orders"] += 1
|
||||
by_hour[h]["revenue"] += revenue
|
||||
by_weekday[d]["orders"] += 1
|
||||
by_weekday[d]["revenue"] += revenue
|
||||
|
||||
for h in by_hour:
|
||||
by_hour[h]["revenue"] = round(by_hour[h]["revenue"], 2)
|
||||
for d in by_weekday:
|
||||
by_weekday[d]["revenue"] = round(by_weekday[d]["revenue"], 2)
|
||||
|
||||
return {
|
||||
"by_hour": list(by_hour.values()),
|
||||
"by_weekday": list(by_weekday.values()),
|
||||
}
|
||||
|
||||
66
local_backend/routers/settings.py
Normal file
66
local_backend/routers/settings.py
Normal file
@@ -0,0 +1,66 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from sqlalchemy.orm import Session
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from database import get_db
|
||||
from models.settings import PosSettings
|
||||
from schemas.settings import UpdateSettingRequest
|
||||
from routers.deps import get_current_user, require_manager
|
||||
from models.user import User
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
VALID_SETTINGS = {
|
||||
"shifts.waiter_self_start": "Allow waiters to start their own shifts without manager action",
|
||||
"shifts.waiter_self_end": "Allow waiters to end their own shifts without manager action",
|
||||
"business_day.force_close_allowed": "Allow force-closing business day with open tables",
|
||||
"system.timezone": "IANA timezone name used by the backend container (e.g. Europe/Athens). Requires container restart to take effect.",
|
||||
"ui.table_colours": "JSON blob of table card colour scheme (light + dark modes) for the Waiter PWA.",
|
||||
}
|
||||
|
||||
DEFAULTS = {
|
||||
"shifts.waiter_self_start": "true",
|
||||
"shifts.waiter_self_end": "true",
|
||||
"business_day.force_close_allowed": "true",
|
||||
"system.timezone": "Europe/Athens",
|
||||
"ui.table_colours": "",
|
||||
}
|
||||
|
||||
|
||||
@router.get("/")
|
||||
def get_all_settings(
|
||||
db: Session = Depends(get_db),
|
||||
user: User = Depends(get_current_user),
|
||||
):
|
||||
stored = {s.key: s.value for s in db.query(PosSettings).all()}
|
||||
result = {}
|
||||
for key, description in VALID_SETTINGS.items():
|
||||
result[key] = {
|
||||
"value": stored.get(key, DEFAULTS.get(key, "true")),
|
||||
"description": description,
|
||||
}
|
||||
return result
|
||||
|
||||
|
||||
@router.put("/{key}")
|
||||
def update_setting(
|
||||
key: str,
|
||||
body: UpdateSettingRequest,
|
||||
db: Session = Depends(get_db),
|
||||
user: User = Depends(require_manager),
|
||||
):
|
||||
if key not in VALID_SETTINGS:
|
||||
raise HTTPException(status_code=400, detail=f"Unknown setting key: {key}")
|
||||
|
||||
setting = db.query(PosSettings).filter(PosSettings.key == key).first()
|
||||
if setting:
|
||||
setting.value = body.value
|
||||
setting.updated_at = datetime.now(timezone.utc)
|
||||
setting.updated_by_id = user.id
|
||||
else:
|
||||
setting = PosSettings(key=key, value=body.value, updated_by_id=user.id)
|
||||
db.add(setting)
|
||||
|
||||
db.commit()
|
||||
db.refresh(setting)
|
||||
return {"key": setting.key, "value": setting.value}
|
||||
347
local_backend/routers/shifts.py
Normal file
347
local_backend/routers/shifts.py
Normal file
@@ -0,0 +1,347 @@
|
||||
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
|
||||
@@ -28,7 +28,7 @@ def create_group(body: TableGroupCreate, db: Session = Depends(get_db), user: Us
|
||||
if db.query(TableGroup).filter(TableGroup.name == body.name).first():
|
||||
raise HTTPException(status_code=400, detail="Group name already exists")
|
||||
sort_order = db.query(TableGroup).count()
|
||||
group = TableGroup(name=body.name, prefix=body.prefix, sort_order=sort_order)
|
||||
group = TableGroup(name=body.name, prefix=body.prefix, color=body.color, sort_order=sort_order)
|
||||
db.add(group)
|
||||
db.commit()
|
||||
db.refresh(group)
|
||||
@@ -86,7 +86,7 @@ def list_tables(include_inactive: bool = False, db: Session = Depends(get_db), u
|
||||
|
||||
active_table_ids = {
|
||||
row[0] for row in db.query(Order.table_id).filter(
|
||||
Order.status.in_(["open", "partially_paid"])
|
||||
Order.status.in_(["open", "partially_paid", "paid"])
|
||||
).all()
|
||||
}
|
||||
|
||||
@@ -167,7 +167,7 @@ def delete_table(table_id: int, hard: bool = False, db: Session = Depends(get_db
|
||||
raise HTTPException(status_code=404, detail="Table not found")
|
||||
active_order = db.query(Order).filter(
|
||||
Order.table_id == table_id,
|
||||
Order.status.in_(["open", "partially_paid"])
|
||||
Order.status.in_(["open", "partially_paid", "paid"])
|
||||
).first()
|
||||
if active_order:
|
||||
raise HTTPException(
|
||||
@@ -194,7 +194,7 @@ def table_status(table_id: int, db: Session = Depends(get_db), user: User = Depe
|
||||
raise HTTPException(status_code=404, detail="Table not found")
|
||||
active_order = (
|
||||
db.query(Order)
|
||||
.filter(Order.table_id == table_id, Order.status.in_(["open", "partially_paid"]))
|
||||
.filter(Order.table_id == table_id, Order.status.in_(["open", "partially_paid", "paid"]))
|
||||
.first()
|
||||
)
|
||||
return {
|
||||
|
||||
@@ -7,8 +7,9 @@ from typing import List
|
||||
|
||||
from database import get_db
|
||||
from models.user import User, AssistantAssignment, WaiterZone
|
||||
from models.shift import WaiterShift
|
||||
from schemas.user import UserCreate, UserUpdate, UserOut, AssistantAssignmentOut, SetZonesRequest
|
||||
from routers.deps import require_manager
|
||||
from routers.deps import require_manager, get_current_user
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
@@ -26,6 +27,13 @@ def _waiter_or_404(waiter_id: int, db: Session) -> User:
|
||||
|
||||
# ── CRUD ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
@router.get("/on-shift", response_model=List[UserOut])
|
||||
def list_waiters_on_shift(db: Session = Depends(get_db), user: User = Depends(get_current_user)):
|
||||
"""Waiters with an active (not-ended) shift. Accessible to all staff."""
|
||||
waiter_ids = db.query(WaiterShift.waiter_id).filter(WaiterShift.ended_at == None).subquery()
|
||||
return db.query(User).filter(User.id.in_(waiter_ids), User.role == "waiter", User.is_active == True).all()
|
||||
|
||||
|
||||
@router.get("/", response_model=List[UserOut])
|
||||
def list_waiters(db: Session = Depends(get_db), user: User = Depends(require_manager)):
|
||||
return db.query(User).filter(User.role == "waiter").all()
|
||||
@@ -36,7 +44,15 @@ def create_waiter(body: UserCreate, db: Session = Depends(get_db), user: User =
|
||||
if db.query(User).filter(User.username == body.username).first():
|
||||
raise HTTPException(status_code=400, detail="Username already exists")
|
||||
pin_hash = bcrypt.hashpw(body.pin.encode(), bcrypt.gensalt()).decode()
|
||||
new_user = User(username=body.username, pin_hash=pin_hash, role=body.role, is_active=body.is_active)
|
||||
new_user = User(
|
||||
username=body.username,
|
||||
pin_hash=pin_hash,
|
||||
role=body.role,
|
||||
is_active=body.is_active,
|
||||
full_name=body.full_name,
|
||||
nickname=body.nickname,
|
||||
mobile_phone=body.mobile_phone,
|
||||
)
|
||||
db.add(new_user)
|
||||
db.commit()
|
||||
db.refresh(new_user)
|
||||
|
||||
Reference in New Issue
Block a user