import json from datetime import datetime, timezone from fastapi import APIRouter, Depends, HTTPException, status, BackgroundTasks from tz import local_strftime def _iso(dt) -> str | None: """Serialize a datetime to ISO 8601 with Z suffix so browsers parse it as UTC.""" if dt is None: return None if dt.tzinfo is None: return dt.isoformat() + "Z" return dt.isoformat() from sqlalchemy.orm import Session from typing import List, Optional from database import get_db from models.order import Order, OrderItem, OrderWaiter, OrderAuditLog, PrintJob, OrderDiscount from models.user import User, WaiterZone from models.table import Table from models.product import Product from schemas.order import OrderCreate, OrderOut, OrderItemOut, AddItemsRequest, AddItemsResponse, PayItemsRequest, OfflinePaymentRequest, AssignWaiterRequest, OrderWaiterOut from services.pricing import ( OrderContext, resolve_price, evaluate_deals, build_active_price_group_ids, write_price_events, check_waiter_discount_limits, _system_round, ) from pydantic import BaseModel class PrintOrderRequest(BaseModel): printer_id: int class TransferOrderRequest(BaseModel): target_table_id: int class MergeOrderRequest(BaseModel): target_order_id: int class SplitItemRequest(BaseModel): quantity: int # how many to split off into a new item row class PrintSynopsisRequest(BaseModel): printer_id: int class MoveItemsRequest(BaseModel): item_ids: List[int] target_order_id: int class RevertPaymentRequest(BaseModel): item_ids: List[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, 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. Standalone orders (table_id=None) are accessible to all waiters with any zone assignment.""" if user.role == "superadmin" or user.perm_access_dashboard: return True zones = db.query(WaiterZone).filter(WaiterZone.waiter_id == user.id).all() if not zones: return False # Standalone orders (takeaway/delivery) have no table — accessible to all zone-assigned waiters if order.table_id is None: return True if any(z.group_id is None for z in zones): return True 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, )) ACTIVE_STATUSES = ["open", "partially_paid", "paid"] @router.get("/shift-log") def shift_order_log( db: Session = Depends(get_db), user: User = Depends(get_current_user), ): """ Returns all orders placed during the current waiter's active shift, newest first. Managers get all orders for the current business day. Includes print job status per order. """ from models.shift import WaiterShift from models.business_day import BusinessDay is_manager = user.role == "superadmin" or user.perm_access_dashboard if is_manager: bd = db.query(BusinessDay).filter(BusinessDay.closed_at == None).order_by(BusinessDay.opened_at.desc()).first() # noqa: E711 since = bd.opened_at if bd else None q = db.query(Order).filter(Order.status.notin_(["cancelled"])) if since: q = q.filter(Order.opened_at >= since) else: shift = db.query(WaiterShift).filter( WaiterShift.waiter_id == user.id, WaiterShift.ended_at == None, # noqa: E711 ).order_by(WaiterShift.started_at.desc()).first() if not shift: return {"orders": []} from models.order import OrderWaiter order_ids = [ow.order_id for ow in db.query(OrderWaiter).filter(OrderWaiter.waiter_id == user.id).all()] q = db.query(Order).filter( Order.id.in_(order_ids), Order.opened_at >= shift.started_at, Order.status.notin_(["cancelled"]), ) orders = q.order_by(Order.opened_at.desc()).limit(200).all() result = [] for order in orders: table_name = None if order.table: table_name = order.table.label or f"T{order.table.number}" # Collect print jobs for this order jobs = db.query(PrintJob).filter(PrintJob.order_id == order.id).all() has_pending_print = any(j.status == "pending" for j in jobs) print_status = "success" if has_pending_print: print_status = "pending" elif any(j.status == "cancelled" and j.cancel_reason == "staff_cancelled" for j in jobs): print_status = "cancelled" elif not jobs: # No print jobs at all — either no printers configured or bypass mode print_status = "success" latest_attempt = max((j.last_attempted_at for j in jobs if j.last_attempted_at), default=None) max_retries = max((j.retry_count for j in jobs), default=0) active_items = [i for i in order.items if i.status == "active"] paid_items = [i for i in order.items if i.status == "paid"] all_items = active_items + paid_items result.append({ "id": order.id, "table_name": table_name, "opened_at": order.opened_at.isoformat() if order.opened_at else None, "status": order.status, "print_status": print_status, "fiscal_status": order.fiscal_status, # None | "pending" | "success" | "failed" "print_attempted_at": latest_attempt.isoformat() if latest_attempt else None, "print_retry_count": max_retries, "print_jobs": [ { "id": j.id, "status": j.status, "retry_count": j.retry_count, "first_attempted_at": j.first_attempted_at.isoformat() if j.first_attempted_at else None, "last_attempted_at": j.last_attempted_at.isoformat() if j.last_attempted_at else None, } for j in jobs ], "items": [ { "id": i.id, "product_name": i.product.name if i.product else f"#{i.product_id}", "quantity": i.quantity, "unit_price": i.unit_price, "price_adjustment": i.price_adjustment or 0.0, "status": i.status, } for i in all_items ], "total": sum((i.unit_price + (i.price_adjustment or 0.0)) * i.quantity for i in all_items), }) return {"orders": result} @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 == "superadmin" or user.perm_access_dashboard 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, waiter_id: Optional[int] = None, all: bool = False, db: Session = Depends(get_db), user: User = Depends(require_manager), ): q = db.query(Order) if order_status: q = q.filter(Order.status == order_status) elif not all: q = q.filter(Order.status.in_(ACTIVE_STATUSES)) if waiter_id: q = q.join(OrderWaiter).filter(OrderWaiter.waiter_id == waiter_id) return q.all() @router.get("/my", response_model=List[OrderOut]) def my_orders(db: Session = Depends(get_db), user: User = Depends(get_current_user)): direct = db.query(Order).join(OrderWaiter).filter( OrderWaiter.waiter_id == user.id, Order.status.in_(["open", "partially_paid"]), ).all() # Also orders where user is opener but not explicitly assigned also_opened = db.query(Order).filter( Order.opened_by == user.id, Order.status.in_(["open", "partially_paid"]), ).all() seen = {o.id for o in direct} return direct + [o for o in also_opened if o.id not in seen] class ActiveOrderSlim(BaseModel): id: int table_id: Optional[int] status: str order_type: str = "here" waiter_ids: List[int] model_config = {"from_attributes": True} @router.get("/active", response_model=List[ActiveOrderSlim]) def list_active_orders(db: Session = Depends(get_db), user: User = Depends(get_current_user)): """All currently open/partially-paid/paid orders (lightweight). Accessible to all staff.""" orders = db.query(Order).filter(Order.status.in_(["open", "partially_paid", "paid"])).all() return [ ActiveOrderSlim( id=o.id, table_id=o.table_id, status=o.status, order_type=o.order_type or "here", waiter_ids=[w.waiter_id for w in o.waiters], ) for o in orders ] # ─── Pending print jobs count (for PWA header badge) ───────────────────────── @router.get("/pending-prints") def get_pending_prints( db: Session = Depends(get_db), user: User = Depends(get_current_user), ): """Returns count of pending print jobs + per-order detail for the current shift.""" pending_jobs = db.query(PrintJob).filter(PrintJob.status == "pending").all() from collections import defaultdict by_order: dict = defaultdict(list) for pj in pending_jobs: by_order[pj.order_id].append(pj) orders_data = [] for order_id, jobs in by_order.items(): order = db.query(Order).filter(Order.id == order_id).first() if not order or order.status in ("closed", "cancelled"): continue table_name = None if order.table: table_name = order.table.label or f"T{order.table.number}" orders_data.append({ "order_id": order_id, "table_name": table_name, "opened_at": order.opened_at.isoformat() if order.opened_at else None, "jobs": [ { "id": pj.id, "printer_id": pj.printer_id, "retry_count": pj.retry_count, "first_attempted_at": pj.first_attempted_at.isoformat() if pj.first_attempted_at else None, "last_attempted_at": pj.last_attempted_at.isoformat() if pj.last_attempted_at else None, "item_ids": json.loads(pj.item_ids or "[]"), } for pj in jobs ], }) return {"count": len(pending_jobs), "orders": orders_data} @router.get("/{order_id}") def get_order(order_id: int, 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") if not _can_access_order(order, user, db): raise HTTPException(status_code=403, detail="Access denied") # Resolve all user IDs referenced by this order in one query user_ids = set() user_ids.add(order.opened_by) if order.closed_by: user_ids.add(order.closed_by) for item in order.items: user_ids.add(item.added_by) if item.paid_by: user_ids.add(item.paid_by) for log in order.audit_logs: if log.waiter_id: user_ids.add(log.waiter_id) users = db.query(User).filter(User.id.in_(user_ids)).all() name_map = {u.id: u.nickname or u.full_name or u.username for u in users} def fmt_item(i): return { "id": i.id, "order_id": i.order_id, "product_id": i.product_id, "product": {"id": i.product.id, "name": i.product.name} if i.product else None, "added_by": i.added_by, "added_by_name": name_map.get(i.added_by), "quantity": i.quantity, "unit_price": i.unit_price, "selected_options": i.selected_options, "removed_ingredients": i.removed_ingredients, "notes": i.notes, "status": i.status, "kds_status": i.kds_status or "pending", "added_at": _iso(i.added_at), "printed": i.printed, "paid_by": i.paid_by, "paid_by_name": name_map.get(i.paid_by) if i.paid_by else None, "paid_at": _iso(i.paid_at), "payment_method": i.payment_method, "paid_in_shift_id": i.paid_in_shift_id, "price_adjustment": getattr(i, 'price_adjustment', 0.0) or 0.0, "unit_type": i.unit_type, } def fmt_log(l): return { "id": l.id, "order_id": l.order_id, "event_type": l.event_type, "waiter_id": l.waiter_id, "waiter_name": name_map.get(l.waiter_id) if l.waiter_id else None, "item_ids": l.item_ids, "amount": l.amount, "payment_method": l.payment_method, "note": l.note, "created_at": _iso(l.created_at), "offline_at": _iso(l.offline_at) if isinstance(l.offline_at, datetime) else l.offline_at, "is_duplicate": l.is_duplicate, } table = db.query(Table).filter(Table.id == order.table_id).first() if order.table_id else None table_name = (table.label or f"T{table.number}") if table else None # Phase 2F: resolve customer name for display customer_name = None customer_phone = None if order.customer_id and order.customer: customer_name = order.customer.name if order.customer.nickname: customer_name = f"{order.customer.name} «{order.customer.nickname}»" customer_phone = order.customer.phone return { "id": order.id, "table_id": order.table_id, "table_name": table_name, "order_type": order.order_type or "here", "opened_by": order.opened_by, "opened_at": _iso(order.opened_at), "status": order.status, "closed_at": _iso(order.closed_at), "closed_by": order.closed_by, "notes": order.notes, "business_day_id": order.business_day_id, "customer_id": order.customer_id, "customer_name": customer_name, "customer_phone": customer_phone, "kds_status": order.kds_status, "kds_status_changed_at": _iso(order.kds_status_changed_at), "customer_count": order.customer_count, "items": [fmt_item(i) for i in order.items], "waiters": [{"waiter_id": w.waiter_id} for w in order.waiters], "audit_logs": [fmt_log(l) for l in order.audit_logs], } @router.post("/", response_model=OrderOut, status_code=status.HTTP_201_CREATED) def open_order(body: OrderCreate, db: Session = Depends(get_db), user: User = Depends(get_current_user)): from models.business_day import BusinessDay from models.shift import WaiterShift if not (user.role == "superadmin" or user.perm_access_dashboard or user.perm_open_orders): raise HTTPException(status_code=403, detail="Δεν έχετε δικαίωμα ανοίγματος παραγγελιών") active_day = db.query(BusinessDay).filter(BusinessDay.status == "open").first() if not active_day: raise HTTPException(status_code=403, detail="Restaurant is not open — manager must open the business day first") if user.perm_access_waiter_app and not user.perm_access_dashboard and user.role != "superadmin": active_shift = db.query(WaiterShift).filter( WaiterShift.waiter_id == user.id, WaiterShift.ended_at == None, ).first() if not active_shift: raise HTTPException(status_code=403, detail="You do not have an active shift") order_type = body.order_type or "here" if order_type not in ("here", "takeaway", "delivery"): raise HTTPException(status_code=400, detail="Invalid order_type") if body.table_id is not None: existing = db.query(Order).filter( Order.table_id == body.table_id, Order.status.in_(["open", "partially_paid", "paid"]), ).first() if existing: raise HTTPException(status_code=400, detail="Table already has an open order") order = Order(table_id=body.table_id, order_type=order_type, opened_by=user.id, business_day_id=active_day.id) 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) broadcast_sync("order_updated", {"order_id": order.id, "table_id": order.table_id, "status": order.status, "action": "opened"}) return order @router.post("/{order_id}/items", response_model=AddItemsResponse) def add_items( order_id: int, body: AddItemsRequest, 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") if not _can_access_order(order, user, db): raise HTTPException(status_code=403, detail="Access denied") if order.status not in ("open", "partially_paid", "paid"): raise HTTPException(status_code=400, detail="Order is not open") # Adding items to a fully-paid order reopens it — partially_paid since prior items were paid if order.status == "paid": order.status = "partially_paid" new_item_ids = [] service_print_entries = [] # { product, quantity } — printed but not saved pending_price_events = [] # (order_item_id, [PriceEvent]) — flushed after commit # Build pricing context once for the whole batch now = datetime.now(timezone.utc) active_pg_ids = build_active_price_group_ids(db, now) # Determine order channel _channel_map = {"online": "online", "takeaway": "takeaway", "delivery": "takeaway"} order_channel = _channel_map.get(order.order_type, "pos") if order.source == "online": order_channel = "online" # Snapshot existing cart items for condition evaluation existing_cart = [ { "product_id": i.product_id, "category_id": i.product.category_id if i.product else None, "quantity": i.quantity, "unit_price": i.unit_price, } for i in order.items if i.status == "active" ] for item_in in body.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") # Priceless service items: print-once, NOT persisted (ephemeral) # Priced service items: saved as real order items AND printed in a SERVICE section is_svc = item_in.is_service_item or getattr(product, 'is_service_item', False) if is_svc and not (product.base_price and product.base_price > 0): service_print_entries.append({'product': product, 'quantity': item_in.quantity}) continue extra_cost = sum( (o.price_delta or o.extra_cost or 0.0) for o in (item_in.selected_options or []) ) # Build OrderContext for this item (includes items added so far in this batch) ctx = OrderContext( channel=order_channel, now=now, cart_items=existing_cart + [ { "product_id": product.id, "category_id": product.category_id, "quantity": item_in.quantity, "unit_price": product.base_price + extra_cost, } ], active_price_group_ids=active_pg_ids, ) # Resolve final unit_price through pricing engine unit_price, price_events = resolve_price(product, item_in.quantity, extra_cost, ctx, db) # Phase 2A: cost snapshot — breakdown takes priority over simple cost unit_cost = None if product.cost_breakdown: try: entries = json.loads(product.cost_breakdown) total = sum(e.get("amount", 0.0) for e in entries if isinstance(e, dict)) if total > 0: unit_cost = total except Exception: pass if unit_cost is None and product.cost_simple: unit_cost = product.cost_simple item = OrderItem( order_id=order_id, product_id=item_in.product_id, added_by=user.id, quantity=item_in.quantity, unit_price=unit_price, unit_cost=unit_cost, price_adjustment=item_in.price_adjustment or 0.0, 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, course_id=item_in.course_id, ) db.add(item) db.flush() new_item_ids.append(item.id) # Stamp order_id on events and queue them — written after commit if price_events: for ev in price_events: ev.order_id = order_id pending_price_events.append((item.id, price_events)) # Update rolling cart snapshot so subsequent items in this batch see correct context existing_cart.append({ "product_id": product.id, "category_id": product.category_id, "quantity": item_in.quantity, "unit_price": unit_price, }) # Attach the whole-order note (only overwrite if a note was explicitly provided) if body.order_note is not None: order.notes = body.order_note or None # empty string → clear note # Update customer count if provided if body.customer_count is not None: order.customer_count = body.customer_count bypass_setting = db.query(PosSettings).filter(PosSettings.key == "orders.bypass_kds_serve").first() global_bypass_kds = bypass_setting and bypass_setting.value == "true" if global_bypass_kds: # Global setting: all new items go straight to served for item in order.items: if item.id in new_item_ids: item.kds_status = "served" order.kds_status = "served" else: # Apply per-zone bypass for each new item individually from models.prep_zone import PrepZone new_items_map = {item.id: item for item in order.items if item.id in set(new_item_ids)} for item in new_items_map.values(): product = db.query(Product).filter(Product.id == item.product_id).first() if not product or not product.prep_zones: continue # Zone with strongest bypass wins: bypass_kds > bypass_pending zone_bypass_kds = any(bool(z.bypass_kds) for z in product.prep_zones) zone_bypass_pending = any(bool(z.bypass_pending) for z in product.prep_zones) if zone_bypass_kds: item.kds_status = "served" elif zone_bypass_pending: item.kds_status = "done" # Recalculate order-level kds_status from all items all_active = [i for i in order.items if i.status != "cancelled"] if all_active: statuses = {i.kds_status for i in all_active} if statuses <= {"served"}: order.kds_status = "served" elif statuses <= {"done", "served"}: order.kds_status = "done" elif "pending" in statuses or "preparing" in statuses: # Pull back to pending so kitchen sees new items if order.kds_status in ("done", "served"): order.kds_status = "pending" _audit(db, order_id, "ITEMS_ADDED", waiter_id=user.id, item_ids=new_item_ids) db.commit() # Write price events now that OrderItems are committed and have real IDs if pending_price_events: for item_id, events in pending_price_events: write_price_events(events, order_id, item_id, db) db.commit() db.refresh(order) # Evaluate deals against updated cart — return offers to surface to waiter from models.pricing import PriceEventLog already_fired = { row.deal_id for row in db.query(PriceEventLog.deal_id).filter( PriceEventLog.order_id == order_id, PriceEventLog.deal_id != None, # noqa: E711 PriceEventLog.event_type.in_(["deal_offer_shown", "deal_offer_accepted"]), ).all() if row.deal_id is not None } final_cart = [ { "product_id": i.product_id, "category_id": i.product.category_id if i.product else None, "quantity": i.quantity, "unit_price": i.unit_price, } for i in order.items if i.status == "active" ] deal_ctx = OrderContext( channel=order_channel, now=now, cart_items=final_cart, active_price_group_ids=active_pg_ids, ) deal_offers = evaluate_deals(order_id, deal_ctx, already_fired, db) # Log deal_offer_shown for each new offer if deal_offers: for offer in deal_offers: db.add(PriceEventLog( order_id=order_id, event_type="deal_offer_shown", deal_id=offer.deal_id, applied_by_user_id=user.id, )) db.commit() print_results = route_and_print_sync(order_id, new_item_ids, db, ephemeral_svc_entries=service_print_entries) if (new_item_ids or service_print_entries) else [] broadcast_sync("order_updated", {"order_id": order.id, "table_id": order.table_id, "status": order.status, "action": "items_added", "item_ids": new_item_ids}) broadcast_sync("kds_order_updated", {"order_id": order.id, "kds_status": order.kds_status}) return { "order": order, "print_results": print_results, "deal_offers": [ { "deal_id": o.deal_id, "deal_name": o.deal_name, "action_type": o.action_type, "free_item_id": o.free_item_id, "free_item_name": o.free_item_name, "free_choices": o.free_choices, "free_quantity": o.free_quantity, } for o in deal_offers ], } @router.post("/{order_id}/retry-print", response_model=AddItemsResponse) def retry_print( order_id: int, 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") if not _can_access_order(order, user, db): raise HTTPException(status_code=403, detail="Access denied") unprinted_ids = [item.id for item in order.items if not item.printed and item.status == "active"] if not unprinted_ids: return {"order": order, "print_results": []} print_results = route_and_print_sync(order_id, unprinted_ids, db) db.refresh(order) return {"order": order, "print_results": print_results} @router.put("/{order_id}/items/{item_id}", response_model=OrderItemOut) def edit_item(order_id: int, item_id: int, notes: Optional[str] = None, db: Session = Depends(get_db), user: User = Depends(require_manager)): 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 notes is not None: item.notes = notes db.commit() db.refresh(item) return item @router.delete("/{order_id}/items/{item_id}", status_code=status.HTTP_204_NO_CONTENT) 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 == "superadmin" or user.perm_access_dashboard 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 cancelled_ids = [item_id] # Cascade: also cancel any deal-linked items bound to this one (free items that came with it) linked = db.query(OrderItem).filter( OrderItem.linked_item_id == item_id, OrderItem.order_id == order_id, OrderItem.status == "active", ).all() for li in linked: li.status = "cancelled" li.cancelled_by = user.id li.cancelled_at = now cancelled_ids.append(li.id) _audit(db, order_id, "ITEM_CANCELLED", waiter_id=user.id, item_ids=cancelled_ids) db.commit() should_print = print_cancellation if is_manager else True if should_print: background_tasks.add_task(print_cancellation_ticket, order_id, cancelled_ids) @router.post("/{order_id}/pay") def pay_items(order_id: int, body: PayItemsRequest, 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") if not _can_access_order(order, user, db): raise HTTPException(status_code=403, detail="Access denied") from models.shift import WaiterShift from services.fiscal_service import ( is_fiscal_enabled, validate_items_for_fiscal, build_fiscal_items, send_fiscal_receipt_background, ) items = db.query(OrderItem).filter( OrderItem.id.in_(body.item_ids), OrderItem.order_id == order_id, OrderItem.status.in_(["active", "ready"]), # ready items are also payable ).all() # Fiscal validation: if enabled, all items must have VAT group assigned before payment fiscal_on = is_fiscal_enabled(db) if fiscal_on and items: missing = validate_items_for_fiscal(items, db) if missing: names = ", ".join(missing) raise HTTPException( status_code=422, detail=f"Fiscal printing is ON but the following products have no VAT group assigned: {names}" ) now = datetime.now(timezone.utc) active_shift = db.query(WaiterShift).filter( WaiterShift.waiter_id == user.id, WaiterShift.ended_at == None, ).first() effective_method = body.payment_method or "cash" total_paid = 0.0 for item in items: item.status = "paid" item.paid_by = user.id item.paid_at = now item.payment_method = effective_method item.paid_in_shift_id = active_shift.id if active_shift else None adj = getattr(item, 'price_adjustment', 0.0) or 0.0 total_paid += (item.unit_price + adj) * item.quantity db.flush() # write item status changes before counting, since autoflush=False active_remaining = db.query(OrderItem).filter( OrderItem.order_id == order_id, OrderItem.status.in_(["active", "ready"]) ).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) auto_closed = False if order.status == "paid": setting = db.query(PosSettings).filter(PosSettings.key == "orders.auto_close_on_full_payment").first() if setting and setting.value == "true": for oi in order.items: if oi.kds_status != "served": oi.kds_status = "served" order.kds_status = "served" order.status = "closed" order.closed_at = now order.closed_by = user.id auto_closed = True if fiscal_on and items: order.fiscal_status = "pending" db.commit() broadcast_sync("order_paid", {"order_id": order_id, "table_id": order.table_id, "status": order.status, "paid_item_ids": paid_ids, "amount": total_paid, "payment_method": body.payment_method}) if auto_closed: broadcast_sync("order_closed", {"order_id": order_id, "table_id": order.table_id, "auto_closed": True}) if fiscal_on and items: from database import SessionLocal fiscal_items = build_fiscal_items(items) send_fiscal_receipt_background(fiscal_items, effective_method, total_paid, db, order_id=order_id, db_factory=SessionLocal, timeout_seconds=60) return { "status": order.status, "paid_item_ids": paid_ids, "auto_closed": auto_closed, "fiscal": "pending" if fiscal_on and items else None, } @router.post("/{order_id}/revert-payment") def revert_payment(order_id: int, body: RevertPaymentRequest, 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") if not _can_access_order(order, user, db): raise HTTPException(status_code=403, detail="Access denied") revert_setting = db.query(PosSettings).filter(PosSettings.key == "payments.waiter_revert_allowed").first() if not revert_setting or revert_setting.value != "true": raise HTTPException(status_code=403, detail="Payment revert is not enabled") items = db.query(OrderItem).filter( OrderItem.id.in_(body.item_ids), OrderItem.order_id == order_id, OrderItem.status == "paid", ).all() if not items: raise HTTPException(status_code=404, detail="No paid items found to revert") reverted_ids = [] for item in items: item.status = "active" item.paid_by = None item.paid_at = None item.payment_method = None item.paid_in_shift_id = None reverted_ids.append(item.id) db.flush() active_remaining = db.query(OrderItem).filter( OrderItem.order_id == order_id, OrderItem.status.in_(["active", "ready"]) ).count() paid_remaining = db.query(OrderItem).filter( OrderItem.order_id == order_id, OrderItem.status == "paid" ).count() if active_remaining > 0 and paid_remaining > 0: order.status = "partially_paid" elif active_remaining > 0: order.status = "open" # if somehow all remaining are paid after revert (shouldn't happen), leave status _audit(db, order_id, "PAYMENT_REVERTED", waiter_id=user.id, item_ids=reverted_ids) db.commit() broadcast_sync("order_updated", {"order_id": order_id, "table_id": order.table_id, "status": order.status}) return {"status": order.status, "reverted_item_ids": reverted_ids} @router.post("/{order_id}/close") def close_order(order_id: int, 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") if not (user.role == "superadmin" or user.perm_access_dashboard or user.perm_close_orders): raise HTTPException(status_code=403, detail="Δεν έχετε δικαίωμα κλεισίματος παραγγελιών") if not _can_access_order(order, user, db): raise HTTPException(status_code=403, detail="Access denied") if order.status not in ("paid", "open", "partially_paid"): raise HTTPException(status_code=400, detail="Cannot close order in current status") now = datetime.now(timezone.utc) # Mark all still-active items as 'closed' — unpaid, closed by manager active_items = db.query(OrderItem).filter( OrderItem.order_id == order_id, OrderItem.status == "active", ).all() closed_item_ids = [] for item in active_items: item.status = "closed" closed_item_ids.append(item.id) # Mark every non-served item as served on KDS so the order disappears from KDS immediately for item in order.items: if item.kds_status != "served": item.kds_status = "served" order.kds_status = "served" order.status = "closed" order.closed_at = now order.closed_by = user.id note = f"Κλείσιμο από manager — {len(closed_item_ids)} απλήρωτα αντικείμενα" if closed_item_ids else None _audit(db, order_id, "ORDER_CLOSED", waiter_id=user.id, item_ids=closed_item_ids if closed_item_ids else None, note=note) db.commit() broadcast_sync("order_closed", {"order_id": order_id, "table_id": order.table_id}) return {"status": "closed"} @router.post("/{order_id}/pay-offline") def pay_items_offline( order_id: int, body: OfflinePaymentRequest, db: Session = Depends(get_db), user: User = Depends(get_current_user), ): """ Sync an emergency payment that was taken while the server was offline. The UUID prevents double-processing. If a payment with the same UUID already exists on this order, the duplicate is logged in red (is_duplicate=1) rather than silently dropped — so managers can reconcile. """ order = db.query(Order).filter(Order.id == order_id).first() if not order: raise HTTPException(status_code=404, detail="Order not found") if not _can_access_order(order, user, db): raise HTTPException(status_code=403, detail="Access denied") # Check for duplicate UUID on this order existing_uuid = db.query(OrderAuditLog).filter( OrderAuditLog.order_id == order_id, OrderAuditLog.offline_uuid == body.uuid, ).first() is_duplicate = existing_uuid is not None from models.shift import WaiterShift items = db.query(OrderItem).filter( OrderItem.id.in_(body.item_ids), OrderItem.order_id == order_id, OrderItem.status == "active", ).all() # Reject empty payments — client had no offline snapshot for this table if not items and not is_duplicate: raise HTTPException(status_code=400, detail="No active items found — payment rejected") # Use the client-recorded offline timestamp as paid_at so audit reflects real payment time try: paid_at = datetime.fromisoformat(body.offline_at.replace("Z", "+00:00")) if body.offline_at else datetime.now(timezone.utc) except (ValueError, AttributeError): paid_at = datetime.now(timezone.utc) active_shift = db.query(WaiterShift).filter( WaiterShift.waiter_id == user.id, WaiterShift.ended_at == None, ).first() effective_method = body.payment_method or "cash" total_paid = 0.0 paid_ids = [] if not is_duplicate: for item in items: item.status = "paid" item.paid_by = user.id item.paid_at = paid_at item.payment_method = effective_method item.paid_in_shift_id = active_shift.id if active_shift else None adj = getattr(item, 'price_adjustment', 0.0) or 0.0 total_paid += (item.unit_price + adj) * item.quantity paid_ids.append(item.id) db.flush() 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" else: # Duplicate — compute total for audit record without changing item state total_paid = sum((i.unit_price + (getattr(i, 'price_adjustment', 0.0) or 0.0)) * i.quantity for i in items) paid_ids = [i.id for i in items] # Always write audit log — duplicate flag makes it visible in red in manager dashboard db.add(OrderAuditLog( order_id=order_id, event_type="PAYMENT_OFFLINE", waiter_id=user.id, item_ids=json.dumps(paid_ids), amount=total_paid, payment_method=body.payment_method, note=f"Emergency offline payment (uuid={body.uuid}){' — DUPLICATE' if is_duplicate else ''}", offline_uuid=body.uuid, offline_at=body.offline_at, is_duplicate=1 if is_duplicate else 0, )) db.commit() if not is_duplicate: broadcast_sync("order_paid", {"order_id": order_id, "table_id": order.table_id, "status": order.status, "paid_item_ids": paid_ids, "amount": total_paid, "payment_method": body.payment_method}) return { "status": order.status if not is_duplicate else "duplicate", "paid_item_ids": paid_ids, "is_duplicate": is_duplicate, } @router.delete("/{order_id}", status_code=status.HTTP_204_NO_CONTENT) 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 == "superadmin" or user.perm_access_dashboard 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) active_items = db.query(OrderItem).filter( OrderItem.order_id == order_id, OrderItem.status == "active", ).all() 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"Ακύρωση — {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 == "superadmin" or user.perm_access_dashboard 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) # Cascade: cancel deal-linked items (free items bound to this trigger item) linked = db.query(OrderItem).filter( OrderItem.linked_item_id == item.id, OrderItem.order_id == order_id, OrderItem.status == "active", ).all() for li in linked: li.status = "cancelled" li.cancelled_by = user.id li.cancelled_at = now cancelled_ids.append(li.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)): order = db.query(Order).filter(Order.id == order_id).first() if not order: raise HTTPException(status_code=404, detail="Order not found") existing = db.query(OrderWaiter).filter( OrderWaiter.order_id == order_id, OrderWaiter.waiter_id == body.waiter_id ).first() if existing: raise HTTPException(status_code=400, detail="Waiter already assigned") db.add(OrderWaiter(order_id=order_id, waiter_id=body.waiter_id)) db.commit() return {"status": "assigned"} class AssignCustomerBody(BaseModel): customer_id: Optional[int] = None # null = unassign class TabItemBody(BaseModel): pass # no body needed — tab derived from order's customer @router.put("/{order_id}/customer") def assign_customer(order_id: int, body: AssignCustomerBody, db: Session = Depends(get_db), user: User = Depends(require_manager)): from models.customers import Customer order = db.query(Order).filter(Order.id == order_id).first() if not order: raise HTTPException(status_code=404, detail="Order not found") if body.customer_id is not None: customer = db.query(Customer).filter(Customer.id == body.customer_id, Customer.is_active == True).first() if not customer: raise HTTPException(status_code=404, detail="Customer not found") order.customer_id = body.customer_id db.commit() broadcast_sync("order_updated", {"order_id": order.id, "table_id": order.table_id, "status": order.status, "action": "customer_assigned"}) return {"status": "ok", "customer_id": order.customer_id} @router.delete("/{order_id}/waiters/{waiter_id}", status_code=status.HTTP_204_NO_CONTENT) def remove_waiter(order_id: int, waiter_id: int, db: Session = Depends(get_db), user: User = Depends(require_manager)): assignment = db.query(OrderWaiter).filter( OrderWaiter.order_id == order_id, OrderWaiter.waiter_id == waiter_id ).first() if not assignment: 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}" adj = getattr(item, 'price_adjustment', 0.0) or 0.0 effective_price = item.unit_price + adj items_data.append({ "name": product_name, "quantity": item.quantity, "unit_type": item.product.unit_type if item.product else "piece", "unit_price": effective_price, "total": effective_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": local_strftime(order.opened_at, "%d/%m/%Y %H:%M"), "closed_at": local_strftime(order.closed_at, "%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, printer.line_width, printer.codepage_n) return {"status": "printing"} # ─── Transfer order to a different table ───────────────────────────────────── @router.post("/{order_id}/transfer") def transfer_order( order_id: int, body: TransferOrderRequest, 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") if not _can_access_order(order, user, db): raise HTTPException(status_code=403, detail="Access denied") if order.status not in ("open", "partially_paid", "paid"): raise HTTPException(status_code=400, detail="Order is not active") target_table = db.query(Table).filter(Table.id == body.target_table_id, Table.is_active == True).first() if not target_table: raise HTTPException(status_code=404, detail="Target table not found") if body.target_table_id == order.table_id: raise HTTPException(status_code=400, detail="Table is already assigned to this order") conflict = db.query(Order).filter( Order.table_id == body.target_table_id, Order.status.in_(["open", "partially_paid", "paid"]), ).first() if conflict: raise HTTPException(status_code=400, detail="Target table already has an active order") old_table_id = order.table_id order.table_id = body.target_table_id _audit(db, order_id, "TABLE_TRANSFER", waiter_id=user.id, note=f"Transferred from table {old_table_id} to table {body.target_table_id}") db.commit() db.refresh(order) broadcast_sync("order_updated", {"order_id": order.id, "table_id": order.table_id, "old_table_id": old_table_id, "status": order.status, "action": "transferred"}) return order # ─── Merge another order into this one ─────────────────────────────────────── @router.post("/{order_id}/merge") def merge_order( order_id: int, body: MergeOrderRequest, db: Session = Depends(get_db), user: User = Depends(get_current_user), ): """ Merge source order (order_id) INTO target order (body.target_order_id). All items (paid + active) from the source are reassigned to the target. Source waiters are added to the target if not already there. Source order is cancelled with audit note. """ source = db.query(Order).filter(Order.id == order_id).first() if not source: raise HTTPException(status_code=404, detail="Source order not found") if not _can_access_order(source, user, db): raise HTTPException(status_code=403, detail="Access denied") if source.status not in ("open", "partially_paid", "paid"): raise HTTPException(status_code=400, detail="Source order is not active") target = db.query(Order).filter(Order.id == body.target_order_id).first() if not target: raise HTTPException(status_code=404, detail="Target order not found") if not _can_access_order(target, user, db): raise HTTPException(status_code=403, detail="Access denied to target order") if target.status not in ("open", "partially_paid", "paid"): raise HTTPException(status_code=400, detail="Target order is not active") if source.id == target.id: raise HTTPException(status_code=400, detail="Cannot merge an order with itself") # Move all items to target order moved_item_ids = [] for item in source.items: item.order_id = target.id moved_item_ids.append(item.id) # Copy source waiters to target (no duplicates) existing_waiter_ids = {w.waiter_id for w in target.waiters} for ow in source.waiters: if ow.waiter_id not in existing_waiter_ids: db.add(OrderWaiter(order_id=target.id, waiter_id=ow.waiter_id)) # Recompute target status after flush db.flush() active_remaining = db.query(OrderItem).filter( OrderItem.order_id == target.id, OrderItem.status == "active" ).count() paid_exists = db.query(OrderItem).filter( OrderItem.order_id == target.id, OrderItem.status == "paid" ).count() if active_remaining > 0: target.status = "partially_paid" if paid_exists > 0 else "open" else: target.status = "paid" # Cancel source order source.status = "cancelled" source.closed_at = datetime.now(timezone.utc) source.closed_by = user.id _audit(db, source.id, "ORDER_CANCELLED", waiter_id=user.id, note=f"Merged into order #{target.id} (table {target.table_id})") _audit(db, target.id, "ITEMS_ADDED", waiter_id=user.id, item_ids=moved_item_ids, note=f"Items merged from order #{source.id} (table {source.table_id})") db.commit() db.refresh(target) broadcast_sync("order_updated", {"order_id": target.id, "table_id": target.table_id, "status": target.status, "action": "merged"}) broadcast_sync("order_closed", {"order_id": source.id, "table_id": source.table_id}) return target # ─── Split a stacked item into two rows ────────────────────────────────────── @router.post("/{order_id}/items/{item_id}/split", response_model=List[OrderItemOut]) def split_item( order_id: int, item_id: int, body: SplitItemRequest, db: Session = Depends(get_db), user: User = Depends(get_current_user), ): """ Split qty units off item_id into a new item row. Both rows share all properties (product, price, options, notes). Only active items can be split. """ 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 != "active": raise HTTPException(status_code=400, detail="Only active items can be split") if body.quantity <= 0 or body.quantity >= item.quantity: raise HTTPException( status_code=400, detail=f"Split quantity must be between 1 and {item.quantity - 1}" ) order = db.query(Order).filter(Order.id == order_id).first() if not _can_access_order(order, user, db): raise HTTPException(status_code=403, detail="Access denied") # Reduce original item item.quantity -= body.quantity # Create split-off item new_item = OrderItem( order_id=order_id, product_id=item.product_id, added_by=item.added_by, quantity=body.quantity, unit_price=item.unit_price, selected_options=item.selected_options, removed_ingredients=item.removed_ingredients, notes=item.notes, status="active", printed=item.printed, ) db.add(new_item) db.commit() db.refresh(item) db.refresh(new_item) return [item, new_item] # ─── Adjust price on an existing item ──────────────────────────────────────── class PriceAdjustRequest(BaseModel): price_adjustment: float # delta from unit_price (can be negative) @router.patch("/{order_id}/items/{item_id}/price-adjust", response_model=OrderItemOut) def adjust_item_price( order_id: int, item_id: int, body: PriceAdjustRequest, db: Session = Depends(get_db), user: User = Depends(get_current_user), ): """Apply an on-the-fly price adjustment to an active order item.""" if not (user.role == "superadmin" or user.perm_access_dashboard or user.perm_modify_prices): raise HTTPException(status_code=403, detail="Δεν έχετε δικαίωμα τροποποίησης τιμών") setting = db.query(PosSettings).filter(PosSettings.key == "orders.waiter_price_adjust_allowed").first() if not setting or setting.value != "true": raise HTTPException(status_code=403, detail="Price adjustment is disabled") 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 != "active": raise HTTPException(status_code=400, detail="Only active items can have price adjusted") order = db.query(Order).filter(Order.id == order_id).first() if not _can_access_order(order, user, db): raise HTTPException(status_code=403, detail="Access denied") item.price_adjustment = body.price_adjustment db.commit() db.refresh(item) broadcast_sync("order_updated", {"order_id": order_id, "table_id": order.table_id, "status": order.status, "action": "item_price_adjusted"}) return item # ─── Waiter-applied manual discount ───────────────────────────────────────── from schemas.pricing import ApplyDiscountRequest, ApplyDiscountResponse @router.post("/{order_id}/items/discount", response_model=ApplyDiscountResponse) def apply_waiter_discount( order_id: int, body: ApplyDiscountRequest, db: Session = Depends(get_db), user: User = Depends(get_current_user), ): """ Apply a manual percentage discount to one or more active items. Enforces per-waiter and global limits. Logs every application to both order_discounts and price_event_log. Final item price rounds to nearest 0.10. """ order = db.query(Order).filter(Order.id == order_id).first() if not order: raise HTTPException(status_code=404, detail="Order not found") if not (user.role == "superadmin" or user.perm_access_dashboard or user.perm_apply_discounts): raise HTTPException(status_code=403, detail="Δεν έχετε δικαίωμα εφαρμογής εκπτώσεων") if not _can_access_order(order, user, db): raise HTTPException(status_code=403, detail="Access denied") if body.discount_percent <= 0 or body.discount_percent > 100: raise HTTPException(status_code=400, detail="discount_percent must be between 0 and 100") # Fetch all target items items = db.query(OrderItem).filter( OrderItem.id.in_(body.item_ids), OrderItem.order_id == order_id, OrderItem.status == "active", ).all() if not items: raise HTTPException(status_code=404, detail="No active items found for given IDs") item_unit_prices = {i.id: i.unit_price + i.price_adjustment for i in items} # Validate limits before touching anything ok, error_msg = check_waiter_discount_limits( waiter_id=user.id, order_id=order_id, item_ids=body.item_ids, discount_percent=body.discount_percent, item_unit_prices=item_unit_prices, db=db, ) if not ok: raise HTTPException(status_code=403, detail=error_msg) from models.pricing import PriceEventLog applied = [] for item in items: effective_before = item.unit_price + item.price_adjustment discount_euro = round(effective_before * body.discount_percent / 100.0, 4) new_effective = _system_round(effective_before - discount_euro) # Store as price_adjustment delta (keeps unit_price as the original resolver result) new_adj = round(new_effective - item.unit_price, 4) price_before = effective_before price_after = new_effective item.price_adjustment = new_adj # Write to order_discounts (existing table, extended) db.add(OrderDiscount( order_id=order_id, item_id=item.id, discount_type="percent", discount_value=body.discount_percent, applied_by=user.id, reason=body.note, price_before=price_before, price_after=price_after, )) # Write to price_event_log (immutable audit spine) db.add(PriceEventLog( order_id=order_id, order_item_id=item.id, event_type="waiter_discount", price_before=price_before, price_after=price_after, delta_amount=round(price_after - price_before, 4), applied_by_user_id=user.id, waiter_note=body.note, )) applied.append({ "item_id": item.id, "price_before": round(price_before, 2), "price_after": round(price_after, 2), "delta": round(price_after - price_before, 2), }) db.commit() broadcast_sync("order_updated", { "order_id": order_id, "table_id": order.table_id, "status": order.status, "action": "discount_applied", }) # Return remaining budget info for UX display from models.pricing import WaiterDiscountSettings wds = db.query(WaiterDiscountSettings).filter( WaiterDiscountSettings.user_id == user.id ).first() remaining_shift = None remaining_workday = None if wds: from models.business_day import BusinessDay from models.shift import WaiterShift bd = db.query(BusinessDay).filter(BusinessDay.closed_at == None).first() # noqa: E711 if bd and wds.max_total_value_workday is not None: from models.order import Order as _Order wd_order_ids = [o.id for o in db.query(_Order).filter(_Order.business_day_id == bd.id).all()] wd_rows = db.query(OrderDiscount).filter( OrderDiscount.applied_by == user.id, OrderDiscount.order_id.in_(wd_order_ids), ).all() used = sum( max(0, (r.price_before or 0) - (r.price_after or 0)) for r in wd_rows ) remaining_workday = round(wds.max_total_value_workday - used, 2) shift = db.query(WaiterShift).filter( WaiterShift.waiter_id == user.id, WaiterShift.ended_at == None, # noqa: E711 ).order_by(WaiterShift.started_at.desc()).first() if shift and wds.max_total_value_shift is not None: from models.order import Order as _Order shift_order_ids = [ o.id for o in db.query(_Order).filter(_Order.opened_at >= shift.started_at).all() ] shift_rows = db.query(OrderDiscount).filter( OrderDiscount.applied_by == user.id, OrderDiscount.order_id.in_(shift_order_ids), ).all() used = sum( max(0, (r.price_before or 0) - (r.price_after or 0)) for r in shift_rows ) remaining_shift = round(wds.max_total_value_shift - used, 2) return ApplyDiscountResponse( applied=applied, remaining_budget_shift=remaining_shift, remaining_budget_workday=remaining_workday, ) # ─── Cancel a pending print job ────────────────────────────────────────────── @router.post("/{order_id}/print-jobs/{job_id}/cancel") def cancel_print_job( order_id: int, job_id: int, db: Session = Depends(get_db), user: User = Depends(get_current_user), ): """Staff cancels a pending print job (failed print, still retrying).""" pj = db.query(PrintJob).filter(PrintJob.id == job_id, PrintJob.order_id == order_id).first() if not pj: raise HTTPException(status_code=404, detail="Print job not found") if pj.status != "pending": raise HTTPException(status_code=400, detail="Only pending print jobs can be cancelled") order = db.query(Order).filter(Order.id == order_id).first() if not order or not _can_access_order(order, user, db): raise HTTPException(status_code=403, detail="Access denied") from datetime import datetime, timezone pj.status = "cancelled" pj.cancelled_at = datetime.now(timezone.utc) pj.cancel_reason = "staff_cancelled" # Mark affected items as printed so the UI warning goes away item_ids = json.loads(pj.item_ids or "[]") for item in db.query(OrderItem).filter(OrderItem.id.in_(item_ids)).all(): item.printed = True db.commit() broadcast_sync("order_updated", {"order_id": order_id, "table_id": order.table_id, "status": order.status, "action": "print_job_cancelled"}) return {"ok": True, "job_id": job_id} # ─── Update customer count on an order ─────────────────────────────────────── class CustomerCountRequest(BaseModel): customer_count: Optional[int] = None @router.patch("/{order_id}/customer-count") def update_customer_count( order_id: int, body: CustomerCountRequest, 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") if not _can_access_order(order, user, db): raise HTTPException(status_code=403, detail="Access denied") order.customer_count = body.customer_count db.commit() broadcast_sync("order_updated", {"order_id": order_id, "table_id": order.table_id, "status": order.status, "action": "customer_count_updated"}) return {"customer_count": order.customer_count} # ─── Move selected items to another order ──────────────────────────────────── @router.post("/{order_id}/move-items") def move_items( order_id: int, body: MoveItemsRequest, db: Session = Depends(get_db), user: User = Depends(get_current_user), ): """Move specific active items from this order to another open order.""" source = db.query(Order).filter(Order.id == order_id).first() if not source: raise HTTPException(status_code=404, detail="Source order not found") if not _can_access_order(source, user, db): raise HTTPException(status_code=403, detail="Access denied") if source.status not in ("open", "partially_paid"): raise HTTPException(status_code=400, detail="Source order is not active") target = db.query(Order).filter(Order.id == body.target_order_id).first() if not target: raise HTTPException(status_code=404, detail="Target order not found") if not _can_access_order(target, user, db): raise HTTPException(status_code=403, detail="Access denied to target order") if target.status not in ("open", "partially_paid"): raise HTTPException(status_code=400, detail="Target order is not active") if source.id == target.id: raise HTTPException(status_code=400, detail="Source and target orders are the same") items = db.query(OrderItem).filter( OrderItem.id.in_(body.item_ids), OrderItem.order_id == order_id, OrderItem.status == "active", ).all() if not items: raise HTTPException(status_code=400, detail="No active items found to move") moved_ids = [] for item in items: item.order_id = target.id moved_ids.append(item.id) # Recompute source status db.flush() src_active = db.query(OrderItem).filter(OrderItem.order_id == source.id, OrderItem.status == "active").count() src_paid = db.query(OrderItem).filter(OrderItem.order_id == source.id, OrderItem.status == "paid").count() if src_active == 0 and src_paid == 0: source.status = "open" elif src_active == 0: source.status = "paid" else: source.status = "partially_paid" if src_paid > 0 else "open" # Recompute target status tgt_active = db.query(OrderItem).filter(OrderItem.order_id == target.id, OrderItem.status == "active").count() tgt_paid = db.query(OrderItem).filter(OrderItem.order_id == target.id, OrderItem.status == "paid").count() target.status = "partially_paid" if (tgt_active > 0 and tgt_paid > 0) else ("paid" if tgt_active == 0 else "open") _audit(db, source.id, "ITEMS_MOVED_OUT", waiter_id=user.id, item_ids=moved_ids, note=f"Moved to order #{target.id} (table {target.table_id})") _audit(db, target.id, "ITEMS_MOVED_IN", waiter_id=user.id, item_ids=moved_ids, note=f"Moved from order #{source.id} (table {source.table_id})") db.commit() db.refresh(source) return {"moved_item_ids": moved_ids, "source_status": source.status, "target_status": target.status} # ─── Print order synopsis ───────────────────────────────────────────────────── @router.post("/{order_id}/print-synopsis") def print_synopsis( order_id: int, body: PrintSynopsisRequest, background_tasks: BackgroundTasks, db: Session = Depends(get_db), user: User = Depends(get_current_user), ): 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") if not _can_access_order(order, user, db): raise HTTPException(status_code=403, detail="Access denied") 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.nickname or 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}" adj = getattr(item, 'price_adjustment', 0.0) or 0.0 effective_price = item.unit_price + adj items_data.append({ "name": product_name, "quantity": item.quantity, "unit_type": item.product.unit_type if item.product else "piece", "unit_price": effective_price, "total": effective_price * item.quantity, "status": item.status, }) total = sum(i["total"] for i in items_data) paid_total = sum(i["total"] for i in items_data if i["status"] == "paid") synopsis = { "order_id": order.id, "table_name": table_name, "waiter_name": waiter_name, "opened_at": local_strftime(order.opened_at, "%d/%m/%Y %H:%M"), "items": items_data, "total": total, "paid_total": paid_total, "remaining": total - paid_total, } background_tasks.add_task(print_order_synopsis, printer.ip_address, printer.port, synopsis, printer.line_width, printer.codepage_n) return {"status": "printing"} # ─── Phase 2G: Put item on customer tab ─────────────────────────────────────── @router.post("/{order_id}/items/{item_id}/tab") def put_item_on_tab( order_id: int, item_id: int, db: Session = Depends(get_db), user: User = Depends(require_manager), ): from models.tabs import Tab, TabEntry order = db.query(Order).filter(Order.id == order_id).first() if not order: raise HTTPException(status_code=404, detail="Order not found") if not order.customer_id: raise HTTPException(status_code=400, detail="Order has no customer assigned — assign a customer first") 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 != "active": raise HTTPException(status_code=400, detail=f"Item is already {item.status} — cannot tab it") # Find or auto-create the customer's open tab tab = db.query(Tab).filter(Tab.customer_id == order.customer_id, Tab.status == "open").first() if not tab: tab = Tab(customer_id=order.customer_id) db.add(tab) db.flush() # Build description product_name = item.product.name if item.product else f"#{item.product_id}" description = f"{product_name} ×{item.quantity} @ €{item.unit_price:.2f}" entry = TabEntry( tab_id=tab.id, order_id=order_id, order_item_id=item_id, amount=round(item.unit_price * item.quantity, 2), description=description, created_by_id=user.id, ) db.add(entry) # Mark item as tabbed — distinct from active/paid/cancelled item.status = "tabbed" db.commit() broadcast_sync("order_updated", {"order_id": order.id, "table_id": order.table_id, "status": order.status, "action": "item_tabbed"}) return {"status": "tabbed", "tab_id": tab.id, "entry_amount": entry.amount}