feat: Feature 2 — waiter order cancellation + report updates

Backend:
- Add can_cancel_orders to User model and schema
- Add global orders.waiter_cancellations_allowed setting (migration)
- Cancel endpoints: mark items with cancelled_by/cancelled_at, fire cancellation print
- print_cancellation_ticket: routes to same printer zones, prints ΑΚΥΡΩΣΗ banner
- Fix cancellations_log: date filter, waiter filter, join syntax
- shift/orders: add cancellations count and hours_worked per waiter
- _enrich_shift: add cancellations count to shift data
- Add cancel-permissions endpoint for PWA

Manager dashboard:
- Global cancel setting toggle in Settings > Operation > Shift Settings
- Per-waiter can_cancel_orders checkbox in staff modal
- Manager cancel flow: print confirmation prompt (Ναι/Όχι) in DashboardPage
- ShiftsOverview: Ακυρώσεις column per shift
- Activity: multi-bar chart with ORDERS/ITEMS/CANCELLATIONS/ΕΣΟΔΑ/ΩΡΕΣ checkboxes,
  grouped/stacked switch, right X-axis for hours, full waiter name on hover
- OrderHistory: cancelled items count column per order
- WorkDaySummary drill-down: cancelled items column in orders tab

Waiter PWA:
- Replace 3 pills with CLEAR | ALL | ACTIONS
- ACTIONS opens ItemActionModal for selected items
- ItemActionModal: ORDER AGAIN, MOVE TO OTHER TABLE, SPLIT, CANCEL ORDER
- ActionsSheet: Cancel Παραγγελίας option (greyed if no permission)
- CancelConfirmModal: requires confirmation before cancelling
- TableListPage: cancel order from long-press quick modal

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-06-09 12:47:02 +03:00
parent b5b647422a
commit 8a5a6f8be9
16 changed files with 855 additions and 108 deletions

View File

@@ -42,12 +42,20 @@ class MoveItemsRequest(BaseModel):
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, print_order_synopsis
from services.printer_service import route_and_print, route_and_print_sync, print_order_receipt, print_order_synopsis, print_cancellation_ticket
from models.settings import PosSettings
from services.sse_bus import broadcast_sync
router = APIRouter()
def _waiter_can_cancel(user: User, db: Session) -> bool:
"""True only if both the global setting and the per-waiter flag are on."""
row = db.query(PosSettings).filter(PosSettings.key == "orders.waiter_cancellations_allowed").first()
global_on = row is not None and row.value == "true"
return global_on and bool(user.can_cancel_orders)
def _can_access_order(order: Order, user: User, db: Session) -> bool:
"""Zone-based access: any waiter whose zone covers the order's table group may act on it."""
if user.role in ("manager", "sysadmin"):
@@ -79,6 +87,25 @@ def _audit(db: Session, order_id: int, event_type: str, waiter_id: int = None,
ACTIVE_STATUSES = ["open", "partially_paid", "paid"]
@router.get("/cancel-permissions")
def cancel_permissions(db: Session = Depends(get_db), user: User = Depends(get_current_user)):
"""Return whether the current user is allowed to cancel orders."""
is_manager = user.role in ("manager", "sysadmin")
if is_manager:
return {"can_cancel": True, "reason": None}
can = _waiter_can_cancel(user, db)
reason = None
if not can:
row = db.query(PosSettings).filter(PosSettings.key == "orders.waiter_cancellations_allowed").first()
global_on = row is not None and row.value == "true"
if not global_on:
reason = "global_disabled"
elif not user.can_cancel_orders:
reason = "user_disabled"
return {"can_cancel": can, "reason": reason}
@router.get("/", response_model=List[OrderOut])
def list_orders(
order_status: Optional[str] = None,
@@ -362,14 +389,41 @@ def edit_item(order_id: int, item_id: int, notes: Optional[str] = None, db: Sess
@router.delete("/{order_id}/items/{item_id}", status_code=status.HTTP_204_NO_CONTENT)
def cancel_item(order_id: int, item_id: int, db: Session = Depends(get_db), user: User = Depends(require_manager)):
def cancel_item(
order_id: int,
item_id: int,
background_tasks: BackgroundTasks,
print_cancellation: bool = True,
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")
is_manager = user.role in ("manager", "sysadmin")
if not is_manager and not _waiter_can_cancel(user, db):
raise HTTPException(status_code=403, detail="Δεν επιτρέπεται η ακύρωση παραγγελίας")
if not is_manager and not _can_access_order(order, user, db):
raise HTTPException(status_code=403, detail="Access denied")
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 == "paid":
raise HTTPException(status_code=400, detail="Το αντικείμενο έχει ήδη πληρωθεί")
now = datetime.now(timezone.utc)
item.status = "cancelled"
item.cancelled_by = user.id
item.cancelled_at = now
_audit(db, order_id, "ITEM_CANCELLED", waiter_id=user.id, item_ids=[item_id])
db.commit()
should_print = print_cancellation if is_manager else True
if should_print:
background_tasks.add_task(print_cancellation_ticket, order_id, [item_id])
@router.post("/{order_id}/pay")
def pay_items(order_id: int, body: PayItemsRequest, db: Session = Depends(get_db), user: User = Depends(get_current_user)):
@@ -546,14 +600,25 @@ def pay_items_offline(
@router.delete("/{order_id}", status_code=status.HTTP_204_NO_CONTENT)
def cancel_order(order_id: int, db: Session = Depends(get_db), user: User = Depends(require_manager)):
def cancel_order(
order_id: int,
background_tasks: BackgroundTasks,
print_cancellation: bool = True,
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")
is_manager = user.role in ("manager", "sysadmin")
if not is_manager and not _waiter_can_cancel(user, db):
raise HTTPException(status_code=403, detail="Δεν επιτρέπεται η ακύρωση παραγγελίας")
if not is_manager and not _can_access_order(order, user, db):
raise HTTPException(status_code=403, detail="Access denied")
now = datetime.now(timezone.utc)
# Cancel all still-active items
active_items = db.query(OrderItem).filter(
OrderItem.order_id == order_id,
OrderItem.status == "active",
@@ -561,18 +626,76 @@ def cancel_order(order_id: int, db: Session = Depends(get_db), user: User = Depe
cancelled_item_ids = []
for item in active_items:
item.status = "cancelled"
item.cancelled_by = user.id
item.cancelled_at = now
cancelled_item_ids.append(item.id)
order.status = "cancelled"
order.closed_at = now
order.closed_by = user.id
note = f"Ακύρωση από manager{len(cancelled_item_ids)} αντικείμενα ακυρώθηκαν" if cancelled_item_ids else None
note = f"Ακύρωση — {len(cancelled_item_ids)} αντικείμενα ακυρώθηκαν" if cancelled_item_ids else None
_audit(db, order_id, "ORDER_CANCELLED", waiter_id=user.id,
item_ids=cancelled_item_ids if cancelled_item_ids else None, note=note)
db.commit()
broadcast_sync("order_closed", {"order_id": order_id, "table_id": order.table_id})
should_print = print_cancellation if is_manager else True
if should_print and cancelled_item_ids:
background_tasks.add_task(print_cancellation_ticket, order_id, cancelled_item_ids)
class CancelItemsRequest(BaseModel):
item_ids: List[int]
print_cancellation: bool = True
@router.post("/{order_id}/cancel-items")
def cancel_items_bulk(
order_id: int,
body: CancelItemsRequest,
background_tasks: BackgroundTasks,
db: Session = Depends(get_db),
user: User = Depends(get_current_user),
):
"""Cancel a selection of items by ID. Used by the waiter PWA multi-select cancel."""
order = db.query(Order).filter(Order.id == order_id).first()
if not order:
raise HTTPException(status_code=404, detail="Order not found")
is_manager = user.role in ("manager", "sysadmin")
if not is_manager and not _waiter_can_cancel(user, db):
raise HTTPException(status_code=403, detail="Δεν επιτρέπεται η ακύρωση παραγγελίας")
if not is_manager and not _can_access_order(order, user, db):
raise HTTPException(status_code=403, detail="Access denied")
items = db.query(OrderItem).filter(
OrderItem.id.in_(body.item_ids),
OrderItem.order_id == order_id,
).all()
now = datetime.now(timezone.utc)
cancelled_ids = []
skipped_paid = []
for item in items:
if item.status == "paid":
skipped_paid.append(item.id)
continue
item.status = "cancelled"
item.cancelled_by = user.id
item.cancelled_at = now
cancelled_ids.append(item.id)
if cancelled_ids:
_audit(db, order_id, "ITEM_CANCELLED", waiter_id=user.id, item_ids=cancelled_ids)
db.commit()
should_print = body.print_cancellation if is_manager else True
if should_print and cancelled_ids:
background_tasks.add_task(print_cancellation_ticket, order_id, cancelled_ids)
return {"cancelled": cancelled_ids, "skipped_paid": skipped_paid}
@router.put("/{order_id}/assign-waiter")
def assign_waiter(order_id: int, body: AssignWaiterRequest, db: Session = Depends(get_db), user: User = Depends(get_current_user)):

View File

@@ -8,7 +8,7 @@ from fastapi.responses import StreamingResponse
from pydantic import BaseModel
from sqlalchemy.orm import Session
from sqlalchemy import func
from datetime import date, datetime, timedelta
from datetime import date, datetime, timedelta, timezone
from typing import Optional, List
from database import get_db
@@ -136,9 +136,36 @@ def shift_orders_summary(
q = q.filter(OrderItem.added_by == waiter_id)
items = q.all()
# Also fetch cancelled items for the same window/waiter
cq = db.query(OrderItem).filter(OrderItem.status == "cancelled")
if business_day_id:
cq = cq.join(Order, OrderItem.order_id == Order.id).filter(Order.business_day_id == business_day_id)
else:
cq = cq.filter(OrderItem.added_at >= start, OrderItem.added_at < end)
if waiter_id:
cq = cq.filter(OrderItem.added_by == waiter_id)
cancelled_items = cq.all()
waiters_db = {u.id: u for u in db.query(User).all()}
tables_db = {t.id: (t.label or f"T{t.number}") for t in db.query(Table).all()}
# Track shifts for duration (hours worked)
shift_q = db.query(WaiterShift)
if business_day_id:
shift_q = shift_q.filter(WaiterShift.business_day_id == business_day_id)
else:
shift_q = shift_q.filter(WaiterShift.started_at >= start, WaiterShift.started_at < end)
if waiter_id:
shift_q = shift_q.filter(WaiterShift.waiter_id == waiter_id)
shifts_list = shift_q.all()
# hours worked per waiter
hours_by_waiter: dict[int, float] = {}
for s in shifts_list:
wid = s.waiter_id
end_time = s.ended_at or datetime.now(timezone.utc).replace(tzinfo=None)
delta = (end_time - s.started_at).total_seconds() / 3600.0
hours_by_waiter[wid] = hours_by_waiter.get(wid, 0.0) + delta
summary: dict[int, dict] = {}
for item in items:
wid = item.added_by
@@ -150,6 +177,7 @@ def shift_orders_summary(
"waiter_name": wname,
"items": 0,
"total": 0.0,
"cancellations": 0,
"order_data": {},
}
summary[wid]["items"] += item.quantity
@@ -173,10 +201,26 @@ def shift_orders_summary(
{"name": product_name, "quantity": item.quantity}
)
for item in cancelled_items:
wid = item.added_by
if wid not in summary:
w = waiters_db.get(wid)
wname = (w.full_name or w.username) if w else f"#{wid}"
summary[wid] = {
"waiter_id": wid,
"waiter_name": wname,
"items": 0,
"total": 0.0,
"cancellations": 0,
"order_data": {},
}
summary[wid]["cancellations"] = summary[wid].get("cancellations", 0) + item.quantity
result = []
for entry in summary.values():
entry["orders"] = len(entry["order_data"])
entry["order_data"] = list(entry["order_data"].values())
entry["hours_worked"] = round(hours_by_waiter.get(entry["waiter_id"], 0.0), 2)
result.append(entry)
return {"from": start.isoformat(), "to": end.isoformat(), "waiters": result}
@@ -1380,22 +1424,45 @@ def cancellations_log(
user: User = Depends(require_manager),
):
q = db.query(OrderItem).filter(OrderItem.status == "cancelled")
# Date filter on cancelled_at if available, otherwise fall back to added_at
if from_dt:
q = q.filter(OrderItem.added_at >= datetime.fromisoformat(from_dt))
dt_from = datetime.fromisoformat(from_dt)
from sqlalchemy import or_
q = q.filter(or_(
OrderItem.cancelled_at >= dt_from,
(OrderItem.cancelled_at == None) & (OrderItem.added_at >= dt_from),
))
if to_dt:
q = q.filter(OrderItem.added_at <= datetime.fromisoformat(to_dt))
dt_to = datetime.fromisoformat(to_dt)
from sqlalchemy import or_
q = q.filter(or_(
OrderItem.cancelled_at <= dt_to,
(OrderItem.cancelled_at == None) & (OrderItem.added_at <= dt_to),
))
if business_day_id:
q = q.join(Order).filter(Order.business_day_id == business_day_id)
q = q.join(Order, Order.id == OrderItem.order_id).filter(Order.business_day_id == business_day_id)
if waiter_id:
q = q.filter(OrderItem.cancelled_by == waiter_id)
# Show items cancelled BY this waiter OR items that were on this waiter's order
from sqlalchemy import or_
q = q.filter(or_(
OrderItem.cancelled_by == waiter_id,
OrderItem.added_by == waiter_id,
))
items = q.order_by(OrderItem.added_at.desc()).all()
waiters_db = {u.id: u for u in db.query(User).all()}
tables_db = {t.id: (t.label or f"T{t.number}") for t in db.query(Table).all()}
orders_cache = {}
def _get_order(oid):
if oid not in orders_cache:
orders_cache[oid] = db.query(Order).filter(Order.id == oid).first()
return orders_cache[oid]
result = []
for item in items:
order = db.query(Order).filter(Order.id == item.order_id).first()
order = _get_order(item.order_id)
table_name = tables_db.get(order.table_id, f"#{order.table_id}") if order else ""
waiter = waiters_db.get(item.added_by)
waiter_name = (waiter.full_name or waiter.username) if waiter else f"#{item.added_by}"

View File

@@ -75,6 +75,15 @@ def _enrich_shift(shift: WaiterShift, db: Session) -> dict:
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)
# Count cancelled items attributed to this waiter during shift window
cancelled_q = db.query(OrderItem).filter(
OrderItem.status == "cancelled",
OrderItem.added_by == shift.waiter_id,
OrderItem.added_at >= shift.started_at,
)
if shift.ended_at:
cancelled_q = cancelled_q.filter(OrderItem.added_at <= shift.ended_at)
cancellations = cancelled_q.count()
return {
"id": shift.id,
"waiter_id": shift.waiter_id,
@@ -90,6 +99,7 @@ def _enrich_shift(shift: WaiterShift, db: Session) -> dict:
"hourly_rate_snapshot": shift.hourly_rate_snapshot,
"duration_hours": pay_data["duration_hours"],
"shift_pay": pay_data["shift_pay"],
"cancellations": cancellations,
# Phase 2E
"counted_cash_end": shift.counted_cash_end,
"cash_discrepancy": shift.cash_discrepancy,