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

@@ -1002,6 +1002,90 @@ def print_order_synopsis(ip: str, port: int, synopsis: dict, line_width: int = L
logger.error("print_order_synopsis failed for %s:%s%s", ip, port, e)
def print_cancellation_ticket(order_id: int, item_ids: List[int]):
"""
Background task: print a ΑΚΥΡΩΣΗ ticket to the same printer zones
as the cancelled items. Connects to its own DB session.
"""
db: Session = SessionLocal()
try:
_do_print_cancellation(order_id, item_ids, db)
except Exception as e:
logger.exception("Unexpected error in print_cancellation_ticket for order %s: %s", order_id, e)
finally:
db.close()
def _do_print_cancellation(order_id: int, item_ids: List[int], db: Session):
if _is_spoof_mode(db):
logger.info("Spoof printing ON — dropping cancellation ticket for order %s", order_id)
return
order = db.query(Order).filter(Order.id == order_id).first()
if not order:
return
items = db.query(OrderItem).filter(OrderItem.id.in_(item_ids)).all()
zone_map: dict[int, List[OrderItem]] = {}
for item in items:
product = db.query(Product).filter(Product.id == item.product_id).first()
if product and product.printer_zone_id:
zone_map.setdefault(product.printer_zone_id, []).append(item)
div = load_divider_style(db)
for printer_id, zone_items in zone_map.items():
printer = db.query(Printer).filter(Printer.id == printer_id, Printer.is_active == True).first()
if not printer:
continue
try:
p = _get_printer(printer.ip_address, printer.port)
_print_cancel_ticket(p, order, zone_items, db, printer.line_width, div)
p.close()
except Exception as e:
logger.error("Cancellation print failed for printer %s: %s", printer.name, e)
def _print_cancel_ticket(p: Network, order: Order, items: List[OrderItem], db: Session, line_width: int, div: str):
cfg = _load_print_settings(db)
table_name = order.table.label or str(order.table.number) if order.table else str(order.table_id)
now_str = _greek_date(datetime.datetime.now(datetime.timezone.utc))
def _cancel_banner():
p._raw(b'\x1b\x61\x01') # center
p._raw(b'\x1b\x21\x30') # double height+width
p._raw(b'\x1b\x45\x01') # bold on
_raw_text(p, "*** AKYPΩΣH ***\n")
p._raw(b'\x1b\x45\x00')
p._raw(b'\x1b\x21\x00')
_divider(p, div, line_width)
_cancel_banner()
p._raw(b'\x1b\x61\x00')
p._raw(b'\x1b\x21\x10')
_raw_text(p, f"Παραγγελια: #{order.id}\n")
_raw_text(p, f"Τραπεζι: {table_name}\n")
_raw_text(p, f"Ωρα: {now_str}\n")
p._raw(b'\x1b\x21\x00')
_divider(p, div, line_width)
for item in items:
product = db.query(Product).filter(Product.id == item.product_id).first()
name = product.name if product else f"#{item.product_id}"
p._raw(b'\x1b\x21\x10')
_raw_text(p, _item_line(name, item.quantity, line_width) + "\n")
p._raw(b'\x1b\x21\x00')
_divider(p, div, line_width)
_cancel_banner()
p._raw(b'\n\n\n')
p.cut()
# ── Analytical report prints (products / categories / tables) ─────────────────
def print_products_report(ip: str, port: int, report: dict):