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:
2026-07-19 10:00:14 +03:00
parent 02ec1aa28f
commit 34ae328b0d
182 changed files with 34874 additions and 3556 deletions

View File

@@ -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")