Phase 1: scaffold local backend — models, schemas, routers, printer service, Docker
This commit is contained in:
307
PLANS AND STRATEGIES/01_LOCAL_BACKEND.md
Normal file
307
PLANS AND STRATEGIES/01_LOCAL_BACKEND.md
Normal file
@@ -0,0 +1,307 @@
|
||||
# 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).
|
||||
Reference in New Issue
Block a user