Files
simple-pos-system/local_backend/routers/orders.py
bonamin 26c4818aa1 Backend: synchronous print status on add_items
add_items now runs printer routing synchronously and returns
{ order, print_results } so the waiter PWA can show per-printer
ack or failure without guessing. Extracted _do_route_and_print
so the background-task path (route_and_print) is unchanged for
other callers.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-24 17:35:08 +03:00

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"}