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>
321 lines
12 KiB
Python
321 lines
12 KiB
Python
import json
|
|
from datetime import datetime
|
|
from fastapi import APIRouter, Depends, HTTPException, status, BackgroundTasks
|
|
from sqlalchemy.orm import Session
|
|
from typing import List, Optional
|
|
|
|
from database import get_db
|
|
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, 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, 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
|
|
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
|
|
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])
|
|
def list_orders(
|
|
order_status: Optional[str] = None,
|
|
waiter_id: Optional[int] = None,
|
|
db: Session = Depends(get_db),
|
|
user: User = Depends(require_manager),
|
|
):
|
|
q = db.query(Order)
|
|
if order_status:
|
|
q = q.filter(Order.status == order_status)
|
|
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]
|
|
|
|
|
|
@router.get("/{order_id}", response_model=OrderOut)
|
|
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")
|
|
return order
|
|
|
|
|
|
@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)):
|
|
existing = db.query(Order).filter(
|
|
Order.table_id == body.table_id,
|
|
Order.status.in_(["open", "partially_paid"]),
|
|
).first()
|
|
if existing:
|
|
raise HTTPException(status_code=400, detail="Table already has an open order")
|
|
order = Order(table_id=body.table_id, opened_by=user.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)
|
|
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"):
|
|
raise HTTPException(status_code=400, detail="Order is not open")
|
|
|
|
new_item_ids = []
|
|
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")
|
|
extra_cost = sum(
|
|
(o.price_delta or o.extra_cost or 0.0)
|
|
for o in (item_in.selected_options or [])
|
|
)
|
|
item = OrderItem(
|
|
order_id=order_id,
|
|
product_id=item_in.product_id,
|
|
added_by=user.id,
|
|
quantity=item_in.quantity,
|
|
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,
|
|
)
|
|
db.add(item)
|
|
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)
|
|
|
|
print_results = route_and_print_sync(order_id, new_item_ids, db)
|
|
|
|
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, 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")
|
|
item.status = "cancelled"
|
|
_audit(db, order_id, "ITEM_CANCELLED", waiter_id=user.id, item_ids=[item_id])
|
|
db.commit()
|
|
|
|
|
|
@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")
|
|
|
|
items = db.query(OrderItem).filter(
|
|
OrderItem.id.in_(body.item_ids),
|
|
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": paid_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 _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")
|
|
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"}
|
|
|
|
|
|
@router.delete("/{order_id}", status_code=status.HTTP_204_NO_CONTENT)
|
|
def cancel_order(order_id: int, db: Session = Depends(get_db), user: User = Depends(require_manager)):
|
|
order = db.query(Order).filter(Order.id == order_id).first()
|
|
if not order:
|
|
raise HTTPException(status_code=404, detail="Order not found")
|
|
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()
|
|
|
|
|
|
@router.put("/{order_id}/assign-waiter")
|
|
def assign_waiter(order_id: int, body: AssignWaiterRequest, db: Session = Depends(get_db), user: User = Depends(require_manager)):
|
|
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"}
|
|
|
|
|
|
@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}"
|
|
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"}
|