diff --git a/local_backend/routers/orders.py b/local_backend/routers/orders.py index 24a4b11..a604c6e 100644 --- a/local_backend/routers/orders.py +++ b/local_backend/routers/orders.py @@ -5,30 +5,48 @@ from sqlalchemy.orm import Session from typing import List, Optional from database import get_db -from models.order import Order, OrderItem, OrderWaiter -from models.user import User, AssistantAssignment +from models.order import Order, OrderItem, OrderWaiter, OrderAuditLog +from models.user import User, WaiterZone +from models.table import Table 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 services.printer_service import route_and_print +from services.printer_service import route_and_print, route_and_print_sync, print_order_receipt router = APIRouter() 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"): 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 - if any(ow.waiter_id == user.id for ow in order.waiters): - return True - # Assistant check: user is assistant to any waiter assigned to this order - assigned_ids = {ow.waiter_id for ow in order.waiters} - assistant_of = db.query(AssistantAssignment).filter( - AssistantAssignment.assistant_waiter_id == user.id, - AssistantAssignment.primary_waiter_id.in_(assigned_ids), - ).first() - return assistant_of is not None + table = db.query(Table).filter(Table.id == order.table_id).first() + if not table: + return False + allowed_group_ids = {z.group_id for z in zones} + return table.group_id in allowed_group_ids + + +def _audit(db: Session, order_id: int, event_type: str, waiter_id: int = 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]) @@ -83,16 +101,16 @@ def open_order(body: OrderCreate, db: Session = Depends(get_db), user: User = De db.add(order) db.flush() db.add(OrderWaiter(order_id=order.id, waiter_id=user.id)) + _audit(db, order.id, "ORDER_OPENED", waiter_id=user.id) db.commit() db.refresh(order) return order -@router.post("/{order_id}/items", response_model=OrderOut) +@router.post("/{order_id}/items", response_model=AddItemsResponse) def add_items( order_id: int, body: AddItemsRequest, - background_tasks: BackgroundTasks, db: Session = Depends(get_db), 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() if not product or not product.is_available: raise HTTPException(status_code=400, detail=f"Product {item_in.product_id} not available") - # Calculate extra cost from selected options extra_cost = sum( (o.price_delta or o.extra_cost or 0.0) for o in (item_in.selected_options or []) @@ -119,7 +136,7 @@ def add_items( product_id=item_in.product_id, added_by=user.id, 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, removed_ingredients=json.dumps(item_in.removed_ingredients) if item_in.removed_ingredients else None, notes=item_in.notes, @@ -128,13 +145,13 @@ def add_items( db.flush() new_item_ids.append(item.id) + _audit(db, order_id, "ITEMS_ADDED", waiter_id=user.id, item_ids=new_item_ids) db.commit() db.refresh(order) - # Printer routing runs in background — must never block the order save - background_tasks.add_task(route_and_print, order_id, new_item_ids) + print_results = route_and_print_sync(order_id, new_item_ids, db) - return order + return {"order": order, "print_results": print_results} @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: raise HTTPException(status_code=404, detail="Item not found") item.status = "cancelled" + _audit(db, order_id, "ITEM_CANCELLED", waiter_id=user.id, item_ids=[item_id]) 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.status == "active", ).all() + now = datetime.utcnow() + total_paid = 0.0 for item in items: 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( OrderItem.order_id == order_id, OrderItem.status == "active" ).count() 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() - 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") @@ -195,6 +222,7 @@ def close_order(order_id: int, db: Session = Depends(get_db), user: User = Depen order.status = "closed" order.closed_at = datetime.utcnow() order.closed_by = user.id + _audit(db, order_id, "ORDER_CLOSED", waiter_id=user.id) db.commit() 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.closed_at = datetime.utcnow() order.closed_by = user.id + _audit(db, order_id, "ORDER_CANCELLED", waiter_id=user.id) 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") db.delete(assignment) 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"} diff --git a/local_backend/schemas/order.py b/local_backend/schemas/order.py index c9b8ff0..430497a 100644 --- a/local_backend/schemas/order.py +++ b/local_backend/schemas/order.py @@ -42,6 +42,22 @@ class OrderItemOut(BaseModel): status: str added_at: datetime 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} @@ -52,6 +68,7 @@ class OrderCreate(BaseModel): class PayItemsRequest(BaseModel): item_ids: List[int] + payment_method: Optional[str] = None # 'cash' | 'card' | 'other' — optional for now class AssignWaiterRequest(BaseModel): @@ -63,6 +80,21 @@ class OrderWaiterOut(BaseModel): 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): id: int table_id: int @@ -74,5 +106,6 @@ class OrderOut(BaseModel): notes: Optional[str] = None items: List[OrderItemOut] = [] waiters: List[OrderWaiterOut] = [] + audit_logs: List[AuditLogOut] = [] model_config = {"from_attributes": True} diff --git a/local_backend/services/printer_service.py b/local_backend/services/printer_service.py index 1d857f7..7a98892 100644 --- a/local_backend/services/printer_service.py +++ b/local_backend/services/printer_service.py @@ -160,6 +160,173 @@ def _print_kitchen_ticket(p: Network, order: Order, items: List[OrderItem], db: 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 ──────────────────────────────────────────────────────────── 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() try: - 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 - - 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() - + _do_route_and_print(order_id, item_ids, db) except Exception as e: logger.exception("Unexpected error in route_and_print for order %s: %s", order_id, e) finally: 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