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

View 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
}