Backend overhaul: new models, routers, schemas for shifts, business day, flags, messages, settings
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -1,5 +1,5 @@
|
||||
import json
|
||||
from datetime import datetime
|
||||
from datetime import datetime, timezone
|
||||
from fastapi import APIRouter, Depends, HTTPException, status, BackgroundTasks
|
||||
from sqlalchemy.orm import Session
|
||||
from typing import List, Optional
|
||||
@@ -14,8 +14,25 @@ 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
|
||||
|
||||
from routers.deps import get_current_user, require_manager
|
||||
from services.printer_service import route_and_print, route_and_print_sync, print_order_receipt
|
||||
from services.printer_service import route_and_print, route_and_print_sync, print_order_receipt, print_order_synopsis
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
@@ -79,6 +96,29 @@ def my_orders(db: Session = Depends(get_db), user: User = Depends(get_current_us
|
||||
return direct + [o for o in also_opened if o.id not in seen]
|
||||
|
||||
|
||||
class ActiveOrderSlim(BaseModel):
|
||||
id: int
|
||||
table_id: int
|
||||
status: str
|
||||
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,
|
||||
waiter_ids=[w.waiter_id for w in o.waiters],
|
||||
)
|
||||
for o in orders
|
||||
]
|
||||
|
||||
|
||||
@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()
|
||||
@@ -91,13 +131,28 @@ def get_order(order_id: int, db: Session = Depends(get_db), user: User = Depends
|
||||
|
||||
@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
|
||||
|
||||
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.role == "waiter":
|
||||
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")
|
||||
|
||||
existing = db.query(Order).filter(
|
||||
Order.table_id == body.table_id,
|
||||
Order.status.in_(["open", "partially_paid"]),
|
||||
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, opened_by=user.id)
|
||||
order = Order(table_id=body.table_id, 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))
|
||||
@@ -119,9 +174,13 @@ def add_items(
|
||||
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"):
|
||||
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 = []
|
||||
for item_in in body.items:
|
||||
product = db.query(Product).filter(Product.id == item_in.product_id).first()
|
||||
@@ -154,6 +213,27 @@ def add_items(
|
||||
return {"order": order, "print_results": print_results}
|
||||
|
||||
|
||||
@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()
|
||||
@@ -184,20 +264,28 @@ def pay_items(order_id: int, body: PayItemsRequest, db: Session = Depends(get_db
|
||||
if not _can_access_order(order, user, db):
|
||||
raise HTTPException(status_code=403, detail="Access denied")
|
||||
|
||||
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()
|
||||
now = datetime.utcnow()
|
||||
now = datetime.now(timezone.utc)
|
||||
active_shift = db.query(WaiterShift).filter(
|
||||
WaiterShift.waiter_id == user.id,
|
||||
WaiterShift.ended_at == None,
|
||||
).first()
|
||||
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
|
||||
item.paid_in_shift_id = active_shift.id if active_shift else None
|
||||
total_paid += item.unit_price * 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 == "active"
|
||||
).count()
|
||||
@@ -220,7 +308,7 @@ def close_order(order_id: int, db: Session = Depends(get_db), user: User = Depen
|
||||
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_at = datetime.now(timezone.utc)
|
||||
order.closed_by = user.id
|
||||
_audit(db, order_id, "ORDER_CLOSED", waiter_id=user.id)
|
||||
db.commit()
|
||||
@@ -233,14 +321,14 @@ def cancel_order(order_id: int, db: Session = Depends(get_db), user: User = Depe
|
||||
if not order:
|
||||
raise HTTPException(status_code=404, detail="Order not found")
|
||||
order.status = "cancelled"
|
||||
order.closed_at = datetime.utcnow()
|
||||
order.closed_at = datetime.now(timezone.utc)
|
||||
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)):
|
||||
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")
|
||||
@@ -318,3 +406,295 @@ def print_order(
|
||||
|
||||
background_tasks.add_task(print_order_receipt, printer.ip_address, printer.port, receipt)
|
||||
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)
|
||||
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)
|
||||
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]
|
||||
|
||||
|
||||
# ─── 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}"
|
||||
items_data.append({
|
||||
"name": product_name,
|
||||
"quantity": item.quantity,
|
||||
"unit_price": item.unit_price,
|
||||
"total": item.unit_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": order.opened_at.strftime("%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)
|
||||
return {"status": "printing"}
|
||||
|
||||
Reference in New Issue
Block a user