# Guide 01 — Local Backend (FastAPI) ## Overview The local backend is the heart of the entire system. It runs on a Linux machine on the restaurant's LAN, operates fully without internet, and handles all business logic: orders, tables, waiters, products, and printer routing. --- ## Project Structure ``` local_backend/ ├── main.py # FastAPI app entry point ├── config.py # Settings (printer IPs, secret keys, cloud URL, etc.) ├── database.py # SQLAlchemy engine + session ├── models/ │ ├── user.py │ ├── table.py │ ├── product.py │ ├── order.py │ └── printer.py ├── schemas/ # Pydantic request/response models (mirror models/) ├── routers/ │ ├── auth.py │ ├── tables.py │ ├── products.py │ ├── orders.py │ ├── waiters.py │ ├── reports.py │ └── system.py ├── services/ │ ├── printer_service.py # Routing logic + escpos calls │ └── cloud_sync.py # Periodic check-in with cloud backend ├── middleware/ │ └── license_check.py # Validates local license token, blocks if expired └── requirements.txt ``` --- ## Database Schema ### `users` table ``` id INTEGER PK username TEXT UNIQUE NOT NULL pin_hash TEXT NOT NULL # bcrypt hash of PIN role TEXT NOT NULL # 'waiter' | 'manager' | 'sysadmin' is_active BOOLEAN DEFAULT TRUE created_at DATETIME ``` ### `assistant_assignments` table ``` id INTEGER PK primary_waiter_id INTEGER FK(users.id) assistant_waiter_id INTEGER FK(users.id) assigned_at DATETIME # If waiter B is assistant to waiter A, waiter B can manage all of A's tables ``` ### `tables` table ``` id INTEGER PK number INTEGER UNIQUE NOT NULL # Table number shown to users label TEXT # Optional custom label is_active BOOLEAN DEFAULT TRUE # Manager can deactivate floor_x FLOAT # For future floorplan feature floor_y FLOAT # For future floorplan feature ``` ### `categories` table ``` id INTEGER PK name TEXT NOT NULL color TEXT # Hex color for UI display sort_order INTEGER DEFAULT 0 ``` ### `products` table ``` id INTEGER PK name TEXT NOT NULL category_id INTEGER FK(categories.id) base_price FLOAT NOT NULL is_available BOOLEAN DEFAULT TRUE printer_zone_id INTEGER FK(printers.id) # Which printer zone gets this product ``` ### `product_options` table ``` id INTEGER PK product_id INTEGER FK(products.id) name TEXT NOT NULL # e.g. "Size", "Temperature" extra_cost FLOAT DEFAULT 0.0 ``` ### `product_ingredients` table ``` id INTEGER PK product_id INTEGER FK(products.id) name TEXT NOT NULL # e.g. "Onions", "Ice" # Can be removed by waiter when ordering ``` ### `printers` table ``` id INTEGER PK name TEXT NOT NULL # e.g. "Kitchen", "Bar" ip_address TEXT NOT NULL port INTEGER DEFAULT 9100 is_active BOOLEAN DEFAULT TRUE ``` ### `orders` table ``` id INTEGER PK table_id INTEGER FK(tables.id) opened_by INTEGER FK(users.id) # Waiter who opened it opened_at DATETIME status TEXT # 'open' | 'partially_paid' | 'paid' | 'closed' | 'cancelled' closed_at DATETIME NULL closed_by INTEGER FK(users.id) NULL notes TEXT NULL ``` ### `order_waiters` table ``` id INTEGER PK order_id INTEGER FK(orders.id) waiter_id INTEGER FK(users.id) assigned_at DATETIME # Supports multiple waiters per table (manager can add/remove) ``` ### `order_items` table ``` id INTEGER PK order_id INTEGER FK(orders.id) product_id INTEGER FK(products.id) added_by INTEGER FK(users.id) quantity INTEGER NOT NULL unit_price FLOAT NOT NULL # Price AT TIME OF ORDER (snapshot) selected_options TEXT NULL # JSON array of option ids chosen removed_ingredients TEXT NULL # JSON array of ingredient ids removed notes TEXT NULL # Freetext note per item status TEXT DEFAULT 'active' # 'active' | 'paid' | 'cancelled' added_at DATETIME printed BOOLEAN DEFAULT FALSE ``` ### `print_log` table ``` id INTEGER PK order_id INTEGER FK(orders.id) printer_id INTEGER FK(printers.id) printed_at DATETIME item_ids TEXT # JSON array of order_item ids printed success BOOLEAN error_message TEXT NULL ``` --- ## API Endpoints ### Auth — `/api/auth` ``` POST /login Body: { username, pin } Returns: { access_token, user: { id, username, role } } Note: JWT token, short expiry (8h), auto-refresh on activity POST /refresh Header: Bearer token Returns: new access_token POST /logout Invalidates token server-side (token blacklist in memory or DB) ``` ### Tables — `/api/tables` ``` GET / # All tables with current status (manager/waiter) POST / # Create table (manager only) PUT /:id # Edit table number/label (manager only) DELETE /:id # Deactivate table (manager only) GET /:id/status # Single table status + active order summary PUT /:id/floorplan # Update x/y position (manager only, future feature) ``` ### Products — `/api/products` ``` GET / # All products with options + ingredients (all roles) POST / # Create product (manager only) PUT /:id # Edit product (manager only) DELETE /:id # Deactivate product (manager only) GET /categories # All categories POST /categories # Create category (manager only) PUT /categories/:id # Edit category (manager only) DELETE /categories/:id # Delete category (manager only) ``` ### Orders — `/api/orders` ``` GET / # All orders, filterable by status/date/waiter (manager only) GET /my # Current waiter's active orders GET /:id # Full order detail with items POST / # Open new order { table_id } POST /:id/items # Add batch of items to order Body: { items: [{ product_id, quantity, selected_options, removed_ingredients, notes }] } Triggers: printer routing for new items PUT /:id/items/:item_id # (manager only) edit item DELETE /:id/items/:item_id # (manager only) cancel/remove item POST /:id/pay # Mark items as paid Body: { item_ids: [...] } # Supports partial payment POST /:id/close # Close order (waiter who owns it, or manager) DELETE /:id # Cancel entire order (manager only) PUT /:id/assign-waiter # Add waiter to order (manager only) DELETE /:id/waiters/:waiter_id # Remove waiter from order (manager only) ``` ### Waiters — `/api/waiters` ``` GET / # All waiter accounts (manager only) POST / # Create waiter account (manager only) PUT /:id # Edit waiter (manager only) PUT /:id/reset-pin # Reset PIN (manager only) PUT /:id/block # Block/unblock waiter (manager only) DELETE /:id # Delete waiter (manager only) POST /:id/assign-assistant # Assign assistant waiter (manager only) DELETE /:id/assistant # Remove assistant assignment (manager only) ``` ### Reports — `/api/reports` ``` GET /shift # End-of-shift summary Query: ?date=YYYY-MM-DD&waiter_id=X Returns: per-waiter breakdown of orders + totals GET /orders/history # Paginated order history with filters Query: ?from=&to=&waiter_id=&status= GET /tables/summary # Current snapshot of all tables ``` ### System — `/api/system` ``` GET /health # Liveness check (no auth required, used by cloud) GET /status # System info: version, uptime, printer statuses POST /printers/test # Send test print to a printer (manager/sysadmin) PUT /printers/:id # Edit printer config (sysadmin only) POST /lock # Lock the system (cloud backend calls this) POST /unlock # Unlock (requires valid cloud token) ``` --- ## Business Logic Notes ### Printer Routing (printer_service.py) When items are submitted via `POST /orders/:id/items`: 1. Group items by their `product.printer_zone_id` 2. For each group, format an ESC/POS receipt: table number, waiter name, timestamp, list of items with options/notes 3. Send to the corresponding printer IP 4. Log result in `print_log` 5. Mark items as `printed = TRUE` 6. If a printer fails, log the error but do NOT fail the order submission — the order is saved regardless ### Authorization Rules - Waiter can only access orders where their `user.id` is in `order_waiters` OR they are assigned as assistant to the order's primary waiter - Manager can access everything on the local system - Token middleware should attach user role to every request ### License Check Middleware - On every request, check in-memory license state - License state is refreshed by `cloud_sync.py` background task every 6 hours - If cloud is unreachable, use last known state + grace period (24h) - If license is expired/locked, return `HTTP 402` or `HTTP 423` on all endpoints except `/api/system/health` ### cloud_sync.py - Runs as a FastAPI background task (lifespan event) - Every 6 hours: POST to cloud backend with site ID + heartbeat - Cloud responds with: `{ licensed: bool, locked: bool, expires_at: datetime }` - Store response locally (SQLite or a flat JSON file) --- ## Environment / Config (config.py) ```python SITE_ID = "..." # Unique ID registered on cloud CLOUD_URL = "https://..." SECRET_KEY = "..." # JWT signing key LICENSE_GRACE_HOURS = 24 DATABASE_URL = "sqlite:///./pos.db" ``` --- ## Running Locally ```bash pip install fastapi uvicorn sqlalchemy python-escpos bcrypt pyjwt uvicorn main:app --host 0.0.0.0 --port 8000 ``` The `--host 0.0.0.0` is critical — this makes it accessible from other devices on the LAN. Set a static IP on the host machine at the router level (DHCP reservation).