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:
commit
b94585084a
35 changed files with 5195 additions and 0 deletions
59
_copy_from_archive.sh
Normal file
59
_copy_from_archive.sh
Normal file
|
|
@ -0,0 +1,59 @@
|
|||
#!/usr/bin/env bash
|
||||
# Phase 2 setup — copy KDS-specific archive files into kds/
|
||||
# Run once from the repo root: bash kds/_copy_from_archive.sh
|
||||
set -euo pipefail
|
||||
|
||||
ARCHIVE=/home/jtr/laptop-archive/kitchen-invoice-flash-docker
|
||||
DEST=/home/jtr/dev/HNF-PROXMOX/kds
|
||||
|
||||
echo "==> Copying KDS backend from archive..."
|
||||
|
||||
# KDS API router
|
||||
cp "$ARCHIVE/backend/api/kds.py" "$DEST/backend/api/"
|
||||
|
||||
# KDS models
|
||||
cp "$ARCHIVE/backend/models/kds.py" "$DEST/backend/models/"
|
||||
cp "$ARCHIVE/backend/models/settings.py" "$DEST/backend/models/"
|
||||
cp "$ARCHIVE/backend/models/resos.py" "$DEST/backend/models/"
|
||||
|
||||
# KDS migrations (4 files — add kds_tables, course_flow, order_tracking, bookings_refresh)
|
||||
cp "$ARCHIVE/backend/migrations/add_kds_tables.py" "$DEST/backend/migrations/"
|
||||
cp "$ARCHIVE/backend/migrations/add_kds_course_flow.py" "$DEST/backend/migrations/"
|
||||
cp "$ARCHIVE/backend/migrations/add_kds_order_tracking.py" "$DEST/backend/migrations/"
|
||||
cp "$ARCHIVE/backend/migrations/add_kds_bookings_refresh.py" "$DEST/backend/migrations/"
|
||||
|
||||
# SignalR listener (already has exponential-backoff reconnect — log A12 satisfied)
|
||||
cp "$ARCHIVE/backend/services/signalr_listener.py" "$DEST/backend/services/"
|
||||
|
||||
# SambaPOS GraphQL client (httpx-based — no MSSQL/ODBC needed in KDS)
|
||||
cp "$ARCHIVE/backend/services/kds_graphql.py" "$DEST/backend/services/"
|
||||
|
||||
# database.py — same asyncpg/SQLAlchemy setup as kitchen
|
||||
cp "$ARCHIVE/backend/database.py" "$DEST/backend/"
|
||||
|
||||
echo "==> Patching auth import in api/kds.py..."
|
||||
# Replace old local JWT import with new central-auth import
|
||||
sed -i 's/from auth\.jwt import get_current_user/from auth import get_current_user/g' \
|
||||
"$DEST/backend/api/kds.py"
|
||||
# Remove old User model import from kds.py — now uses auth stub (auth.py returns SimpleNamespace)
|
||||
sed -i 's/^from models\.user import User$/from models.user import User # type stub — actual user is SimpleNamespace from auth.py/g' \
|
||||
"$DEST/backend/api/kds.py"
|
||||
|
||||
echo "==> Copying KDS frontend page..."
|
||||
# KDS.tsx: full board UI — patch URL prefix from /api/kds/ to /kds/api/kds/
|
||||
cp "$ARCHIVE/frontend/src/pages/KDS.tsx" "$DEST/frontend/src/pages/"
|
||||
sed -i "s|fetch('/api/kds/|fetch('/kds/api/kds/|g" "$DEST/frontend/src/pages/KDS.tsx"
|
||||
sed -i "s|new EventSource('/api/kds/|new EventSource('/kds/api/kds/|g" \
|
||||
"$DEST/frontend/src/pages/KDS.tsx"
|
||||
# Recipe images cross-app: KDS fetches recipe images from kitchen backend
|
||||
sed -i "s|src={\`/api/recipes/|src={\`/kitchen/api/recipes/|g" \
|
||||
"$DEST/frontend/src/pages/KDS.tsx"
|
||||
# Also handle token-based Authorization headers in KDS fetch calls:
|
||||
# KDS.tsx uses { headers: { Authorization: ... } } from useAuth().token.
|
||||
# token = '__session__' (truthy sentinel) — header is sent but ignored by backend;
|
||||
# cookie auth (hnf_session) is used instead. No change needed: same-origin fetches
|
||||
# send cookies automatically. B5b tracking not required for KDS (only ~8 endpoints).
|
||||
|
||||
echo ""
|
||||
echo "Done. Verify build: cd kds && docker compose build && cd frontend && npm install && npm run build"
|
||||
echo "Deploy order: commit → push to Forgejo → then pct exec 125 ..."
|
||||
19
backend/Dockerfile
Normal file
19
backend/Dockerfile
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
FROM python:3.11-slim
|
||||
|
||||
ENV PYTHONDONTWRITEBYTECODE=1
|
||||
ENV PYTHONUNBUFFERED=1
|
||||
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends curl \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY requirements.txt .
|
||||
RUN pip install --no-cache-dir --upgrade pip && \
|
||||
pip install --no-cache-dir -r requirements.txt
|
||||
|
||||
COPY . .
|
||||
|
||||
EXPOSE 8000
|
||||
|
||||
CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000"]
|
||||
0
backend/api/__init__.py
Normal file
0
backend/api/__init__.py
Normal file
1514
backend/api/kds.py
Normal file
1514
backend/api/kds.py
Normal file
File diff suppressed because it is too large
Load diff
63
backend/auth.py
Normal file
63
backend/auth.py
Normal file
|
|
@ -0,0 +1,63 @@
|
|||
import os
|
||||
from types import SimpleNamespace
|
||||
|
||||
from fastapi import Depends, HTTPException, Request, status
|
||||
from jose import JWTError, jwt
|
||||
|
||||
CENTRAL_AUTH_SECRET = os.getenv("CENTRAL_AUTH_SECRET", "")
|
||||
JWT_ALGORITHM = "HS256"
|
||||
APP_SLUG = os.getenv("APP_SLUG", "kds")
|
||||
|
||||
|
||||
async def get_current_user(request: Request):
|
||||
"""
|
||||
Verify the stack hnf_session cookie.
|
||||
Returns a SimpleNamespace so archive kds.py can use current_user.kitchen_id,
|
||||
current_user.is_admin, etc. without modification. kitchen_id is pinned to 1.
|
||||
"""
|
||||
token = request.cookies.get("hnf_session")
|
||||
if not token:
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Not authenticated")
|
||||
|
||||
try:
|
||||
payload = jwt.decode(token, CENTRAL_AUTH_SECRET, algorithms=[JWT_ALGORITHM])
|
||||
except JWTError:
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid session")
|
||||
|
||||
apps = payload.get("apps", [])
|
||||
if APP_SLUG not in apps:
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="No permission for this app")
|
||||
|
||||
prefix = f"{APP_SLUG}:"
|
||||
raw_caps = payload.get("caps", [])
|
||||
caps = [c[len(prefix):] for c in raw_caps if isinstance(c, str) and c.startswith(prefix)]
|
||||
|
||||
is_admin = payload.get("is_admin", False)
|
||||
|
||||
return SimpleNamespace(
|
||||
id=0,
|
||||
email=payload.get("sub", ""),
|
||||
username=payload.get("sub", ""),
|
||||
name=payload.get("name", ""),
|
||||
display_name=payload.get("name", ""),
|
||||
is_admin=is_admin,
|
||||
is_active=True,
|
||||
kitchen_id=1,
|
||||
caps=caps,
|
||||
role="admin" if is_admin else "user",
|
||||
)
|
||||
|
||||
|
||||
def has_cap(user, cap: str) -> bool:
|
||||
return user.is_admin or cap in user.caps
|
||||
|
||||
|
||||
def require_cap(cap: str):
|
||||
async def checker(user=Depends(get_current_user)):
|
||||
if not has_cap(user, cap):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail=f"Missing capability: {cap}",
|
||||
)
|
||||
return user
|
||||
return checker
|
||||
38
backend/database.py
Normal file
38
backend/database.py
Normal file
|
|
@ -0,0 +1,38 @@
|
|||
import os
|
||||
from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession, async_sessionmaker
|
||||
from sqlalchemy.orm import DeclarativeBase
|
||||
|
||||
DATABASE_URL = os.getenv(
|
||||
"DATABASE_URL",
|
||||
"postgresql+asyncpg://kitchen:kitchen_secret@localhost:5432/kitchen_gp"
|
||||
)
|
||||
|
||||
# Convert standard postgres URL to asyncpg format
|
||||
if DATABASE_URL.startswith("postgresql://"):
|
||||
DATABASE_URL = DATABASE_URL.replace("postgresql://", "postgresql+asyncpg://", 1)
|
||||
|
||||
engine = create_async_engine(
|
||||
DATABASE_URL,
|
||||
echo=False,
|
||||
pool_size=10, # Default is 5
|
||||
max_overflow=20, # Default is 10 - allows burst to 30 connections
|
||||
pool_pre_ping=True # Verify connections are alive before use
|
||||
)
|
||||
|
||||
AsyncSessionLocal = async_sessionmaker(
|
||||
engine,
|
||||
class_=AsyncSession,
|
||||
expire_on_commit=False
|
||||
)
|
||||
|
||||
|
||||
class Base(DeclarativeBase):
|
||||
pass
|
||||
|
||||
|
||||
async def get_db():
|
||||
async with AsyncSessionLocal() as session:
|
||||
try:
|
||||
yield session
|
||||
finally:
|
||||
await session.close()
|
||||
66
backend/main.py
Normal file
66
backend/main.py
Normal file
|
|
@ -0,0 +1,66 @@
|
|||
import logging
|
||||
from contextlib import asynccontextmanager
|
||||
|
||||
from fastapi import FastAPI
|
||||
|
||||
from database import engine, Base
|
||||
|
||||
logging.basicConfig(
|
||||
level=logging.INFO,
|
||||
format='%(asctime)s - %(name)s - %(levelname)s - %(message)s'
|
||||
)
|
||||
|
||||
from api import kds as kds_api
|
||||
|
||||
from migrations.add_kds_tables import run_migration as run_kds_tables
|
||||
from migrations.add_kds_course_flow import run_migration as run_kds_course_flow
|
||||
from migrations.add_kds_order_tracking import run_migration as run_kds_order_tracking
|
||||
from migrations.add_kds_bookings_refresh import run_migration as run_kds_bookings_refresh
|
||||
|
||||
from services.signalr_listener import start_signalr_listener, stop_signalr_listener
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def _run(name, coro):
|
||||
try:
|
||||
await coro()
|
||||
logger.info(f"{name} migration completed")
|
||||
except Exception as e:
|
||||
logger.warning(f"{name} migration warning (may be expected): {e}")
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI):
|
||||
# Create any SQLAlchemy-mapped KDS tables (idempotent)
|
||||
async with engine.begin() as conn:
|
||||
await conn.run_sync(Base.metadata.create_all)
|
||||
|
||||
# KDS migrations — extend kitchen_db schema with KDS columns/tables.
|
||||
# Requires kitchen to be deployed first (kitchen_settings must exist).
|
||||
await _run("KDS tables", run_kds_tables)
|
||||
await _run("KDS course flow", run_kds_course_flow)
|
||||
await _run("KDS order tracking", run_kds_order_tracking)
|
||||
await _run("KDS bookings refresh", run_kds_bookings_refresh)
|
||||
|
||||
await start_signalr_listener()
|
||||
|
||||
yield
|
||||
|
||||
await stop_signalr_listener()
|
||||
await engine.dispose()
|
||||
|
||||
|
||||
app = FastAPI(
|
||||
title="KDS",
|
||||
description="Kitchen Display System — SambaPOS SignalR ticket feed, course flow",
|
||||
version="1.0.0",
|
||||
lifespan=lifespan,
|
||||
)
|
||||
|
||||
app.include_router(kds_api.router, prefix="/api/kds", tags=["KDS"])
|
||||
|
||||
|
||||
@app.get("/health")
|
||||
async def health_check():
|
||||
return {"status": "healthy"}
|
||||
0
backend/migrations/__init__.py
Normal file
0
backend/migrations/__init__.py
Normal file
38
backend/migrations/add_kds_bookings_refresh.py
Normal file
38
backend/migrations/add_kds_bookings_refresh.py
Normal file
|
|
@ -0,0 +1,38 @@
|
|||
"""
|
||||
Migration: Add KDS bookings refresh interval setting.
|
||||
|
||||
Adds:
|
||||
- kds_bookings_refresh_seconds column to kitchen_settings
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
from sqlalchemy import text
|
||||
from database import engine
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def run_migration():
|
||||
"""Add KDS bookings refresh interval column."""
|
||||
|
||||
alter_statements = [
|
||||
"""
|
||||
ALTER TABLE kitchen_settings
|
||||
ADD COLUMN IF NOT EXISTS kds_bookings_refresh_seconds INTEGER DEFAULT 60
|
||||
""",
|
||||
]
|
||||
|
||||
try:
|
||||
async with engine.begin() as conn:
|
||||
for sql in alter_statements:
|
||||
await conn.execute(text(sql))
|
||||
logger.info("Added KDS bookings refresh interval column")
|
||||
except Exception as e:
|
||||
if "already exists" not in str(e).lower():
|
||||
raise
|
||||
logger.warning(f"KDS bookings refresh migration: {e}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(run_migration())
|
||||
54
backend/migrations/add_kds_course_flow.py
Normal file
54
backend/migrations/add_kds_course_flow.py
Normal file
|
|
@ -0,0 +1,54 @@
|
|||
"""
|
||||
Migration: Add KDS course flow settings (away timer thresholds)
|
||||
and action column to course bumps audit trail.
|
||||
|
||||
Adds:
|
||||
- kds_away_timer_green/amber/red_seconds to kitchen_settings
|
||||
- action column to kds_course_bumps
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
from sqlalchemy import text
|
||||
from database import engine
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def run_migration():
|
||||
"""Add KDS course flow columns."""
|
||||
|
||||
alter_statements = [
|
||||
# Away timer thresholds (time since food sent to table)
|
||||
"""
|
||||
ALTER TABLE kitchen_settings
|
||||
ADD COLUMN IF NOT EXISTS kds_away_timer_green_seconds INTEGER DEFAULT 600
|
||||
""",
|
||||
"""
|
||||
ALTER TABLE kitchen_settings
|
||||
ADD COLUMN IF NOT EXISTS kds_away_timer_amber_seconds INTEGER DEFAULT 900
|
||||
""",
|
||||
"""
|
||||
ALTER TABLE kitchen_settings
|
||||
ADD COLUMN IF NOT EXISTS kds_away_timer_red_seconds INTEGER DEFAULT 1200
|
||||
""",
|
||||
# Action column on course bumps audit trail ('away' or 'sent')
|
||||
"""
|
||||
ALTER TABLE kds_course_bumps
|
||||
ADD COLUMN IF NOT EXISTS action VARCHAR(20) DEFAULT 'sent'
|
||||
""",
|
||||
]
|
||||
|
||||
try:
|
||||
async with engine.begin() as conn:
|
||||
for sql in alter_statements:
|
||||
await conn.execute(text(sql))
|
||||
logger.info("Added KDS course flow columns")
|
||||
except Exception as e:
|
||||
if "already exists" not in str(e).lower():
|
||||
raise
|
||||
logger.warning(f"KDS course flow migration: {e}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(run_migration())
|
||||
39
backend/migrations/add_kds_order_tracking.py
Normal file
39
backend/migrations/add_kds_order_tracking.py
Normal file
|
|
@ -0,0 +1,39 @@
|
|||
"""
|
||||
Migration: Add KDS per-order tracking column.
|
||||
|
||||
Adds:
|
||||
- initial_order_ids JSONB column to kds_tickets (captures order IDs at ticket creation
|
||||
for detecting +ADDITION orders added later)
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
from sqlalchemy import text
|
||||
from database import engine
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def run_migration():
|
||||
"""Add KDS order tracking column."""
|
||||
|
||||
alter_statements = [
|
||||
"""
|
||||
ALTER TABLE kds_tickets
|
||||
ADD COLUMN IF NOT EXISTS initial_order_ids JSONB
|
||||
""",
|
||||
]
|
||||
|
||||
try:
|
||||
async with engine.begin() as conn:
|
||||
for sql in alter_statements:
|
||||
await conn.execute(text(sql))
|
||||
logger.info("Added KDS order tracking column (initial_order_ids)")
|
||||
except Exception as e:
|
||||
if "already exists" not in str(e).lower():
|
||||
raise
|
||||
logger.warning(f"KDS order tracking migration: {e}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(run_migration())
|
||||
124
backend/migrations/add_kds_tables.py
Normal file
124
backend/migrations/add_kds_tables.py
Normal file
|
|
@ -0,0 +1,124 @@
|
|||
"""
|
||||
Migration: Add KDS (Kitchen Display System) tables and settings
|
||||
|
||||
Creates:
|
||||
- kds_tickets: Local ticket state tracking
|
||||
- kds_course_bumps: Course bump audit trail
|
||||
- KDS settings columns in kitchen_settings
|
||||
"""
|
||||
|
||||
import logging
|
||||
from sqlalchemy import text
|
||||
from database import engine
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def run_migration():
|
||||
"""Add KDS tables and settings columns."""
|
||||
migrations = [
|
||||
# KDS settings columns in kitchen_settings
|
||||
"""
|
||||
ALTER TABLE kitchen_settings
|
||||
ADD COLUMN IF NOT EXISTS kds_enabled BOOLEAN DEFAULT FALSE
|
||||
""",
|
||||
"""
|
||||
ALTER TABLE kitchen_settings
|
||||
ADD COLUMN IF NOT EXISTS kds_graphql_url VARCHAR(500)
|
||||
""",
|
||||
"""
|
||||
ALTER TABLE kitchen_settings
|
||||
ADD COLUMN IF NOT EXISTS kds_graphql_username VARCHAR(255)
|
||||
""",
|
||||
"""
|
||||
ALTER TABLE kitchen_settings
|
||||
ADD COLUMN IF NOT EXISTS kds_graphql_password VARCHAR(500)
|
||||
""",
|
||||
"""
|
||||
ALTER TABLE kitchen_settings
|
||||
ADD COLUMN IF NOT EXISTS kds_graphql_client_id VARCHAR(255)
|
||||
""",
|
||||
"""
|
||||
ALTER TABLE kitchen_settings
|
||||
ADD COLUMN IF NOT EXISTS kds_poll_interval_seconds INTEGER DEFAULT 6000
|
||||
""",
|
||||
"""
|
||||
ALTER TABLE kitchen_settings
|
||||
ADD COLUMN IF NOT EXISTS kds_timer_green_seconds INTEGER DEFAULT 300
|
||||
""",
|
||||
"""
|
||||
ALTER TABLE kitchen_settings
|
||||
ADD COLUMN IF NOT EXISTS kds_timer_amber_seconds INTEGER DEFAULT 600
|
||||
""",
|
||||
"""
|
||||
ALTER TABLE kitchen_settings
|
||||
ADD COLUMN IF NOT EXISTS kds_timer_red_seconds INTEGER DEFAULT 900
|
||||
""",
|
||||
"""
|
||||
ALTER TABLE kitchen_settings
|
||||
ADD COLUMN IF NOT EXISTS kds_course_order JSONB DEFAULT '["Starters", "Mains", "Desserts"]'::jsonb
|
||||
""",
|
||||
"""
|
||||
ALTER TABLE kitchen_settings
|
||||
ADD COLUMN IF NOT EXISTS kds_show_completed_for_seconds INTEGER DEFAULT 30
|
||||
""",
|
||||
|
||||
# KDS Tickets table
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS kds_tickets (
|
||||
id SERIAL PRIMARY KEY,
|
||||
kitchen_id INTEGER NOT NULL REFERENCES kitchens(id),
|
||||
sambapos_ticket_id INTEGER NOT NULL,
|
||||
sambapos_ticket_uid VARCHAR(100),
|
||||
ticket_number VARCHAR(50) NOT NULL,
|
||||
table_name VARCHAR(100),
|
||||
covers INTEGER,
|
||||
total_amount FLOAT,
|
||||
received_at TIMESTAMP DEFAULT NOW(),
|
||||
last_sambapos_update TIMESTAMP,
|
||||
is_active BOOLEAN DEFAULT TRUE,
|
||||
is_bumped BOOLEAN DEFAULT FALSE,
|
||||
bumped_at TIMESTAMP,
|
||||
course_states JSONB DEFAULT '{}'::jsonb,
|
||||
orders_data JSONB,
|
||||
created_at TIMESTAMP DEFAULT NOW(),
|
||||
updated_at TIMESTAMP DEFAULT NOW()
|
||||
)
|
||||
""",
|
||||
"""
|
||||
CREATE INDEX IF NOT EXISTS idx_kds_tickets_kitchen_id ON kds_tickets(kitchen_id)
|
||||
""",
|
||||
"""
|
||||
CREATE INDEX IF NOT EXISTS idx_kds_tickets_sambapos_id ON kds_tickets(sambapos_ticket_id)
|
||||
""",
|
||||
"""
|
||||
CREATE INDEX IF NOT EXISTS idx_kds_tickets_active ON kds_tickets(kitchen_id, is_active)
|
||||
""",
|
||||
|
||||
# KDS Course Bumps table
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS kds_course_bumps (
|
||||
id SERIAL PRIMARY KEY,
|
||||
ticket_id INTEGER NOT NULL REFERENCES kds_tickets(id) ON DELETE CASCADE,
|
||||
course_name VARCHAR(100) NOT NULL,
|
||||
bumped_at TIMESTAMP DEFAULT NOW(),
|
||||
bumped_by_user_id INTEGER REFERENCES users(id),
|
||||
time_since_previous_seconds INTEGER
|
||||
)
|
||||
""",
|
||||
"""
|
||||
CREATE INDEX IF NOT EXISTS idx_kds_course_bumps_ticket_id ON kds_course_bumps(ticket_id)
|
||||
""",
|
||||
]
|
||||
|
||||
for sql in migrations:
|
||||
try:
|
||||
async with engine.begin() as conn:
|
||||
await conn.execute(text(sql.strip()))
|
||||
logger.info(f"KDS Migration executed: {sql.strip()[:60]}...")
|
||||
except Exception as e:
|
||||
error_str = str(e).lower()
|
||||
if "already exists" in error_str or "duplicate" in error_str:
|
||||
logger.info(f"KDS Migration: already exists, skipping")
|
||||
else:
|
||||
logger.warning(f"KDS Migration warning: {e}")
|
||||
0
backend/models/__init__.py
Normal file
0
backend/models/__init__.py
Normal file
88
backend/models/kds.py
Normal file
88
backend/models/kds.py
Normal 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
|
||||
145
backend/models/resos.py
Normal file
145
backend/models/resos.py
Normal file
|
|
@ -0,0 +1,145 @@
|
|||
from datetime import datetime, date, time
|
||||
from sqlalchemy import String, DateTime, Date, Time, ForeignKey, Boolean, Text, Integer, UniqueConstraint
|
||||
from sqlalchemy.dialects.postgresql import JSONB
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
from database import Base
|
||||
|
||||
|
||||
class ResosBooking(Base):
|
||||
"""Individual booking records from Resos API"""
|
||||
__tablename__ = "resos_bookings"
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True, index=True)
|
||||
kitchen_id: Mapped[int] = mapped_column(ForeignKey("kitchens.id"), nullable=False)
|
||||
resos_booking_id: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||
|
||||
# Booking details
|
||||
booking_date: Mapped[date] = mapped_column(Date, nullable=False, index=True)
|
||||
booking_time: Mapped[time] = mapped_column(Time, nullable=False)
|
||||
people: Mapped[int] = mapped_column(Integer, nullable=False)
|
||||
status: Mapped[str] = mapped_column(String(50), nullable=False)
|
||||
|
||||
# Guest info (non-PII)
|
||||
seating_area: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
||||
table_name: Mapped[str | None] = mapped_column(String(100), nullable=True) # Phase 8.1: Table from Resos
|
||||
|
||||
# Custom fields
|
||||
hotel_booking_number: Mapped[str | None] = mapped_column(String(100), nullable=True)
|
||||
is_hotel_guest: Mapped[bool | None] = mapped_column(Boolean, nullable=True)
|
||||
is_dbb: Mapped[bool | None] = mapped_column(Boolean, nullable=True)
|
||||
is_package: Mapped[bool | None] = mapped_column(Boolean, nullable=True)
|
||||
exclude_flag: Mapped[str | None] = mapped_column(String(500), nullable=True)
|
||||
allergies: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
|
||||
# Notes
|
||||
notes: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
|
||||
# Metadata
|
||||
booked_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
|
||||
opening_hour_id: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
||||
opening_hour_name: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
||||
|
||||
# Flags
|
||||
is_flagged: Mapped[bool] = mapped_column(Boolean, default=False, index=True)
|
||||
flag_reasons: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
|
||||
# Sync metadata
|
||||
fetched_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
|
||||
is_forecast: Mapped[bool] = mapped_column(Boolean, default=False)
|
||||
|
||||
# Relationships
|
||||
kitchen: Mapped["Kitchen"] = relationship("Kitchen", back_populates="resos_bookings")
|
||||
|
||||
__table_args__ = (
|
||||
UniqueConstraint('kitchen_id', 'resos_booking_id', name='uq_resos_booking'),
|
||||
)
|
||||
|
||||
|
||||
class ResosDailyStats(Base):
|
||||
"""Aggregated daily booking statistics"""
|
||||
__tablename__ = "resos_daily_stats"
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True, index=True)
|
||||
kitchen_id: Mapped[int] = mapped_column(ForeignKey("kitchens.id"), nullable=False, index=True)
|
||||
date: Mapped[date] = mapped_column(Date, nullable=False, index=True)
|
||||
|
||||
# Overall totals
|
||||
total_bookings: Mapped[int] = mapped_column(Integer, default=0)
|
||||
total_covers: Mapped[int] = mapped_column(Integer, default=0)
|
||||
|
||||
# By service period (JSONB)
|
||||
# Format: [{"period": "Lunch", "bookings": 15, "covers": 32}, ...]
|
||||
service_breakdown: Mapped[dict | None] = mapped_column(JSONB, nullable=True)
|
||||
|
||||
# Flags
|
||||
flagged_booking_count: Mapped[int] = mapped_column(Integer, default=0)
|
||||
|
||||
# Unique flag types present on this day (JSONB list)
|
||||
# Format: ["allergies", "large_group", "note_keyword_birthday"]
|
||||
unique_flag_types: Mapped[list | None] = mapped_column(JSONB, nullable=True)
|
||||
|
||||
# Consolidated booking data for quick access (JSONB)
|
||||
# Format: [{"time": "19:00", "people": 2, "period": "Dinner", "booked_at": "2026-01-15T10:30:00", "is_flagged": true, "status": "confirmed"}, ...]
|
||||
bookings_summary: Mapped[list | None] = mapped_column(JSONB, nullable=True)
|
||||
|
||||
# Metadata
|
||||
fetched_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
|
||||
is_forecast: Mapped[bool] = mapped_column(Boolean, default=False)
|
||||
|
||||
# Relationships
|
||||
kitchen: Mapped["Kitchen"] = relationship("Kitchen", back_populates="resos_daily_stats")
|
||||
|
||||
__table_args__ = (
|
||||
UniqueConstraint('kitchen_id', 'date', name='uq_resos_daily_stat'),
|
||||
)
|
||||
|
||||
|
||||
class ResosOpeningHour(Base):
|
||||
"""Cached service period definitions from Resos"""
|
||||
__tablename__ = "resos_opening_hours"
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True, index=True)
|
||||
kitchen_id: Mapped[int] = mapped_column(ForeignKey("kitchens.id"), nullable=False, index=True)
|
||||
resos_opening_hour_id: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||
|
||||
# Period details
|
||||
name: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||
start_time: Mapped[time | None] = mapped_column(Time, nullable=True)
|
||||
end_time: Mapped[time | None] = mapped_column(Time, nullable=True)
|
||||
days_of_week: Mapped[str | None] = mapped_column(String(100), nullable=True)
|
||||
|
||||
# Metadata
|
||||
is_special: Mapped[bool] = mapped_column(Boolean, default=False)
|
||||
fetched_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
|
||||
|
||||
# Relationships
|
||||
kitchen: Mapped["Kitchen"] = relationship("Kitchen", back_populates="resos_opening_hours")
|
||||
|
||||
__table_args__ = (
|
||||
UniqueConstraint('kitchen_id', 'resos_opening_hour_id', name='uq_resos_opening_hour'),
|
||||
)
|
||||
|
||||
|
||||
class ResosSyncLog(Base):
|
||||
"""Audit trail for sync operations"""
|
||||
__tablename__ = "resos_sync_log"
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True, index=True)
|
||||
kitchen_id: Mapped[int] = mapped_column(ForeignKey("kitchens.id"), nullable=False, index=True)
|
||||
|
||||
sync_type: Mapped[str] = mapped_column(String(50), nullable=False) # 'forecast', 'historical', 'daily'
|
||||
status: Mapped[str] = mapped_column(String(20), nullable=False) # 'running', 'success', 'failed'
|
||||
|
||||
date_from: Mapped[date | None] = mapped_column(Date, nullable=True)
|
||||
date_to: Mapped[date | None] = mapped_column(Date, nullable=True)
|
||||
|
||||
bookings_fetched: Mapped[int] = mapped_column(Integer, default=0)
|
||||
bookings_flagged: Mapped[int] = mapped_column(Integer, default=0)
|
||||
|
||||
error_message: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
|
||||
started_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, index=True)
|
||||
completed_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
|
||||
|
||||
# Relationships
|
||||
kitchen: Mapped["Kitchen"] = relationship("Kitchen", back_populates="resos_sync_logs")
|
||||
238
backend/models/settings.py
Normal file
238
backend/models/settings.py
Normal file
|
|
@ -0,0 +1,238 @@
|
|||
from datetime import datetime
|
||||
from decimal import Decimal
|
||||
from sqlalchemy import String, DateTime, ForeignKey, Text, Boolean, Numeric, Integer
|
||||
from sqlalchemy.dialects.postgresql import JSONB
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
from database import Base
|
||||
|
||||
|
||||
class KitchenSettings(Base):
|
||||
"""Kitchen-level settings including OCR and Newbook configuration"""
|
||||
__tablename__ = "kitchen_settings"
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True)
|
||||
kitchen_id: Mapped[int] = mapped_column(ForeignKey("kitchens.id"), unique=True)
|
||||
|
||||
# Azure Document Intelligence settings
|
||||
azure_endpoint: Mapped[str | None] = mapped_column(String(500), nullable=True)
|
||||
azure_key: Mapped[str | None] = mapped_column(String(500), nullable=True)
|
||||
|
||||
# OCR post-processing options
|
||||
ocr_clean_product_codes: Mapped[bool] = mapped_column(Boolean, default=False) # Strip section headers from product codes
|
||||
ocr_filter_subtotal_rows: Mapped[bool] = mapped_column(Boolean, default=False) # Filter subtotal/total rows from line items
|
||||
ocr_use_weight_as_quantity: Mapped[bool] = mapped_column(Boolean, default=False) # For KG items, use weight as quantity when it matches total
|
||||
|
||||
# Newbook API settings
|
||||
newbook_api_username: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
||||
newbook_api_password: Mapped[str | None] = mapped_column(String(500), nullable=True)
|
||||
newbook_api_key: Mapped[str | None] = mapped_column(String(500), nullable=True)
|
||||
newbook_api_region: Mapped[str | None] = mapped_column(String(10), nullable=True) # au, ap, eu, us
|
||||
newbook_instance_id: Mapped[str | None] = mapped_column(String(100), nullable=True)
|
||||
|
||||
# Newbook sync configuration
|
||||
newbook_last_sync: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
|
||||
newbook_auto_sync_enabled: Mapped[bool] = mapped_column(Boolean, default=False)
|
||||
# Separate sync interval for next 7 days (in minutes, default 15)
|
||||
newbook_upcoming_sync_interval: Mapped[int] = mapped_column(Integer, default=15)
|
||||
newbook_upcoming_sync_enabled: Mapped[bool] = mapped_column(Boolean, default=False)
|
||||
newbook_last_upcoming_sync: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
|
||||
|
||||
# Newbook allocation GL mapping (CSV-style, e.g. "4100,4101,4102")
|
||||
newbook_breakfast_gl_codes: Mapped[str | None] = mapped_column(String(500), nullable=True)
|
||||
newbook_dinner_gl_codes: Mapped[str | None] = mapped_column(String(500), nullable=True)
|
||||
|
||||
# VAT rates for calculating net from gross (e.g., 0.10 for 10% VAT)
|
||||
newbook_breakfast_vat_rate: Mapped[Decimal | None] = mapped_column(Numeric(5, 4), nullable=True, default=Decimal("0.10"))
|
||||
newbook_dinner_vat_rate: Mapped[Decimal | None] = mapped_column(Numeric(5, 4), nullable=True, default=Decimal("0.10"))
|
||||
|
||||
# Resos API Configuration
|
||||
resos_api_key: Mapped[str | None] = mapped_column(String(500), nullable=True)
|
||||
resos_last_sync: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
|
||||
resos_auto_sync_enabled: Mapped[bool] = mapped_column(Boolean, default=False)
|
||||
# Separate sync interval for next 7 days (in minutes, default 15)
|
||||
resos_upcoming_sync_interval: Mapped[int] = mapped_column(Integer, default=15)
|
||||
resos_upcoming_sync_enabled: Mapped[bool] = mapped_column(Boolean, default=False)
|
||||
resos_last_upcoming_sync: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
|
||||
|
||||
# Resos Flagging Configuration
|
||||
resos_large_group_threshold: Mapped[int] = mapped_column(Integer, default=8)
|
||||
resos_note_keywords: Mapped[str | None] = mapped_column(Text, nullable=True) # Pipe-separated: "birthday|anniversary|proposal"
|
||||
resos_allergy_keywords: Mapped[str | None] = mapped_column(Text, nullable=True) # Pipe-separated: "gluten|dairy|nut|shellfish"
|
||||
|
||||
# Resos Custom Field & Period Mapping
|
||||
resos_custom_field_mapping: Mapped[dict | None] = mapped_column(JSONB, nullable=True) # Format: {"booking_number": "field_id_123", ...}
|
||||
resos_opening_hours_mapping: Mapped[list | None] = mapped_column(JSONB, nullable=True) # Format: [{"resos_id": "abc123", "display_name": "Lunch", "actual_end": "14:30"}, ...]
|
||||
|
||||
# Resos SambaPOS Integration
|
||||
resos_restaurant_table_entities: Mapped[str | None] = mapped_column(Text, nullable=True) # Comma-separated entity names
|
||||
|
||||
# Manual Breakfast Configuration (not in Resos)
|
||||
resos_enable_manual_breakfast: Mapped[bool] = mapped_column(Boolean, default=False)
|
||||
# Format: [{"day": 1, "start": "07:00", "end": "11:00"}, ...] where day: 1=Monday, 7=Sunday
|
||||
resos_manual_breakfast_periods: Mapped[list | None] = mapped_column(JSONB, nullable=True)
|
||||
|
||||
# Resos Flag Icon Mapping (customizable icons for each flag type)
|
||||
# Format: {"allergies": "🦀", "large_group": "⚠️", "birthday": "🎂", "anniversary": "💍", ...}
|
||||
resos_flag_icon_mapping: Mapped[dict | None] = mapped_column(JSONB, nullable=True)
|
||||
|
||||
# Resos Arrival Widget Service Filter (filter arrivals widget by service type from mapping)
|
||||
resos_arrival_widget_service_filter: Mapped[str | None] = mapped_column(String(50), nullable=True) # service_type: breakfast/lunch/dinner/other
|
||||
|
||||
# SambaPOS MSSQL Connection
|
||||
sambapos_db_host: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
||||
sambapos_db_port: Mapped[int | None] = mapped_column(Integer, nullable=True, default=1433)
|
||||
sambapos_db_name: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
||||
sambapos_db_username: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
||||
sambapos_db_password: Mapped[str | None] = mapped_column(String(500), nullable=True)
|
||||
# SambaPOS tracked categories (comma-separated list, order preserved for display)
|
||||
sambapos_tracked_categories: Mapped[str | None] = mapped_column(String(1000), nullable=True)
|
||||
# SambaPOS excluded menu items (comma-separated list of menu item names to exclude from reports)
|
||||
sambapos_excluded_items: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
# Phase 8.1: GL code configuration for food/beverage split
|
||||
sambapos_food_gl_codes: Mapped[str | None] = mapped_column(Text, nullable=True) # Comma-separated GL codes for food items
|
||||
sambapos_beverage_gl_codes: Mapped[str | None] = mapped_column(Text, nullable=True) # Comma-separated GL codes for beverage items
|
||||
|
||||
# SMTP email configuration
|
||||
smtp_host: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
||||
smtp_port: Mapped[int | None] = mapped_column(Integer, nullable=True, default=587)
|
||||
smtp_username: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
||||
smtp_password: Mapped[str | None] = mapped_column(String(500), nullable=True)
|
||||
smtp_use_tls: Mapped[bool] = mapped_column(Boolean, default=True)
|
||||
smtp_from_email: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
||||
smtp_from_name: Mapped[str | None] = mapped_column(String(255), nullable=True, default="Kitchen Invoice System")
|
||||
|
||||
# Support request email (where screenshot reports are sent)
|
||||
support_email: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
||||
|
||||
# Dext integration
|
||||
dext_email: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
||||
dext_include_notes: Mapped[bool] = mapped_column(Boolean, default=True)
|
||||
dext_include_non_stock: Mapped[bool] = mapped_column(Boolean, default=True)
|
||||
dext_auto_send_enabled: Mapped[bool] = mapped_column(Boolean, default=False)
|
||||
dext_manual_send_enabled: Mapped[bool] = mapped_column(Boolean, default=True)
|
||||
dext_include_annotations: Mapped[bool] = mapped_column(Boolean, default=True) # Include PDF annotations when sending to Dext
|
||||
|
||||
# Nextcloud settings
|
||||
nextcloud_host: Mapped[str | None] = mapped_column(String(500), nullable=True)
|
||||
nextcloud_username: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
||||
nextcloud_password: Mapped[str | None] = mapped_column(String(500), nullable=True)
|
||||
nextcloud_base_path: Mapped[str | None] = mapped_column(String(500), nullable=True, default="/Kitchen Invoices")
|
||||
nextcloud_enabled: Mapped[bool] = mapped_column(Boolean, default=False)
|
||||
nextcloud_delete_local: Mapped[bool] = mapped_column(Boolean, default=False) # Delete local file after successful archive
|
||||
|
||||
# Backup settings
|
||||
backup_frequency: Mapped[str | None] = mapped_column(String(20), nullable=True, default="manual") # daily, weekly, manual
|
||||
backup_retention_count: Mapped[int] = mapped_column(Integer, default=7)
|
||||
backup_destination: Mapped[str | None] = mapped_column(String(20), nullable=True, default="local") # local, nextcloud, smb
|
||||
backup_time: Mapped[str | None] = mapped_column(String(5), nullable=True, default="03:00")
|
||||
|
||||
# Nextcloud backup path (when backup_destination = "nextcloud")
|
||||
backup_nextcloud_path: Mapped[str | None] = mapped_column(String(500), nullable=True, default="/Backups")
|
||||
|
||||
# SMB backup settings (used when backup_destination = "smb")
|
||||
backup_smb_host: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
||||
backup_smb_share: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
||||
backup_smb_username: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
||||
backup_smb_password: Mapped[str | None] = mapped_column(String(500), nullable=True)
|
||||
backup_smb_path: Mapped[str | None] = mapped_column(String(500), nullable=True, default="/backups")
|
||||
|
||||
# Last backup tracking
|
||||
backup_last_run_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
|
||||
backup_last_status: Mapped[str | None] = mapped_column(String(50), nullable=True)
|
||||
backup_last_error: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
|
||||
# IMAP Email Inbox settings
|
||||
imap_host: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
||||
imap_port: Mapped[int | None] = mapped_column(Integer, nullable=True, default=993)
|
||||
imap_use_ssl: Mapped[bool] = mapped_column(Boolean, default=True)
|
||||
imap_username: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
||||
imap_password: Mapped[str | None] = mapped_column(String(500), nullable=True)
|
||||
imap_folder: Mapped[str | None] = mapped_column(String(255), nullable=True, default="INBOX")
|
||||
imap_poll_interval: Mapped[int] = mapped_column(Integer, default=15) # minutes
|
||||
imap_enabled: Mapped[bool] = mapped_column(Boolean, default=False)
|
||||
imap_confidence_threshold: Mapped[Decimal | None] = mapped_column(Numeric(3, 2), nullable=True, default=Decimal("0.50"))
|
||||
imap_last_sync: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
|
||||
|
||||
# General settings
|
||||
currency_symbol: Mapped[str] = mapped_column(String(5), default="£")
|
||||
date_format: Mapped[str] = mapped_column(String(20), default="DD/MM/YYYY")
|
||||
|
||||
# Warning thresholds
|
||||
high_quantity_threshold: Mapped[int] = mapped_column(default=100) # Warn if qty > this value
|
||||
|
||||
# PDF annotation settings
|
||||
pdf_annotations_enabled: Mapped[bool] = mapped_column(Boolean, default=True) # Enable adding annotations to PDFs
|
||||
pdf_preview_show_annotations: Mapped[bool] = mapped_column(Boolean, default=True) # Show annotations in preview window
|
||||
|
||||
# Price change detection settings
|
||||
price_change_lookback_days: Mapped[int] = mapped_column(Integer, default=30) # Days to look back for price comparison
|
||||
price_change_amber_threshold: Mapped[int] = mapped_column(Integer, default=10) # % change for amber warning
|
||||
price_change_red_threshold: Mapped[int] = mapped_column(Integer, default=20) # % change for red alert
|
||||
|
||||
# Admin-only page restrictions (comma-separated list of page paths, e.g., "/settings,/suppliers")
|
||||
admin_restricted_pages: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
|
||||
# Forecast API integration (Spend Budget feature)
|
||||
forecast_api_url: Mapped[str | None] = mapped_column(String(500), nullable=True)
|
||||
forecast_api_key: Mapped[str | None] = mapped_column(String(500), nullable=True)
|
||||
|
||||
# Budget settings
|
||||
budget_gp_target: Mapped[Decimal | None] = mapped_column(Numeric(5, 2), nullable=True, default=Decimal("65.00"))
|
||||
budget_lookback_weeks: Mapped[int] = mapped_column(Integer, default=4)
|
||||
|
||||
# KDS (Kitchen Display System) settings
|
||||
kds_enabled: Mapped[bool] = mapped_column(Boolean, default=False)
|
||||
kds_graphql_url: Mapped[str | None] = mapped_column(String(500), nullable=True)
|
||||
kds_graphql_username: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
||||
kds_graphql_password: Mapped[str | None] = mapped_column(String(500), nullable=True)
|
||||
kds_graphql_client_id: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
||||
kds_poll_interval_seconds: Mapped[int] = mapped_column(Integer, default=6000)
|
||||
kds_timer_green_seconds: Mapped[int] = mapped_column(Integer, default=300)
|
||||
kds_timer_amber_seconds: Mapped[int] = mapped_column(Integer, default=600)
|
||||
kds_timer_red_seconds: Mapped[int] = mapped_column(Integer, default=900)
|
||||
kds_course_order: Mapped[list | None] = mapped_column(JSONB, nullable=True, default=["Starters", "Mains", "Desserts"])
|
||||
kds_show_completed_for_seconds: Mapped[int] = mapped_column(Integer, default=30)
|
||||
|
||||
# Away timer thresholds (time since food sent to table - "eating" phase)
|
||||
kds_away_timer_green_seconds: Mapped[int] = mapped_column(Integer, default=600) # 10 minutes
|
||||
kds_away_timer_amber_seconds: Mapped[int] = mapped_column(Integer, default=900) # 15 minutes
|
||||
kds_away_timer_red_seconds: Mapped[int] = mapped_column(Integer, default=1200) # 20 minutes
|
||||
kds_bookings_refresh_seconds: Mapped[int] = mapped_column(Integer, default=60)
|
||||
|
||||
# Cost distribution settings
|
||||
cost_distribution_max_days: Mapped[int] = mapped_column(Integer, default=90)
|
||||
|
||||
# LLM integration (Claude) — see LLM-MANIFEST.md for removal instructions
|
||||
llm_enabled: Mapped[bool] = mapped_column(Boolean, default=False) # Master kill switch — False = zero AI footprint
|
||||
anthropic_api_key: Mapped[str | None] = mapped_column(String(500), nullable=True)
|
||||
llm_model: Mapped[str | None] = mapped_column(String(100), nullable=True, default="claude-haiku-4-5-20251001")
|
||||
llm_confidence_threshold: Mapped[Decimal | None] = mapped_column(Numeric(3, 2), nullable=True, default=Decimal("0.80"))
|
||||
llm_monthly_token_limit: Mapped[int] = mapped_column(Integer, default=500000) # ~$1.25/month on Haiku
|
||||
llm_features_enabled: Mapped[dict | None] = mapped_column(JSONB, nullable=True, default={
|
||||
"label_parsing": True, "invoice_assist": True, "ingredient_match": True,
|
||||
"recipe_scanning": True, "line_item_reconciliation": True, "menu_description": True,
|
||||
"dispute_email": True, "duplicate_detection": True, "supplier_alias": True, "yield_estimation": True
|
||||
})
|
||||
|
||||
# Internal API key (for in-house apps like menu display plugin)
|
||||
api_key: Mapped[str | None] = mapped_column(String(100), nullable=True)
|
||||
api_key_enabled: Mapped[bool] = mapped_column(Boolean, default=False)
|
||||
|
||||
# Kitchen details (for PO letterhead)
|
||||
kitchen_display_name: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
||||
kitchen_address_line1: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
||||
kitchen_address_line2: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
||||
kitchen_city: Mapped[str | None] = mapped_column(String(100), nullable=True)
|
||||
kitchen_postcode: Mapped[str | None] = mapped_column(String(20), nullable=True)
|
||||
kitchen_phone: Mapped[str | None] = mapped_column(String(50), nullable=True)
|
||||
kitchen_email: Mapped[str | None] = mapped_column(String(255), 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", back_populates="settings")
|
||||
|
||||
|
||||
# Forward reference
|
||||
from .user import Kitchen
|
||||
14
backend/models/user.py
Normal file
14
backend/models/user.py
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
"""
|
||||
Type stub — kds.py has `from models.user import User` for the type annotation on
|
||||
Depends(get_current_user). At runtime the dependency returns a SimpleNamespace from
|
||||
auth.py; this class satisfies the import without pulling in the full SQLAlchemy model.
|
||||
"""
|
||||
|
||||
|
||||
class User:
|
||||
id: int
|
||||
email: str
|
||||
name: str
|
||||
is_admin: bool
|
||||
kitchen_id: int
|
||||
caps: list
|
||||
19
backend/requirements.txt
Normal file
19
backend/requirements.txt
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
# Web framework
|
||||
fastapi==0.109.0
|
||||
uvicorn[standard]==0.27.0
|
||||
python-multipart==0.0.6
|
||||
|
||||
# Database — connects to kitchen_db (shared schema; KDS adds its own tables/columns)
|
||||
sqlalchemy==2.0.25
|
||||
asyncpg==0.29.0
|
||||
|
||||
# Authentication — python-jose verifies the central hnf_session JWT
|
||||
python-jose[cryptography]==3.3.0
|
||||
|
||||
# HTTP — SignalR WebSocket upgrade + SambaPOS GraphQL
|
||||
httpx==0.27.0
|
||||
|
||||
# Utilities
|
||||
pydantic==2.5.3
|
||||
python-dotenv==1.0.0
|
||||
aiofiles==23.2.1
|
||||
0
backend/services/__init__.py
Normal file
0
backend/services/__init__.py
Normal file
382
backend/services/kds_graphql.py
Normal file
382
backend/services/kds_graphql.py
Normal file
|
|
@ -0,0 +1,382 @@
|
|||
"""
|
||||
SambaPOS GraphQL Client for KDS
|
||||
|
||||
Connects to SambaPOS Message Server GraphQL API to fetch open tickets
|
||||
with kitchen orders for the Kitchen Display System.
|
||||
"""
|
||||
|
||||
import logging
|
||||
from typing import Optional
|
||||
from datetime import datetime
|
||||
import httpx
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class SambaPOSGraphQLClient:
|
||||
"""Client for SambaPOS Message Server GraphQL API."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
server_url: str,
|
||||
username: str,
|
||||
password: str,
|
||||
client_id: str
|
||||
):
|
||||
self.server_url = server_url.rstrip('/')
|
||||
self.username = username
|
||||
self.password = password
|
||||
self.client_id = client_id
|
||||
self.access_token: Optional[str] = None
|
||||
self.token_expires_at: Optional[datetime] = None
|
||||
|
||||
async def authenticate(self) -> bool:
|
||||
"""
|
||||
Authenticate with SambaPOS and get access token.
|
||||
|
||||
POST /Token with:
|
||||
grant_type=password
|
||||
username=<user>
|
||||
password=<pass>
|
||||
client_id=<app_key>
|
||||
"""
|
||||
token_url = f"{self.server_url}/Token"
|
||||
|
||||
data = {
|
||||
"grant_type": "password",
|
||||
"username": self.username,
|
||||
"password": self.password,
|
||||
"client_id": self.client_id,
|
||||
}
|
||||
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=10.0) as client:
|
||||
response = await client.post(
|
||||
token_url,
|
||||
data=data,
|
||||
headers={"Content-Type": "application/x-www-form-urlencoded"}
|
||||
)
|
||||
if response.status_code == 200:
|
||||
result = response.json()
|
||||
self.access_token = result.get("access_token")
|
||||
expires_in = result.get("expires_in", 86400)
|
||||
self.token_expires_at = datetime.utcnow()
|
||||
logger.info(f"KDS: Authenticated with SambaPOS (expires in {expires_in}s)")
|
||||
return True
|
||||
else:
|
||||
logger.error(f"KDS: Authentication failed: {response.status_code} - {response.text}")
|
||||
return False
|
||||
except httpx.ConnectError:
|
||||
logger.error(f"KDS: Could not connect to {token_url}")
|
||||
return False
|
||||
except Exception as e:
|
||||
logger.error(f"KDS: Authentication error: {e}")
|
||||
return False
|
||||
|
||||
async def ensure_authenticated(self) -> bool:
|
||||
"""Ensure we have a valid token, re-authenticating if needed."""
|
||||
if not self.access_token:
|
||||
return await self.authenticate()
|
||||
return True
|
||||
|
||||
async def graphql_query(self, query: str, variables: Optional[dict] = None) -> dict:
|
||||
"""Execute a GraphQL query against SambaPOS."""
|
||||
if not await self.ensure_authenticated():
|
||||
return {"error": "Authentication failed"}
|
||||
|
||||
graphql_url = f"{self.server_url}/api/graphql"
|
||||
|
||||
headers = {
|
||||
"Content-Type": "application/json",
|
||||
"Authorization": f"Bearer {self.access_token}"
|
||||
}
|
||||
|
||||
payload = {
|
||||
"query": query,
|
||||
"variables": variables,
|
||||
"operationName": None
|
||||
}
|
||||
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=15.0) as client:
|
||||
response = await client.post(
|
||||
graphql_url,
|
||||
json=payload,
|
||||
headers=headers
|
||||
)
|
||||
if response.status_code == 200:
|
||||
return response.json()
|
||||
elif response.status_code == 401:
|
||||
# Token expired, try re-auth
|
||||
self.access_token = None
|
||||
if await self.authenticate():
|
||||
return await self.graphql_query(query, variables)
|
||||
return {"error": "Re-authentication failed"}
|
||||
else:
|
||||
return {"error": f"HTTP {response.status_code}: {response.text}"}
|
||||
except httpx.ConnectError:
|
||||
return {"error": f"Could not connect to {graphql_url}"}
|
||||
except Exception as e:
|
||||
return {"error": str(e)}
|
||||
|
||||
async def get_open_tickets(self) -> dict:
|
||||
"""
|
||||
Query for open (not closed) tickets with full order details.
|
||||
|
||||
Returns tickets with:
|
||||
- Ticket info (id, number, date, table)
|
||||
- Orders with Kitchen Course and Kitchen Print states
|
||||
"""
|
||||
query = """
|
||||
{
|
||||
getTickets(isClosed: false, orderBy: date) {
|
||||
id
|
||||
uid
|
||||
number
|
||||
date
|
||||
lastUpdateTime
|
||||
totalAmount
|
||||
remainingAmount
|
||||
note
|
||||
tags {
|
||||
tag
|
||||
tagName
|
||||
}
|
||||
states {
|
||||
stateName
|
||||
state
|
||||
}
|
||||
orders {
|
||||
id
|
||||
uid
|
||||
name
|
||||
portion
|
||||
quantity
|
||||
price
|
||||
priceTag
|
||||
date
|
||||
tags {
|
||||
tag
|
||||
tagName
|
||||
quantity
|
||||
}
|
||||
states {
|
||||
stateName
|
||||
state
|
||||
stateValue
|
||||
}
|
||||
}
|
||||
entities {
|
||||
type
|
||||
name
|
||||
}
|
||||
}
|
||||
}
|
||||
"""
|
||||
return await self.graphql_query(query)
|
||||
|
||||
async def get_ticket_by_id(self, ticket_id: int) -> dict:
|
||||
"""Query for a specific ticket by ID.
|
||||
|
||||
Uses inline ID rather than GraphQL variables because the SambaPOS
|
||||
Message Server has a NullReferenceException bug when processing
|
||||
variable bindings for getTicket.
|
||||
"""
|
||||
query = f"""
|
||||
{{
|
||||
getTicket(id: {int(ticket_id)}) {{
|
||||
id
|
||||
uid
|
||||
number
|
||||
date
|
||||
lastUpdateTime
|
||||
totalAmount
|
||||
tags {{
|
||||
tag
|
||||
tagName
|
||||
}}
|
||||
states {{
|
||||
stateName
|
||||
state
|
||||
}}
|
||||
orders {{
|
||||
id
|
||||
uid
|
||||
name
|
||||
portion
|
||||
quantity
|
||||
price
|
||||
date
|
||||
tags {{
|
||||
tag
|
||||
tagName
|
||||
quantity
|
||||
}}
|
||||
states {{
|
||||
stateName
|
||||
state
|
||||
stateValue
|
||||
}}
|
||||
}}
|
||||
entities {{
|
||||
type
|
||||
name
|
||||
}}
|
||||
}}
|
||||
}}
|
||||
"""
|
||||
return await self.graphql_query(query)
|
||||
|
||||
|
||||
def parse_kitchen_course(order: dict) -> Optional[str]:
|
||||
"""Extract Kitchen Course from order states."""
|
||||
states = order.get("states", [])
|
||||
for state in states:
|
||||
if state.get("stateName") == "Kitchen Course":
|
||||
return state.get("state")
|
||||
return None
|
||||
|
||||
|
||||
def parse_order_status(order: dict) -> str:
|
||||
"""Extract Status state from order (e.g., Submitted, New)."""
|
||||
states = order.get("states", [])
|
||||
for state in states:
|
||||
if state.get("stateName") == "Status":
|
||||
return state.get("state", "Unknown")
|
||||
return "Unknown"
|
||||
|
||||
|
||||
def parse_kitchen_print_state(order: dict) -> Optional[str]:
|
||||
"""Extract Kitchen Print state from order."""
|
||||
states = order.get("states", [])
|
||||
for state in states:
|
||||
if state.get("stateName") == "Kitchen Print":
|
||||
return state.get("state")
|
||||
return None
|
||||
|
||||
|
||||
def parse_gstatus(order: dict) -> tuple[Optional[str], Optional[str]]:
|
||||
"""Extract GStatus state and timestamp from order (used for void detection)."""
|
||||
states = order.get("states", [])
|
||||
for state in states:
|
||||
if state.get("stateName") == "GStatus":
|
||||
return state.get("state"), state.get("stateDateTime")
|
||||
return None, None
|
||||
|
||||
|
||||
def get_table_name(ticket: dict) -> Optional[str]:
|
||||
"""Extract table name from ticket entities."""
|
||||
entities = ticket.get("entities", [])
|
||||
for entity in entities:
|
||||
if entity.get("type") == "Tables":
|
||||
return entity.get("name")
|
||||
return None
|
||||
|
||||
|
||||
def transform_ticket_for_kds(ticket: dict) -> dict:
|
||||
"""
|
||||
Transform a SambaPOS ticket into KDS-friendly format.
|
||||
|
||||
Groups orders by Kitchen Course and filters for kitchen-relevant items.
|
||||
"""
|
||||
table_name = get_table_name(ticket)
|
||||
|
||||
# Group orders by kitchen course
|
||||
orders_by_course = {}
|
||||
all_orders = []
|
||||
deferred_voided = []
|
||||
earliest_kitchen_order_date = None # Track earliest kitchen-printable order time
|
||||
|
||||
for order in ticket.get("orders", []):
|
||||
kitchen_course = parse_kitchen_course(order) or "Uncategorized"
|
||||
order_status = parse_order_status(order)
|
||||
kitchen_print = parse_kitchen_print_state(order)
|
||||
gstatus, gstatus_datetime = parse_gstatus(order)
|
||||
|
||||
# Log order states for debugging
|
||||
logger.debug(f"Order '{order.get('name')}': status={order_status}, gstatus={gstatus}, kitchen_print={kitchen_print}")
|
||||
|
||||
# Must have Kitchen Print state set (meaning it's a kitchen item)
|
||||
if not kitchen_print:
|
||||
continue
|
||||
|
||||
# Determine if item is voided (show with strikethrough)
|
||||
is_voided = (
|
||||
kitchen_print in ["Canceled", "Void"] or
|
||||
gstatus in ["Void", "Cancelled", "Canceled"] or
|
||||
order_status in ["Void", "Cancelled", "Canceled"]
|
||||
)
|
||||
|
||||
# Get void timestamp if available
|
||||
voided_at = gstatus_datetime if is_voided and gstatus in ["Void", "Cancelled", "Canceled"] else None
|
||||
|
||||
# Skip non-voided items that are not submitted (e.g., "New" status)
|
||||
if not is_voided and order_status not in ["Submitted"]:
|
||||
logger.debug(f"Skipping order '{order.get('name')}' - status is '{order_status}', not 'Submitted'")
|
||||
continue
|
||||
|
||||
# Extract order tags (modifiers like "Rare", "No sauce", etc.)
|
||||
order_tags = order.get("tags", [])
|
||||
|
||||
order_data = {
|
||||
"id": order.get("id"),
|
||||
"uid": order.get("uid"),
|
||||
"name": order.get("name"),
|
||||
"portion": order.get("portion"),
|
||||
"quantity": order.get("quantity"),
|
||||
"price": order.get("price"),
|
||||
"kitchen_course": kitchen_course,
|
||||
"status": order_status,
|
||||
"kitchen_print": kitchen_print,
|
||||
"is_voided": is_voided,
|
||||
"voided_at": voided_at,
|
||||
"tags": order_tags,
|
||||
}
|
||||
|
||||
if is_voided:
|
||||
# Defer voided orders - only add to existing course groups later
|
||||
deferred_voided.append((kitchen_course, order_data))
|
||||
else:
|
||||
if kitchen_course not in orders_by_course:
|
||||
orders_by_course[kitchen_course] = []
|
||||
orders_by_course[kitchen_course].append(order_data)
|
||||
all_orders.append(order_data)
|
||||
|
||||
# Track earliest kitchen-printable order date
|
||||
order_date = order.get("date")
|
||||
if order_date:
|
||||
if earliest_kitchen_order_date is None or order_date < earliest_kitchen_order_date:
|
||||
earliest_kitchen_order_date = order_date
|
||||
|
||||
# Add voided orders only to course groups that already exist (avoids "Uncategorized" ghost courses)
|
||||
for course, order_data in deferred_voided:
|
||||
if course in orders_by_course:
|
||||
orders_by_course[course].append(order_data)
|
||||
all_orders.append(order_data)
|
||||
|
||||
# Skip tickets with no kitchen orders
|
||||
if not all_orders:
|
||||
return None
|
||||
|
||||
# Get covers from tags
|
||||
covers = None
|
||||
for tag in ticket.get("tags", []):
|
||||
if tag.get("tagName") == "Covers":
|
||||
try:
|
||||
covers = int(tag.get("tag"))
|
||||
except (ValueError, TypeError):
|
||||
pass
|
||||
|
||||
return {
|
||||
"id": ticket.get("id"),
|
||||
"uid": ticket.get("uid"),
|
||||
"number": ticket.get("number"),
|
||||
"date": ticket.get("date"),
|
||||
"last_update": ticket.get("lastUpdateTime"),
|
||||
"table": table_name,
|
||||
"covers": covers,
|
||||
"total_amount": ticket.get("totalAmount"),
|
||||
"orders": all_orders,
|
||||
"orders_by_course": orders_by_course,
|
||||
"submitted_at": earliest_kitchen_order_date or ticket.get("date"), # First kitchen order time, fallback to ticket date
|
||||
}
|
||||
296
backend/services/signalr_listener.py
Normal file
296
backend/services/signalr_listener.py
Normal file
|
|
@ -0,0 +1,296 @@
|
|||
"""
|
||||
SambaPOS SignalR Listener for KDS
|
||||
|
||||
Connects to SambaPOS Message Server via SignalR 2.x WebSocket and listens
|
||||
for TICKET_REFRESH broadcasts. When received, fetches the specific ticket
|
||||
by ID (works even for closed/zero-total tickets) and creates/updates
|
||||
KDS entries for real-time display.
|
||||
|
||||
This solves the problem of tickets that close instantly (e.g. free breakfast
|
||||
for residents, bar orders paid immediately) not appearing in KDS polling.
|
||||
|
||||
SignalR 2.x Protocol:
|
||||
1. GET /signalr/negotiate - get connection token
|
||||
2. WS /signalr/connect?transport=webSockets&connectionToken=... - WebSocket
|
||||
3. Messages arrive as JSON: {"C": "...", "M": [{...}]}
|
||||
- TICKET_REFRESH: {"H": "Default", "M": "update", "A": ["guid:<TICKET_REFRESH>ticketId"]}
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
import urllib.parse
|
||||
from typing import Optional
|
||||
from datetime import datetime
|
||||
|
||||
import httpx
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class KDSEventBus:
|
||||
"""Simple pub/sub for notifying SSE subscribers of KDS events."""
|
||||
|
||||
def __init__(self):
|
||||
self._subscribers: list[asyncio.Queue] = []
|
||||
|
||||
def subscribe(self) -> asyncio.Queue:
|
||||
q: asyncio.Queue = asyncio.Queue()
|
||||
self._subscribers.append(q)
|
||||
return q
|
||||
|
||||
def unsubscribe(self, q: asyncio.Queue):
|
||||
if q in self._subscribers:
|
||||
self._subscribers.remove(q)
|
||||
|
||||
async def publish(self, event: dict):
|
||||
for q in list(self._subscribers):
|
||||
try:
|
||||
q.put_nowait(event)
|
||||
except asyncio.QueueFull:
|
||||
pass
|
||||
|
||||
|
||||
# Global event bus - imported by kds.py for the SSE endpoint
|
||||
kds_event_bus = KDSEventBus()
|
||||
|
||||
|
||||
class SignalRListener:
|
||||
"""Background listener for SambaPOS SignalR broadcasts."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
base_url: str,
|
||||
graphql_username: str,
|
||||
graphql_password: str,
|
||||
graphql_client_id: str,
|
||||
kitchen_id: int,
|
||||
course_order: list,
|
||||
):
|
||||
self.base_url = base_url.rstrip('/')
|
||||
self.graphql_username = graphql_username
|
||||
self.graphql_password = graphql_password
|
||||
self.graphql_client_id = graphql_client_id
|
||||
self.kitchen_id = kitchen_id
|
||||
self.course_order = course_order
|
||||
self._running = False
|
||||
self._task: Optional[asyncio.Task] = None
|
||||
|
||||
def _get_ws_base(self) -> str:
|
||||
"""Convert HTTP URL to WS URL."""
|
||||
return self.base_url.replace('http://', 'ws://').replace('https://', 'wss://')
|
||||
|
||||
async def _negotiate(self) -> Optional[str]:
|
||||
"""Negotiate SignalR connection and get token."""
|
||||
url = f"{self.base_url}/signalr/negotiate"
|
||||
params = {
|
||||
"clientProtocol": "1.5",
|
||||
"connectionData": json.dumps([{"name": "default"}])
|
||||
}
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=10.0) as client:
|
||||
response = await client.get(url, params=params)
|
||||
if response.status_code == 200:
|
||||
data = response.json()
|
||||
return data.get("ConnectionToken")
|
||||
else:
|
||||
logger.error(f"SignalR negotiate failed: {response.status_code}")
|
||||
return None
|
||||
except Exception as e:
|
||||
logger.error(f"SignalR negotiate error: {e}")
|
||||
return None
|
||||
|
||||
async def _listen_loop(self):
|
||||
"""Main WebSocket listen loop with auto-reconnection."""
|
||||
try:
|
||||
import websockets
|
||||
except ImportError:
|
||||
logger.error("SignalR: websockets package not installed")
|
||||
return
|
||||
|
||||
while self._running:
|
||||
try:
|
||||
token = await self._negotiate()
|
||||
if not token:
|
||||
logger.warning("SignalR: Failed to negotiate, retrying in 10s...")
|
||||
await asyncio.sleep(10)
|
||||
continue
|
||||
|
||||
encoded_token = urllib.parse.quote(token, safe='')
|
||||
conn_data = urllib.parse.quote(json.dumps([{"name": "default"}]), safe='')
|
||||
ws_url = (
|
||||
f"{self._get_ws_base()}/signalr/connect"
|
||||
f"?transport=webSockets"
|
||||
f"&clientProtocol=1.5"
|
||||
f"&connectionToken={encoded_token}"
|
||||
f"&connectionData={conn_data}"
|
||||
)
|
||||
|
||||
logger.info("SignalR: Connecting to WebSocket...")
|
||||
async with websockets.connect(ws_url) as ws:
|
||||
logger.info("SignalR: Connected, listening for broadcasts")
|
||||
|
||||
while self._running:
|
||||
try:
|
||||
msg = await asyncio.wait_for(ws.recv(), timeout=30)
|
||||
if msg:
|
||||
data = json.loads(msg)
|
||||
messages = data.get("M", [])
|
||||
for m in messages:
|
||||
await self._handle_message(m)
|
||||
except asyncio.TimeoutError:
|
||||
# Normal - no messages received, keep listening
|
||||
continue
|
||||
except asyncio.CancelledError:
|
||||
return
|
||||
except Exception as e:
|
||||
logger.warning(f"SignalR: WebSocket recv error: {e}")
|
||||
break
|
||||
|
||||
except asyncio.CancelledError:
|
||||
return
|
||||
except Exception as e:
|
||||
logger.warning(f"SignalR: Connection failed: {e}, reconnecting in 5s...")
|
||||
await asyncio.sleep(5)
|
||||
|
||||
async def _handle_message(self, message: dict):
|
||||
"""Handle a SignalR broadcast message."""
|
||||
args = message.get("A", [])
|
||||
for arg in args:
|
||||
if "<TICKET_REFRESH>" in arg:
|
||||
try:
|
||||
ticket_id_str = arg.split("<TICKET_REFRESH>")[1]
|
||||
ticket_id = int(ticket_id_str)
|
||||
logger.info(f"SignalR: TICKET_REFRESH for ticket {ticket_id}")
|
||||
await self._process_ticket_refresh(ticket_id)
|
||||
except (ValueError, IndexError) as e:
|
||||
logger.warning(f"SignalR: Failed to parse TICKET_REFRESH: {arg} - {e}")
|
||||
|
||||
async def _process_ticket_refresh(self, sambapos_ticket_id: int):
|
||||
"""Handle a TICKET_REFRESH broadcast.
|
||||
|
||||
Fetches the specific ticket by ID from SambaPOS GraphQL and
|
||||
persists it as a KDS entry. This captures instantly-closed tickets
|
||||
(free breakfast, bar tabs) that never appear in getTickets(isClosed: false).
|
||||
|
||||
Always publishes an SSE event afterwards so the frontend refreshes.
|
||||
"""
|
||||
# Fetch and persist the ticket directly
|
||||
try:
|
||||
await self._fetch_and_persist_ticket(sambapos_ticket_id)
|
||||
except Exception as e:
|
||||
logger.warning(f"SignalR: Failed to fetch/persist ticket {sambapos_ticket_id}: {e}")
|
||||
|
||||
# Always notify SSE subscribers for instant frontend refresh
|
||||
await kds_event_bus.publish({
|
||||
"type": "ticket_refresh",
|
||||
"sambapos_ticket_id": sambapos_ticket_id,
|
||||
"timestamp": datetime.utcnow().isoformat(),
|
||||
})
|
||||
logger.info(f"SignalR: Published SSE event for ticket {sambapos_ticket_id}")
|
||||
|
||||
async def _fetch_and_persist_ticket(self, sambapos_ticket_id: int):
|
||||
"""Fetch a specific ticket from SambaPOS and create/update a KDS entry."""
|
||||
from services.kds_graphql import SambaPOSGraphQLClient, transform_ticket_for_kds
|
||||
from api.kds import get_or_create_kds_ticket
|
||||
from database import AsyncSessionLocal
|
||||
|
||||
client = SambaPOSGraphQLClient(
|
||||
server_url=self.base_url,
|
||||
username=self.graphql_username,
|
||||
password=self.graphql_password,
|
||||
client_id=self.graphql_client_id,
|
||||
)
|
||||
|
||||
result = await client.get_ticket_by_id(sambapos_ticket_id)
|
||||
|
||||
if "error" in result:
|
||||
logger.warning(f"SignalR: get_ticket_by_id({sambapos_ticket_id}) error: {result['error']}")
|
||||
return
|
||||
|
||||
ticket_data = result.get("data", {}).get("getTicket")
|
||||
if not ticket_data:
|
||||
logger.debug(f"SignalR: get_ticket_by_id({sambapos_ticket_id}) returned no data")
|
||||
return
|
||||
|
||||
transformed = transform_ticket_for_kds(ticket_data)
|
||||
if not transformed:
|
||||
logger.debug(f"SignalR: Ticket {sambapos_ticket_id} has no kitchen orders, skipping")
|
||||
return
|
||||
|
||||
async with AsyncSessionLocal() as db:
|
||||
kds_ticket = await get_or_create_kds_ticket(
|
||||
db, self.kitchen_id, transformed, self.course_order
|
||||
)
|
||||
logger.info(
|
||||
f"SignalR: Persisted ticket {sambapos_ticket_id} "
|
||||
f"(KDS #{kds_ticket.id}, number={kds_ticket.ticket_number})"
|
||||
)
|
||||
|
||||
def start(self):
|
||||
"""Start the listener as a background asyncio task."""
|
||||
self._running = True
|
||||
self._task = asyncio.create_task(self._listen_loop())
|
||||
logger.info("SignalR: Listener started")
|
||||
|
||||
def stop(self):
|
||||
"""Stop the listener."""
|
||||
self._running = False
|
||||
if self._task:
|
||||
self._task.cancel()
|
||||
logger.info("SignalR: Listener stopped")
|
||||
|
||||
|
||||
# Global listener instance
|
||||
_listener: Optional[SignalRListener] = None
|
||||
|
||||
|
||||
async def start_signalr_listener():
|
||||
"""Start the global SignalR listener using KDS settings from DB."""
|
||||
global _listener
|
||||
|
||||
from database import AsyncSessionLocal
|
||||
from sqlalchemy import select
|
||||
from models.settings import KitchenSettings
|
||||
|
||||
try:
|
||||
async with AsyncSessionLocal() as db:
|
||||
# Get first kitchen with KDS GraphQL configured
|
||||
result = await db.execute(
|
||||
select(KitchenSettings).where(
|
||||
KitchenSettings.kds_graphql_url.isnot(None)
|
||||
)
|
||||
)
|
||||
settings = result.scalar_one_or_none()
|
||||
|
||||
if not settings:
|
||||
logger.info("SignalR: No KDS GraphQL URL configured, listener not started")
|
||||
return
|
||||
|
||||
if not all([settings.kds_graphql_url, settings.kds_graphql_username,
|
||||
settings.kds_graphql_password, settings.kds_graphql_client_id]):
|
||||
logger.info("SignalR: KDS GraphQL credentials incomplete, listener not started")
|
||||
return
|
||||
|
||||
course_order = settings.kds_course_order or ["Starters", "Mains", "Desserts"]
|
||||
|
||||
_listener = SignalRListener(
|
||||
base_url=settings.kds_graphql_url,
|
||||
graphql_username=settings.kds_graphql_username,
|
||||
graphql_password=settings.kds_graphql_password,
|
||||
graphql_client_id=settings.kds_graphql_client_id,
|
||||
kitchen_id=settings.kitchen_id,
|
||||
course_order=course_order,
|
||||
)
|
||||
_listener.start()
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"SignalR: Failed to start listener: {e}")
|
||||
|
||||
|
||||
async def stop_signalr_listener():
|
||||
"""Stop the global SignalR listener."""
|
||||
global _listener
|
||||
if _listener:
|
||||
_listener.stop()
|
||||
_listener = None
|
||||
32
docker-compose.yml
Normal file
32
docker-compose.yml
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
services:
|
||||
backend:
|
||||
build: ./backend
|
||||
environment:
|
||||
- DATABASE_URL=${DATABASE_URL}
|
||||
- CENTRAL_AUTH_SECRET=${CENTRAL_AUTH_SECRET}
|
||||
- APP_SLUG=kds
|
||||
healthcheck:
|
||||
test: ["CMD", "curl", "-f", "http://localhost:8000/health"]
|
||||
interval: 15s
|
||||
timeout: 10s
|
||||
retries: 10
|
||||
start_period: 60s
|
||||
restart: unless-stopped
|
||||
|
||||
frontend:
|
||||
build:
|
||||
context: ./frontend
|
||||
args:
|
||||
VITE_HOTEL_NAME: ${VITE_HOTEL_NAME:-Hotel}
|
||||
security_opt:
|
||||
- apparmor=unconfined
|
||||
ports:
|
||||
- "${FRONTEND_PORT:-3080}:80"
|
||||
depends_on:
|
||||
backend:
|
||||
condition: service_healthy
|
||||
restart: unless-stopped
|
||||
|
||||
networks:
|
||||
default:
|
||||
driver: bridge
|
||||
15
frontend/Dockerfile
Normal file
15
frontend/Dockerfile
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
FROM node:22-alpine AS builder
|
||||
|
||||
ARG VITE_HOTEL_NAME=Hotel
|
||||
ENV VITE_HOTEL_NAME=$VITE_HOTEL_NAME
|
||||
|
||||
WORKDIR /app
|
||||
COPY package.json ./
|
||||
RUN npm install
|
||||
COPY . .
|
||||
RUN npm run build
|
||||
|
||||
FROM nginx:alpine
|
||||
COPY --from=builder /app/dist /usr/share/nginx/html/kds
|
||||
COPY nginx.conf /etc/nginx/conf.d/default.conf
|
||||
EXPOSE 80
|
||||
13
frontend/index.html
Normal file
13
frontend/index.html
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1, user-scalable=no" />
|
||||
<title>Kitchen Display</title>
|
||||
<link rel="manifest" href="/kds/manifest.json" />
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
41
frontend/nginx.conf
Normal file
41
frontend/nginx.conf
Normal file
|
|
@ -0,0 +1,41 @@
|
|||
server {
|
||||
listen 80;
|
||||
server_name _;
|
||||
root /usr/share/nginx/html;
|
||||
|
||||
# Block internal inter-app endpoints from the public internet
|
||||
location /kds/api/internal/ {
|
||||
return 403;
|
||||
}
|
||||
|
||||
# Central auth proxy (must come before the general /api/ block)
|
||||
location /kds/api/auth/ {
|
||||
proxy_pass http://10.10.10.101:3001/api/auth/;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
}
|
||||
|
||||
# KDS backend API (preserves /api/ prefix: /kds/api/kds/tickets → backend:8000/api/kds/tickets)
|
||||
location /kds/api/ {
|
||||
proxy_pass http://backend:8000/api/;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header Cookie $http_cookie;
|
||||
# SSE requires these headers
|
||||
proxy_buffering off;
|
||||
proxy_cache off;
|
||||
proxy_read_timeout 86400s;
|
||||
}
|
||||
|
||||
# Health check
|
||||
location /kds/health {
|
||||
proxy_pass http://backend:8000/health;
|
||||
}
|
||||
|
||||
# SPA fallback — all other /kds/* paths serve the React app
|
||||
location /kds/ {
|
||||
try_files $uri $uri/ /kds/index.html;
|
||||
}
|
||||
}
|
||||
25
frontend/package.json
Normal file
25
frontend/package.json
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
{
|
||||
"name": "kds-frontend",
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc && vite build",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"@tanstack/react-query": "^5.17.9",
|
||||
"lucide-react": "^0.395.0",
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0",
|
||||
"react-router-dom": "^6.21.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/react": "^18.2.47",
|
||||
"@types/react-dom": "^18.2.18",
|
||||
"@vitejs/plugin-react": "^4.2.1",
|
||||
"typescript": "^5.3.3",
|
||||
"vite": "^5.0.11"
|
||||
}
|
||||
}
|
||||
18
frontend/src/App.tsx
Normal file
18
frontend/src/App.tsx
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
import { Routes, Route, Navigate } from 'react-router-dom'
|
||||
import AuthGate from './components/AuthGate'
|
||||
|
||||
// Re-export for any archive imports that use `import { useAuth } from '../App'`
|
||||
export { useAuth } from './components/AuthGate'
|
||||
|
||||
import KDSApp from './pages/KDSApp'
|
||||
|
||||
export default function App() {
|
||||
return (
|
||||
<AuthGate>
|
||||
<Routes>
|
||||
<Route path="/" element={<KDSApp />} />
|
||||
<Route path="*" element={<Navigate to="/" replace />} />
|
||||
</Routes>
|
||||
</AuthGate>
|
||||
)
|
||||
}
|
||||
67
frontend/src/components/AuthGate.tsx
Normal file
67
frontend/src/components/AuthGate.tsx
Normal file
|
|
@ -0,0 +1,67 @@
|
|||
import { createContext, useContext, useEffect, useState } from 'react'
|
||||
import type { ReactNode } from 'react'
|
||||
import type { User } from '../types'
|
||||
|
||||
interface AuthCtx {
|
||||
user: User
|
||||
token: string
|
||||
restrictedPages: string[]
|
||||
login: (t: string) => void
|
||||
logout: () => void
|
||||
}
|
||||
const Ctx = createContext<AuthCtx | null>(null)
|
||||
|
||||
export function useAuth() {
|
||||
const ctx = useContext(Ctx)
|
||||
if (!ctx) throw new Error('useAuth outside AuthGate')
|
||||
return ctx
|
||||
}
|
||||
|
||||
export default function AuthGate({ children }: { children: ReactNode }) {
|
||||
const [user, setUser] = useState<User | null>(null)
|
||||
const [checking, setChecking] = useState(true)
|
||||
|
||||
useEffect(() => {
|
||||
fetch('/kds/api/auth/verify?app=kds', { credentials: 'include' })
|
||||
.then((r) => {
|
||||
if (!r.ok) throw new Error('unauth')
|
||||
return r.json()
|
||||
})
|
||||
.then((data) =>
|
||||
setUser({
|
||||
email: data.email || data.sub || '',
|
||||
name: data.name || data.display_name || '',
|
||||
is_admin: data.is_admin ?? false,
|
||||
caps: data.caps ?? [],
|
||||
})
|
||||
)
|
||||
.catch(() => {
|
||||
;(window.top ?? window).location.href = '/login'
|
||||
})
|
||||
.finally(() => setChecking(false))
|
||||
}, [])
|
||||
|
||||
if (checking) {
|
||||
return (
|
||||
<div style={{
|
||||
position: 'fixed', inset: 0,
|
||||
background: 'var(--kds-bg)',
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
}}>
|
||||
<div className="spinner" style={{ width: 32, height: 32 }} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (!user) return null
|
||||
|
||||
const ctx: AuthCtx = {
|
||||
user,
|
||||
token: '__session__',
|
||||
restrictedPages: [],
|
||||
login: () => {},
|
||||
logout: () => { (window.top ?? window).location.href = '/login' },
|
||||
}
|
||||
|
||||
return <Ctx.Provider value={ctx}>{children}</Ctx.Provider>
|
||||
}
|
||||
89
frontend/src/index.css
Normal file
89
frontend/src/index.css
Normal file
|
|
@ -0,0 +1,89 @@
|
|||
:root {
|
||||
/* Stack palette */
|
||||
--navy-dark: #1a1a2e;
|
||||
--navy-mid: #16213e;
|
||||
--navy-light: #0f3460;
|
||||
--gold: #c9a84c;
|
||||
--text-primary: #e8e8e8;
|
||||
--text-muted: #9ca3af;
|
||||
--bg-content: #f4f5f7;
|
||||
--border: rgba(255, 255, 255, 0.1);
|
||||
|
||||
/* KDS-specific — dark board theme */
|
||||
--kds-bg: #0d0d1a;
|
||||
--kds-card: #1a1a2e;
|
||||
--kds-border: rgba(255, 255, 255, 0.08);
|
||||
--kds-green: #22c55e;
|
||||
--kds-amber: #f59e0b;
|
||||
--kds-red: #ef4444;
|
||||
--kds-blue: #3b82f6;
|
||||
--kds-sent: #6b7280;
|
||||
|
||||
/* App primary — teal (shared with kitchen for recipe images etc.) */
|
||||
--app-primary: #0d9488;
|
||||
}
|
||||
|
||||
*, *::before, *::after {
|
||||
box-sizing: border-box;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
html, body, #root {
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
body {
|
||||
background: var(--kds-bg);
|
||||
color: var(--text-primary);
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||
font-size: 14px;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
}
|
||||
|
||||
/* KDS is fullscreen — no sidebar layout needed */
|
||||
|
||||
/* Spinner */
|
||||
.spinner {
|
||||
border: 3px solid rgba(255,255,255,0.15);
|
||||
border-top-color: var(--gold);
|
||||
border-radius: 50%;
|
||||
animation: spin 0.7s linear infinite;
|
||||
}
|
||||
@keyframes spin { to { transform: rotate(360deg); } }
|
||||
|
||||
/* Shared badge style used by KDS status indicators */
|
||||
.badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 2px 8px;
|
||||
border-radius: 9999px;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.badge-green { background: rgba(34,197,94,0.15); color: var(--kds-green); }
|
||||
.badge-amber { background: rgba(245,158,11,0.15); color: var(--kds-amber); }
|
||||
.badge-red { background: rgba(239,68,68,0.15); color: var(--kds-red); }
|
||||
.badge-blue { background: rgba(59,130,246,0.15); color: var(--kds-blue); }
|
||||
.badge-grey { background: rgba(107,114,128,0.15);color: var(--kds-sent); }
|
||||
|
||||
/* Button */
|
||||
.btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 8px 16px;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: opacity 0.15s;
|
||||
}
|
||||
.btn:hover { opacity: 0.85; }
|
||||
.btn:disabled { opacity: 0.4; cursor: not-allowed; }
|
||||
.btn-primary { background: var(--gold); color: #000; }
|
||||
.btn-ghost { background: transparent; color: var(--text-primary); border: 1px solid var(--border); }
|
||||
20
frontend/src/main.tsx
Normal file
20
frontend/src/main.tsx
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
import React from 'react'
|
||||
import ReactDOM from 'react-dom/client'
|
||||
import { BrowserRouter } from 'react-router-dom'
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||
import App from './App'
|
||||
import './index.css'
|
||||
|
||||
const qc = new QueryClient({
|
||||
defaultOptions: { queries: { staleTime: 10 * 1000, retry: 1 } },
|
||||
})
|
||||
|
||||
ReactDOM.createRoot(document.getElementById('root')!).render(
|
||||
<React.StrictMode>
|
||||
<BrowserRouter basename="/kds">
|
||||
<QueryClientProvider client={qc}>
|
||||
<App />
|
||||
</QueryClientProvider>
|
||||
</BrowserRouter>
|
||||
</React.StrictMode>,
|
||||
)
|
||||
1629
frontend/src/pages/KDS.tsx
Normal file
1629
frontend/src/pages/KDS.tsx
Normal file
File diff suppressed because it is too large
Load diff
13
frontend/src/pages/KDSApp.tsx
Normal file
13
frontend/src/pages/KDSApp.tsx
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
import { useEffect } from 'react'
|
||||
import KDS from './KDS'
|
||||
|
||||
export default function KDSApp() {
|
||||
// Lock viewport for touch-screen wall display
|
||||
useEffect(() => {
|
||||
const prev = document.title
|
||||
document.title = 'Kitchen Display'
|
||||
return () => { document.title = prev }
|
||||
}, [])
|
||||
|
||||
return <KDS />
|
||||
}
|
||||
11
frontend/src/types.ts
Normal file
11
frontend/src/types.ts
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
export interface User {
|
||||
email: string
|
||||
name: string
|
||||
is_admin: boolean
|
||||
caps: string[]
|
||||
}
|
||||
|
||||
export function can(user: User | null, cap: string): boolean {
|
||||
if (!user) return false
|
||||
return user.is_admin || user.caps.includes(cap)
|
||||
}
|
||||
19
frontend/tsconfig.json
Normal file
19
frontend/tsconfig.json
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2020",
|
||||
"useDefineForClassFields": true,
|
||||
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
||||
"module": "ESNext",
|
||||
"skipLibCheck": true,
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"noEmit": true,
|
||||
"jsx": "react-jsx",
|
||||
"strict": false,
|
||||
"noUnusedLocals": false,
|
||||
"noUnusedParameters": false
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
7
frontend/vite.config.ts
Normal file
7
frontend/vite.config.ts
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
import { defineConfig } from 'vite'
|
||||
import react from '@vitejs/plugin-react'
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
base: '/kds/',
|
||||
})
|
||||
Loading…
Add table
Add a link
Reference in a new issue