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)):