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:
@@ -408,6 +408,9 @@ def _run_migrations():
|
||||
)""",
|
||||
# Printer duplicate copies (0 = print once, 1 = print twice, etc.)
|
||||
"ALTER TABLE printers ADD COLUMN duplicates INTEGER NOT NULL DEFAULT 0",
|
||||
# Waiter cancellation permissions
|
||||
"ALTER TABLE users ADD COLUMN can_cancel_orders INTEGER NOT NULL DEFAULT 0",
|
||||
"INSERT OR IGNORE INTO pos_settings (key, value, updated_at) VALUES ('orders.waiter_cancellations_allowed', 'false', CURRENT_TIMESTAMP)",
|
||||
]
|
||||
for sql in migrations:
|
||||
try:
|
||||
|
||||
@@ -26,6 +26,8 @@ class User(Base):
|
||||
created_at = Column(DateTime(timezone=True), default=_utcnow)
|
||||
# Phase 2B — payroll
|
||||
hourly_rate = Column(Float, nullable=True)
|
||||
# Waiter cancellation permission
|
||||
can_cancel_orders = Column(Boolean, default=False, nullable=False)
|
||||
|
||||
orders_opened = relationship("Order", foreign_keys="Order.opened_by", back_populates="opener")
|
||||
orders_closed = relationship("Order", foreign_keys="Order.closed_by", back_populates="closer")
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -16,6 +16,7 @@ class UserBase(BaseModel):
|
||||
email: Optional[str] = None
|
||||
# Phase 2B — payroll
|
||||
hourly_rate: Optional[float] = None
|
||||
can_cancel_orders: bool = False
|
||||
|
||||
|
||||
class UserCreate(UserBase):
|
||||
@@ -33,6 +34,7 @@ class UserUpdate(BaseModel):
|
||||
note: Optional[str] = None
|
||||
# Phase 2B — payroll
|
||||
hourly_rate: Optional[float] = None
|
||||
can_cancel_orders: Optional[bool] = None
|
||||
|
||||
|
||||
class WaiterZoneOut(BaseModel):
|
||||
|
||||
@@ -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