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:
@@ -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)):
|
||||
|
||||
Reference in New Issue
Block a user