Initial KDS scaffold — Phase 2 kitchen port

FastAPI backend (Python 3.11, httpx for SignalR/GraphQL — no MSSQL ODBC),
shares kitchen_db directly. React/TS/Vite fullscreen board frontend.

Backend: auth.py (APP_SLUG=kds, SimpleNamespace), main.py (4 KDS migrations,
SignalR start/stop), kds.py router, models (kds/settings/resos — read from
kitchen_db), signalr_listener.py (backoff pre-existing), kds_graphql.py,
database.py. Requirements stripped to ~9 packages; image ~400 MB lighter
than kitchen (no MSSQL ODBC layer).

Frontend: AuthGate (app=kds), single fullscreen route, dark board theme.
KDS.tsx URL prefix patched (/api/kds/ → /kds/api/kds/),
recipe images cross-app (/kitchen/api/recipes/).
nginx: 5 blocks with SSE proxy headers on /kds/api/ block.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
jtricerolph 2026-07-12 12:15:16 +00:00
commit b94585084a
35 changed files with 5195 additions and 0 deletions

88
backend/models/kds.py Normal file
View file

@ -0,0 +1,88 @@
"""
KDS (Kitchen Display System) Models
Local state tracking for kitchen orders, course bumping, and display.
"""
from datetime import datetime
from sqlalchemy import String, DateTime, ForeignKey, Text, Boolean, Integer, Float
from sqlalchemy.dialects.postgresql import JSONB
from sqlalchemy.orm import Mapped, mapped_column, relationship
from database import Base
class KDSTicket(Base):
"""
Local tracking of SambaPOS tickets for KDS display.
Stores the current state of each ticket being displayed on KDS,
including course progress and timing.
"""
__tablename__ = "kds_tickets"
id: Mapped[int] = mapped_column(primary_key=True)
kitchen_id: Mapped[int] = mapped_column(ForeignKey("kitchens.id"), index=True)
# SambaPOS ticket reference
sambapos_ticket_id: Mapped[int] = mapped_column(Integer, index=True)
sambapos_ticket_uid: Mapped[str | None] = mapped_column(String(100), nullable=True)
ticket_number: Mapped[str] = mapped_column(String(50))
# Ticket info from SambaPOS
table_name: Mapped[str | None] = mapped_column(String(100), nullable=True)
covers: Mapped[int | None] = mapped_column(Integer, nullable=True)
total_amount: Mapped[float | None] = mapped_column(Float, nullable=True)
# Timing
received_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
last_sambapos_update: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
# Local state tracking
is_active: Mapped[bool] = mapped_column(Boolean, default=True)
is_bumped: Mapped[bool] = mapped_column(Boolean, default=False) # Fully bumped/completed
bumped_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
# Current course state (which courses have been bumped)
# Format: {"Starters": {"bumped": true, "bumped_at": "2025-01-25T12:00:00"}, ...}
course_states: Mapped[dict | None] = mapped_column(JSONB, nullable=True, default=dict)
# Cached order data (refreshed on each poll)
orders_data: Mapped[list | None] = mapped_column(JSONB, nullable=True)
# Order IDs captured at ticket creation (for detecting +ADDITION orders later)
initial_order_ids: Mapped[list | None] = mapped_column(JSONB, nullable=True)
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
updated_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
# Relationships
kitchen: Mapped["Kitchen"] = relationship("Kitchen")
course_bumps: Mapped[list["KDSCourseBump"]] = relationship("KDSCourseBump", back_populates="ticket", cascade="all, delete-orphan")
class KDSCourseBump(Base):
"""
Track individual course bumps for audit trail.
Records when each course was bumped for a ticket.
"""
__tablename__ = "kds_course_bumps"
id: Mapped[int] = mapped_column(primary_key=True)
ticket_id: Mapped[int] = mapped_column(ForeignKey("kds_tickets.id", ondelete="CASCADE"), index=True)
course_name: Mapped[str] = mapped_column(String(100)) # e.g., "Starters", "Mains", "Desserts"
action: Mapped[str] = mapped_column(String(20), default="sent") # "away" or "sent"
bumped_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
bumped_by_user_id: Mapped[int | None] = mapped_column(ForeignKey("users.id"), nullable=True)
# Time since previous course bump (for analytics)
time_since_previous_seconds: Mapped[int | None] = mapped_column(Integer, nullable=True)
# Relationships
ticket: Mapped["KDSTicket"] = relationship("KDSTicket", back_populates="course_bumps")
bumped_by: Mapped["User"] = relationship("User")
# Forward references
from .user import Kitchen, User