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

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

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

145
backend/models/resos.py Normal file
View 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
View 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
View 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