Files
simple-pos-system/local_backend/routers/orders.py
bonamin 24a029a8cc Fix order saving, isMyOrder, blocked waiters, options pricing; add preferences, table groups, product images
Backend:
- OrderItemInput accepts option objects {id,name,price_delta} instead of int IDs
- extra_cost from selected options added to unit_price snapshot
- GET /api/products/?all=true for manager (includes unavailable)
- PUT /api/products/{id} now replaces options, ingredients, preference_sets
- POST /api/products/{id}/image — persistent image upload to /app/data/product_images
- New models: ProductPreferenceSet, ProductPreferenceChoice, TableGroup
- tables: group_id FK, hard delete (?hard=true), batch create POST /api/tables/batch
- GET /api/tables/groups + POST/PUT/DELETE groups endpoints
- POST /api/auth/me endpoint for token rehydration
- Auto-migration on startup for new columns

PWA:
- AuthRehydrator: fetches /auth/me on load so isMyOrder works after page reload
- 401 response force-logs out (covers blocked waiters)
- ItemOptionsModal: uses extra_cost correctly, shows preferences as radio buttons

Manager:
- ProductsPage: shows unavailable products greyed out, category color picker + reorder,
  full option/ingredient/preference editing, image upload
- TablesPage: table groups, auto-increment, deactivate vs hard delete, batch add
2026-04-20 18:39:51 +03:00

237 lines
9.4 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
from models.user import User, AssistantAssignment
from models.product import Product
from schemas.order import OrderCreate, OrderOut, OrderItemOut, AddItemsRequest, PayItemsRequest, AssignWaiterRequest, OrderWaiterOut
from routers.deps import get_current_user, require_manager
from services.printer_service import route_and_print
router = APIRouter()
def _can_access_order(order: Order, user: User, db: Session) -> bool:
if user.role in ("manager", "sysadmin"):
return True
if order.opened_by == user.id:
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
@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))
db.commit()
db.refresh(order)
return order
@router.post("/{order_id}/items", response_model=OrderOut)
def add_items(
order_id: int,
body: AddItemsRequest,
background_tasks: BackgroundTasks,
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")
# 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 [])
)
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, # price snapshot with options
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)
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)
return order
@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"
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()
for item in items:
item.status = "paid"
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"
db.commit()
return {"status": order.status, "paid_item_ids": [i.id for i in items]}
@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
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
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()