Backend: synchronous print status on add_items

add_items now runs printer routing synchronously and returns
{ order, print_results } so the waiter PWA can show per-printer
ack or failure without guessing. Extracted _do_route_and_print
so the background-task path (route_and_print) is unchanged for
other callers.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-04-24 17:35:08 +03:00
parent da29d73520
commit 26c4818aa1
3 changed files with 375 additions and 73 deletions

View File

@@ -5,30 +5,48 @@ from sqlalchemy.orm import Session
from typing import List, Optional from typing import List, Optional
from database import get_db from database import get_db
from models.order import Order, OrderItem, OrderWaiter from models.order import Order, OrderItem, OrderWaiter, OrderAuditLog
from models.user import User, AssistantAssignment from models.user import User, WaiterZone
from models.table import Table
from models.product import Product from models.product import Product
from schemas.order import OrderCreate, OrderOut, OrderItemOut, AddItemsRequest, PayItemsRequest, AssignWaiterRequest, OrderWaiterOut from schemas.order import OrderCreate, OrderOut, OrderItemOut, AddItemsRequest, AddItemsResponse, PayItemsRequest, AssignWaiterRequest, OrderWaiterOut
from pydantic import BaseModel
class PrintOrderRequest(BaseModel):
printer_id: int
from routers.deps import get_current_user, require_manager from routers.deps import get_current_user, require_manager
from services.printer_service import route_and_print from services.printer_service import route_and_print, route_and_print_sync, print_order_receipt
router = APIRouter() router = APIRouter()
def _can_access_order(order: Order, user: User, db: Session) -> bool: 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"): if user.role in ("manager", "sysadmin"):
return True return True
if order.opened_by == user.id: zones = db.query(WaiterZone).filter(WaiterZone.waiter_id == user.id).all()
if not zones:
return False
if any(z.group_id is None for z in zones):
return True return True
if any(ow.waiter_id == user.id for ow in order.waiters): table = db.query(Table).filter(Table.id == order.table_id).first()
return True if not table:
# Assistant check: user is assistant to any waiter assigned to this order return False
assigned_ids = {ow.waiter_id for ow in order.waiters} allowed_group_ids = {z.group_id for z in zones}
assistant_of = db.query(AssistantAssignment).filter( return table.group_id in allowed_group_ids
AssistantAssignment.assistant_waiter_id == user.id,
AssistantAssignment.primary_waiter_id.in_(assigned_ids),
).first() def _audit(db: Session, order_id: int, event_type: str, waiter_id: int = None,
return assistant_of is not None item_ids: list = None, amount: float = None, payment_method: str = None, note: str = None):
db.add(OrderAuditLog(
order_id=order_id,
event_type=event_type,
waiter_id=waiter_id,
item_ids=json.dumps(item_ids) if item_ids is not None else None,
amount=amount,
payment_method=payment_method,
note=note,
))
@router.get("/", response_model=List[OrderOut]) @router.get("/", response_model=List[OrderOut])
@@ -83,16 +101,16 @@ def open_order(body: OrderCreate, db: Session = Depends(get_db), user: User = De
db.add(order) db.add(order)
db.flush() db.flush()
db.add(OrderWaiter(order_id=order.id, waiter_id=user.id)) db.add(OrderWaiter(order_id=order.id, waiter_id=user.id))
_audit(db, order.id, "ORDER_OPENED", waiter_id=user.id)
db.commit() db.commit()
db.refresh(order) db.refresh(order)
return order return order
@router.post("/{order_id}/items", response_model=OrderOut) @router.post("/{order_id}/items", response_model=AddItemsResponse)
def add_items( def add_items(
order_id: int, order_id: int,
body: AddItemsRequest, body: AddItemsRequest,
background_tasks: BackgroundTasks,
db: Session = Depends(get_db), db: Session = Depends(get_db),
user: User = Depends(get_current_user), user: User = Depends(get_current_user),
): ):
@@ -109,7 +127,6 @@ def add_items(
product = db.query(Product).filter(Product.id == item_in.product_id).first() product = db.query(Product).filter(Product.id == item_in.product_id).first()
if not product or not product.is_available: if not product or not product.is_available:
raise HTTPException(status_code=400, detail=f"Product {item_in.product_id} not available") raise HTTPException(status_code=400, detail=f"Product {item_in.product_id} not available")
# Calculate extra cost from selected options
extra_cost = sum( extra_cost = sum(
(o.price_delta or o.extra_cost or 0.0) (o.price_delta or o.extra_cost or 0.0)
for o in (item_in.selected_options or []) for o in (item_in.selected_options or [])
@@ -119,7 +136,7 @@ def add_items(
product_id=item_in.product_id, product_id=item_in.product_id,
added_by=user.id, added_by=user.id,
quantity=item_in.quantity, quantity=item_in.quantity,
unit_price=product.base_price + extra_cost, # price snapshot with options unit_price=product.base_price + extra_cost,
selected_options=json.dumps([o.model_dump() for o in item_in.selected_options]) if item_in.selected_options else None, selected_options=json.dumps([o.model_dump() for o in item_in.selected_options]) if item_in.selected_options else None,
removed_ingredients=json.dumps(item_in.removed_ingredients) if item_in.removed_ingredients else None, removed_ingredients=json.dumps(item_in.removed_ingredients) if item_in.removed_ingredients else None,
notes=item_in.notes, notes=item_in.notes,
@@ -128,13 +145,13 @@ def add_items(
db.flush() db.flush()
new_item_ids.append(item.id) new_item_ids.append(item.id)
_audit(db, order_id, "ITEMS_ADDED", waiter_id=user.id, item_ids=new_item_ids)
db.commit() db.commit()
db.refresh(order) db.refresh(order)
# Printer routing runs in background — must never block the order save print_results = route_and_print_sync(order_id, new_item_ids, db)
background_tasks.add_task(route_and_print, order_id, new_item_ids)
return order return {"order": order, "print_results": print_results}
@router.put("/{order_id}/items/{item_id}", response_model=OrderItemOut) @router.put("/{order_id}/items/{item_id}", response_model=OrderItemOut)
@@ -155,6 +172,7 @@ def cancel_item(order_id: int, item_id: int, db: Session = Depends(get_db), user
if not item: if not item:
raise HTTPException(status_code=404, detail="Item not found") raise HTTPException(status_code=404, detail="Item not found")
item.status = "cancelled" item.status = "cancelled"
_audit(db, order_id, "ITEM_CANCELLED", waiter_id=user.id, item_ids=[item_id])
db.commit() db.commit()
@@ -171,16 +189,25 @@ def pay_items(order_id: int, body: PayItemsRequest, db: Session = Depends(get_db
OrderItem.order_id == order_id, OrderItem.order_id == order_id,
OrderItem.status == "active", OrderItem.status == "active",
).all() ).all()
now = datetime.utcnow()
total_paid = 0.0
for item in items: for item in items:
item.status = "paid" item.status = "paid"
item.paid_by = user.id
item.paid_at = now
item.payment_method = body.payment_method
total_paid += item.unit_price * item.quantity
active_remaining = db.query(OrderItem).filter( active_remaining = db.query(OrderItem).filter(
OrderItem.order_id == order_id, OrderItem.status == "active" OrderItem.order_id == order_id, OrderItem.status == "active"
).count() ).count()
order.status = "paid" if active_remaining == 0 else "partially_paid" order.status = "paid" if active_remaining == 0 else "partially_paid"
paid_ids = [i.id for i in items]
_audit(db, order_id, "PAYMENT", waiter_id=user.id, item_ids=paid_ids,
amount=total_paid, payment_method=body.payment_method)
db.commit() db.commit()
return {"status": order.status, "paid_item_ids": [i.id for i in items]} return {"status": order.status, "paid_item_ids": paid_ids}
@router.post("/{order_id}/close") @router.post("/{order_id}/close")
@@ -195,6 +222,7 @@ def close_order(order_id: int, db: Session = Depends(get_db), user: User = Depen
order.status = "closed" order.status = "closed"
order.closed_at = datetime.utcnow() order.closed_at = datetime.utcnow()
order.closed_by = user.id order.closed_by = user.id
_audit(db, order_id, "ORDER_CLOSED", waiter_id=user.id)
db.commit() db.commit()
return {"status": "closed"} return {"status": "closed"}
@@ -207,6 +235,7 @@ def cancel_order(order_id: int, db: Session = Depends(get_db), user: User = Depe
order.status = "cancelled" order.status = "cancelled"
order.closed_at = datetime.utcnow() order.closed_at = datetime.utcnow()
order.closed_by = user.id order.closed_by = user.id
_audit(db, order_id, "ORDER_CANCELLED", waiter_id=user.id)
db.commit() db.commit()
@@ -234,3 +263,58 @@ def remove_waiter(order_id: int, waiter_id: int, db: Session = Depends(get_db),
raise HTTPException(status_code=404, detail="Assignment not found") raise HTTPException(status_code=404, detail="Assignment not found")
db.delete(assignment) db.delete(assignment)
db.commit() db.commit()
@router.post("/{order_id}/print")
def print_order(
order_id: int,
body: PrintOrderRequest,
background_tasks: BackgroundTasks,
db: Session = Depends(get_db),
user: User = Depends(require_manager),
):
from models.printer import Printer
order = db.query(Order).filter(Order.id == order_id).first()
if not order:
raise HTTPException(status_code=404, detail="Order not found")
printer = db.query(Printer).filter(Printer.id == body.printer_id, Printer.is_active == True).first()
if not printer:
raise HTTPException(status_code=404, detail="Printer not found or inactive")
table = db.query(Table).filter(Table.id == order.table_id).first()
table_name = (table.label or f"T{table.number}") if table else f"#{order.table_id}"
opener = db.query(User).filter(User.id == order.opened_by).first()
waiter_name = opener.username if opener else f"#{order.opened_by}"
items_data = []
for item in order.items:
if item.status == "cancelled":
continue
product_name = item.product.name if item.product else f"#{item.product_id}"
items_data.append({
"name": product_name,
"quantity": item.quantity,
"unit_price": item.unit_price,
"total": item.unit_price * item.quantity,
"status": item.status,
})
grand_total = sum(i["total"] for i in items_data)
receipt = {
"order_id": order.id,
"table_name": table_name,
"waiter_name": waiter_name,
"opened_at": order.opened_at.strftime("%d/%m/%Y %H:%M"),
"closed_at": order.closed_at.strftime("%d/%m/%Y %H:%M") if order.closed_at else None,
"status": order.status,
"items": items_data,
"total": grand_total,
"notes": order.notes,
}
background_tasks.add_task(print_order_receipt, printer.ip_address, printer.port, receipt)
return {"status": "printing"}

View File

@@ -42,6 +42,22 @@ class OrderItemOut(BaseModel):
status: str status: str
added_at: datetime added_at: datetime
printed: bool printed: bool
paid_by: Optional[int] = None
paid_at: Optional[datetime] = None
payment_method: Optional[str] = None
model_config = {"from_attributes": True}
class PrintResultOut(BaseModel):
printer_name: str
success: bool
error: Optional[str] = None
class AddItemsResponse(BaseModel):
order: "OrderOut"
print_results: List[PrintResultOut]
model_config = {"from_attributes": True} model_config = {"from_attributes": True}
@@ -52,6 +68,7 @@ class OrderCreate(BaseModel):
class PayItemsRequest(BaseModel): class PayItemsRequest(BaseModel):
item_ids: List[int] item_ids: List[int]
payment_method: Optional[str] = None # 'cash' | 'card' | 'other' — optional for now
class AssignWaiterRequest(BaseModel): class AssignWaiterRequest(BaseModel):
@@ -63,6 +80,21 @@ class OrderWaiterOut(BaseModel):
model_config = {"from_attributes": True} model_config = {"from_attributes": True}
class AuditLogOut(BaseModel):
id: int
order_id: int
event_type: str
waiter_id: Optional[int] = None
waiter_name: Optional[str] = None # resolved server-side
item_ids: Optional[str] = None
amount: Optional[float] = None
payment_method: Optional[str] = None
note: Optional[str] = None
created_at: datetime
model_config = {"from_attributes": True}
class OrderOut(BaseModel): class OrderOut(BaseModel):
id: int id: int
table_id: int table_id: int
@@ -74,5 +106,6 @@ class OrderOut(BaseModel):
notes: Optional[str] = None notes: Optional[str] = None
items: List[OrderItemOut] = [] items: List[OrderItemOut] = []
waiters: List[OrderWaiterOut] = [] waiters: List[OrderWaiterOut] = []
audit_logs: List[AuditLogOut] = []
model_config = {"from_attributes": True} model_config = {"from_attributes": True}

View File

@@ -160,6 +160,173 @@ def _print_kitchen_ticket(p: Network, order: Order, items: List[OrderItem], db:
p.cut() p.cut()
# ── On-demand report / receipt prints ────────────────────────────────────────
def print_waiter_report(ip: str, port: int, report: dict, mode: str):
"""Print a waiter shift/period report. mode='simple'|'extensive'."""
try:
p = _get_printer(ip, port)
p._raw(b'\x1b\x61\x01')
p._raw(b'\x1b\x21\x30')
_raw_text(p, "ΑΝΑΦΟΡΑ ΣΕΡΒΙΤΟΡΟΥ\n")
p._raw(b'\x1b\x21\x00')
_divider(p)
p._raw(b'\x1b\x61\x00')
p._raw(b'\x1b\x21\x10')
_raw_text(p, f"Σερβιτορος: {report['waiter_name']}\n")
_raw_text(p, f"Απο: {report['from_dt']}\n")
_raw_text(p, f"Εως: {report['to_dt']}\n")
p._raw(b'\x1b\x21\x00')
_divider(p)
p._raw(b'\x1b\x21\x10')
_raw_text(p, f"Παραγγελιες: {report['orders']}\n")
_raw_text(p, f"Αντικειμενα: {report['items']}\n")
p._raw(b'\x1b\x21\x00')
_divider(p)
p._raw(b'\x1b\x61\x01')
p._raw(b'\x1b\x21\x30')
_raw_text(p, f"ΣΥΝΟΛΟ: {report['total']:.2f}e\n")
p._raw(b'\x1b\x21\x00')
if mode == "extensive" and report.get("order_data"):
_divider(p)
p._raw(b'\x1b\x61\x00')
_raw_text(p, "ΑΝΑΛΥΤΙΚΑ\n")
_divider(p)
for od in report["order_data"]:
# Build right-aligned total: "HH:MM - HH:MM - TABLE . . . 9.99e"
time_open = od.get("time_open", "")
time_close = od.get("time_close", "")
table = od["table"]
value = f"{od['total']:.2f}e"
times_part = f"{time_open} - {time_close}" if time_close else time_open
prefix = f"{times_part} - {table}"
gap = LINE_WIDTH - len(prefix) - len(value)
if gap < 3:
line = f"{prefix} {value}"
else:
dots = (". " * ((gap // 2) + 1))[:gap]
line = f"{prefix}{dots}{value}"
_raw_text(p, line + "\n")
p._raw(b'\n\n\n')
p.cut()
p.close()
except Exception as e:
logger.error("print_waiter_report failed for %s:%s%s", ip, port, e)
def print_printer_report(ip: str, port: int, report: dict, mode: str):
"""Print a per-printer totals report. mode='simple'|'extensive'."""
try:
p = _get_printer(ip, port)
p._raw(b'\x1b\x61\x01')
p._raw(b'\x1b\x21\x30')
_raw_text(p, "ΑΝΑΦΟΡΑ ΕΚΤΥΠΩΤΗ\n")
p._raw(b'\x1b\x21\x00')
_divider(p)
p._raw(b'\x1b\x61\x00')
p._raw(b'\x1b\x21\x10')
_raw_text(p, f"Εκτυπωτης: {report['printer_name']}\n")
_raw_text(p, f"Απο: {report['from_dt']}\n")
_raw_text(p, f"Εως: {report['to_dt']}\n")
p._raw(b'\x1b\x21\x00')
_divider(p)
p._raw(b'\x1b\x21\x10')
_raw_text(p, f"Εργασιες εκτ.: {report['print_jobs']}\n")
_raw_text(p, f"Παραγγελιες: {report['orders']}\n")
_raw_text(p, f"Αντικειμενα: {report['items']}\n")
p._raw(b'\x1b\x21\x00')
_divider(p)
p._raw(b'\x1b\x61\x01')
p._raw(b'\x1b\x21\x30')
_raw_text(p, f"ΣΥΝΟΛΟ: {report['total']:.2f}e\n")
p._raw(b'\x1b\x21\x00')
if mode == "extensive" and report.get("order_data"):
_divider(p)
p._raw(b'\x1b\x61\x00')
_raw_text(p, "ΑΝΑΛΥΤΙΚΑ\n")
_divider(p)
for od in report["order_data"]:
# Header line: "HH:MM - TABLE . . . . 9.99e"
prefix = f"{od['time']} - {od['table']}"
value = f"{od['total']:.2f}e"
gap = LINE_WIDTH - len(prefix) - len(value)
if gap < 3:
header_line = f"{prefix} {value}"
else:
dots = (". " * ((gap // 2) + 1))[:gap]
header_line = f"{prefix}{dots}{value}"
p._raw(b'\x1b\x45\x01')
_raw_text(p, header_line + "\n")
p._raw(b'\x1b\x45\x00')
# Indented items
for item in od.get("items", []):
_raw_text(p, f" {item['quantity']} x {item['name']}\n")
p._raw(b'\n\n\n')
p.cut()
p.close()
except Exception as e:
logger.error("print_printer_report failed for %s:%s%s", ip, port, e)
def print_order_receipt(ip: str, port: int, receipt: dict):
"""Print a manager-triggered order receipt."""
try:
p = _get_printer(ip, port)
p._raw(b'\x1b\x61\x01')
p._raw(b'\x1b\x21\x30')
_raw_text(p, f"ΠΑΡΑΓΓΕΛΙΑ #{receipt['order_id']}\n")
p._raw(b'\x1b\x21\x00')
_divider(p)
p._raw(b'\x1b\x61\x00')
p._raw(b'\x1b\x21\x10')
_raw_text(p, f"Τραπεζι: {receipt['table_name']}\n")
_raw_text(p, f"Σερβιτορος: {receipt['waiter_name']}\n")
_raw_text(p, f"Ανοιχτηκε: {receipt['opened_at']}\n")
if receipt.get("closed_at"):
_raw_text(p, f"Εκλεισε: {receipt['closed_at']}\n")
p._raw(b'\x1b\x21\x00')
_divider(p)
for item in receipt.get("items", []):
p._raw(b'\x1b\x21\x10')
_raw_text(p, _item_line(item["name"], item["quantity"]) + "\n")
p._raw(b'\x1b\x21\x00')
_raw_text(p, f" {item['unit_price']:.2f}e x{item['quantity']} = {item['total']:.2f}e\n")
_divider(p)
if receipt.get("notes"):
p._raw(b'\x1b\x21\x10')
_raw_text(p, f"Σημ: {receipt['notes']}\n")
p._raw(b'\x1b\x21\x00')
_divider(p)
p._raw(b'\x1b\x61\x01')
p._raw(b'\x1b\x21\x30')
_raw_text(p, f"ΣΥΝΟΛΟ: {receipt['total']:.2f}e\n")
p._raw(b'\x1b\x21\x00')
p._raw(b'\n\n\n')
p.cut()
p.close()
except Exception as e:
logger.error("print_order_receipt failed for %s:%s%s", ip, port, e)
# ── Routing logic ──────────────────────────────────────────────────────────── # ── Routing logic ────────────────────────────────────────────────────────────
def route_and_print(order_id: int, item_ids: List[int]): def route_and_print(order_id: int, item_ids: List[int]):
@@ -169,58 +336,76 @@ def route_and_print(order_id: int, item_ids: List[int]):
""" """
db: Session = SessionLocal() db: Session = SessionLocal()
try: try:
order = db.query(Order).filter(Order.id == order_id).first() _do_route_and_print(order_id, item_ids, db)
if not order:
logger.error("route_and_print: order %s not found", order_id)
return
items = db.query(OrderItem).filter(OrderItem.id.in_(item_ids)).all()
# Group items by printer zone
zone_map: dict[int, List[OrderItem]] = {}
unzoned: 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)
else:
unzoned.append(item)
if unzoned:
logger.warning("order %s has %d item(s) with no printer zone — skipped", order_id, len(unzoned))
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:
logger.warning("Printer %s not found or inactive", printer_id)
continue
success = False
error_msg = None
try:
p = _get_printer(printer.ip_address, printer.port)
_print_kitchen_ticket(p, order, zone_items, db)
p.close()
success = True
# Mark items as printed
for item in zone_items:
item.printed = True
db.commit()
except Exception as e:
error_msg = str(e)
logger.error("Print failed for printer %s (%s:%s): %s", printer.name, printer.ip_address, printer.port, e)
log = PrintLog(
order_id=order_id,
printer_id=printer_id,
item_ids=json.dumps([i.id for i in zone_items]),
success=success,
error_message=error_msg,
)
db.add(log)
db.commit()
except Exception as e: except Exception as e:
logger.exception("Unexpected error in route_and_print for order %s: %s", order_id, e) logger.exception("Unexpected error in route_and_print for order %s: %s", order_id, e)
finally: finally:
db.close() db.close()
def route_and_print_sync(order_id: int, item_ids: List[int], db: Session) -> List[dict]:
"""
Synchronous variant used when the caller needs print results.
Returns a list of per-printer result dicts:
{ printer_name, success, error }
"""
return _do_route_and_print(order_id, item_ids, db)
def _do_route_and_print(order_id: int, item_ids: List[int], db: Session) -> List[dict]:
results = []
order = db.query(Order).filter(Order.id == order_id).first()
if not order:
logger.error("route_and_print: order %s not found", order_id)
return results
items = db.query(OrderItem).filter(OrderItem.id.in_(item_ids)).all()
# Group items by printer zone
zone_map: dict[int, List[OrderItem]] = {}
unzoned: 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)
else:
unzoned.append(item)
if unzoned:
logger.warning("order %s has %d item(s) with no printer zone — skipped", order_id, len(unzoned))
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:
logger.warning("Printer %s not found or inactive", printer_id)
results.append({"printer_name": f"#{printer_id}", "success": False, "error": "Printer not found or inactive"})
continue
success = False
error_msg = None
try:
p = _get_printer(printer.ip_address, printer.port)
_print_kitchen_ticket(p, order, zone_items, db)
p.close()
success = True
for item in zone_items:
item.printed = True
db.commit()
except Exception as e:
error_msg = str(e)
logger.error("Print failed for printer %s (%s:%s): %s", printer.name, printer.ip_address, printer.port, e)
log = PrintLog(
order_id=order_id,
printer_id=printer_id,
item_ids=json.dumps([i.id for i in zone_items]),
success=success,
error_message=error_msg,
)
db.add(log)
db.commit()
results.append({"printer_name": printer.name, "success": success, "error": error_msg})
return results