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