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