Files
xenia-pos-local/local_backend/main.py
bonamin 4b1201ecaa feat(connect): Phase 4 — local backend online order integration
4.1 — cloud_sync.py
  - _push_menu_snapshot(): serializes digital-visible products+categories
    and POSTs to cloud /api/menu/sync every 5 minutes
  - _pull_pending_orders(): polls cloud /api/orders/pending/{site_id}
    every CONNECT_SYNC_INTERVAL_SECONDS (default 30s); creates local
    Order + OrderItem rows, marks synced on cloud, broadcasts SSE event
  - _connect_loop(): second asyncio task running the fast poll loop;
    piggybacked menu push fires every 5 min regardless of poll interval
  - _sync_once(): captures site_numeric_id from heartbeat response and
    stores it in license_state so Connect loops can use it
  - start_cloud_sync(): now creates and returns both tasks

4.2 — orders model/schema/migrations
  - models/order.py: table_id made nullable (online orders have no
    table); 7 new online_* columns added to Order
  - schemas/order.py: OrderOut table_id Optional, all 7 online_* fields
    added
  - main.py: 8 additive ALTER TABLE migrations for orders table

4.3 — routers/connect_orders.py (NEW)
  GET  /api/connect/orders/incoming      — pending online orders (any auth)
  POST /api/connect/orders/{id}/accept   — accept (manager)
  POST /api/connect/orders/{id}/reject   — reject with optional reason (manager)
  POST /api/connect/orders/{id}/status   — progress through lifecycle (manager)
  All state changes mirror to cloud via background task and broadcast SSE

4.4 — main.py router registration
  connect_orders router registered at /api/connect

config.py
  CONNECT_SYNC_INTERVAL_SECONDS setting added (default 30)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-01 00:12:01 +03:00

297 lines
18 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import os
from contextlib import asynccontextmanager
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from fastapi.staticfiles import StaticFiles
from database import engine, Base
from middleware.license_check import LicenseCheckMiddleware
from services.cloud_sync import start_cloud_sync
# Import all models so SQLAlchemy can create their tables
import models.user # noqa: F401 — also registers WaiterZone
import models.table # noqa: F401
import models.printer # noqa: F401
import models.product # noqa: F401
import models.order # noqa: F401 — also registers OrderAuditLog, OrderDiscount
import models.business_day # noqa: F401
import models.shift # noqa: F401 — registers WaiterShift, ShiftBreak
import models.settings # noqa: F401
import models.flag # noqa: F401 — registers TableFlagDef, TableFlagAssignment
import models.message # noqa: F401 — registers StaffMessage, StaffMessageAck, QuickMessageTemplate
from routers import auth, tables, products, orders, waiters, reports, system, setup as setup_router
from routers import business_day as business_day_router
from routers import shifts as shifts_router
from routers import settings as settings_router
from routers import flags as flags_router
from routers import messages as messages_router
from routers import sse as sse_router
from routers import data_transfer as data_transfer_router
from routers import connect_orders as connect_orders_router
def _run_migrations():
"""Apply additive schema changes that create_all won't handle.
Each migration gets its own connection so a no-op (column already exists)
doesn't leave a dirty transaction that blocks subsequent migrations."""
from sqlalchemy import text
migrations = [
"ALTER TABLE product_ingredients ADD COLUMN extra_cost REAL NOT NULL DEFAULT 0.0",
"ALTER TABLE products ADD COLUMN image_url VARCHAR",
"ALTER TABLE tables ADD COLUMN group_id INTEGER REFERENCES table_groups(id)",
"ALTER TABLE table_groups ADD COLUMN prefix VARCHAR",
"ALTER TABLE table_groups ADD COLUMN color VARCHAR",
"ALTER TABLE products ADD COLUMN sort_order INTEGER NOT NULL DEFAULT 0",
"ALTER TABLE product_preference_sets ADD COLUMN default_choice_id INTEGER",
"ALTER TABLE product_preference_choices ADD COLUMN sub_choices TEXT",
"ALTER TABLE product_preference_choices ADD COLUMN disables_subset INTEGER NOT NULL DEFAULT 0",
"ALTER TABLE product_preference_sets ADD COLUMN shared_subset TEXT",
"ALTER TABLE product_options ADD COLUMN sub_choices TEXT",
# Zone-based access control
"""CREATE TABLE IF NOT EXISTS waiter_zones (
id INTEGER PRIMARY KEY AUTOINCREMENT,
waiter_id INTEGER NOT NULL REFERENCES users(id),
group_id INTEGER REFERENCES table_groups(id),
assigned_at DATETIME DEFAULT CURRENT_TIMESTAMP
)""",
# Payment tracking on items
"ALTER TABLE order_items ADD COLUMN paid_by INTEGER REFERENCES users(id)",
"ALTER TABLE order_items ADD COLUMN paid_at DATETIME",
"ALTER TABLE order_items ADD COLUMN payment_method VARCHAR",
# Full audit log
"""CREATE TABLE IF NOT EXISTS order_audit_log (
id INTEGER PRIMARY KEY AUTOINCREMENT,
order_id INTEGER NOT NULL REFERENCES orders(id),
event_type VARCHAR NOT NULL,
waiter_id INTEGER REFERENCES users(id),
item_ids TEXT,
amount REAL,
payment_method VARCHAR,
note TEXT,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
)""",
# Waiter profile fields
"ALTER TABLE users ADD COLUMN full_name VARCHAR",
"ALTER TABLE users ADD COLUMN nickname VARCHAR",
"ALTER TABLE users ADD COLUMN mobile_phone VARCHAR",
"ALTER TABLE users ADD COLUMN avatar_url VARCHAR",
# Quick options (flat, allow_multiple)
"""CREATE TABLE IF NOT EXISTS product_quick_options (
id INTEGER PRIMARY KEY AUTOINCREMENT,
product_id INTEGER NOT NULL REFERENCES products(id),
name VARCHAR NOT NULL,
price REAL NOT NULL DEFAULT 0.0,
allow_multiple INTEGER NOT NULL DEFAULT 0,
sort_order INTEGER NOT NULL DEFAULT 0
)""",
# allow_multiple flag on extras (product_options)
"ALTER TABLE product_options ADD COLUMN allow_multiple INTEGER NOT NULL DEFAULT 0",
# Discounts table (future-proofed, schema ready now)
"""CREATE TABLE IF NOT EXISTS order_discounts (
id INTEGER PRIMARY KEY AUTOINCREMENT,
order_id INTEGER NOT NULL REFERENCES orders(id),
item_id INTEGER REFERENCES order_items(id),
discount_type VARCHAR NOT NULL,
discount_value REAL NOT NULL,
applied_by INTEGER NOT NULL REFERENCES users(id),
applied_at DATETIME DEFAULT CURRENT_TIMESTAMP,
reason TEXT
)""",
# Business day scoping on orders
"ALTER TABLE orders ADD COLUMN business_day_id INTEGER REFERENCES business_days(id)",
# Shift attribution on paid items
"ALTER TABLE order_items ADD COLUMN paid_in_shift_id INTEGER REFERENCES waiter_shifts(id)",
# Seed default POS settings (INSERT OR IGNORE = no-op if already exists)
"INSERT OR IGNORE INTO pos_settings (key, value, updated_at) VALUES ('shifts.waiter_self_start', 'true', CURRENT_TIMESTAMP)",
"INSERT OR IGNORE INTO pos_settings (key, value, updated_at) VALUES ('shifts.waiter_self_end', 'true', CURRENT_TIMESTAMP)",
"INSERT OR IGNORE INTO pos_settings (key, value, updated_at) VALUES ('business_day.force_close_allowed', 'true', CURRENT_TIMESTAMP)",
"INSERT OR IGNORE INTO pos_settings (key, value, updated_at) VALUES ('flags.display_mode', 'both', CURRENT_TIMESTAMP)",
# Table flags
"""CREATE TABLE IF NOT EXISTS table_flag_defs (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name VARCHAR NOT NULL,
emoji VARCHAR,
color VARCHAR DEFAULT '#6b7280',
text_color VARCHAR DEFAULT NULL,
sort_order INTEGER NOT NULL DEFAULT 0,
is_active INTEGER NOT NULL DEFAULT 1,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
)""",
# Migration: add text_color if upgrading from older schema
"ALTER TABLE table_flag_defs ADD COLUMN text_color VARCHAR DEFAULT NULL",
"""CREATE TABLE IF NOT EXISTS table_flag_assignments (
id INTEGER PRIMARY KEY AUTOINCREMENT,
table_id INTEGER NOT NULL REFERENCES tables(id),
flag_id INTEGER NOT NULL REFERENCES table_flag_defs(id),
assigned_at DATETIME DEFAULT CURRENT_TIMESTAMP,
assigned_by INTEGER REFERENCES users(id)
)""",
# Staff messaging
"""CREATE TABLE IF NOT EXISTS quick_message_templates (
id INTEGER PRIMARY KEY AUTOINCREMENT,
body VARCHAR NOT NULL,
sort_order INTEGER NOT NULL DEFAULT 0,
is_active INTEGER NOT NULL DEFAULT 1,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
)""",
"""CREATE TABLE IF NOT EXISTS staff_messages (
id INTEGER PRIMARY KEY AUTOINCREMENT,
sender_id INTEGER NOT NULL REFERENCES users(id),
body TEXT NOT NULL,
target_waiter_ids TEXT NOT NULL DEFAULT '[]',
table_ids TEXT NOT NULL DEFAULT '[]',
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
)""",
"""CREATE TABLE IF NOT EXISTS staff_message_acks (
id INTEGER PRIMARY KEY AUTOINCREMENT,
message_id INTEGER NOT NULL REFERENCES staff_messages(id),
waiter_id INTEGER NOT NULL REFERENCES users(id),
acked_at DATETIME DEFAULT CURRENT_TIMESTAMP
)""",
# Seed default flag definitions
"INSERT OR IGNORE INTO table_flag_defs (id, name, emoji, color, sort_order) VALUES (1, 'Χρειάζεται καθάρισμα', '🧹', '#ef4444', 1)",
"INSERT OR IGNORE INTO table_flag_defs (id, name, emoji, color, sort_order) VALUES (2, 'Χρειάζεται Βοήθεια', '🆘', '#f97316', 2)",
"INSERT OR IGNORE INTO table_flag_defs (id, name, emoji, color, sort_order) VALUES (3, 'Χρειάζεται Σερβιτόρο', '🔔', '#eab308', 3)",
"INSERT OR IGNORE INTO table_flag_defs (id, name, emoji, color, sort_order) VALUES (4, 'Περιμένει να πληρώσει', '💳', '#3b82f6', 4)",
"INSERT OR IGNORE INTO table_flag_defs (id, name, emoji, color, sort_order) VALUES (5, 'VIP', '', '#8b5cf6', 5)",
"INSERT OR IGNORE INTO table_flag_defs (id, name, emoji, color, sort_order) VALUES (6, 'Ευγενικός Πελάτης', '😊', '#22c55e', 6)",
"INSERT OR IGNORE INTO table_flag_defs (id, name, emoji, color, sort_order) VALUES (7, 'Αγενής Πελάτης', '😤', '#dc2626', 7)",
"INSERT OR IGNORE INTO table_flag_defs (id, name, emoji, color, sort_order) VALUES (8, 'Αλλεργίες', '⚠️', '#f59e0b', 8)",
"INSERT OR IGNORE INTO table_flag_defs (id, name, emoji, color, sort_order) VALUES (9, 'Παιδιά στο τραπέζι', '👶', '#06b6d4', 9)",
"INSERT OR IGNORE INTO table_flag_defs (id, name, emoji, color, sort_order) VALUES (10, 'Επέτειος / Γενέθλια', '🎂', '#ec4899', 10)",
# Seed default quick message templates
"INSERT OR IGNORE INTO quick_message_templates (id, body, sort_order) VALUES (1, 'Σε χρειάζομαι τώρα', 1)",
"INSERT OR IGNORE INTO quick_message_templates (id, body, sort_order) VALUES (2, 'Πάρε διάλειμμα', 2)",
"INSERT OR IGNORE INTO quick_message_templates (id, body, sort_order) VALUES (3, 'Ετοιμάσου για κλείσιμο', 3)",
"INSERT OR IGNORE INTO quick_message_templates (id, body, sort_order) VALUES (4, 'Ήρθε νέος πελάτης', 4)",
"INSERT OR IGNORE INTO quick_message_templates (id, body, sort_order) VALUES (5, 'Ο πελάτης περιμένει να πληρώσει', 5)",
# Product lifecycle status (active / archived)
"ALTER TABLE products ADD COLUMN lifecycle_status VARCHAR NOT NULL DEFAULT 'active'",
# Favorite flags + ordering on all product sub-item types
"ALTER TABLE product_quick_options ADD COLUMN is_favorite INTEGER NOT NULL DEFAULT 0",
"ALTER TABLE product_quick_options ADD COLUMN favorite_sort_order INTEGER NOT NULL DEFAULT 0",
"ALTER TABLE product_options ADD COLUMN is_favorite INTEGER NOT NULL DEFAULT 0",
"ALTER TABLE product_options ADD COLUMN favorite_sort_order INTEGER NOT NULL DEFAULT 0",
"ALTER TABLE product_ingredients ADD COLUMN is_favorite INTEGER NOT NULL DEFAULT 0",
"ALTER TABLE product_ingredients ADD COLUMN favorite_sort_order INTEGER NOT NULL DEFAULT 0",
"ALTER TABLE product_preference_sets ADD COLUMN is_favorite INTEGER NOT NULL DEFAULT 0",
"ALTER TABLE product_preference_sets ADD COLUMN favorite_sort_order INTEGER NOT NULL DEFAULT 0",
# Sub-category support
"ALTER TABLE categories ADD COLUMN parent_id INTEGER REFERENCES categories(id)",
"ALTER TABLE categories ADD COLUMN general_sort_order INTEGER NOT NULL DEFAULT 0",
# Auto-expand flag for sub-categories on the PWA accordion
"ALTER TABLE categories ADD COLUMN auto_expanded INTEGER NOT NULL DEFAULT 0",
# Printer protocol field
"ALTER TABLE printers ADD COLUMN protocol VARCHAR NOT NULL DEFAULT 'escpos_tcp'",
# Compact (half-width) display flag for quick options
"ALTER TABLE product_quick_options ADD COLUMN is_compact INTEGER NOT NULL DEFAULT 0",
# Print layout + per-type font settings
"INSERT OR IGNORE INTO pos_settings (key, value, updated_at) VALUES ('print.ticket_mode', 'detailed', CURRENT_TIMESTAMP)",
"INSERT OR IGNORE INTO pos_settings (key, value, updated_at) VALUES ('print.font_order_number', '48:1:0', CURRENT_TIMESTAMP)",
"INSERT OR IGNORE INTO pos_settings (key, value, updated_at) VALUES ('print.font_meta', '0:0:0', CURRENT_TIMESTAMP)",
"INSERT OR IGNORE INTO pos_settings (key, value, updated_at) VALUES ('print.font_item_name', '16:1:0', CURRENT_TIMESTAMP)",
"INSERT OR IGNORE INTO pos_settings (key, value, updated_at) VALUES ('print.font_quick', '0:0:0', CURRENT_TIMESTAMP)",
"INSERT OR IGNORE INTO pos_settings (key, value, updated_at) VALUES ('print.font_pref', '0:0:0', CURRENT_TIMESTAMP)",
"INSERT OR IGNORE INTO pos_settings (key, value, updated_at) VALUES ('print.font_extra', '0:0:0', CURRENT_TIMESTAMP)",
"INSERT OR IGNORE INTO pos_settings (key, value, updated_at) VALUES ('print.font_ingredient', '0:0:0', CURRENT_TIMESTAMP)",
"INSERT OR IGNORE INTO pos_settings (key, value, updated_at) VALUES ('print.font_item_note', '0:0:0', CURRENT_TIMESTAMP)",
"INSERT OR IGNORE INTO pos_settings (key, value, updated_at) VALUES ('print.font_order_note', '0:1:0', CURRENT_TIMESTAMP)",
# Offline/emergency payment tracking
"ALTER TABLE order_audit_log ADD COLUMN offline_uuid VARCHAR",
"ALTER TABLE order_audit_log ADD COLUMN offline_at VARCHAR",
"ALTER TABLE order_audit_log ADD COLUMN is_duplicate INTEGER NOT NULL DEFAULT 0",
# Cancellation tracking on order items (for reports)
"ALTER TABLE order_items ADD COLUMN cancelled_by INTEGER REFERENCES users(id)",
"ALTER TABLE order_items ADD COLUMN cancel_reason TEXT",
"ALTER TABLE order_items ADD COLUMN cancelled_at DATETIME",
# Manager account fields (added for setup wizard / future password login)
"ALTER TABLE users ADD COLUMN password_hash VARCHAR",
"ALTER TABLE users ADD COLUMN email VARCHAR",
# Venue identity settings
"INSERT OR IGNORE INTO pos_settings (key, value, updated_at) VALUES ('venue.name', '', CURRENT_TIMESTAMP)",
"INSERT OR IGNORE INTO pos_settings (key, value, updated_at) VALUES ('venue.type', '', CURRENT_TIMESTAMP)",
# Security settings
"INSERT OR IGNORE INTO pos_settings (key, value, updated_at) VALUES ('security.login_method', 'password', CURRENT_TIMESTAMP)",
"INSERT OR IGNORE INTO pos_settings (key, value, updated_at) VALUES ('security.autofill_username', 'true', CURRENT_TIMESTAMP)",
"INSERT OR IGNORE INTO pos_settings (key, value, updated_at) VALUES ('security.auto_lock', 'false', CURRENT_TIMESTAMP)",
"INSERT OR IGNORE INTO pos_settings (key, value, updated_at) VALUES ('security.auto_lock_seconds', '300', CURRENT_TIMESTAMP)",
"INSERT OR IGNORE INTO pos_settings (key, value, updated_at) VALUES ('security.auto_logout', 'false', CURRENT_TIMESTAMP)",
"INSERT OR IGNORE INTO pos_settings (key, value, updated_at) VALUES ('security.auto_logout_seconds', '1800', CURRENT_TIMESTAMP)",
# Xenia Connect — online order fields on orders table
"ALTER TABLE orders ADD COLUMN source VARCHAR NOT NULL DEFAULT 'pos'",
"ALTER TABLE orders ADD COLUMN online_order_ref VARCHAR",
"ALTER TABLE orders ADD COLUMN online_status VARCHAR",
"ALTER TABLE orders ADD COLUMN online_customer_name VARCHAR",
"ALTER TABLE orders ADD COLUMN online_customer_phone VARCHAR",
"ALTER TABLE orders ADD COLUMN online_customer_address TEXT",
"ALTER TABLE orders ADD COLUMN online_customer_notes TEXT",
"ALTER TABLE orders ADD COLUMN online_order_type VARCHAR",
# Xenia Connect — digital menu fields on products
"ALTER TABLE products ADD COLUMN digital_visible INTEGER NOT NULL DEFAULT 1",
"ALTER TABLE products ADD COLUMN digital_available INTEGER NOT NULL DEFAULT 1",
"ALTER TABLE products ADD COLUMN digital_name VARCHAR",
"ALTER TABLE products ADD COLUMN digital_description VARCHAR",
"ALTER TABLE products ADD COLUMN digital_price REAL",
"ALTER TABLE products ADD COLUMN digital_discount REAL NOT NULL DEFAULT 0.0",
"ALTER TABLE products ADD COLUMN digital_image_url VARCHAR",
]
for sql in migrations:
try:
with engine.connect() as conn:
conn.execute(text(sql))
conn.commit()
except Exception:
pass
@asynccontextmanager
async def lifespan(app: FastAPI):
import asyncio
from services.sse_bus import init_loop
init_loop(asyncio.get_running_loop())
Base.metadata.create_all(bind=engine)
_run_migrations()
sync_task = await start_cloud_sync()
yield
sync_task.cancel()
app = FastAPI(title="POS Local Backend", lifespan=lifespan)
app.add_middleware(
CORSMiddleware,
allow_origins=["*"],
allow_methods=["*"],
allow_headers=["*"],
)
app.add_middleware(LicenseCheckMiddleware)
# Serve product images as static files
IMAGE_DIR = "/app/data/product_images"
os.makedirs(IMAGE_DIR, exist_ok=True)
app.mount("/static/product_images", StaticFiles(directory=IMAGE_DIR), name="product_images")
# Serve waiter avatars as static files
AVATAR_DIR = "/app/data/avatars"
os.makedirs(AVATAR_DIR, exist_ok=True)
app.mount("/static/avatars", StaticFiles(directory=AVATAR_DIR), name="avatars")
app.include_router(setup_router.router, prefix="/api/setup", tags=["setup"])
app.include_router(auth.router, prefix="/api/auth", tags=["auth"])
app.include_router(tables.router, prefix="/api/tables", tags=["tables"])
app.include_router(products.router, prefix="/api/products", tags=["products"])
app.include_router(orders.router, prefix="/api/orders", tags=["orders"])
app.include_router(waiters.router, prefix="/api/waiters", tags=["waiters"])
app.include_router(reports.router, prefix="/api/reports", tags=["reports"])
app.include_router(system.router, prefix="/api/system", tags=["system"])
app.include_router(business_day_router.router, prefix="/api/business-day", tags=["business-day"])
app.include_router(shifts_router.router, prefix="/api/shifts", tags=["shifts"])
app.include_router(settings_router.router, prefix="/api/settings", tags=["settings"])
app.include_router(flags_router.router, prefix="/api/flags", tags=["flags"])
app.include_router(messages_router.router, prefix="/api/messages", tags=["messages"])
app.include_router(sse_router.router, prefix="/api/sse", tags=["sse"])
app.include_router(data_transfer_router.router, prefix="/api/data-transfer", tags=["data-transfer"])
app.include_router(connect_orders_router.router, prefix="/api/connect", tags=["connect"])