Files
simple-pos-system/PLANS AND STRATEGIES/01_LOCAL_BACKEND.md

10 KiB

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)

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

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).