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>
824 lines
29 KiB
Python
824 lines
29 KiB
Python
"""
|
||
KDS — Kitchen Display System router.
|
||
|
||
GET /api/kds/orders
|
||
Returns all open orders enriched with resolved modifier names,
|
||
grouped by nothing (flat list). Frontend handles grouping/filtering.
|
||
|
||
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)
|
||
"""
|
||
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, 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 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(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
|
||
|
||
|
||
@router.get("/items")
|
||
def kds_items(
|
||
db: Session = Depends(get_db),
|
||
user: User = Depends(get_current_user),
|
||
):
|
||
"""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",
|
||
).order_by(OrderItem.added_at.asc()).all()
|
||
|
||
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()}
|
||
|
||
zones: dict = {}
|
||
|
||
def _zone_key(zone_id):
|
||
return 0 if zone_id is None else zone_id
|
||
|
||
def _zone_name(zone_id):
|
||
if zone_id is None:
|
||
return "Χωρίς Ζώνη"
|
||
return printers_map.get(zone_id, f"Ζώνη #{zone_id}")
|
||
|
||
for item in 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": []}
|
||
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,
|
||
"table_name": table_name,
|
||
"product_name": product.name if product else f"#{item.product_id}",
|
||
"quantity": item.quantity,
|
||
"notes": item.notes,
|
||
"added_at": item.added_at,
|
||
"status": item.status,
|
||
})
|
||
|
||
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_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")
|
||
|
||
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=f"Item is '{item.status}' — only active items can be marked ready")
|
||
|
||
item.status = "ready"
|
||
db.commit()
|
||
|
||
broadcast_sync("item_status_changed", {
|
||
"order_id": order_id,
|
||
"item_id": item_id,
|
||
"status": "ready",
|
||
})
|
||
|
||
return {"status": "ready", "item_id": item_id}
|