Files
xenia-pos-local/local_backend/routers/connect_orders.py
bonamin aed71a18d8 feat(manager): Part 2 — Online Orders page and sidebar integration
- connect_orders.py: add GET /api/connect/orders?status=active|history endpoints
- api/client.js: add getIncomingOrders, getActiveOrders, getOrderHistory,
  acceptOnlineOrder, rejectOnlineOrder, updateOnlineOrderStatus
- OnlineOrdersPage.jsx: three-tab page (Incoming / Active / History) with
  order cards, action buttons (accept/reject/status progression),
  20 s polling, SSE listener for online_order_received/updated events,
  and optimistic UI updates
- App.jsx: add /online-orders route
- Sidebar.jsx: add Online Orders nav item with red badge showing pending count,
  polling every 20 s independently of the page

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-01 09:59:21 +03:00

177 lines
6.1 KiB
Python

import logging
from typing import Optional
from fastapi import APIRouter, Depends, HTTPException, status, BackgroundTasks
from pydantic import BaseModel
from sqlalchemy.orm import Session
import httpx
from config import settings
from database import get_db
from models.order import Order
from schemas.order import OrderOut
from routers.deps import get_current_user, require_manager
from models.user import User
from services.sse_bus import broadcast_sync
logger = logging.getLogger(__name__)
router = APIRouter()
class RejectRequest(BaseModel):
reason: Optional[str] = None
class StatusUpdateRequest(BaseModel):
status: str # "preparing" | "ready" | "out_for_delivery" | "delivered"
# ── Helpers ───────────────────────────────────────────────────────────────────
def _get_online_order(order_id: int, db: Session) -> Order:
order = db.query(Order).filter(Order.id == order_id, Order.source == "online").first()
if not order:
raise HTTPException(status_code=404, detail="Online order not found")
return order
def _cloud_headers() -> dict:
return {"X-Site-ID": settings.SITE_ID, "X-Site-Key": settings.SITE_KEY}
async def _mirror_status_to_cloud(cloud_order_id: int, new_status: str, rejection_reason: str = None):
"""Best-effort push of status update to cloud. Failures are logged, not raised."""
if not settings.CLOUD_URL or not cloud_order_id:
return
try:
body = {"status": new_status}
if rejection_reason:
body["rejection_reason"] = rejection_reason
async with httpx.AsyncClient(timeout=10) as client:
resp = await client.patch(
f"{settings.CLOUD_URL}/api/orders/{cloud_order_id}/status",
headers=_cloud_headers(),
json=body,
)
resp.raise_for_status()
logger.info("Mirrored status %s for cloud order id %d", new_status, cloud_order_id)
except Exception as e:
logger.warning("Failed to mirror status to cloud for order id %d: %s", cloud_order_id, e)
# ── Endpoints ─────────────────────────────────────────────────────────────────
@router.get("/orders", response_model=list[OrderOut])
def get_orders_by_status(
status: str,
db: Session = Depends(get_db),
user: User = Depends(get_current_user),
):
"""
?status=active → accepted | preparing | ready | out_for_delivery, newest first
?status=history → delivered | rejected, newest first, limit 50
"""
if status == "active":
statuses = ["accepted", "preparing", "ready", "out_for_delivery"]
orders = (
db.query(Order)
.filter(Order.source == "online", Order.online_status.in_(statuses))
.order_by(Order.opened_at.desc())
.all()
)
elif status == "history":
statuses = ["delivered", "rejected"]
orders = (
db.query(Order)
.filter(Order.source == "online", Order.online_status.in_(statuses))
.order_by(Order.opened_at.desc())
.limit(50)
.all()
)
else:
raise HTTPException(status_code=400, detail=f"Unknown status filter '{status}'. Use 'active' or 'history'.")
return orders
@router.get("/orders/incoming", response_model=list[OrderOut])
def get_incoming_orders(
db: Session = Depends(get_db),
user: User = Depends(get_current_user),
):
"""Return all online orders pending acceptance, newest first."""
orders = (
db.query(Order)
.filter(Order.source == "online", Order.online_status == "pending_acceptance")
.order_by(Order.opened_at.desc())
.all()
)
return orders
@router.post("/orders/{order_id}/accept", response_model=OrderOut)
async def accept_order(
order_id: int,
background: BackgroundTasks,
db: Session = Depends(get_db),
user: User = Depends(require_manager),
):
order = _get_online_order(order_id, db)
if order.online_status != "pending_acceptance":
raise HTTPException(status_code=400, detail=f"Order is already '{order.online_status}'")
order.online_status = "accepted"
db.commit()
db.refresh(order)
background.add_task(_mirror_status_to_cloud, order.online_order_cloud_id, "accepted")
broadcast_sync("online_order_updated", {"order_id": order_id, "status": "accepted"})
return order
@router.post("/orders/{order_id}/reject", response_model=OrderOut)
async def reject_order(
order_id: int,
body: RejectRequest,
background: BackgroundTasks,
db: Session = Depends(get_db),
user: User = Depends(require_manager),
):
order = _get_online_order(order_id, db)
if order.online_status != "pending_acceptance":
raise HTTPException(status_code=400, detail=f"Order is already '{order.online_status}'")
order.online_status = "rejected"
order.status = "cancelled"
db.commit()
db.refresh(order)
background.add_task(_mirror_status_to_cloud, order.online_order_cloud_id, "rejected", body.reason)
broadcast_sync("online_order_updated", {"order_id": order_id, "status": "rejected"})
return order
@router.post("/orders/{order_id}/status", response_model=OrderOut)
async def update_order_status(
order_id: int,
body: StatusUpdateRequest,
background: BackgroundTasks,
db: Session = Depends(get_db),
user: User = Depends(require_manager),
):
allowed = {"preparing", "ready", "out_for_delivery", "delivered"}
if body.status not in allowed:
raise HTTPException(status_code=400, detail=f"Invalid status '{body.status}'")
order = _get_online_order(order_id, db)
order.online_status = body.status
if body.status == "delivered":
order.status = "closed"
db.commit()
db.refresh(order)
background.add_task(_mirror_status_to_cloud, order.online_order_cloud_id, body.status)
broadcast_sync("online_order_updated", {"order_id": order_id, "status": body.status})
return order