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
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
|
||||
Loading…
Add table
Add a link
Reference in a new issue