feat: bump client-services (accumulated feature work + deploy fixes)
Snapshot of in-progress work across local_backend, manager_dashboard, and waiter_pwa (pricing, chat, fiscal, prep zones, recovery codes, CRM, inventory, permissions), plus the nginx/docker-compose deploy fixes for the Unraid + NPM reverse-proxy setup. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -1,30 +1,737 @@
|
||||
"""
|
||||
KDS — Kitchen Display System router.
|
||||
|
||||
GET /api/kds/items
|
||||
Returns all active (status='active') order items for open orders,
|
||||
enriched with zone name and table name. Used by the KDS frontend.
|
||||
GET /api/kds/orders
|
||||
Returns all open orders enriched with resolved modifier names,
|
||||
grouped by nothing (flat list). Frontend handles grouping/filtering.
|
||||
|
||||
PUT /api/orders/{order_id}/items/{item_id}/status
|
||||
Mark an item ready (active → ready). Only that transition is allowed here.
|
||||
Broadcasts item_status_changed SSE event.
|
||||
PUT /api/kds/orders/{order_id}/kds_status
|
||||
Update an order's KDS aggregate status (pending|preparing|done).
|
||||
Broadcasts kds_order_updated SSE event.
|
||||
|
||||
PUT /api/kds/orders/{order_id}/items/{item_id}/kds_status
|
||||
Update a single item's KDS status (pending|preparing|done).
|
||||
Broadcasts kds_item_updated SSE event.
|
||||
|
||||
PUT /api/kds/orders/{order_id}/order_type
|
||||
Update an order's type (here|takeaway|delivery).
|
||||
|
||||
--- Legacy endpoint kept for backward compat (printer-zone grouped items) ---
|
||||
GET /api/kds/items (unchanged)
|
||||
PUT /api/kds/orders/{order_id}/items/{item_id}/status (unchanged)
|
||||
"""
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from sqlalchemy.orm import Session
|
||||
from pydantic import BaseModel
|
||||
import json
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
from datetime import datetime, timezone
|
||||
from sqlalchemy.orm import Session, joinedload
|
||||
from pydantic import BaseModel
|
||||
from typing import List, Optional
|
||||
|
||||
from database import get_db
|
||||
from models.order import Order, OrderItem
|
||||
from models.product import Product
|
||||
from models.order import Order, OrderItem, OrderWaiter
|
||||
from models.product import Product, ProductPreferenceSet
|
||||
from models.prep_zone import PrepZone
|
||||
from models.printer import Printer
|
||||
from models.table import Table
|
||||
from models.user import User
|
||||
from routers.deps import get_current_user
|
||||
from models.message import StaffMessage, StaffMessageAck
|
||||
from models.shift import WaiterShift
|
||||
from routers.deps import get_current_user, require_kds
|
||||
from services.sse_bus import broadcast_sync
|
||||
|
||||
router = APIRouter()
|
||||
router = APIRouter(dependencies=[Depends(require_kds)])
|
||||
|
||||
VALID_KDS_STATUSES = {"pending", "preparing", "done", "served", "declined"}
|
||||
VALID_ORDER_TYPES = {"here", "takeaway", "delivery"}
|
||||
|
||||
|
||||
# ─────────────────────────── helpers ───────────────────────────────────────
|
||||
|
||||
def _resolve_item_modifiers(item: OrderItem, product: Product | None, db: Session):
|
||||
"""
|
||||
Returns a dict with four lists:
|
||||
removed - ingredient names removed (from removed_ingredients JSON id array)
|
||||
extras - option names selected (from selected_options JSON id array)
|
||||
prefs - preference choice names selected (from selected_options JSON id array)
|
||||
notes - plain text note
|
||||
"""
|
||||
removed, extras, prefs = [], [], []
|
||||
|
||||
if product is None:
|
||||
return {"removed": removed, "extras": extras, "prefs": prefs, "notes": item.notes}
|
||||
|
||||
# Build lookup maps (avoid N+1 — product relationships already loaded)
|
||||
ing_map = {i.id: i.name for i in product.ingredients}
|
||||
opt_map = {o.id: o.name for o in product.options}
|
||||
quick_map = {q.id: q.name for q in product.quick_options}
|
||||
|
||||
# Preference choice lookup: pref_choice_map[choice_id] = (set_name, choice_name)
|
||||
pref_choice_map: dict[int, tuple[str, str]] = {}
|
||||
for ps in product.preference_sets:
|
||||
for pc in ps.choices:
|
||||
pref_choice_map[pc.id] = (ps.name, pc.name)
|
||||
|
||||
# Removed ingredients — stored as JSON array of name strings (not ids)
|
||||
if item.removed_ingredients:
|
||||
try:
|
||||
vals = json.loads(item.removed_ingredients)
|
||||
for v in vals:
|
||||
if isinstance(v, str) and v:
|
||||
removed.append(v)
|
||||
elif isinstance(v, int):
|
||||
# legacy: id reference
|
||||
name = ing_map.get(v)
|
||||
if name:
|
||||
removed.append(name)
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
pass
|
||||
|
||||
# Selected options — stored as JSON array of objects:
|
||||
# [{"id": int, "name": str, "type": "extra"|"quick"|"pref"|"pref_sub", ...}]
|
||||
# We use the stored name directly; type tag tells us which bucket.
|
||||
if item.selected_options:
|
||||
try:
|
||||
opts = json.loads(item.selected_options)
|
||||
for o in opts:
|
||||
if isinstance(o, dict):
|
||||
name = o.get("name") or ""
|
||||
otype = o.get("type", "")
|
||||
if not name:
|
||||
continue
|
||||
if otype in ("pref", "pref_sub"):
|
||||
prefs.append(name)
|
||||
else:
|
||||
# "extra", "extra_sub", "quick", or unknown → extras
|
||||
extras.append(name)
|
||||
elif isinstance(o, int):
|
||||
# legacy: id-only reference — fall back to lookup maps
|
||||
if o in opt_map:
|
||||
extras.append(opt_map[o])
|
||||
elif o in quick_map:
|
||||
extras.append(quick_map[o])
|
||||
elif o in pref_choice_map:
|
||||
_, choice_name = pref_choice_map[o]
|
||||
prefs.append(choice_name)
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
pass
|
||||
|
||||
return {"removed": removed, "extras": extras, "prefs": prefs, "notes": item.notes}
|
||||
|
||||
|
||||
def _build_order_payload(order: Order, tables_map: dict, db: Session) -> dict:
|
||||
"""Build the full order dict the KDS frontend expects."""
|
||||
items_out = []
|
||||
for item in order.items:
|
||||
if item.status == "cancelled":
|
||||
continue
|
||||
if item.kds_status == "served":
|
||||
continue
|
||||
product = item.product
|
||||
mods = _resolve_item_modifiers(item, product, db)
|
||||
unit_type = product.unit_type if product else "piece"
|
||||
prep_zone_ids = [z.id for z in product.prep_zones] if product and hasattr(product, 'prep_zones') else []
|
||||
items_out.append({
|
||||
"id": item.id,
|
||||
"product_id": item.product_id,
|
||||
"product_name": product.name if product else f"#{item.product_id}",
|
||||
"quantity": item.quantity,
|
||||
"unit_type": unit_type or "piece",
|
||||
"kds_status": item.kds_status,
|
||||
"decline_note": item.decline_note,
|
||||
"added_at": item.added_at.isoformat() if item.added_at else None,
|
||||
"_added_at_raw": item.added_at, # stripped before response
|
||||
"prep_zone_ids": prep_zone_ids,
|
||||
"course_id": item.course_id,
|
||||
"removed": mods["removed"],
|
||||
"extras": mods["extras"],
|
||||
"prefs": mods["prefs"],
|
||||
"notes": mods["notes"],
|
||||
})
|
||||
|
||||
# Waiter names from first assignment
|
||||
waiter_names = [ow.waiter.username for ow in order.waiters if ow.waiter] if order.waiters else []
|
||||
|
||||
# Clock = oldest added_at among pending/preparing items (the active batch).
|
||||
# Falls back to order.opened_at only if no such item exists.
|
||||
def _fmt_ts(dt):
|
||||
if dt is None:
|
||||
return None
|
||||
return dt.isoformat() + "Z" if not dt.tzinfo else dt.isoformat()
|
||||
|
||||
active_batch_times = [
|
||||
item["_added_at_raw"] for item in items_out
|
||||
if item.get("_added_at_raw") and item["kds_status"] in ("pending", "preparing")
|
||||
]
|
||||
batch_ts = min(active_batch_times) if active_batch_times else None
|
||||
opened_at_out = _fmt_ts(batch_ts) if batch_ts else _fmt_ts(order.opened_at)
|
||||
|
||||
for item in items_out:
|
||||
item.pop("_added_at_raw", None)
|
||||
|
||||
return {
|
||||
"id": order.id,
|
||||
"kds_status": order.kds_status,
|
||||
"order_type": order.order_type,
|
||||
"table_name": tables_map.get(order.table_id) if order.table_id else None,
|
||||
"table_id": order.table_id,
|
||||
"opened_at": opened_at_out,
|
||||
"closed_at": _fmt_ts(order.closed_at) if order.closed_at else None,
|
||||
"notes": order.notes,
|
||||
"waiters": waiter_names,
|
||||
"items": items_out,
|
||||
}
|
||||
|
||||
|
||||
def _sync_order_kds_status(order: Order):
|
||||
"""Derive and set order.kds_status from all non-cancelled items. Call before db.commit()."""
|
||||
relevant = [i for i in order.items if i.status != "cancelled"]
|
||||
if not relevant:
|
||||
return
|
||||
statuses = {i.kds_status for i in relevant}
|
||||
if statuses <= {"served"}:
|
||||
new_status = "served"
|
||||
elif statuses <= {"done", "served"}:
|
||||
new_status = "done"
|
||||
elif statuses <= {"declined"}:
|
||||
new_status = "declined"
|
||||
elif "preparing" in statuses or "done" in statuses:
|
||||
new_status = "preparing"
|
||||
else:
|
||||
new_status = "pending"
|
||||
if new_status != order.kds_status:
|
||||
order.kds_status = new_status
|
||||
order.kds_status_changed_at = datetime.now(timezone.utc)
|
||||
|
||||
|
||||
# ─────────────────────────── endpoints ─────────────────────────────────────
|
||||
|
||||
@router.get("/orders")
|
||||
def kds_orders(
|
||||
zone_id: Optional[int] = Query(default=None, description="Filter orders to only those containing items in this prep zone"),
|
||||
db: Session = Depends(get_db),
|
||||
user: User = Depends(get_current_user),
|
||||
):
|
||||
"""Return all open/partially_paid/paid orders that still have unserved items.
|
||||
If zone_id is provided, only orders containing at least one item belonging to that prep zone are returned,
|
||||
and items not in that zone are stripped from the payload.
|
||||
"""
|
||||
open_orders = (
|
||||
db.query(Order)
|
||||
.filter(Order.status.in_(["open", "partially_paid", "paid"]))
|
||||
.options(
|
||||
joinedload(Order.items).joinedload(OrderItem.product).joinedload(Product.ingredients),
|
||||
joinedload(Order.items).joinedload(OrderItem.product).joinedload(Product.options),
|
||||
joinedload(Order.items).joinedload(OrderItem.product).joinedload(Product.quick_options),
|
||||
joinedload(Order.items).joinedload(OrderItem.product).joinedload(Product.preference_sets).joinedload(ProductPreferenceSet.choices),
|
||||
joinedload(Order.items).joinedload(OrderItem.product).joinedload(Product.prep_zones),
|
||||
joinedload(Order.waiters).joinedload(OrderWaiter.waiter),
|
||||
)
|
||||
.order_by(Order.opened_at.asc())
|
||||
.all()
|
||||
)
|
||||
|
||||
tables_map = {t.id: (t.label or f"T{t.number}") for t in db.query(Table).all()}
|
||||
|
||||
payloads = []
|
||||
for o in open_orders:
|
||||
p = _build_order_payload(o, tables_map, db)
|
||||
if not p["items"]:
|
||||
continue
|
||||
# Zone filtering: keep only items whose product belongs to the requested zone
|
||||
if zone_id is not None:
|
||||
p["items"] = [it for it in p["items"] if zone_id in it.get("prep_zone_ids", [])]
|
||||
if not p["items"]:
|
||||
continue
|
||||
payloads.append(p)
|
||||
return {"orders": payloads}
|
||||
|
||||
|
||||
class KdsStatusBody(BaseModel):
|
||||
status: str
|
||||
|
||||
|
||||
class OrderTypeBody(BaseModel):
|
||||
order_type: str
|
||||
|
||||
|
||||
@router.put("/orders/{order_id}/kds_status")
|
||||
def update_order_kds_status(
|
||||
order_id: int,
|
||||
body: KdsStatusBody,
|
||||
db: Session = Depends(get_db),
|
||||
user: User = Depends(get_current_user),
|
||||
):
|
||||
if body.status not in VALID_KDS_STATUSES:
|
||||
raise HTTPException(status_code=400, detail=f"Invalid status '{body.status}'")
|
||||
|
||||
order = db.query(Order).filter(Order.id == order_id).first()
|
||||
if not order:
|
||||
raise HTTPException(status_code=404, detail="Order not found")
|
||||
|
||||
if body.status != order.kds_status:
|
||||
order.kds_status = body.status
|
||||
order.kds_status_changed_at = datetime.now(timezone.utc)
|
||||
|
||||
# Cascade order-level status to non-cancelled, non-served items only
|
||||
if body.status in ("pending", "preparing", "done"):
|
||||
for item in order.items:
|
||||
if item.status != "cancelled" and item.kds_status != "served":
|
||||
item.kds_status = body.status
|
||||
# auto_ready_to_served: if cascading to 'done', immediately upgrade qualifying items
|
||||
if body.status == "done" and item.product:
|
||||
if any(bool(z.auto_ready_to_served) for z in item.product.prep_zones):
|
||||
item.kds_status = "served"
|
||||
|
||||
db.commit()
|
||||
|
||||
broadcast_sync("kds_order_updated", {
|
||||
"order_id": order_id,
|
||||
"kds_status": body.status,
|
||||
})
|
||||
|
||||
return {"order_id": order_id, "kds_status": body.status}
|
||||
|
||||
|
||||
@router.put("/orders/{order_id}/items/{item_id}/kds_status")
|
||||
def update_item_kds_status(
|
||||
order_id: int,
|
||||
item_id: int,
|
||||
body: KdsStatusBody,
|
||||
db: Session = Depends(get_db),
|
||||
user: User = Depends(get_current_user),
|
||||
):
|
||||
if body.status not in VALID_KDS_STATUSES:
|
||||
raise HTTPException(status_code=400, detail=f"Invalid status '{body.status}'")
|
||||
|
||||
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.kds_status = body.status
|
||||
|
||||
# auto_ready_to_served: if item reaches 'done' and any of its zones has the flag,
|
||||
# immediately upgrade it to 'served' — item was ready at the KDS, no explicit serve needed.
|
||||
if body.status == "done" and item.product:
|
||||
auto_serve = any(bool(z.auto_ready_to_served) for z in item.product.prep_zones)
|
||||
if auto_serve:
|
||||
item.kds_status = "served"
|
||||
|
||||
if item.order:
|
||||
_sync_order_kds_status(item.order)
|
||||
|
||||
db.commit()
|
||||
|
||||
broadcast_sync("kds_item_updated", {
|
||||
"order_id": order_id,
|
||||
"item_id": item_id,
|
||||
"kds_status": item.kds_status,
|
||||
})
|
||||
|
||||
return {"item_id": item_id, "kds_status": item.kds_status}
|
||||
|
||||
|
||||
@router.put("/orders/{order_id}/order_type")
|
||||
def update_order_type(
|
||||
order_id: int,
|
||||
body: OrderTypeBody,
|
||||
db: Session = Depends(get_db),
|
||||
user: User = Depends(get_current_user),
|
||||
):
|
||||
if body.order_type not in VALID_ORDER_TYPES:
|
||||
raise HTTPException(status_code=400, detail=f"Invalid order_type '{body.order_type}'")
|
||||
|
||||
order = db.query(Order).filter(Order.id == order_id).first()
|
||||
if not order:
|
||||
raise HTTPException(status_code=404, detail="Order not found")
|
||||
|
||||
order.order_type = body.order_type
|
||||
db.commit()
|
||||
|
||||
return {"order_id": order_id, "order_type": body.order_type}
|
||||
|
||||
|
||||
class MarkServedBody(BaseModel):
|
||||
item_ids: list[int]
|
||||
|
||||
|
||||
@router.post("/orders/{order_id}/items/mark-served")
|
||||
def mark_items_served(
|
||||
order_id: int,
|
||||
body: MarkServedBody,
|
||||
db: Session = Depends(get_db),
|
||||
user: User = Depends(get_current_user),
|
||||
):
|
||||
"""Mark a list of items as served (kds_status=served). Works on active and paid items."""
|
||||
order = db.query(Order).filter(Order.id == order_id).first()
|
||||
if not order:
|
||||
raise HTTPException(status_code=404, detail="Order not found")
|
||||
|
||||
updated = []
|
||||
ids_set = set(body.item_ids)
|
||||
for item in order.items:
|
||||
if item.id in ids_set:
|
||||
item.kds_status = "served"
|
||||
updated.append(item.id)
|
||||
|
||||
_sync_order_kds_status(order)
|
||||
db.commit()
|
||||
|
||||
broadcast_sync("kds_item_updated", {
|
||||
"order_id": order_id,
|
||||
"item_ids": updated,
|
||||
"kds_status": "served",
|
||||
})
|
||||
|
||||
return {"updated": updated}
|
||||
|
||||
|
||||
@router.post("/orders/{order_id}/items/mark-all-served")
|
||||
def mark_all_items_served(
|
||||
order_id: int,
|
||||
db: Session = Depends(get_db),
|
||||
user: User = Depends(get_current_user),
|
||||
):
|
||||
"""Mark all non-pending items in an order as served."""
|
||||
order = db.query(Order).filter(Order.id == order_id).first()
|
||||
if not order:
|
||||
raise HTTPException(status_code=404, detail="Order not found")
|
||||
|
||||
updated = []
|
||||
for item in order.items:
|
||||
if item.kds_status in ("preparing", "done"):
|
||||
item.kds_status = "served"
|
||||
updated.append(item.id)
|
||||
|
||||
_sync_order_kds_status(order)
|
||||
db.commit()
|
||||
|
||||
broadcast_sync("kds_item_updated", {
|
||||
"order_id": order_id,
|
||||
"item_ids": updated,
|
||||
"kds_status": "served",
|
||||
})
|
||||
|
||||
return {"updated": updated}
|
||||
|
||||
|
||||
# ─────────────────────────── KDS print endpoint ─────────────────────────────
|
||||
|
||||
class KdsPrintBody(BaseModel):
|
||||
printer_id: Optional[int] = None
|
||||
item_ids: Optional[List[int]] = None
|
||||
copies: int = 1
|
||||
zone_id: Optional[int] = None
|
||||
mode: str = "primary" # none | primary | all
|
||||
|
||||
|
||||
@router.post("/orders/{order_id}/print")
|
||||
def kds_print_order(
|
||||
order_id: int,
|
||||
body: KdsPrintBody,
|
||||
db: Session = Depends(get_db),
|
||||
user: User = Depends(get_current_user),
|
||||
):
|
||||
"""Print order to zone printers. mode=primary prints to first printer, mode=all prints to all."""
|
||||
from services import printer_service
|
||||
if body.mode == "none":
|
||||
return {"printed": 0}
|
||||
if body.printer_id is not None:
|
||||
# Direct printer specified
|
||||
result = printer_service.print_to_printer(order_id, body.item_ids, body.printer_id, body.copies, db)
|
||||
return result
|
||||
# Zone-based print
|
||||
if body.zone_id is None:
|
||||
return {"printed": 0, "message": "No zone configured"}
|
||||
from models.prep_zone import PrepZone
|
||||
zone = db.query(PrepZone).filter(PrepZone.id == body.zone_id).first()
|
||||
if not zone or not zone.printers:
|
||||
return {"printed": 0, "message": "Zone has no printers"}
|
||||
printers_to_use = zone.printers if body.mode == "all" else [zone.printers[0]]
|
||||
printed = 0
|
||||
for printer in printers_to_use:
|
||||
try:
|
||||
printer_service.print_to_printer(order_id, body.item_ids, printer.id, body.copies, db)
|
||||
printed += 1
|
||||
except Exception:
|
||||
pass
|
||||
return {"printed": printed}
|
||||
|
||||
|
||||
# ─────────────────────────── KDS decline endpoints ─────────────────────────
|
||||
|
||||
class DeclineBody(BaseModel):
|
||||
decline_note: Optional[str] = None # reason string (preset or free text)
|
||||
|
||||
|
||||
@router.put("/orders/{order_id}/decline")
|
||||
def decline_order(
|
||||
order_id: int,
|
||||
body: DeclineBody,
|
||||
db: Session = Depends(get_db),
|
||||
user: User = Depends(get_current_user),
|
||||
):
|
||||
"""Mark all active items on an order as declined."""
|
||||
order = db.query(Order).filter(Order.id == order_id).first()
|
||||
if not order:
|
||||
raise HTTPException(status_code=404, detail="Order not found")
|
||||
|
||||
for item in order.items:
|
||||
if item.status != "cancelled":
|
||||
item.kds_status = "declined"
|
||||
item.decline_note = body.decline_note
|
||||
|
||||
order.kds_status = "declined"
|
||||
db.commit()
|
||||
|
||||
broadcast_sync("kds_order_updated", {
|
||||
"order_id": order_id,
|
||||
"kds_status": "declined",
|
||||
"decline_note": body.decline_note,
|
||||
})
|
||||
return {"order_id": order_id, "kds_status": "declined"}
|
||||
|
||||
|
||||
@router.put("/orders/{order_id}/items/{item_id}/decline")
|
||||
def decline_item(
|
||||
order_id: int,
|
||||
item_id: int,
|
||||
body: DeclineBody,
|
||||
db: Session = Depends(get_db),
|
||||
user: User = Depends(get_current_user),
|
||||
):
|
||||
"""Mark a single item as declined."""
|
||||
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.kds_status = "declined"
|
||||
item.decline_note = body.decline_note
|
||||
|
||||
if item.order:
|
||||
_sync_order_kds_status(item.order)
|
||||
|
||||
db.commit()
|
||||
|
||||
broadcast_sync("kds_item_updated", {
|
||||
"order_id": order_id,
|
||||
"item_id": item_id,
|
||||
"kds_status": "declined",
|
||||
"decline_note": body.decline_note,
|
||||
})
|
||||
return {"item_id": item_id, "kds_status": "declined"}
|
||||
|
||||
|
||||
# ─────────────────────────── KDS notifications ──────────────────────────────
|
||||
|
||||
def _waiter_ids_for_order(order: Order) -> list[int]:
|
||||
"""Return the list of waiter IDs assigned to an order."""
|
||||
return [ow.waiter_id for ow in order.waiters if ow.waiter_id]
|
||||
|
||||
|
||||
def _save_and_broadcast_message(
|
||||
db: Session,
|
||||
sender_id: int,
|
||||
body: str,
|
||||
target_waiter_ids: list[int],
|
||||
message_type: str,
|
||||
kds_zone: str | None,
|
||||
table_ids: list[int] | None = None,
|
||||
) -> dict:
|
||||
"""Persist a StaffMessage and broadcast via SSE. Returns the SSE payload dict."""
|
||||
from models.message import StaffMessage
|
||||
from datetime import datetime, timezone
|
||||
|
||||
msg = StaffMessage(
|
||||
sender_id=sender_id,
|
||||
body=body,
|
||||
target_waiter_ids=json.dumps(target_waiter_ids),
|
||||
table_ids=json.dumps(table_ids or []),
|
||||
message_type=message_type,
|
||||
kds_zone=kds_zone,
|
||||
)
|
||||
db.add(msg)
|
||||
db.commit()
|
||||
db.refresh(msg)
|
||||
|
||||
payload = {
|
||||
"id": msg.id,
|
||||
"sender_id": msg.sender_id,
|
||||
"sender_name": kds_zone or "KDS",
|
||||
"body": msg.body,
|
||||
"table_ids": msg.table_ids,
|
||||
"message_type": message_type,
|
||||
"kds_zone": kds_zone,
|
||||
"created_at": msg.created_at.isoformat() if msg.created_at else None,
|
||||
}
|
||||
user_ids = target_waiter_ids if target_waiter_ids else None
|
||||
broadcast_sync("message_sent", payload, user_ids=user_ids)
|
||||
return payload
|
||||
|
||||
|
||||
class KdsNotifyBody(BaseModel):
|
||||
kds_zone: Optional[str] = None
|
||||
|
||||
|
||||
class KdsCallWaiterBody(BaseModel):
|
||||
kds_zone: Optional[str] = None
|
||||
waiter_ids: List[int] # for call_general: list of on-shift waiter ids to notify
|
||||
|
||||
|
||||
@router.post("/orders/{order_id}/notify-complete")
|
||||
def kds_notify_order_complete(
|
||||
order_id: int,
|
||||
body: KdsNotifyBody,
|
||||
db: Session = Depends(get_db),
|
||||
user: User = Depends(get_current_user),
|
||||
):
|
||||
"""Notify assigned waiters that their order is fully complete."""
|
||||
order = db.query(Order).options(
|
||||
joinedload(Order.waiters),
|
||||
).filter(Order.id == order_id).first()
|
||||
if not order:
|
||||
raise HTTPException(status_code=404, detail="Order not found")
|
||||
|
||||
waiter_ids = _waiter_ids_for_order(order)
|
||||
if not waiter_ids:
|
||||
return {"sent": False, "reason": "no_waiters"}
|
||||
|
||||
tables_map = {t.id: (t.label or f"T{t.number}") for t in db.query(Table).all()}
|
||||
table_name = tables_map.get(order.table_id, str(order.table_id)) if order.table_id else "Takeaway"
|
||||
zone = body.kds_zone or "Κουζίνα"
|
||||
|
||||
msg_body = (
|
||||
f"Η παραγγελία #{order.id} για το τραπέζι {table_name} "
|
||||
f"είναι έτοιμη προς παραλαβή - {zone}"
|
||||
)
|
||||
_save_and_broadcast_message(
|
||||
db, user.id, msg_body, waiter_ids,
|
||||
"kds_order_done", zone,
|
||||
table_ids=[order.table_id] if order.table_id else [],
|
||||
)
|
||||
return {"sent": True, "waiter_ids": waiter_ids}
|
||||
|
||||
|
||||
class KdsNotifyItemsBody(BaseModel):
|
||||
kds_zone: Optional[str] = None
|
||||
ready_count: int
|
||||
total_count: int
|
||||
|
||||
|
||||
@router.post("/orders/{order_id}/notify-items-ready")
|
||||
def kds_notify_items_ready(
|
||||
order_id: int,
|
||||
body: KdsNotifyItemsBody,
|
||||
db: Session = Depends(get_db),
|
||||
user: User = Depends(get_current_user),
|
||||
):
|
||||
"""Notify assigned waiters that some (but not all) items are ready."""
|
||||
order = db.query(Order).options(
|
||||
joinedload(Order.waiters),
|
||||
).filter(Order.id == order_id).first()
|
||||
if not order:
|
||||
raise HTTPException(status_code=404, detail="Order not found")
|
||||
|
||||
waiter_ids = _waiter_ids_for_order(order)
|
||||
if not waiter_ids:
|
||||
return {"sent": False, "reason": "no_waiters"}
|
||||
|
||||
tables_map = {t.id: (t.label or f"T{t.number}") for t in db.query(Table).all()}
|
||||
table_name = tables_map.get(order.table_id, str(order.table_id)) if order.table_id else "Takeaway"
|
||||
zone = body.kds_zone or "Κουζίνα"
|
||||
|
||||
msg_body = (
|
||||
f"{body.ready_count} αντικείμενα από την παραγγελία #{order.id} "
|
||||
f"για το τραπέζι {table_name} είναι έτοιμα - {zone}"
|
||||
)
|
||||
_save_and_broadcast_message(
|
||||
db, user.id, msg_body, waiter_ids,
|
||||
"kds_item_done", zone,
|
||||
table_ids=[order.table_id] if order.table_id else [],
|
||||
)
|
||||
return {"sent": True, "waiter_ids": waiter_ids}
|
||||
|
||||
|
||||
@router.post("/orders/{order_id}/call-waiter")
|
||||
def kds_call_waiter_order(
|
||||
order_id: int,
|
||||
body: KdsNotifyBody,
|
||||
db: Session = Depends(get_db),
|
||||
user: User = Depends(get_current_user),
|
||||
):
|
||||
"""Manually call the waiters assigned to a specific order."""
|
||||
order = db.query(Order).options(
|
||||
joinedload(Order.waiters),
|
||||
).filter(Order.id == order_id).first()
|
||||
if not order:
|
||||
raise HTTPException(status_code=404, detail="Order not found")
|
||||
|
||||
waiter_ids = _waiter_ids_for_order(order)
|
||||
if not waiter_ids:
|
||||
return {"sent": False, "reason": "no_waiters"}
|
||||
|
||||
tables_map = {t.id: (t.label or f"T{t.number}") for t in db.query(Table).all()}
|
||||
table_name = tables_map.get(order.table_id, str(order.table_id)) if order.table_id else "Takeaway"
|
||||
zone = body.kds_zone or "Κουζίνα"
|
||||
|
||||
msg_body = (
|
||||
f"Παραγγελία #{order.id} - τραπέζι {table_name} "
|
||||
f"- {zone}"
|
||||
)
|
||||
_save_and_broadcast_message(
|
||||
db, user.id, msg_body, waiter_ids,
|
||||
"kds_call_order", zone,
|
||||
table_ids=[order.table_id] if order.table_id else [],
|
||||
)
|
||||
return {"sent": True, "waiter_ids": waiter_ids}
|
||||
|
||||
|
||||
@router.post("/call-waiter-general")
|
||||
def kds_call_waiter_general(
|
||||
body: KdsCallWaiterBody,
|
||||
db: Session = Depends(get_db),
|
||||
user: User = Depends(get_current_user),
|
||||
):
|
||||
"""Call one or more on-shift waiters to the prep zone."""
|
||||
if not body.waiter_ids:
|
||||
raise HTTPException(status_code=400, detail="waiter_ids must not be empty")
|
||||
|
||||
zone = body.kds_zone or "Κουζίνα"
|
||||
msg_body = f"Κλήση από {zone}"
|
||||
_save_and_broadcast_message(
|
||||
db, user.id, msg_body, body.waiter_ids,
|
||||
"kds_call_general", zone,
|
||||
)
|
||||
return {"sent": True, "waiter_ids": body.waiter_ids}
|
||||
|
||||
|
||||
@router.get("/on-shift-waiters")
|
||||
def kds_on_shift_waiters(
|
||||
db: Session = Depends(get_db),
|
||||
user: User = Depends(get_current_user),
|
||||
):
|
||||
"""Return waiters currently on shift (ended_at IS NULL)."""
|
||||
on_shift_ids = {
|
||||
row.waiter_id
|
||||
for row in db.query(WaiterShift).filter(WaiterShift.ended_at == None).all()
|
||||
}
|
||||
waiters = db.query(User).filter(
|
||||
User.id.in_(on_shift_ids),
|
||||
User.perm_access_waiter_app == True,
|
||||
User.is_active == True,
|
||||
).order_by(User.username).all()
|
||||
return [
|
||||
{"id": w.id, "username": w.username, "nickname": w.nickname, "avatar_url": w.avatar_url}
|
||||
for w in waiters
|
||||
]
|
||||
|
||||
|
||||
# ─────────────────────────── legacy endpoints ──────────────────────────────
|
||||
|
||||
class ItemStatusUpdate(BaseModel):
|
||||
status: str # only "ready" is accepted
|
||||
@@ -35,15 +742,13 @@ def kds_items(
|
||||
db: Session = Depends(get_db),
|
||||
user: User = Depends(get_current_user),
|
||||
):
|
||||
"""Return all active order items grouped by zone for the KDS display."""
|
||||
# Only look at open / partially_paid orders
|
||||
"""Legacy: return active order items grouped by printer zone."""
|
||||
open_orders = db.query(Order).filter(Order.status.in_(["open", "partially_paid"])).all()
|
||||
order_ids = [o.id for o in open_orders]
|
||||
if not order_ids:
|
||||
return {"zones": []}
|
||||
|
||||
order_map = {o.id: o for o in open_orders}
|
||||
|
||||
items = db.query(OrderItem).filter(
|
||||
OrderItem.order_id.in_(order_ids),
|
||||
OrderItem.status == "active",
|
||||
@@ -52,13 +757,10 @@ def kds_items(
|
||||
tables_map = {t.id: (t.label or f"T{t.number}") for t in db.query(Table).all()}
|
||||
printers_map = {p.id: p.name for p in db.query(Printer).all()}
|
||||
|
||||
# Zone = printer_zone_id (None = no zone)
|
||||
zones: dict = {}
|
||||
|
||||
def _zone_key(zone_id):
|
||||
if zone_id is None:
|
||||
return 0
|
||||
return zone_id
|
||||
return 0 if zone_id is None else zone_id
|
||||
|
||||
def _zone_name(zone_id):
|
||||
if zone_id is None:
|
||||
@@ -69,17 +771,10 @@ def kds_items(
|
||||
product = item.product
|
||||
zone_id = product.printer_zone_id if product else None
|
||||
zkey = _zone_key(zone_id)
|
||||
|
||||
if zkey not in zones:
|
||||
zones[zkey] = {
|
||||
"zone_id": zone_id,
|
||||
"zone_name": _zone_name(zone_id),
|
||||
"items": [],
|
||||
}
|
||||
|
||||
zones[zkey] = {"zone_id": zone_id, "zone_name": _zone_name(zone_id), "items": []}
|
||||
order = order_map.get(item.order_id)
|
||||
table_name = tables_map.get(order.table_id) if order and order.table_id else None
|
||||
|
||||
zones[zkey]["items"].append({
|
||||
"id": item.id,
|
||||
"order_id": item.order_id,
|
||||
@@ -91,19 +786,19 @@ def kds_items(
|
||||
"status": item.status,
|
||||
})
|
||||
|
||||
# Sort zones: named zones first (by zone_id), then no-zone last
|
||||
zone_list = sorted(zones.values(), key=lambda z: (z["zone_id"] is None, z["zone_id"] or 0))
|
||||
return {"zones": zone_list}
|
||||
|
||||
|
||||
@router.put("/orders/{order_id}/items/{item_id}/status")
|
||||
def update_item_status(
|
||||
def update_item_status_legacy(
|
||||
order_id: int,
|
||||
item_id: int,
|
||||
body: ItemStatusUpdate,
|
||||
db: Session = Depends(get_db),
|
||||
user: User = Depends(get_current_user),
|
||||
):
|
||||
"""Legacy: mark an item ready (active → ready)."""
|
||||
if body.status != "ready":
|
||||
raise HTTPException(status_code=400, detail="Only 'ready' is a valid status transition via this endpoint")
|
||||
|
||||
|
||||
Reference in New Issue
Block a user