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