Waiter PWA fixes, and extra feautures. Also added Emergency Mode, search etc

This commit is contained in:
2026-05-02 21:08:53 +03:00
parent 8e27b7666e
commit c9ad78ec71
50 changed files with 4441 additions and 643 deletions

View File

@@ -7,6 +7,7 @@ from models.flag import TableFlagDef, TableFlagAssignment
from schemas.flag import FlagDefCreate, FlagDefUpdate, FlagDefOut, FlagAssignmentOut, SetTableFlagsRequest
from routers.deps import get_current_user, require_manager
from models.user import User
from services.sse_bus import broadcast_sync
router = APIRouter()
@@ -124,9 +125,11 @@ def set_table_flags(
))
db.commit()
return db.query(TableFlagAssignment).filter(
result = db.query(TableFlagAssignment).filter(
TableFlagAssignment.table_id == table_id
).all()
broadcast_sync("table_flags_changed", {"table_id": table_id, "flag_ids": body.flag_ids})
return result
@router.delete("/table/{table_id}/all", status_code=status.HTTP_204_NO_CONTENT)
@@ -139,3 +142,4 @@ def clear_table_flags(
TableFlagAssignment.table_id == table_id
).delete(synchronize_session=False)
db.commit()
broadcast_sync("table_flags_changed", {"table_id": table_id, "flag_ids": []})

View File

@@ -11,6 +11,7 @@ from schemas.message import (
QuickTemplateCreate, QuickTemplateUpdate, QuickTemplateOut,
)
from routers.deps import get_current_user, require_manager
from services.sse_bus import broadcast_sync
router = APIRouter()
@@ -113,7 +114,22 @@ def send_message(
db.add(msg)
db.commit()
msg = _load_msg(db, msg.id)
return _message_out(msg)
out = _message_out(msg)
# Broadcast to targeted users (empty list = all connected users)
target_ids = body.target_waiter_ids if body.target_waiter_ids else None
broadcast_sync(
"message_sent",
{
"id": out.id,
"sender_id": out.sender_id,
"sender_name": out.sender_name,
"body": out.body,
"table_ids": out.table_ids,
"created_at": out.created_at.isoformat() if out.created_at else None,
},
user_ids=target_ids,
)
return out
@router.get("/unread", response_model=List[StaffMessageOut])

View File

@@ -9,7 +9,7 @@ from models.order import Order, OrderItem, OrderWaiter, OrderAuditLog
from models.user import User, WaiterZone
from models.table import Table
from models.product import Product
from schemas.order import OrderCreate, OrderOut, OrderItemOut, AddItemsRequest, AddItemsResponse, PayItemsRequest, AssignWaiterRequest, OrderWaiterOut
from schemas.order import OrderCreate, OrderOut, OrderItemOut, AddItemsRequest, AddItemsResponse, PayItemsRequest, OfflinePaymentRequest, AssignWaiterRequest, OrderWaiterOut
from pydantic import BaseModel
class PrintOrderRequest(BaseModel):
@@ -33,6 +33,7 @@ class MoveItemsRequest(BaseModel):
from routers.deps import get_current_user, require_manager
from services.printer_service import route_and_print, route_and_print_sync, print_order_receipt, print_order_synopsis
from services.sse_bus import broadcast_sync
router = APIRouter()
@@ -159,6 +160,7 @@ def open_order(body: OrderCreate, db: Session = Depends(get_db), user: User = De
_audit(db, order.id, "ORDER_OPENED", waiter_id=user.id)
db.commit()
db.refresh(order)
broadcast_sync("order_updated", {"order_id": order.id, "table_id": order.table_id, "status": order.status, "action": "opened"})
return order
@@ -209,7 +211,7 @@ def add_items(
db.refresh(order)
print_results = route_and_print_sync(order_id, new_item_ids, db)
broadcast_sync("order_updated", {"order_id": order.id, "table_id": order.table_id, "status": order.status, "action": "items_added", "item_ids": new_item_ids})
return {"order": order, "print_results": print_results}
@@ -295,6 +297,7 @@ def pay_items(order_id: int, body: PayItemsRequest, db: Session = Depends(get_db
_audit(db, order_id, "PAYMENT", waiter_id=user.id, item_ids=paid_ids,
amount=total_paid, payment_method=body.payment_method)
db.commit()
broadcast_sync("order_paid", {"order_id": order_id, "table_id": order.table_id, "status": order.status, "paid_item_ids": paid_ids, "amount": total_paid, "payment_method": body.payment_method})
return {"status": order.status, "paid_item_ids": paid_ids}
@@ -312,9 +315,105 @@ def close_order(order_id: int, db: Session = Depends(get_db), user: User = Depen
order.closed_by = user.id
_audit(db, order_id, "ORDER_CLOSED", waiter_id=user.id)
db.commit()
broadcast_sync("order_closed", {"order_id": order_id, "table_id": order.table_id})
return {"status": "closed"}
@router.post("/{order_id}/pay-offline")
def pay_items_offline(
order_id: int,
body: OfflinePaymentRequest,
db: Session = Depends(get_db),
user: User = Depends(get_current_user),
):
"""
Sync an emergency payment that was taken while the server was offline.
The UUID prevents double-processing. If a payment with the same UUID already
exists on this order, the duplicate is logged in red (is_duplicate=1) rather
than silently dropped — so managers can reconcile.
"""
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")
# Check for duplicate UUID on this order
existing_uuid = db.query(OrderAuditLog).filter(
OrderAuditLog.order_id == order_id,
OrderAuditLog.offline_uuid == body.uuid,
).first()
is_duplicate = existing_uuid is not None
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()
# Reject empty payments — client had no offline snapshot for this table
if not items and not is_duplicate:
raise HTTPException(status_code=400, detail="No active items found — payment rejected")
# Use the client-recorded offline timestamp as paid_at so audit reflects real payment time
try:
paid_at = datetime.fromisoformat(body.offline_at.replace("Z", "+00:00")) if body.offline_at else datetime.now(timezone.utc)
except (ValueError, AttributeError):
paid_at = datetime.now(timezone.utc)
active_shift = db.query(WaiterShift).filter(
WaiterShift.waiter_id == user.id,
WaiterShift.ended_at == None,
).first()
total_paid = 0.0
paid_ids = []
if not is_duplicate:
for item in items:
item.status = "paid"
item.paid_by = user.id
item.paid_at = paid_at
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
paid_ids.append(item.id)
db.flush()
active_remaining = db.query(OrderItem).filter(
OrderItem.order_id == order_id, OrderItem.status == "active"
).count()
order.status = "paid" if active_remaining == 0 else "partially_paid"
else:
# Duplicate — compute total for audit record without changing item state
total_paid = sum(i.unit_price * i.quantity for i in items)
paid_ids = [i.id for i in items]
# Always write audit log — duplicate flag makes it visible in red in manager dashboard
db.add(OrderAuditLog(
order_id=order_id,
event_type="PAYMENT_OFFLINE",
waiter_id=user.id,
item_ids=json.dumps(paid_ids),
amount=total_paid,
payment_method=body.payment_method,
note=f"Emergency offline payment (uuid={body.uuid}){' — DUPLICATE' if is_duplicate else ''}",
offline_uuid=body.uuid,
offline_at=body.offline_at,
is_duplicate=1 if is_duplicate else 0,
))
db.commit()
if not is_duplicate:
broadcast_sync("order_paid", {"order_id": order_id, "table_id": order.table_id, "status": order.status, "paid_item_ids": paid_ids, "amount": total_paid, "payment_method": body.payment_method})
return {
"status": order.status if not is_duplicate else "duplicate",
"paid_item_ids": paid_ids,
"is_duplicate": is_duplicate,
}
@router.delete("/{order_id}", status_code=status.HTTP_204_NO_CONTENT)
def cancel_order(order_id: int, db: Session = Depends(get_db), user: User = Depends(require_manager)):
order = db.query(Order).filter(Order.id == order_id).first()
@@ -325,6 +424,7 @@ def cancel_order(order_id: int, db: Session = Depends(get_db), user: User = Depe
order.closed_by = user.id
_audit(db, order_id, "ORDER_CANCELLED", waiter_id=user.id)
db.commit()
broadcast_sync("order_closed", {"order_id": order_id, "table_id": order.table_id})
@router.put("/{order_id}/assign-waiter")
@@ -444,6 +544,7 @@ def transfer_order(
note=f"Transferred from table {old_table_id} to table {body.target_table_id}")
db.commit()
db.refresh(order)
broadcast_sync("order_updated", {"order_id": order.id, "table_id": order.table_id, "old_table_id": old_table_id, "status": order.status, "action": "transferred"})
return order
@@ -517,6 +618,8 @@ def merge_order(
db.commit()
db.refresh(target)
broadcast_sync("order_updated", {"order_id": target.id, "table_id": target.table_id, "status": target.status, "action": "merged"})
broadcast_sync("order_closed", {"order_id": source.id, "table_id": source.table_id})
return target

View File

@@ -17,13 +17,19 @@ VALID_SETTINGS = {
"system.timezone": "IANA timezone name used by the backend container (e.g. Europe/Athens). Requires container restart to take effect.",
"ui.table_colours": "JSON blob of table card colour scheme (light + dark modes) for the Waiter PWA.",
"dev.spoof_printing": "When enabled, all print jobs are silently dropped. Devices behave as if printing succeeded.",
# Print font settings — values are "SIZE:BOLD" where SIZE is ESC ! base byte (0/16/32/48) and BOLD is 0 or 1
"print.font_item_name": "Font for item name lines: SIZE:BOLD (e.g. '16:0')",
"print.font_options": "Font for option/modifier lines: SIZE:BOLD",
"print.font_table": "Font for table/waiter header lines: SIZE:BOLD",
"print.font_order_number": "Font for order number header: SIZE:BOLD",
"print.font_header": "Font for top header block: SIZE:BOLD",
# Print layout
"print.ticket_mode": "Kitchen ticket layout mode: 'detailed' or 'compact'",
"print.divider_style": "Divider character used between sections: dash, equals, star, or empty",
# Print font settings — values are "SIZE:BOLD:CAPS" where SIZE is ESC ! base byte (0/16/32/48), BOLD 0|1, CAPS 0|1
"print.font_order_number": "Font for order number header: SIZE:BOLD:CAPS",
"print.font_meta": "Font for table/waiter/time header block: SIZE:BOLD:CAPS",
"print.font_item_name": "Font for item name lines: SIZE:BOLD:CAPS",
"print.font_quick": "Font for quick option lines (* marker): SIZE:BOLD:CAPS",
"print.font_pref": "Font for preference choice lines (> marker): SIZE:BOLD:CAPS",
"print.font_extra": "Font for extra/option lines (+ marker): SIZE:BOLD:CAPS",
"print.font_ingredient": "Font for removed ingredient lines (- marker): SIZE:BOLD:CAPS",
"print.font_item_note": "Font for per-item note lines: SIZE:BOLD:CAPS",
"print.font_order_note": "Font for order-level notes: SIZE:BOLD:CAPS",
}
DEFAULTS = {
@@ -33,12 +39,17 @@ DEFAULTS = {
"system.timezone": "Europe/Athens",
"ui.table_colours": "",
"dev.spoof_printing": "false",
"print.font_item_name": "16:0", # double-height, no bold
"print.font_options": "0:0", # normal
"print.font_table": "16:0", # double-height
"print.font_order_number": "48:1", # double-height + double-width + bold
"print.font_header": "48:1", # double-height + double-width + bold
"print.ticket_mode": "detailed",
"print.divider_style": "dash",
"print.font_order_number": "48:1:0",
"print.font_meta": "0:0:0",
"print.font_item_name": "16:1:0",
"print.font_quick": "0:0:0",
"print.font_pref": "0:0:0",
"print.font_extra": "0:0:0",
"print.font_ingredient": "0:0:0",
"print.font_item_note": "0:0:0",
"print.font_order_note": "0:1:0",
}

View File

@@ -0,0 +1,60 @@
"""
SSE stream endpoint — one long-lived GET per connected phone.
Authentication: token passed as query param ?token=<jwt>
(EventSource API in browsers cannot set custom headers, so query param is the standard pattern.)
The client receives a stream of JSON lines:
data: {"type": "...", "data": {...}}\n\n
A keepalive comment (": ping") is sent every 25 seconds to prevent proxy timeouts.
"""
import asyncio
from fastapi import APIRouter, Query
from fastapi.responses import StreamingResponse
from routers.deps import decode_token
from services.sse_bus import subscribe, unsubscribe
router = APIRouter()
KEEPALIVE_INTERVAL = 25 # seconds
async def _event_stream(user_id: int):
q = await subscribe(user_id)
try:
while True:
try:
payload = await asyncio.wait_for(q.get(), timeout=KEEPALIVE_INTERVAL)
yield f"data: {payload}\n\n"
except asyncio.TimeoutError:
# keepalive — prevents nginx/proxies from closing idle connections
yield ": ping\n\n"
except asyncio.CancelledError:
pass
finally:
await unsubscribe(user_id, q)
@router.get("/stream")
async def sse_stream(token: str = Query(...)):
"""
Open an SSE stream for the authenticated user.
The phone connects once on login and stays connected.
On reconnect (after network drop) it does a full GET first, then reconnects here.
"""
# decode_token raises HTTPException on invalid/expired — no manual check needed
payload = decode_token(token)
user_id: int = int(payload["sub"])
return StreamingResponse(
_event_stream(user_id),
media_type="text/event-stream",
headers={
"Cache-Control": "no-cache",
"X-Accel-Buffering": "no", # disable nginx buffering
"Connection": "keep-alive",
},
)

View File

@@ -61,6 +61,15 @@ def test_printer(printer_id: int, db: Session = Depends(get_db), user: User = De
return {"success": success, "error": error}
@router.post("/printers/test-order")
def test_order_print(printer_id: int, db: Session = Depends(get_db), user: User = Depends(require_manager)):
printer = db.query(Printer).filter(Printer.id == printer_id).first()
if not printer:
raise HTTPException(status_code=404, detail="Printer not found")
success, error = printer_service.send_test_order_print(printer.ip_address, printer.port, db)
return {"success": success, "error": error}
@router.put("/printers/{printer_id}", response_model=PrinterOut)
def update_printer(printer_id: int, body: PrinterUpdate, db: Session = Depends(get_db), user: User = Depends(require_manager)):
printer = db.query(Printer).filter(Printer.id == printer_id).first()

View File

@@ -12,6 +12,7 @@ from schemas.table import (
TableBatchCreate,
)
from routers.deps import get_current_user, require_manager
from services.sse_bus import broadcast_sync
router = APIRouter()
@@ -105,6 +106,7 @@ def create_table(body: TableCreate, db: Session = Depends(get_db), user: User =
db.add(table)
db.commit()
db.refresh(table)
broadcast_sync("table_list_changed", {"action": "created", "table_id": table.id})
return table