Phase 1: scaffold local backend — models, schemas, routers, printer service, Docker
This commit is contained in:
28
.gitignore
vendored
Normal file
28
.gitignore
vendored
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
# Environment
|
||||||
|
.env
|
||||||
|
*.env
|
||||||
|
|
||||||
|
# Database
|
||||||
|
*.db
|
||||||
|
*.db-shm
|
||||||
|
*.db-wal
|
||||||
|
|
||||||
|
# License state (runtime file)
|
||||||
|
license_state.json
|
||||||
|
|
||||||
|
# Python
|
||||||
|
__pycache__/
|
||||||
|
*.py[cod]
|
||||||
|
*.pyo
|
||||||
|
.venv/
|
||||||
|
venv/
|
||||||
|
env/
|
||||||
|
|
||||||
|
# Node (for future frontends)
|
||||||
|
node_modules/
|
||||||
|
dist/
|
||||||
|
.next/
|
||||||
|
|
||||||
|
# OS
|
||||||
|
.DS_Store
|
||||||
|
Thumbs.db
|
||||||
85
PLANS AND STRATEGIES/00_PROJECT_OVERVIEW.md
Normal file
85
PLANS AND STRATEGIES/00_PROJECT_OVERVIEW.md
Normal file
@@ -0,0 +1,85 @@
|
|||||||
|
# POS System — Master Project Overview
|
||||||
|
|
||||||
|
## What We're Building
|
||||||
|
A locally-operated Point of Sale system for restaurants. It digitizes order-taking and tracking — think pen-and-paper workflows, modernized. No legal/IRS integration. No payment processing. Pure order management and accountability.
|
||||||
|
|
||||||
|
## Core Philosophy
|
||||||
|
- **Local-first**: Everything runs on the restaurant's LAN. Internet outage = zero impact on operations.
|
||||||
|
- **Simple auth**: Username + PIN. Waiters use their own phones.
|
||||||
|
- **Fraud prevention**: Waiters cannot delete items or orders. Only managers can.
|
||||||
|
- **Zone printing**: Each printer receives only items relevant to its area.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## System Architecture
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────────────────────────────────────┐
|
||||||
|
│ CLOUD (Your VPS) │
|
||||||
|
│ Cloud Backend (FastAPI) │
|
||||||
|
│ Sysadmin Panel (React) │
|
||||||
|
│ - License management │
|
||||||
|
│ - Remote lock/unlock per site │
|
||||||
|
│ - Site registration │
|
||||||
|
└──────────────────┬──────────────────────────┘
|
||||||
|
│ Periodic check-in (24h grace)
|
||||||
|
│ HTTPS
|
||||||
|
┌──────────────────▼──────────────────────────┐
|
||||||
|
│ LOCAL (Restaurant LAN) │
|
||||||
|
│ Local Backend (FastAPI) │
|
||||||
|
│ SQLite Database │
|
||||||
|
│ Printer routing (python-escpos) │
|
||||||
|
│ Static local IP (e.g. 192.168.1.10) │
|
||||||
|
│ Runs on Linux (RPi or old PC) │
|
||||||
|
└────────┬────────────────┬───────────────────┘
|
||||||
|
│ LAN │ LAN
|
||||||
|
┌────────▼──────┐ ┌──────▼────────────────────┐
|
||||||
|
│ Waiter PWA │ │ Manager Dashboard │
|
||||||
|
│ (phones) │ │ (tablet / laptop) │
|
||||||
|
│ React + SW │ │ React │
|
||||||
|
└───────────────┘ └───────────────────────────┘
|
||||||
|
│ LAN
|
||||||
|
┌────────▼──────────────┐
|
||||||
|
│ Thermal Printers │
|
||||||
|
│ Zone A: Kitchen │
|
||||||
|
│ Zone B: Bar │
|
||||||
|
│ Zone C: etc. │
|
||||||
|
└───────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Three User Roles
|
||||||
|
|
||||||
|
| Role | Access | Frontend |
|
||||||
|
|------|--------|----------|
|
||||||
|
| Waiter | Their tables only. Open, add items, view total, mark paid, close. | PWA on phone |
|
||||||
|
| Manager | All tables + orders. Full menu/waiter/table management. Shift reports. | Web Dashboard |
|
||||||
|
| Sysadmin (you) | Everything above + system control, remote lock, multi-site management. | Cloud Panel |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Tech Stack
|
||||||
|
|
||||||
|
| Layer | Technology |
|
||||||
|
|-------|-----------|
|
||||||
|
| Local Backend | FastAPI (Python) |
|
||||||
|
| Database | SQLite (via SQLAlchemy) |
|
||||||
|
| Printer | python-escpos |
|
||||||
|
| Waiter App | React + Vite (PWA) |
|
||||||
|
| Manager Dashboard | React + Vite |
|
||||||
|
| Styling | TailwindCSS |
|
||||||
|
| Cloud Backend | FastAPI (Python) |
|
||||||
|
| Sysadmin Panel | React + Vite |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Build Order
|
||||||
|
|
||||||
|
1. `01_LOCAL_BACKEND.md` — Database schema + all API endpoints
|
||||||
|
2. `02_WAITER_PWA.md` — Waiter-facing PWA
|
||||||
|
3. `03_MANAGER_DASHBOARD.md` — Manager-facing web app
|
||||||
|
4. `04_CLOUD_BACKEND.md` — Cloud licensing + remote control
|
||||||
|
5. `05_SYSADMIN_PANEL.md` — Sysadmin cloud frontend
|
||||||
|
|
||||||
|
Each guide is self-contained with full details for Claude Code to implement.
|
||||||
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).
|
||||||
227
PLANS AND STRATEGIES/02_WAITER_PWA.md
Normal file
227
PLANS AND STRATEGIES/02_WAITER_PWA.md
Normal file
@@ -0,0 +1,227 @@
|
|||||||
|
# Guide 02 — Waiter PWA (React + Vite)
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
A Progressive Web App installed on waiters' personal phones. Minimal, fast, touch-optimized. Works only when connected to the restaurant LAN. No offline functionality needed — show a clear error if backend is unreachable.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Project Structure
|
||||||
|
|
||||||
|
```
|
||||||
|
waiter_pwa/
|
||||||
|
├── public/
|
||||||
|
│ ├── manifest.json # PWA manifest (name, icons, display: standalone)
|
||||||
|
│ └── icons/ # App icons (192x192, 512x512)
|
||||||
|
├── src/
|
||||||
|
│ ├── main.jsx
|
||||||
|
│ ├── App.jsx
|
||||||
|
│ ├── api/
|
||||||
|
│ │ └── client.js # Axios instance pointed at LOCAL backend IP
|
||||||
|
│ ├── store/
|
||||||
|
│ │ └── authStore.js # Zustand store for auth state
|
||||||
|
│ ├── pages/
|
||||||
|
│ │ ├── LoginPage.jsx
|
||||||
|
│ │ ├── TableListPage.jsx
|
||||||
|
│ │ ├── TableDetailPage.jsx
|
||||||
|
│ │ ├── AddItemsPage.jsx
|
||||||
|
│ │ └── OfflinePage.jsx
|
||||||
|
│ ├── components/
|
||||||
|
│ │ ├── PinPad.jsx
|
||||||
|
│ │ ├── TableCard.jsx
|
||||||
|
│ │ ├── OrderSummary.jsx
|
||||||
|
│ │ ├── ProductPicker.jsx
|
||||||
|
│ │ ├── ItemOptionsModal.jsx
|
||||||
|
│ │ └── ConnectionBanner.jsx
|
||||||
|
│ └── service-worker.js # Minimal SW — caches app shell only
|
||||||
|
├── vite.config.js
|
||||||
|
└── package.json
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## PWA Setup
|
||||||
|
|
||||||
|
### manifest.json
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"name": "TableServe",
|
||||||
|
"short_name": "TableServe",
|
||||||
|
"start_url": "/",
|
||||||
|
"display": "standalone",
|
||||||
|
"background_color": "#0f172a",
|
||||||
|
"theme_color": "#0f172a",
|
||||||
|
"icons": [
|
||||||
|
{ "src": "/icons/icon-192.png", "sizes": "192x192", "type": "image/png" },
|
||||||
|
{ "src": "/icons/icon-512.png", "sizes": "512x512", "type": "image/png" }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### vite.config.js
|
||||||
|
Use `vite-plugin-pwa` for service worker generation:
|
||||||
|
```js
|
||||||
|
import { VitePWA } from 'vite-plugin-pwa'
|
||||||
|
export default {
|
||||||
|
plugins: [
|
||||||
|
VitePWA({
|
||||||
|
registerType: 'autoUpdate',
|
||||||
|
workbox: {
|
||||||
|
globPatterns: ['**/*.{js,css,html,ico,png,svg}']
|
||||||
|
// Cache app shell only — no API responses cached
|
||||||
|
}
|
||||||
|
})
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## API Client (client.js)
|
||||||
|
|
||||||
|
```js
|
||||||
|
import axios from 'axios'
|
||||||
|
|
||||||
|
// This base URL must point to the LOCAL backend static IP
|
||||||
|
// It should be configurable — read from an env variable at build time
|
||||||
|
const BASE_URL = import.meta.env.VITE_API_URL || 'http://192.168.1.10:8000'
|
||||||
|
|
||||||
|
const client = axios.create({ baseURL: BASE_URL })
|
||||||
|
|
||||||
|
// Attach token to every request
|
||||||
|
client.interceptors.request.use(config => {
|
||||||
|
const token = localStorage.getItem('token')
|
||||||
|
if (token) config.headers.Authorization = `Bearer ${token}`
|
||||||
|
return config
|
||||||
|
})
|
||||||
|
|
||||||
|
// On 402/423 — show license error screen
|
||||||
|
// On network error — show offline screen
|
||||||
|
client.interceptors.response.use(
|
||||||
|
res => res,
|
||||||
|
err => {
|
||||||
|
if (!err.response) {
|
||||||
|
// Network error = backend unreachable
|
||||||
|
window.dispatchEvent(new Event('backend-offline'))
|
||||||
|
}
|
||||||
|
return Promise.reject(err)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
export default client
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Auth Flow
|
||||||
|
|
||||||
|
### First-time login (no saved user)
|
||||||
|
1. Show username input + PIN pad
|
||||||
|
2. POST `/api/auth/login` → store token + username in localStorage
|
||||||
|
3. Redirect to Table List
|
||||||
|
|
||||||
|
### Returning user (username saved)
|
||||||
|
1. Show "Welcome back, [Name]" + PIN pad only
|
||||||
|
2. Same login flow
|
||||||
|
3. "Not you?" link → clears saved username → shows full login
|
||||||
|
|
||||||
|
### PIN Pad Component
|
||||||
|
- 10 digit buttons (0–9) + backspace + confirm
|
||||||
|
- Large touch targets (minimum 64px)
|
||||||
|
- Dots display (●●●●) as PIN is entered
|
||||||
|
- No keyboard shown — native keyboard is clunky for PINs
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Pages
|
||||||
|
|
||||||
|
### LoginPage
|
||||||
|
- Logo / app name at top
|
||||||
|
- If username saved: greeting + PIN pad
|
||||||
|
- If no username: username text input + PIN pad
|
||||||
|
- "Logout / Switch User" link at bottom
|
||||||
|
|
||||||
|
### TableListPage
|
||||||
|
- Header: waiter's name + logout icon
|
||||||
|
- `ConnectionBanner` shown if backend unreachable
|
||||||
|
- Grid of `TableCard` components
|
||||||
|
- Each card shows: table number, status badge (Free / Active / Your Order)
|
||||||
|
- Filter tabs: All | My Tables | Free Tables
|
||||||
|
- Tap a table → `TableDetailPage`
|
||||||
|
|
||||||
|
### TableDetailPage
|
||||||
|
- Header: "Table #X" + back button
|
||||||
|
- If no active order: large "Open Order" button
|
||||||
|
- If active order exists and waiter is assigned:
|
||||||
|
- Order summary (list of items, quantities, prices)
|
||||||
|
- Total at bottom
|
||||||
|
- "Add Items" button
|
||||||
|
- "Mark as Paid" button (can select specific items for partial payment)
|
||||||
|
- "Close Order" button (only enabled when all items are paid)
|
||||||
|
- If active order exists but waiter is NOT assigned: read-only view, no actions
|
||||||
|
|
||||||
|
### AddItemsPage
|
||||||
|
- Accessed from TableDetailPage → "Add Items"
|
||||||
|
- Category tabs at top (scrollable horizontal)
|
||||||
|
- Product grid/list below
|
||||||
|
- Tap product → `ItemOptionsModal` (select options, remove ingredients, add note, set quantity)
|
||||||
|
- Staging area at bottom: items added so far (like a cart)
|
||||||
|
- "Send Order" button — submits entire batch to backend, triggers printing
|
||||||
|
|
||||||
|
### ItemOptionsModal (bottom sheet)
|
||||||
|
- Product name + base price
|
||||||
|
- Options list (radio or checkbox depending on type) with price adjustments shown
|
||||||
|
- Ingredients list with toggle to remove each
|
||||||
|
- Freetext note input
|
||||||
|
- Quantity stepper (+/-)
|
||||||
|
- "Add to Order" button
|
||||||
|
|
||||||
|
### OfflinePage
|
||||||
|
- Shown when backend is unreachable
|
||||||
|
- Simple message: "Cannot reach the system. Please check your WiFi connection."
|
||||||
|
- Retry button that pings `/api/system/health`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## UI Design Direction
|
||||||
|
- **Theme**: Dark. Deep navy/slate background (`#0f172a`). This is a working tool used in dim restaurant lighting.
|
||||||
|
- **Accent**: Warm amber or teal — something that reads clearly as "action" on dark backgrounds.
|
||||||
|
- **Typography**: Clean, highly legible. Large touch targets. No tiny text.
|
||||||
|
- **Table cards**: Color-coded by status. Free = subtle/muted. Active = accented. Your table = highlighted.
|
||||||
|
- **Touch targets**: All interactive elements minimum 48px height. Prefer 64px for primary actions.
|
||||||
|
- **Transitions**: Subtle slide transitions between pages. No heavy animations — this is a tool, not a showcase.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## State Management (Zustand)
|
||||||
|
|
||||||
|
### authStore
|
||||||
|
```js
|
||||||
|
{
|
||||||
|
user: null, // { id, username, role }
|
||||||
|
token: null,
|
||||||
|
savedUsername: null, // persisted in localStorage
|
||||||
|
login(user, token),
|
||||||
|
logout(),
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### No complex global state needed beyond auth.
|
||||||
|
- Table list: fetched on mount, local component state
|
||||||
|
- Active order: fetched when opening TableDetailPage
|
||||||
|
- AddItems cart: local component state, discarded on submit or back navigation
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Key UX Rules
|
||||||
|
1. Every destructive or confirm action requires a **second tap** (e.g. "Close Order" → confirmation sheet)
|
||||||
|
2. After "Send Order" succeeds, immediately navigate back to TableDetailPage and show updated order
|
||||||
|
3. If "Send Order" fails (network), show error toast — do NOT clear the staged items
|
||||||
|
4. Loading states on all async actions — buttons disabled while in-flight
|
||||||
|
5. "Add Items" should show a badge count of staged items so waiter doesn't lose track
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Installation Instructions (for deployment)
|
||||||
|
Include a simple printed QR code at the restaurant pointing to `http://[LOCAL_IP]:5173`
|
||||||
|
- iOS: Open in Safari → Share → Add to Home Screen
|
||||||
|
- Android: Open in Chrome → Menu → Add to Home Screen
|
||||||
172
PLANS AND STRATEGIES/03_MANAGER_DASHBOARD.md
Normal file
172
PLANS AND STRATEGIES/03_MANAGER_DASHBOARD.md
Normal file
@@ -0,0 +1,172 @@
|
|||||||
|
# Guide 03 — Manager Dashboard (React + Vite)
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
A full web application used by the restaurant manager on a tablet or laptop. Touch-friendly but not phone-optimized. Clean, minimal UI. Full control over orders, tables, products, waiters, and reports.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Project Structure
|
||||||
|
|
||||||
|
```
|
||||||
|
manager_dashboard/
|
||||||
|
├── src/
|
||||||
|
│ ├── main.jsx
|
||||||
|
│ ├── App.jsx
|
||||||
|
│ ├── api/
|
||||||
|
│ │ └── client.js # Same axios setup as waiter PWA
|
||||||
|
│ ├── store/
|
||||||
|
│ │ └── authStore.js
|
||||||
|
│ ├── pages/
|
||||||
|
│ │ ├── LoginPage.jsx
|
||||||
|
│ │ ├── DashboardPage.jsx # Live table overview (home)
|
||||||
|
│ │ ├── OrderDetailPage.jsx
|
||||||
|
│ │ ├── ProductsPage.jsx
|
||||||
|
│ │ ├── WaitersPage.jsx
|
||||||
|
│ │ ├── TablesPage.jsx
|
||||||
|
│ │ ├── ReportsPage.jsx
|
||||||
|
│ │ └── SettingsPage.jsx
|
||||||
|
│ ├── components/
|
||||||
|
│ │ ├── Sidebar.jsx
|
||||||
|
│ │ ├── TableGrid.jsx
|
||||||
|
│ │ ├── OrderItemList.jsx
|
||||||
|
│ │ ├── ProductForm.jsx
|
||||||
|
│ │ ├── WaiterForm.jsx
|
||||||
|
│ │ ├── ConfirmModal.jsx
|
||||||
|
│ │ └── ShiftSummaryTable.jsx
|
||||||
|
│ └── layouts/
|
||||||
|
│ └── AppLayout.jsx # Sidebar + main content area
|
||||||
|
├── vite.config.js
|
||||||
|
└── package.json
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Layout
|
||||||
|
|
||||||
|
### AppLayout
|
||||||
|
- Left sidebar (collapsible on tablet): navigation links
|
||||||
|
- Main content area: renders current page
|
||||||
|
- Top bar: current user name, clock, logout button
|
||||||
|
|
||||||
|
### Sidebar Navigation
|
||||||
|
```
|
||||||
|
📊 Dashboard (live table overview)
|
||||||
|
🪑 Tables (manage table list + floorplan future)
|
||||||
|
📦 Products (menu management)
|
||||||
|
👥 Waiters (account management)
|
||||||
|
📋 Reports (shift summaries + history)
|
||||||
|
⚙️ Settings (printer config, system info)
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Pages
|
||||||
|
|
||||||
|
### LoginPage
|
||||||
|
- Username + PIN pad (same component as waiter but slightly larger for tablet)
|
||||||
|
- Manager/Sysadmin roles land here
|
||||||
|
|
||||||
|
### DashboardPage (Home)
|
||||||
|
- **Primary view**: Grid of all tables
|
||||||
|
- Each table card shows:
|
||||||
|
- Table number
|
||||||
|
- Status: Free | Active | Partially Paid
|
||||||
|
- Assigned waiter name(s)
|
||||||
|
- Order total (if active)
|
||||||
|
- Time elapsed since order opened
|
||||||
|
- Click a table → slide-over panel or modal with `OrderDetailPage` content
|
||||||
|
- Auto-refresh every 30 seconds (or use polling)
|
||||||
|
- Filter bar: All | Active | Free | Partially Paid
|
||||||
|
|
||||||
|
### OrderDetailPage
|
||||||
|
- Full order details: table, waiter(s), opened at, items list
|
||||||
|
- Each item shows: product name, options, notes, quantity, price, status (active/paid/cancelled)
|
||||||
|
- Actions:
|
||||||
|
- Remove individual item (with confirmation)
|
||||||
|
- Cancel entire order (with confirmation + reason optional)
|
||||||
|
- Add waiter to order
|
||||||
|
- Remove waiter from order
|
||||||
|
- Mark specific items as paid (same as waiter partial payment)
|
||||||
|
- Close order
|
||||||
|
- Print receipt (sends formatted summary to a selected printer)
|
||||||
|
|
||||||
|
### ProductsPage
|
||||||
|
- Left panel: category list with "Add Category" button
|
||||||
|
- Right panel: products in selected category
|
||||||
|
- Each product card: name, price, availability toggle
|
||||||
|
- "Add Product" / "Edit Product" opens a form panel (not a separate page):
|
||||||
|
- Name
|
||||||
|
- Category (dropdown)
|
||||||
|
- Base price
|
||||||
|
- Printer zone assignment (which printer this product routes to)
|
||||||
|
- Availability toggle
|
||||||
|
- Options section: list of options, each with name + extra cost (add/remove)
|
||||||
|
- Ingredients section: list of ingredients (add/remove)
|
||||||
|
|
||||||
|
### WaitersPage
|
||||||
|
- Table of all waiter accounts:
|
||||||
|
- Username, status (active/blocked), orders today, total today
|
||||||
|
- Actions per row:
|
||||||
|
- Reset PIN → opens modal to set new PIN
|
||||||
|
- Block / Unblock toggle
|
||||||
|
- Edit username
|
||||||
|
- Delete (with confirmation)
|
||||||
|
- Manage assistants → modal showing current assistant assignments
|
||||||
|
- "Add Waiter" button → form: username + initial PIN + role
|
||||||
|
|
||||||
|
### TablesPage
|
||||||
|
- List of all tables (number, label, active/inactive)
|
||||||
|
- Add table: just a number + optional label
|
||||||
|
- Edit: change number or label
|
||||||
|
- Deactivate/reactivate table
|
||||||
|
- (Future) Floorplan editor placeholder section
|
||||||
|
|
||||||
|
### ReportsPage
|
||||||
|
- **Shift Summary tab**:
|
||||||
|
- Date picker (default: today)
|
||||||
|
- Per-waiter summary table:
|
||||||
|
- Waiter name | Orders completed | Items sold | Total revenue
|
||||||
|
- Export as CSV button
|
||||||
|
- **Order History tab**:
|
||||||
|
- Filterable by date range, waiter, status
|
||||||
|
- Paginated table of orders
|
||||||
|
- Click order → opens OrderDetailPage in read-only mode
|
||||||
|
|
||||||
|
### SettingsPage (Manager/Sysadmin)
|
||||||
|
- Printer list: name, IP, zone, test print button
|
||||||
|
- System info: version, uptime
|
||||||
|
- (Sysadmin only): license status, cloud connection status, lock/unlock system button
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## UI Design Direction
|
||||||
|
- **Theme**: Light. Clean white/light gray. This is used on a well-lit counter or office.
|
||||||
|
- **Accent**: Deep teal or navy for primary actions. Red for destructive actions.
|
||||||
|
- **Typography**: Slightly larger than standard web — readable at arm's length on a tablet.
|
||||||
|
- **Table cards on Dashboard**: Color-coded status indicators. Bold table numbers. Clear waiter attribution.
|
||||||
|
- **Touch targets**: 44px minimum on all interactive elements.
|
||||||
|
- **Tables/lists**: Comfortable row height (52–64px), clear hover states.
|
||||||
|
- **Forms**: Single-column, generous spacing. No cramped inputs.
|
||||||
|
- **Modals/panels**: Slide-over from right for detail views. Centered modals only for confirmations.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## State Management
|
||||||
|
|
||||||
|
### authStore (Zustand) — same as waiter PWA
|
||||||
|
|
||||||
|
### Per-page state: local React state + React Query
|
||||||
|
Use **React Query** (TanStack Query) for all data fetching:
|
||||||
|
- Auto-refetch on window focus
|
||||||
|
- Background polling for DashboardPage (every 30s)
|
||||||
|
- Optimistic updates for quick actions (toggle availability, etc.)
|
||||||
|
- Automatic cache invalidation after mutations
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Key UX Rules
|
||||||
|
1. **Destructive actions always require confirmation** — modal with clear description of what will happen
|
||||||
|
2. **Dashboard is the home** — manager should be able to see everything at a glance without drilling in
|
||||||
|
3. **Inline editing where possible** — don't navigate away for simple changes
|
||||||
|
4. **Clear feedback on every action** — toast notifications for success/error
|
||||||
|
5. **Manager cannot be locked out** — if cloud check-in fails, manager still has full local access
|
||||||
113
PLANS AND STRATEGIES/04_CLOUD_BACKEND.md
Normal file
113
PLANS AND STRATEGIES/04_CLOUD_BACKEND.md
Normal file
@@ -0,0 +1,113 @@
|
|||||||
|
# Guide 04 — Cloud Backend (FastAPI on VPS)
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
A lightweight FastAPI backend hosted on your VPS. It does NOT handle restaurant operations — that's the local backend's job. Its sole responsibilities are: site registration, license management, remote lock/unlock, and receiving periodic heartbeats from local backends.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Project Structure
|
||||||
|
|
||||||
|
```
|
||||||
|
cloud_backend/
|
||||||
|
├── main.py
|
||||||
|
├── config.py # VPS settings, master secret key
|
||||||
|
├── database.py # PostgreSQL (or SQLite for simplicity)
|
||||||
|
├── models/
|
||||||
|
│ ├── site.py
|
||||||
|
│ └── admin.py
|
||||||
|
├── schemas/
|
||||||
|
├── routers/
|
||||||
|
│ ├── auth.py # Sysadmin login
|
||||||
|
│ ├── sites.py # Site management
|
||||||
|
│ └── heartbeat.py # Receives check-ins from local backends
|
||||||
|
└── requirements.txt
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Database Schema
|
||||||
|
|
||||||
|
### `admins` table
|
||||||
|
```
|
||||||
|
id INTEGER PK
|
||||||
|
username TEXT UNIQUE
|
||||||
|
password_hash TEXT
|
||||||
|
role TEXT # 'sysadmin' (you, always)
|
||||||
|
```
|
||||||
|
|
||||||
|
### `sites` table
|
||||||
|
```
|
||||||
|
id INTEGER PK
|
||||||
|
site_id TEXT UNIQUE # UUID, generated on registration
|
||||||
|
name TEXT # Restaurant name
|
||||||
|
owner_name TEXT
|
||||||
|
contact_email TEXT
|
||||||
|
secret_key TEXT # Shared secret for heartbeat auth (hashed)
|
||||||
|
is_active BOOLEAN DEFAULT TRUE
|
||||||
|
is_locked BOOLEAN DEFAULT FALSE
|
||||||
|
lock_reason TEXT NULL
|
||||||
|
license_expires_at DATETIME
|
||||||
|
created_at DATETIME
|
||||||
|
last_seen_at DATETIME NULL # Updated on each heartbeat
|
||||||
|
last_seen_ip TEXT NULL
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## API Endpoints
|
||||||
|
|
||||||
|
### Auth — `/api/auth`
|
||||||
|
```
|
||||||
|
POST /login
|
||||||
|
Body: { username, password }
|
||||||
|
Returns: JWT token (for sysadmin panel use)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Sites — `/api/sites`
|
||||||
|
All routes require sysadmin JWT.
|
||||||
|
```
|
||||||
|
GET / # List all sites with status
|
||||||
|
POST / # Register new site → returns site_id + secret_key
|
||||||
|
GET /:site_id # Site detail + last heartbeat info
|
||||||
|
PUT /:site_id # Update site info / extend license
|
||||||
|
POST /:site_id/lock # Remotely lock a site
|
||||||
|
Body: { reason: "..." }
|
||||||
|
POST /:site_id/unlock # Remotely unlock a site
|
||||||
|
DELETE /:site_id # Deregister site
|
||||||
|
```
|
||||||
|
|
||||||
|
### Heartbeat — `/api/heartbeat`
|
||||||
|
Called by local backends every 6 hours. No sysadmin auth — uses site's own secret key.
|
||||||
|
```
|
||||||
|
POST /
|
||||||
|
Header: X-Site-ID: <site_id>
|
||||||
|
Header: X-Site-Key: <secret_key>
|
||||||
|
Body: { version: "1.0.0", uptime_seconds: 12345 }
|
||||||
|
Returns: {
|
||||||
|
licensed: true,
|
||||||
|
locked: false,
|
||||||
|
lock_reason: null,
|
||||||
|
expires_at: "2026-12-31T00:00:00Z"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Health — `/health`
|
||||||
|
```
|
||||||
|
GET /health # Public, no auth. Cloud liveness check.
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Deployment Notes
|
||||||
|
- Host on your VPS with nginx reverse proxy + SSL (Let's Encrypt)
|
||||||
|
- Use PostgreSQL for the cloud DB (more robust than SQLite for a server)
|
||||||
|
- Run with: `uvicorn main:app --host 0.0.0.0 --port 8001`
|
||||||
|
- Set up systemd service for auto-restart
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Security Notes
|
||||||
|
- The `secret_key` per site is generated once at registration and never shown again (like an API key)
|
||||||
|
- Local backends store it in their `config.py` / environment variable
|
||||||
|
- Heartbeat endpoint rate-limited to prevent abuse
|
||||||
|
- All sysadmin routes require JWT with short expiry
|
||||||
73
PLANS AND STRATEGIES/05_SYSADMIN_PANEL.md
Normal file
73
PLANS AND STRATEGIES/05_SYSADMIN_PANEL.md
Normal file
@@ -0,0 +1,73 @@
|
|||||||
|
# Guide 05 — Sysadmin Panel (React + Vite, Cloud-hosted)
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
A simple web app hosted on your VPS alongside the cloud backend. Used exclusively by you (sysadmin) to manage all registered restaurant sites. Not used in day-to-day restaurant operations.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Project Structure
|
||||||
|
|
||||||
|
```
|
||||||
|
sysadmin_panel/
|
||||||
|
├── src/
|
||||||
|
│ ├── main.jsx
|
||||||
|
│ ├── App.jsx
|
||||||
|
│ ├── api/client.js # Points to CLOUD backend URL
|
||||||
|
│ ├── store/authStore.js
|
||||||
|
│ ├── pages/
|
||||||
|
│ │ ├── LoginPage.jsx
|
||||||
|
│ │ ├── SitesPage.jsx # Home — list of all sites
|
||||||
|
│ │ ├── SiteDetailPage.jsx
|
||||||
|
│ │ └── RegisterSitePage.jsx
|
||||||
|
│ └── components/
|
||||||
|
│ ├── SiteCard.jsx
|
||||||
|
│ ├── LicenseStatus.jsx
|
||||||
|
│ └── ConfirmModal.jsx
|
||||||
|
└── package.json
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Pages
|
||||||
|
|
||||||
|
### LoginPage
|
||||||
|
- Username + password (full password, not PIN — this is your personal admin tool)
|
||||||
|
- Redirects to SitesPage on success
|
||||||
|
|
||||||
|
### SitesPage
|
||||||
|
- Cards or table of all registered sites
|
||||||
|
- Each shows: name, owner, license expiry, last heartbeat, status (active/locked)
|
||||||
|
- Color indicator: green (active, recent heartbeat) | yellow (no heartbeat >12h) | red (locked/expired)
|
||||||
|
- "Register New Site" button
|
||||||
|
- Click site → SiteDetailPage
|
||||||
|
|
||||||
|
### SiteDetailPage
|
||||||
|
- Site info: name, owner, contact, site ID
|
||||||
|
- License section: expiry date, "Extend License" button (date picker)
|
||||||
|
- Status section: last heartbeat timestamp + IP, current lock status
|
||||||
|
- Actions:
|
||||||
|
- Lock Site (with reason input) → calls `POST /api/sites/:id/lock`
|
||||||
|
- Unlock Site → calls `POST /api/sites/:id/unlock`
|
||||||
|
- Delete/Deregister site (with confirmation)
|
||||||
|
- Heartbeat history (last 10 check-ins): timestamp + IP
|
||||||
|
|
||||||
|
### RegisterSitePage
|
||||||
|
- Form: restaurant name, owner name, contact email
|
||||||
|
- On submit: calls `POST /api/sites` → displays the generated `site_id` and `secret_key`
|
||||||
|
- **One-time display warning**: "Copy this secret key now. It will not be shown again."
|
||||||
|
- These credentials are then configured in the local backend's environment
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## UI Design Direction
|
||||||
|
- **Theme**: Dark, utilitarian. This is your ops tool, not a customer-facing product.
|
||||||
|
- **Accent**: Cyan or electric blue. Clear status colors (green/yellow/red) for site health.
|
||||||
|
- **Density**: Compact. You want to see all sites at a glance.
|
||||||
|
- No over-design needed — functional and clear is the goal.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Deployment
|
||||||
|
- Build with `vite build`, serve static files via nginx on your VPS
|
||||||
|
- Or run as a separate Vite dev server proxied through nginx
|
||||||
|
- Protect with nginx basic auth as an extra layer (optional but recommended)
|
||||||
68
PLANS AND STRATEGIES/CLAUDE_CODE_INSTRUCTIONS.md
Normal file
68
PLANS AND STRATEGIES/CLAUDE_CODE_INSTRUCTIONS.md
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
# Claude Code — Session Instructions
|
||||||
|
|
||||||
|
This file is your starting point for every Claude Code session on this project.
|
||||||
|
Paste it (or reference it) at the start of each session to give Claude Code full context.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Project Summary
|
||||||
|
We are building a local-first restaurant POS system. Full architecture and specs live in the `/pos-build-guide/` folder. Always read the relevant guide file before starting work on any component.
|
||||||
|
|
||||||
|
## Guide Files
|
||||||
|
- `00_PROJECT_OVERVIEW.md` — Architecture, stack, build order
|
||||||
|
- `01_LOCAL_BACKEND.md` — FastAPI backend (build this first)
|
||||||
|
- `02_WAITER_PWA.md` — Waiter-facing PWA (build second)
|
||||||
|
- `03_MANAGER_DASHBOARD.md` — Manager web app (build third)
|
||||||
|
- `04_CLOUD_BACKEND.md` — Cloud licensing backend (build fourth)
|
||||||
|
- `05_SYSADMIN_PANEL.md` — Sysadmin cloud panel (build last)
|
||||||
|
|
||||||
|
## Git Workflow
|
||||||
|
- The project uses git. **Commit after every meaningful milestone** (e.g. after scaffolding a phase, after a feature is working, after a bug fix).
|
||||||
|
- Always commit before starting a new phase or major refactor.
|
||||||
|
- Keep commit messages short and descriptive. No co-author lines needed.
|
||||||
|
- Never commit `.env`, `*.db`, or `license_state.json` — they are in `.gitignore`.
|
||||||
|
|
||||||
|
## Ground Rules for Claude Code
|
||||||
|
1. **Read the guide before writing code.** Each guide has schema, endpoints, and UX specs. Follow them.
|
||||||
|
2. **Local backend first.** Nothing else can be built or tested without it.
|
||||||
|
3. **Ask before deviating.** If something in the spec seems wrong or ambiguous, ask — don't invent.
|
||||||
|
4. **Keep business logic in the backend.** Frontends are display + interaction only.
|
||||||
|
5. **Never store sensitive data in frontend localStorage beyond token + username.**
|
||||||
|
6. **All prices are stored and calculated on the backend.** Frontend only displays them.
|
||||||
|
7. **The `unit_price` on `order_items` is a snapshot** — it must be copied from the product price at the time of ordering, not referenced dynamically.
|
||||||
|
8. **Printer failures must never block order saves.** Log and continue.
|
||||||
|
|
||||||
|
## Current Build Phase
|
||||||
|
> Update this line as you progress:
|
||||||
|
> Phase 1: Local Backend — [x] Scaffolded (models, schemas, all routers, printer service, license middleware, Docker). Needs first run + smoke test.
|
||||||
|
> Phase 2: Waiter PWA — [ ] Not Started
|
||||||
|
> Phase 3: Manager Dashboard — [ ] Not Started
|
||||||
|
> Phase 4: Cloud Backend — [ ] Not Started
|
||||||
|
> Phase 5: Sysadmin Panel — [ ] Not Started
|
||||||
|
|
||||||
|
## Environment Variables
|
||||||
|
|
||||||
|
### Local Backend (.env)
|
||||||
|
```
|
||||||
|
SITE_ID=
|
||||||
|
CLOUD_URL=https://your-vps.com
|
||||||
|
SECRET_KEY=generate-a-long-random-string
|
||||||
|
LICENSE_GRACE_HOURS=24
|
||||||
|
DATABASE_URL=sqlite:///./pos.db
|
||||||
|
```
|
||||||
|
|
||||||
|
### Waiter PWA (.env)
|
||||||
|
```
|
||||||
|
VITE_API_URL=http://192.168.1.10:8000
|
||||||
|
```
|
||||||
|
|
||||||
|
### Manager Dashboard (.env)
|
||||||
|
```
|
||||||
|
VITE_API_URL=http://192.168.1.10:8000
|
||||||
|
```
|
||||||
|
|
||||||
|
### Cloud Backend (.env)
|
||||||
|
```
|
||||||
|
SECRET_KEY=different-long-random-string
|
||||||
|
DATABASE_URL=postgresql://... (or sqlite for dev)
|
||||||
|
```
|
||||||
14
docker-compose.yml
Normal file
14
docker-compose.yml
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
services:
|
||||||
|
backend:
|
||||||
|
build: ./local_backend
|
||||||
|
ports:
|
||||||
|
- "8000:8000"
|
||||||
|
restart: unless-stopped
|
||||||
|
env_file:
|
||||||
|
- ./local_backend/.env
|
||||||
|
volumes:
|
||||||
|
- ./local_backend/pos.db:/app/pos.db
|
||||||
|
- ./local_backend/license_state.json:/app/license_state.json
|
||||||
|
- ./logo.png:/app/logo.png:ro
|
||||||
|
extra_hosts:
|
||||||
|
- "host.docker.internal:host-gateway"
|
||||||
5
local_backend/.env.example
Normal file
5
local_backend/.env.example
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
SITE_ID=your-unique-site-id
|
||||||
|
CLOUD_URL=https://your-vps.com
|
||||||
|
SECRET_KEY=generate-a-long-random-string-here
|
||||||
|
LICENSE_GRACE_HOURS=24
|
||||||
|
DATABASE_URL=sqlite:///./pos.db
|
||||||
10
local_backend/Dockerfile
Normal file
10
local_backend/Dockerfile
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
FROM python:3.12-slim
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
COPY requirements.txt .
|
||||||
|
RUN pip install --no-cache-dir -r requirements.txt
|
||||||
|
|
||||||
|
COPY . .
|
||||||
|
|
||||||
|
CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000"]
|
||||||
15
local_backend/config.py
Normal file
15
local_backend/config.py
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
from pydantic_settings import BaseSettings
|
||||||
|
|
||||||
|
|
||||||
|
class Settings(BaseSettings):
|
||||||
|
SITE_ID: str = ""
|
||||||
|
CLOUD_URL: str = "https://your-vps.com"
|
||||||
|
SECRET_KEY: str = "change-me-generate-a-long-random-string"
|
||||||
|
LICENSE_GRACE_HOURS: int = 24
|
||||||
|
DATABASE_URL: str = "sqlite:///./pos.db"
|
||||||
|
|
||||||
|
class Config:
|
||||||
|
env_file = ".env"
|
||||||
|
|
||||||
|
|
||||||
|
settings = Settings()
|
||||||
20
local_backend/database.py
Normal file
20
local_backend/database.py
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
from sqlalchemy import create_engine
|
||||||
|
from sqlalchemy.orm import declarative_base, sessionmaker
|
||||||
|
from config import settings
|
||||||
|
|
||||||
|
engine = create_engine(
|
||||||
|
settings.DATABASE_URL,
|
||||||
|
connect_args={"check_same_thread": False}, # needed for SQLite
|
||||||
|
)
|
||||||
|
|
||||||
|
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
|
||||||
|
|
||||||
|
Base = declarative_base()
|
||||||
|
|
||||||
|
|
||||||
|
def get_db():
|
||||||
|
db = SessionLocal()
|
||||||
|
try:
|
||||||
|
yield db
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
43
local_backend/main.py
Normal file
43
local_backend/main.py
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
from contextlib import asynccontextmanager
|
||||||
|
from fastapi import FastAPI
|
||||||
|
from fastapi.middleware.cors import CORSMiddleware
|
||||||
|
|
||||||
|
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
|
||||||
|
import models.table # noqa: F401
|
||||||
|
import models.printer # noqa: F401
|
||||||
|
import models.product # noqa: F401
|
||||||
|
import models.order # noqa: F401
|
||||||
|
|
||||||
|
from routers import auth, tables, products, orders, waiters, reports, system
|
||||||
|
|
||||||
|
|
||||||
|
@asynccontextmanager
|
||||||
|
async def lifespan(app: FastAPI):
|
||||||
|
Base.metadata.create_all(bind=engine)
|
||||||
|
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)
|
||||||
|
|
||||||
|
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"])
|
||||||
0
local_backend/middleware/__init__.py
Normal file
0
local_backend/middleware/__init__.py
Normal file
35
local_backend/middleware/license_check.py
Normal file
35
local_backend/middleware/license_check.py
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
from fastapi import Request, Response
|
||||||
|
from starlette.middleware.base import BaseHTTPMiddleware
|
||||||
|
|
||||||
|
# Shared mutable state — updated by cloud_sync.py
|
||||||
|
license_state: dict = {
|
||||||
|
"licensed": True,
|
||||||
|
"locked": False,
|
||||||
|
"expires_at": None,
|
||||||
|
"last_sync": None,
|
||||||
|
"sync_failed": False,
|
||||||
|
}
|
||||||
|
|
||||||
|
EXEMPT_PATHS = {"/api/system/health"}
|
||||||
|
|
||||||
|
|
||||||
|
class LicenseCheckMiddleware(BaseHTTPMiddleware):
|
||||||
|
async def dispatch(self, request: Request, call_next):
|
||||||
|
if request.url.path in EXEMPT_PATHS:
|
||||||
|
return await call_next(request)
|
||||||
|
|
||||||
|
if license_state.get("locked"):
|
||||||
|
return Response(
|
||||||
|
content='{"detail": "System is locked by cloud administrator"}',
|
||||||
|
status_code=423,
|
||||||
|
media_type="application/json",
|
||||||
|
)
|
||||||
|
|
||||||
|
if not license_state.get("licensed", True):
|
||||||
|
return Response(
|
||||||
|
content='{"detail": "License expired or invalid"}',
|
||||||
|
status_code=402,
|
||||||
|
media_type="application/json",
|
||||||
|
)
|
||||||
|
|
||||||
|
return await call_next(request)
|
||||||
0
local_backend/models/__init__.py
Normal file
0
local_backend/models/__init__.py
Normal file
72
local_backend/models/order.py
Normal file
72
local_backend/models/order.py
Normal file
@@ -0,0 +1,72 @@
|
|||||||
|
from sqlalchemy import Column, Integer, String, Boolean, Float, DateTime, ForeignKey, Text
|
||||||
|
from sqlalchemy.orm import relationship
|
||||||
|
from datetime import datetime
|
||||||
|
from database import Base
|
||||||
|
|
||||||
|
|
||||||
|
class Order(Base):
|
||||||
|
__tablename__ = "orders"
|
||||||
|
|
||||||
|
id = Column(Integer, primary_key=True, index=True)
|
||||||
|
table_id = Column(Integer, ForeignKey("tables.id"), nullable=False)
|
||||||
|
opened_by = Column(Integer, ForeignKey("users.id"), nullable=False)
|
||||||
|
opened_at = Column(DateTime, default=datetime.utcnow)
|
||||||
|
status = Column(String, default="open", nullable=False) # open|partially_paid|paid|closed|cancelled
|
||||||
|
closed_at = Column(DateTime, nullable=True)
|
||||||
|
closed_by = Column(Integer, ForeignKey("users.id"), nullable=True)
|
||||||
|
notes = Column(Text, nullable=True)
|
||||||
|
|
||||||
|
table = relationship("Table", back_populates="orders")
|
||||||
|
opener = relationship("User", foreign_keys=[opened_by], back_populates="orders_opened")
|
||||||
|
closer = relationship("User", foreign_keys=[closed_by], back_populates="orders_closed")
|
||||||
|
items = relationship("OrderItem", back_populates="order", cascade="all, delete-orphan")
|
||||||
|
waiters = relationship("OrderWaiter", back_populates="order", cascade="all, delete-orphan")
|
||||||
|
print_logs = relationship("PrintLog", back_populates="order")
|
||||||
|
|
||||||
|
|
||||||
|
class OrderWaiter(Base):
|
||||||
|
__tablename__ = "order_waiters"
|
||||||
|
|
||||||
|
id = Column(Integer, primary_key=True, index=True)
|
||||||
|
order_id = Column(Integer, ForeignKey("orders.id"), nullable=False)
|
||||||
|
waiter_id = Column(Integer, ForeignKey("users.id"), nullable=False)
|
||||||
|
assigned_at = Column(DateTime, default=datetime.utcnow)
|
||||||
|
|
||||||
|
order = relationship("Order", back_populates="waiters")
|
||||||
|
waiter = relationship("User", back_populates="order_assignments")
|
||||||
|
|
||||||
|
|
||||||
|
class OrderItem(Base):
|
||||||
|
__tablename__ = "order_items"
|
||||||
|
|
||||||
|
id = Column(Integer, primary_key=True, index=True)
|
||||||
|
order_id = Column(Integer, ForeignKey("orders.id"), nullable=False)
|
||||||
|
product_id = Column(Integer, ForeignKey("products.id"), nullable=False)
|
||||||
|
added_by = Column(Integer, ForeignKey("users.id"), nullable=False)
|
||||||
|
quantity = Column(Integer, nullable=False)
|
||||||
|
unit_price = Column(Float, nullable=False) # price snapshot at time of order
|
||||||
|
selected_options = Column(Text, nullable=True) # JSON array of option ids
|
||||||
|
removed_ingredients = Column(Text, nullable=True) # JSON array of ingredient ids
|
||||||
|
notes = Column(Text, nullable=True)
|
||||||
|
status = Column(String, default="active", nullable=False) # active|paid|cancelled
|
||||||
|
added_at = Column(DateTime, default=datetime.utcnow)
|
||||||
|
printed = Column(Boolean, default=False, nullable=False)
|
||||||
|
|
||||||
|
order = relationship("Order", back_populates="items")
|
||||||
|
product = relationship("Product", back_populates="order_items")
|
||||||
|
added_by_user = relationship("User", back_populates="order_items")
|
||||||
|
|
||||||
|
|
||||||
|
class PrintLog(Base):
|
||||||
|
__tablename__ = "print_log"
|
||||||
|
|
||||||
|
id = Column(Integer, primary_key=True, index=True)
|
||||||
|
order_id = Column(Integer, ForeignKey("orders.id"), nullable=False)
|
||||||
|
printer_id = Column(Integer, ForeignKey("printers.id"), nullable=False)
|
||||||
|
printed_at = Column(DateTime, default=datetime.utcnow)
|
||||||
|
item_ids = Column(Text, nullable=False) # JSON array of order_item ids
|
||||||
|
success = Column(Boolean, nullable=False)
|
||||||
|
error_message = Column(Text, nullable=True)
|
||||||
|
|
||||||
|
order = relationship("Order", back_populates="print_logs")
|
||||||
|
printer = relationship("Printer", back_populates="print_logs")
|
||||||
16
local_backend/models/printer.py
Normal file
16
local_backend/models/printer.py
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
from sqlalchemy import Column, Integer, String, Boolean
|
||||||
|
from sqlalchemy.orm import relationship
|
||||||
|
from database import Base
|
||||||
|
|
||||||
|
|
||||||
|
class Printer(Base):
|
||||||
|
__tablename__ = "printers"
|
||||||
|
|
||||||
|
id = Column(Integer, primary_key=True, index=True)
|
||||||
|
name = Column(String, nullable=False)
|
||||||
|
ip_address = Column(String, nullable=False)
|
||||||
|
port = Column(Integer, default=9100, nullable=False)
|
||||||
|
is_active = Column(Boolean, default=True, nullable=False)
|
||||||
|
|
||||||
|
products = relationship("Product", back_populates="printer_zone")
|
||||||
|
print_logs = relationship("PrintLog", back_populates="printer")
|
||||||
52
local_backend/models/product.py
Normal file
52
local_backend/models/product.py
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
from sqlalchemy import Column, Integer, String, Boolean, Float, ForeignKey
|
||||||
|
from sqlalchemy.orm import relationship
|
||||||
|
from database import Base
|
||||||
|
|
||||||
|
|
||||||
|
class Category(Base):
|
||||||
|
__tablename__ = "categories"
|
||||||
|
|
||||||
|
id = Column(Integer, primary_key=True, index=True)
|
||||||
|
name = Column(String, nullable=False)
|
||||||
|
color = Column(String, nullable=True)
|
||||||
|
sort_order = Column(Integer, default=0)
|
||||||
|
|
||||||
|
products = relationship("Product", back_populates="category")
|
||||||
|
|
||||||
|
|
||||||
|
class Product(Base):
|
||||||
|
__tablename__ = "products"
|
||||||
|
|
||||||
|
id = Column(Integer, primary_key=True, index=True)
|
||||||
|
name = Column(String, nullable=False)
|
||||||
|
category_id = Column(Integer, ForeignKey("categories.id"), nullable=True)
|
||||||
|
base_price = Column(Float, nullable=False)
|
||||||
|
is_available = Column(Boolean, default=True, nullable=False)
|
||||||
|
printer_zone_id = Column(Integer, ForeignKey("printers.id"), nullable=True)
|
||||||
|
|
||||||
|
category = relationship("Category", back_populates="products")
|
||||||
|
printer_zone = relationship("Printer", back_populates="products")
|
||||||
|
options = relationship("ProductOption", back_populates="product", cascade="all, delete-orphan")
|
||||||
|
ingredients = relationship("ProductIngredient", back_populates="product", cascade="all, delete-orphan")
|
||||||
|
order_items = relationship("OrderItem", back_populates="product")
|
||||||
|
|
||||||
|
|
||||||
|
class ProductOption(Base):
|
||||||
|
__tablename__ = "product_options"
|
||||||
|
|
||||||
|
id = Column(Integer, primary_key=True, index=True)
|
||||||
|
product_id = Column(Integer, ForeignKey("products.id"), nullable=False)
|
||||||
|
name = Column(String, nullable=False)
|
||||||
|
extra_cost = Column(Float, default=0.0)
|
||||||
|
|
||||||
|
product = relationship("Product", back_populates="options")
|
||||||
|
|
||||||
|
|
||||||
|
class ProductIngredient(Base):
|
||||||
|
__tablename__ = "product_ingredients"
|
||||||
|
|
||||||
|
id = Column(Integer, primary_key=True, index=True)
|
||||||
|
product_id = Column(Integer, ForeignKey("products.id"), nullable=False)
|
||||||
|
name = Column(String, nullable=False)
|
||||||
|
|
||||||
|
product = relationship("Product", back_populates="ingredients")
|
||||||
16
local_backend/models/table.py
Normal file
16
local_backend/models/table.py
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
from sqlalchemy import Column, Integer, String, Boolean, Float
|
||||||
|
from sqlalchemy.orm import relationship
|
||||||
|
from database import Base
|
||||||
|
|
||||||
|
|
||||||
|
class Table(Base):
|
||||||
|
__tablename__ = "tables"
|
||||||
|
|
||||||
|
id = Column(Integer, primary_key=True, index=True)
|
||||||
|
number = Column(Integer, unique=True, nullable=False)
|
||||||
|
label = Column(String, nullable=True)
|
||||||
|
is_active = Column(Boolean, default=True, nullable=False)
|
||||||
|
floor_x = Column(Float, nullable=True)
|
||||||
|
floor_y = Column(Float, nullable=True)
|
||||||
|
|
||||||
|
orders = relationship("Order", back_populates="table")
|
||||||
43
local_backend/models/user.py
Normal file
43
local_backend/models/user.py
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
from sqlalchemy import Column, Integer, String, Boolean, DateTime, ForeignKey
|
||||||
|
from sqlalchemy.orm import relationship
|
||||||
|
from datetime import datetime
|
||||||
|
from database import Base
|
||||||
|
|
||||||
|
|
||||||
|
class User(Base):
|
||||||
|
__tablename__ = "users"
|
||||||
|
|
||||||
|
id = Column(Integer, primary_key=True, index=True)
|
||||||
|
username = Column(String, unique=True, nullable=False, index=True)
|
||||||
|
pin_hash = Column(String, nullable=False)
|
||||||
|
role = Column(String, nullable=False) # 'waiter' | 'manager' | 'sysadmin'
|
||||||
|
is_active = Column(Boolean, default=True, nullable=False)
|
||||||
|
created_at = Column(DateTime, default=datetime.utcnow)
|
||||||
|
|
||||||
|
orders_opened = relationship("Order", foreign_keys="Order.opened_by", back_populates="opener")
|
||||||
|
orders_closed = relationship("Order", foreign_keys="Order.closed_by", back_populates="closer")
|
||||||
|
order_items = relationship("OrderItem", back_populates="added_by_user")
|
||||||
|
order_assignments = relationship("OrderWaiter", back_populates="waiter")
|
||||||
|
|
||||||
|
primary_assignments = relationship(
|
||||||
|
"AssistantAssignment",
|
||||||
|
foreign_keys="AssistantAssignment.primary_waiter_id",
|
||||||
|
back_populates="primary_waiter",
|
||||||
|
)
|
||||||
|
assistant_assignments = relationship(
|
||||||
|
"AssistantAssignment",
|
||||||
|
foreign_keys="AssistantAssignment.assistant_waiter_id",
|
||||||
|
back_populates="assistant_waiter",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class AssistantAssignment(Base):
|
||||||
|
__tablename__ = "assistant_assignments"
|
||||||
|
|
||||||
|
id = Column(Integer, primary_key=True, index=True)
|
||||||
|
primary_waiter_id = Column(Integer, ForeignKey("users.id"), nullable=False)
|
||||||
|
assistant_waiter_id = Column(Integer, ForeignKey("users.id"), nullable=False)
|
||||||
|
assigned_at = Column(DateTime, default=datetime.utcnow)
|
||||||
|
|
||||||
|
primary_waiter = relationship("User", foreign_keys=[primary_waiter_id], back_populates="primary_assignments")
|
||||||
|
assistant_waiter = relationship("User", foreign_keys=[assistant_waiter_id], back_populates="assistant_assignments")
|
||||||
9
local_backend/requirements.txt
Normal file
9
local_backend/requirements.txt
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
fastapi==0.115.0
|
||||||
|
uvicorn==0.30.6
|
||||||
|
sqlalchemy==2.0.36
|
||||||
|
pydantic-settings==2.6.1
|
||||||
|
python-escpos==3.1
|
||||||
|
Pillow==10.4.0
|
||||||
|
bcrypt==4.2.0
|
||||||
|
pyjwt==2.9.0
|
||||||
|
httpx==0.27.2
|
||||||
0
local_backend/routers/__init__.py
Normal file
0
local_backend/routers/__init__.py
Normal file
64
local_backend/routers/auth.py
Normal file
64
local_backend/routers/auth.py
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
import jwt
|
||||||
|
import bcrypt
|
||||||
|
from datetime import datetime, timedelta, timezone
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException, status
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from database import get_db
|
||||||
|
from config import settings
|
||||||
|
from models.user import User
|
||||||
|
from schemas.auth import LoginRequest, TokenResponse
|
||||||
|
from schemas.user import UserOut
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
TOKEN_EXPIRY_HOURS = 8
|
||||||
|
# In-memory token blacklist (cleared on restart — acceptable for local use)
|
||||||
|
_blacklisted_tokens: set[str] = set()
|
||||||
|
|
||||||
|
|
||||||
|
def _make_token(user: User) -> str:
|
||||||
|
payload = {
|
||||||
|
"sub": str(user.id),
|
||||||
|
"username": user.username,
|
||||||
|
"role": user.role,
|
||||||
|
"exp": datetime.now(timezone.utc) + timedelta(hours=TOKEN_EXPIRY_HOURS),
|
||||||
|
}
|
||||||
|
return jwt.encode(payload, settings.SECRET_KEY, algorithm="HS256")
|
||||||
|
|
||||||
|
|
||||||
|
def decode_token(token: str) -> dict:
|
||||||
|
if token in _blacklisted_tokens:
|
||||||
|
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Token revoked")
|
||||||
|
try:
|
||||||
|
return jwt.decode(token, settings.SECRET_KEY, algorithms=["HS256"])
|
||||||
|
except jwt.ExpiredSignatureError:
|
||||||
|
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Token expired")
|
||||||
|
except jwt.InvalidTokenError:
|
||||||
|
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid token")
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/login", response_model=TokenResponse)
|
||||||
|
def login(body: LoginRequest, db: Session = Depends(get_db)):
|
||||||
|
user = db.query(User).filter(User.username == body.username, User.is_active == True).first()
|
||||||
|
if not user or not bcrypt.checkpw(body.pin.encode(), user.pin_hash.encode()):
|
||||||
|
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid credentials")
|
||||||
|
token = _make_token(user)
|
||||||
|
return TokenResponse(access_token=token, user=UserOut.model_validate(user))
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/refresh", response_model=TokenResponse)
|
||||||
|
def refresh(token: str, db: Session = Depends(get_db)):
|
||||||
|
payload = decode_token(token)
|
||||||
|
user = db.query(User).filter(User.id == int(payload["sub"]), User.is_active == True).first()
|
||||||
|
if not user:
|
||||||
|
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="User not found")
|
||||||
|
_blacklisted_tokens.add(token)
|
||||||
|
new_token = _make_token(user)
|
||||||
|
return TokenResponse(access_token=new_token, user=UserOut.model_validate(user))
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/logout")
|
||||||
|
def logout(token: str):
|
||||||
|
_blacklisted_tokens.add(token)
|
||||||
|
return {"status": "logged out"}
|
||||||
32
local_backend/routers/deps.py
Normal file
32
local_backend/routers/deps.py
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
from fastapi import Depends, HTTPException, status
|
||||||
|
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from database import get_db
|
||||||
|
from models.user import User
|
||||||
|
from routers.auth import decode_token
|
||||||
|
|
||||||
|
bearer = HTTPBearer()
|
||||||
|
|
||||||
|
|
||||||
|
def get_current_user(
|
||||||
|
credentials: HTTPAuthorizationCredentials = Depends(bearer),
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
) -> User:
|
||||||
|
payload = decode_token(credentials.credentials)
|
||||||
|
user = db.query(User).filter(User.id == int(payload["sub"]), User.is_active == True).first()
|
||||||
|
if not user:
|
||||||
|
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="User not found")
|
||||||
|
return user
|
||||||
|
|
||||||
|
|
||||||
|
def require_manager(user: User = Depends(get_current_user)) -> User:
|
||||||
|
if user.role not in ("manager", "sysadmin"):
|
||||||
|
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Manager access required")
|
||||||
|
return user
|
||||||
|
|
||||||
|
|
||||||
|
def require_sysadmin(user: User = Depends(get_current_user)) -> User:
|
||||||
|
if user.role != "sysadmin":
|
||||||
|
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Sysadmin access required")
|
||||||
|
return user
|
||||||
231
local_backend/routers/orders.py
Normal file
231
local_backend/routers/orders.py
Normal file
@@ -0,0 +1,231 @@
|
|||||||
|
import json
|
||||||
|
from datetime import datetime
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException, status, BackgroundTasks
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
from typing import List, Optional
|
||||||
|
|
||||||
|
from database import get_db
|
||||||
|
from models.order import Order, OrderItem, OrderWaiter
|
||||||
|
from models.user import User, AssistantAssignment
|
||||||
|
from models.product import Product
|
||||||
|
from schemas.order import OrderCreate, OrderOut, OrderItemOut, AddItemsRequest, PayItemsRequest, AssignWaiterRequest
|
||||||
|
from routers.deps import get_current_user, require_manager
|
||||||
|
from services.printer_service import route_and_print
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
|
def _can_access_order(order: Order, user: User, db: Session) -> bool:
|
||||||
|
if user.role in ("manager", "sysadmin"):
|
||||||
|
return True
|
||||||
|
if order.opened_by == user.id:
|
||||||
|
return True
|
||||||
|
if any(ow.waiter_id == user.id for ow in order.waiters):
|
||||||
|
return True
|
||||||
|
# Assistant check: user is assistant to any waiter assigned to this order
|
||||||
|
assigned_ids = {ow.waiter_id for ow in order.waiters}
|
||||||
|
assistant_of = db.query(AssistantAssignment).filter(
|
||||||
|
AssistantAssignment.assistant_waiter_id == user.id,
|
||||||
|
AssistantAssignment.primary_waiter_id.in_(assigned_ids),
|
||||||
|
).first()
|
||||||
|
return assistant_of is not None
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/", response_model=List[OrderOut])
|
||||||
|
def list_orders(
|
||||||
|
order_status: Optional[str] = None,
|
||||||
|
waiter_id: Optional[int] = None,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
user: User = Depends(require_manager),
|
||||||
|
):
|
||||||
|
q = db.query(Order)
|
||||||
|
if order_status:
|
||||||
|
q = q.filter(Order.status == order_status)
|
||||||
|
if waiter_id:
|
||||||
|
q = q.join(OrderWaiter).filter(OrderWaiter.waiter_id == waiter_id)
|
||||||
|
return q.all()
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/my", response_model=List[OrderOut])
|
||||||
|
def my_orders(db: Session = Depends(get_db), user: User = Depends(get_current_user)):
|
||||||
|
direct = db.query(Order).join(OrderWaiter).filter(
|
||||||
|
OrderWaiter.waiter_id == user.id,
|
||||||
|
Order.status.in_(["open", "partially_paid"]),
|
||||||
|
).all()
|
||||||
|
# Also orders where user is opener but not explicitly assigned
|
||||||
|
also_opened = db.query(Order).filter(
|
||||||
|
Order.opened_by == user.id,
|
||||||
|
Order.status.in_(["open", "partially_paid"]),
|
||||||
|
).all()
|
||||||
|
seen = {o.id for o in direct}
|
||||||
|
return direct + [o for o in also_opened if o.id not in seen]
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{order_id}", response_model=OrderOut)
|
||||||
|
def get_order(order_id: int, db: Session = Depends(get_db), user: User = Depends(get_current_user)):
|
||||||
|
order = db.query(Order).filter(Order.id == order_id).first()
|
||||||
|
if not order:
|
||||||
|
raise HTTPException(status_code=404, detail="Order not found")
|
||||||
|
if not _can_access_order(order, user, db):
|
||||||
|
raise HTTPException(status_code=403, detail="Access denied")
|
||||||
|
return order
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/", response_model=OrderOut, status_code=status.HTTP_201_CREATED)
|
||||||
|
def open_order(body: OrderCreate, db: Session = Depends(get_db), user: User = Depends(get_current_user)):
|
||||||
|
existing = db.query(Order).filter(
|
||||||
|
Order.table_id == body.table_id,
|
||||||
|
Order.status.in_(["open", "partially_paid"]),
|
||||||
|
).first()
|
||||||
|
if existing:
|
||||||
|
raise HTTPException(status_code=400, detail="Table already has an open order")
|
||||||
|
order = Order(table_id=body.table_id, opened_by=user.id)
|
||||||
|
db.add(order)
|
||||||
|
db.flush()
|
||||||
|
db.add(OrderWaiter(order_id=order.id, waiter_id=user.id))
|
||||||
|
db.commit()
|
||||||
|
db.refresh(order)
|
||||||
|
return order
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/{order_id}/items", response_model=OrderOut)
|
||||||
|
def add_items(
|
||||||
|
order_id: int,
|
||||||
|
body: AddItemsRequest,
|
||||||
|
background_tasks: BackgroundTasks,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
user: User = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
order = db.query(Order).filter(Order.id == order_id).first()
|
||||||
|
if not order:
|
||||||
|
raise HTTPException(status_code=404, detail="Order not found")
|
||||||
|
if not _can_access_order(order, user, db):
|
||||||
|
raise HTTPException(status_code=403, detail="Access denied")
|
||||||
|
if order.status not in ("open", "partially_paid"):
|
||||||
|
raise HTTPException(status_code=400, detail="Order is not open")
|
||||||
|
|
||||||
|
new_item_ids = []
|
||||||
|
for item_in in body.items:
|
||||||
|
product = db.query(Product).filter(Product.id == item_in.product_id).first()
|
||||||
|
if not product or not product.is_available:
|
||||||
|
raise HTTPException(status_code=400, detail=f"Product {item_in.product_id} not available")
|
||||||
|
item = OrderItem(
|
||||||
|
order_id=order_id,
|
||||||
|
product_id=item_in.product_id,
|
||||||
|
added_by=user.id,
|
||||||
|
quantity=item_in.quantity,
|
||||||
|
unit_price=product.base_price, # price snapshot
|
||||||
|
selected_options=json.dumps(item_in.selected_options) if item_in.selected_options else None,
|
||||||
|
removed_ingredients=json.dumps(item_in.removed_ingredients) if item_in.removed_ingredients else None,
|
||||||
|
notes=item_in.notes,
|
||||||
|
)
|
||||||
|
db.add(item)
|
||||||
|
db.flush()
|
||||||
|
new_item_ids.append(item.id)
|
||||||
|
|
||||||
|
db.commit()
|
||||||
|
db.refresh(order)
|
||||||
|
|
||||||
|
# Printer routing runs in background — must never block the order save
|
||||||
|
background_tasks.add_task(route_and_print, order_id, new_item_ids)
|
||||||
|
|
||||||
|
return order
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/{order_id}/items/{item_id}", response_model=OrderItemOut)
|
||||||
|
def edit_item(order_id: int, item_id: int, notes: Optional[str] = None, db: Session = Depends(get_db), user: User = Depends(require_manager)):
|
||||||
|
item = db.query(OrderItem).filter(OrderItem.id == item_id, OrderItem.order_id == order_id).first()
|
||||||
|
if not item:
|
||||||
|
raise HTTPException(status_code=404, detail="Item not found")
|
||||||
|
if notes is not None:
|
||||||
|
item.notes = notes
|
||||||
|
db.commit()
|
||||||
|
db.refresh(item)
|
||||||
|
return item
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/{order_id}/items/{item_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||||
|
def cancel_item(order_id: int, item_id: int, db: Session = Depends(get_db), user: User = Depends(require_manager)):
|
||||||
|
item = db.query(OrderItem).filter(OrderItem.id == item_id, OrderItem.order_id == order_id).first()
|
||||||
|
if not item:
|
||||||
|
raise HTTPException(status_code=404, detail="Item not found")
|
||||||
|
item.status = "cancelled"
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/{order_id}/pay")
|
||||||
|
def pay_items(order_id: int, body: PayItemsRequest, db: Session = Depends(get_db), user: User = Depends(get_current_user)):
|
||||||
|
order = db.query(Order).filter(Order.id == order_id).first()
|
||||||
|
if not order:
|
||||||
|
raise HTTPException(status_code=404, detail="Order not found")
|
||||||
|
if not _can_access_order(order, user, db):
|
||||||
|
raise HTTPException(status_code=403, detail="Access denied")
|
||||||
|
|
||||||
|
items = db.query(OrderItem).filter(
|
||||||
|
OrderItem.id.in_(body.item_ids),
|
||||||
|
OrderItem.order_id == order_id,
|
||||||
|
OrderItem.status == "active",
|
||||||
|
).all()
|
||||||
|
for item in items:
|
||||||
|
item.status = "paid"
|
||||||
|
|
||||||
|
active_remaining = db.query(OrderItem).filter(
|
||||||
|
OrderItem.order_id == order_id, OrderItem.status == "active"
|
||||||
|
).count()
|
||||||
|
order.status = "paid" if active_remaining == 0 else "partially_paid"
|
||||||
|
|
||||||
|
db.commit()
|
||||||
|
return {"status": order.status, "paid_item_ids": [i.id for i in items]}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/{order_id}/close")
|
||||||
|
def close_order(order_id: int, db: Session = Depends(get_db), user: User = Depends(get_current_user)):
|
||||||
|
order = db.query(Order).filter(Order.id == order_id).first()
|
||||||
|
if not order:
|
||||||
|
raise HTTPException(status_code=404, detail="Order not found")
|
||||||
|
if not _can_access_order(order, user, db):
|
||||||
|
raise HTTPException(status_code=403, detail="Access denied")
|
||||||
|
if order.status not in ("paid", "open", "partially_paid"):
|
||||||
|
raise HTTPException(status_code=400, detail="Cannot close order in current status")
|
||||||
|
order.status = "closed"
|
||||||
|
order.closed_at = datetime.utcnow()
|
||||||
|
order.closed_by = user.id
|
||||||
|
db.commit()
|
||||||
|
return {"status": "closed"}
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/{order_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||||
|
def cancel_order(order_id: int, db: Session = Depends(get_db), user: User = Depends(require_manager)):
|
||||||
|
order = db.query(Order).filter(Order.id == order_id).first()
|
||||||
|
if not order:
|
||||||
|
raise HTTPException(status_code=404, detail="Order not found")
|
||||||
|
order.status = "cancelled"
|
||||||
|
order.closed_at = datetime.utcnow()
|
||||||
|
order.closed_by = user.id
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/{order_id}/assign-waiter")
|
||||||
|
def assign_waiter(order_id: int, body: AssignWaiterRequest, db: Session = Depends(get_db), user: User = Depends(require_manager)):
|
||||||
|
order = db.query(Order).filter(Order.id == order_id).first()
|
||||||
|
if not order:
|
||||||
|
raise HTTPException(status_code=404, detail="Order not found")
|
||||||
|
existing = db.query(OrderWaiter).filter(
|
||||||
|
OrderWaiter.order_id == order_id, OrderWaiter.waiter_id == body.waiter_id
|
||||||
|
).first()
|
||||||
|
if existing:
|
||||||
|
raise HTTPException(status_code=400, detail="Waiter already assigned")
|
||||||
|
db.add(OrderWaiter(order_id=order_id, waiter_id=body.waiter_id))
|
||||||
|
db.commit()
|
||||||
|
return {"status": "assigned"}
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/{order_id}/waiters/{waiter_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||||
|
def remove_waiter(order_id: int, waiter_id: int, db: Session = Depends(get_db), user: User = Depends(require_manager)):
|
||||||
|
assignment = db.query(OrderWaiter).filter(
|
||||||
|
OrderWaiter.order_id == order_id, OrderWaiter.waiter_id == waiter_id
|
||||||
|
).first()
|
||||||
|
if not assignment:
|
||||||
|
raise HTTPException(status_code=404, detail="Assignment not found")
|
||||||
|
db.delete(assignment)
|
||||||
|
db.commit()
|
||||||
94
local_backend/routers/products.py
Normal file
94
local_backend/routers/products.py
Normal file
@@ -0,0 +1,94 @@
|
|||||||
|
from fastapi import APIRouter, Depends, HTTPException, status
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
from typing import List
|
||||||
|
|
||||||
|
from database import get_db
|
||||||
|
from models.product import Product, Category, ProductOption, ProductIngredient
|
||||||
|
from models.user import User
|
||||||
|
from schemas.product import (
|
||||||
|
ProductCreate, ProductUpdate, ProductOut,
|
||||||
|
CategoryCreate, CategoryUpdate, CategoryOut,
|
||||||
|
)
|
||||||
|
from routers.deps import get_current_user, require_manager
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
|
# ── Categories ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@router.get("/categories", response_model=List[CategoryOut])
|
||||||
|
def list_categories(db: Session = Depends(get_db), user: User = Depends(get_current_user)):
|
||||||
|
return db.query(Category).order_by(Category.sort_order).all()
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/categories", response_model=CategoryOut, status_code=status.HTTP_201_CREATED)
|
||||||
|
def create_category(body: CategoryCreate, db: Session = Depends(get_db), user: User = Depends(require_manager)):
|
||||||
|
cat = Category(**body.model_dump())
|
||||||
|
db.add(cat)
|
||||||
|
db.commit()
|
||||||
|
db.refresh(cat)
|
||||||
|
return cat
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/categories/{category_id}", response_model=CategoryOut)
|
||||||
|
def update_category(category_id: int, body: CategoryUpdate, db: Session = Depends(get_db), user: User = Depends(require_manager)):
|
||||||
|
cat = db.query(Category).filter(Category.id == category_id).first()
|
||||||
|
if not cat:
|
||||||
|
raise HTTPException(status_code=404, detail="Category not found")
|
||||||
|
for field, value in body.model_dump(exclude_none=True).items():
|
||||||
|
setattr(cat, field, value)
|
||||||
|
db.commit()
|
||||||
|
db.refresh(cat)
|
||||||
|
return cat
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/categories/{category_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||||
|
def delete_category(category_id: int, db: Session = Depends(get_db), user: User = Depends(require_manager)):
|
||||||
|
cat = db.query(Category).filter(Category.id == category_id).first()
|
||||||
|
if not cat:
|
||||||
|
raise HTTPException(status_code=404, detail="Category not found")
|
||||||
|
db.delete(cat)
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
|
||||||
|
# ── Products ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@router.get("/", response_model=List[ProductOut])
|
||||||
|
def list_products(db: Session = Depends(get_db), user: User = Depends(get_current_user)):
|
||||||
|
return db.query(Product).filter(Product.is_available == True).all()
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/", response_model=ProductOut, status_code=status.HTTP_201_CREATED)
|
||||||
|
def create_product(body: ProductCreate, db: Session = Depends(get_db), user: User = Depends(require_manager)):
|
||||||
|
data = body.model_dump(exclude={"options", "ingredients"})
|
||||||
|
product = Product(**data)
|
||||||
|
db.add(product)
|
||||||
|
db.flush()
|
||||||
|
for opt in body.options:
|
||||||
|
db.add(ProductOption(product_id=product.id, **opt.model_dump()))
|
||||||
|
for ing in body.ingredients:
|
||||||
|
db.add(ProductIngredient(product_id=product.id, **ing.model_dump()))
|
||||||
|
db.commit()
|
||||||
|
db.refresh(product)
|
||||||
|
return product
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/{product_id}", response_model=ProductOut)
|
||||||
|
def update_product(product_id: int, body: ProductUpdate, db: Session = Depends(get_db), user: User = Depends(require_manager)):
|
||||||
|
product = db.query(Product).filter(Product.id == product_id).first()
|
||||||
|
if not product:
|
||||||
|
raise HTTPException(status_code=404, detail="Product not found")
|
||||||
|
for field, value in body.model_dump(exclude_none=True).items():
|
||||||
|
setattr(product, field, value)
|
||||||
|
db.commit()
|
||||||
|
db.refresh(product)
|
||||||
|
return product
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/{product_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||||
|
def deactivate_product(product_id: int, db: Session = Depends(get_db), user: User = Depends(require_manager)):
|
||||||
|
product = db.query(Product).filter(Product.id == product_id).first()
|
||||||
|
if not product:
|
||||||
|
raise HTTPException(status_code=404, detail="Product not found")
|
||||||
|
product.is_available = False
|
||||||
|
db.commit()
|
||||||
86
local_backend/routers/reports.py
Normal file
86
local_backend/routers/reports.py
Normal file
@@ -0,0 +1,86 @@
|
|||||||
|
from fastapi import APIRouter, Depends, Query
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
from sqlalchemy import func
|
||||||
|
from datetime import date, datetime, timedelta
|
||||||
|
from typing import Optional, List
|
||||||
|
|
||||||
|
from database import get_db
|
||||||
|
from models.order import Order, OrderItem, OrderWaiter
|
||||||
|
from models.user import User
|
||||||
|
from models.table import Table
|
||||||
|
from schemas.order import OrderOut
|
||||||
|
from schemas.table import TableOut
|
||||||
|
from routers.deps import require_manager
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/shift")
|
||||||
|
def shift_summary(
|
||||||
|
report_date: Optional[date] = Query(default=None, alias="date"),
|
||||||
|
waiter_id: Optional[int] = None,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
user: User = Depends(require_manager),
|
||||||
|
):
|
||||||
|
target = report_date or date.today()
|
||||||
|
start = datetime.combine(target, datetime.min.time())
|
||||||
|
end = start + timedelta(days=1)
|
||||||
|
|
||||||
|
q = db.query(Order).filter(Order.opened_at >= start, Order.opened_at < end)
|
||||||
|
if waiter_id:
|
||||||
|
q = q.join(OrderWaiter).filter(OrderWaiter.waiter_id == waiter_id)
|
||||||
|
orders = q.all()
|
||||||
|
|
||||||
|
summary = {}
|
||||||
|
for order in orders:
|
||||||
|
waiter = db.query(User).filter(User.id == order.opened_by).first()
|
||||||
|
key = waiter.username if waiter else "unknown"
|
||||||
|
if key not in summary:
|
||||||
|
summary[key] = {"orders": 0, "items": 0, "total": 0.0}
|
||||||
|
summary[key]["orders"] += 1
|
||||||
|
for item in order.items:
|
||||||
|
if item.status in ("active", "paid"):
|
||||||
|
summary[key]["items"] += item.quantity
|
||||||
|
summary[key]["total"] += item.unit_price * item.quantity
|
||||||
|
|
||||||
|
return {"date": str(target), "waiters": summary}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/orders/history", response_model=List[OrderOut])
|
||||||
|
def order_history(
|
||||||
|
from_date: Optional[str] = Query(default=None, alias="from"),
|
||||||
|
to_date: Optional[str] = Query(default=None, alias="to"),
|
||||||
|
waiter_id: Optional[int] = None,
|
||||||
|
order_status: Optional[str] = Query(default=None, alias="status"),
|
||||||
|
page: int = 1,
|
||||||
|
page_size: int = 50,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
user: User = Depends(require_manager),
|
||||||
|
):
|
||||||
|
q = db.query(Order)
|
||||||
|
if from_date:
|
||||||
|
q = q.filter(Order.opened_at >= datetime.fromisoformat(from_date))
|
||||||
|
if to_date:
|
||||||
|
q = q.filter(Order.opened_at <= datetime.fromisoformat(to_date))
|
||||||
|
if waiter_id:
|
||||||
|
q = q.join(OrderWaiter).filter(OrderWaiter.waiter_id == waiter_id)
|
||||||
|
if order_status:
|
||||||
|
q = q.filter(Order.status == order_status)
|
||||||
|
return q.order_by(Order.opened_at.desc()).offset((page - 1) * page_size).limit(page_size).all()
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/tables/summary")
|
||||||
|
def tables_summary(db: Session = Depends(get_db), user: User = Depends(require_manager)):
|
||||||
|
tables = db.query(Table).filter(Table.is_active == True).all()
|
||||||
|
result = []
|
||||||
|
for table in tables:
|
||||||
|
active_order = db.query(Order).filter(
|
||||||
|
Order.table_id == table.id,
|
||||||
|
Order.status.in_(["open", "partially_paid"]),
|
||||||
|
).first()
|
||||||
|
result.append({
|
||||||
|
"table": TableOut.model_validate(table),
|
||||||
|
"status": active_order.status if active_order else "free",
|
||||||
|
"order_id": active_order.id if active_order else None,
|
||||||
|
})
|
||||||
|
return result
|
||||||
71
local_backend/routers/system.py
Normal file
71
local_backend/routers/system.py
Normal file
@@ -0,0 +1,71 @@
|
|||||||
|
import time
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
from typing import List
|
||||||
|
|
||||||
|
from database import get_db
|
||||||
|
from models.printer import Printer
|
||||||
|
from schemas.printer import PrinterUpdate, PrinterOut
|
||||||
|
from routers.deps import get_current_user, require_manager, require_sysadmin
|
||||||
|
from models.user import User
|
||||||
|
from services import printer_service
|
||||||
|
from middleware.license_check import license_state
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
_start_time = time.time()
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/health")
|
||||||
|
def health():
|
||||||
|
return {"status": "ok"}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/status")
|
||||||
|
def system_status(db: Session = Depends(get_db), user: User = Depends(get_current_user)):
|
||||||
|
printers = db.query(Printer).filter(Printer.is_active == True).all()
|
||||||
|
printer_statuses = []
|
||||||
|
for p in printers:
|
||||||
|
reachable = printer_service.check_printer(p.ip_address, p.port)
|
||||||
|
printer_statuses.append({"id": p.id, "name": p.name, "reachable": reachable})
|
||||||
|
|
||||||
|
return {
|
||||||
|
"uptime_seconds": int(time.time() - _start_time),
|
||||||
|
"licensed": license_state.get("licensed", True),
|
||||||
|
"locked": license_state.get("locked", False),
|
||||||
|
"expires_at": license_state.get("expires_at"),
|
||||||
|
"printers": printer_statuses,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/printers/test")
|
||||||
|
def test_printer(printer_id: int, db: Session = Depends(get_db), user: User = Depends(require_manager)):
|
||||||
|
printer = db.query(Printer).filter(Printer.id == printer_id).first()
|
||||||
|
if not printer:
|
||||||
|
raise HTTPException(status_code=404, detail="Printer not found")
|
||||||
|
success, error = printer_service.send_test_print(printer.ip_address, printer.port, printer.name)
|
||||||
|
return {"success": success, "error": error}
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/printers/{printer_id}", response_model=PrinterOut)
|
||||||
|
def update_printer(printer_id: int, body: PrinterUpdate, db: Session = Depends(get_db), user: User = Depends(require_sysadmin)):
|
||||||
|
printer = db.query(Printer).filter(Printer.id == printer_id).first()
|
||||||
|
if not printer:
|
||||||
|
raise HTTPException(status_code=404, detail="Printer not found")
|
||||||
|
for field, value in body.model_dump(exclude_none=True).items():
|
||||||
|
setattr(printer, field, value)
|
||||||
|
db.commit()
|
||||||
|
db.refresh(printer)
|
||||||
|
return printer
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/lock")
|
||||||
|
def lock_system(token: str, user: User = Depends(require_sysadmin)):
|
||||||
|
license_state["locked"] = True
|
||||||
|
return {"status": "locked"}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/unlock")
|
||||||
|
def unlock_system(token: str, user: User = Depends(require_sysadmin)):
|
||||||
|
license_state["locked"] = False
|
||||||
|
return {"status": "unlocked"}
|
||||||
78
local_backend/routers/tables.py
Normal file
78
local_backend/routers/tables.py
Normal file
@@ -0,0 +1,78 @@
|
|||||||
|
from fastapi import APIRouter, Depends, HTTPException, status
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
from typing import List
|
||||||
|
|
||||||
|
from database import get_db
|
||||||
|
from models.table import Table
|
||||||
|
from models.order import Order
|
||||||
|
from models.user import User
|
||||||
|
from schemas.table import TableCreate, TableUpdate, TableFloorplanUpdate, TableOut
|
||||||
|
from routers.deps import get_current_user, require_manager
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/", response_model=List[TableOut])
|
||||||
|
def list_tables(db: Session = Depends(get_db), user: User = Depends(get_current_user)):
|
||||||
|
return db.query(Table).filter(Table.is_active == True).all()
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/", response_model=TableOut, status_code=status.HTTP_201_CREATED)
|
||||||
|
def create_table(body: TableCreate, db: Session = Depends(get_db), user: User = Depends(require_manager)):
|
||||||
|
if db.query(Table).filter(Table.number == body.number).first():
|
||||||
|
raise HTTPException(status_code=400, detail="Table number already exists")
|
||||||
|
table = Table(**body.model_dump())
|
||||||
|
db.add(table)
|
||||||
|
db.commit()
|
||||||
|
db.refresh(table)
|
||||||
|
return table
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/{table_id}", response_model=TableOut)
|
||||||
|
def update_table(table_id: int, body: TableUpdate, db: Session = Depends(get_db), user: User = Depends(require_manager)):
|
||||||
|
table = db.query(Table).filter(Table.id == table_id).first()
|
||||||
|
if not table:
|
||||||
|
raise HTTPException(status_code=404, detail="Table not found")
|
||||||
|
for field, value in body.model_dump(exclude_none=True).items():
|
||||||
|
setattr(table, field, value)
|
||||||
|
db.commit()
|
||||||
|
db.refresh(table)
|
||||||
|
return table
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/{table_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||||
|
def deactivate_table(table_id: int, db: Session = Depends(get_db), user: User = Depends(require_manager)):
|
||||||
|
table = db.query(Table).filter(Table.id == table_id).first()
|
||||||
|
if not table:
|
||||||
|
raise HTTPException(status_code=404, detail="Table not found")
|
||||||
|
table.is_active = False
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{table_id}/status")
|
||||||
|
def table_status(table_id: int, db: Session = Depends(get_db), user: User = Depends(get_current_user)):
|
||||||
|
table = db.query(Table).filter(Table.id == table_id).first()
|
||||||
|
if not table:
|
||||||
|
raise HTTPException(status_code=404, detail="Table not found")
|
||||||
|
active_order = (
|
||||||
|
db.query(Order)
|
||||||
|
.filter(Order.table_id == table_id, Order.status.in_(["open", "partially_paid"]))
|
||||||
|
.first()
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
"table": TableOut.model_validate(table),
|
||||||
|
"active_order_id": active_order.id if active_order else None,
|
||||||
|
"order_status": active_order.status if active_order else None,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/{table_id}/floorplan", response_model=TableOut)
|
||||||
|
def update_floorplan(table_id: int, body: TableFloorplanUpdate, db: Session = Depends(get_db), user: User = Depends(require_manager)):
|
||||||
|
table = db.query(Table).filter(Table.id == table_id).first()
|
||||||
|
if not table:
|
||||||
|
raise HTTPException(status_code=404, detail="Table not found")
|
||||||
|
table.floor_x = body.floor_x
|
||||||
|
table.floor_y = body.floor_y
|
||||||
|
db.commit()
|
||||||
|
db.refresh(table)
|
||||||
|
return table
|
||||||
100
local_backend/routers/waiters.py
Normal file
100
local_backend/routers/waiters.py
Normal file
@@ -0,0 +1,100 @@
|
|||||||
|
import bcrypt
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException, status
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
from typing import List
|
||||||
|
|
||||||
|
from database import get_db
|
||||||
|
from models.user import User, AssistantAssignment
|
||||||
|
from schemas.user import UserCreate, UserUpdate, UserOut, AssistantAssignmentOut
|
||||||
|
from routers.deps import require_manager
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
|
class ResetPinRequest:
|
||||||
|
def __init__(self, pin: str):
|
||||||
|
self.pin = pin
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/", response_model=List[UserOut])
|
||||||
|
def list_waiters(db: Session = Depends(get_db), user: User = Depends(require_manager)):
|
||||||
|
return db.query(User).filter(User.role == "waiter").all()
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/", response_model=UserOut, status_code=status.HTTP_201_CREATED)
|
||||||
|
def create_waiter(body: UserCreate, db: Session = Depends(get_db), user: User = Depends(require_manager)):
|
||||||
|
if db.query(User).filter(User.username == body.username).first():
|
||||||
|
raise HTTPException(status_code=400, detail="Username already exists")
|
||||||
|
pin_hash = bcrypt.hashpw(body.pin.encode(), bcrypt.gensalt()).decode()
|
||||||
|
new_user = User(username=body.username, pin_hash=pin_hash, role=body.role, is_active=body.is_active)
|
||||||
|
db.add(new_user)
|
||||||
|
db.commit()
|
||||||
|
db.refresh(new_user)
|
||||||
|
return new_user
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/{waiter_id}", response_model=UserOut)
|
||||||
|
def update_waiter(waiter_id: int, body: UserUpdate, db: Session = Depends(get_db), user: User = Depends(require_manager)):
|
||||||
|
waiter = db.query(User).filter(User.id == waiter_id).first()
|
||||||
|
if not waiter:
|
||||||
|
raise HTTPException(status_code=404, detail="Waiter not found")
|
||||||
|
for field, value in body.model_dump(exclude_none=True).items():
|
||||||
|
setattr(waiter, field, value)
|
||||||
|
db.commit()
|
||||||
|
db.refresh(waiter)
|
||||||
|
return waiter
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/{waiter_id}/reset-pin")
|
||||||
|
def reset_pin(waiter_id: int, pin: str, db: Session = Depends(get_db), user: User = Depends(require_manager)):
|
||||||
|
waiter = db.query(User).filter(User.id == waiter_id).first()
|
||||||
|
if not waiter:
|
||||||
|
raise HTTPException(status_code=404, detail="Waiter not found")
|
||||||
|
waiter.pin_hash = bcrypt.hashpw(pin.encode(), bcrypt.gensalt()).decode()
|
||||||
|
db.commit()
|
||||||
|
return {"status": "pin reset"}
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/{waiter_id}/block")
|
||||||
|
def toggle_block(waiter_id: int, db: Session = Depends(get_db), user: User = Depends(require_manager)):
|
||||||
|
waiter = db.query(User).filter(User.id == waiter_id).first()
|
||||||
|
if not waiter:
|
||||||
|
raise HTTPException(status_code=404, detail="Waiter not found")
|
||||||
|
waiter.is_active = not waiter.is_active
|
||||||
|
db.commit()
|
||||||
|
return {"is_active": waiter.is_active}
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/{waiter_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||||
|
def delete_waiter(waiter_id: int, db: Session = Depends(get_db), user: User = Depends(require_manager)):
|
||||||
|
waiter = db.query(User).filter(User.id == waiter_id).first()
|
||||||
|
if not waiter:
|
||||||
|
raise HTTPException(status_code=404, detail="Waiter not found")
|
||||||
|
db.delete(waiter)
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/{waiter_id}/assign-assistant", response_model=AssistantAssignmentOut)
|
||||||
|
def assign_assistant(waiter_id: int, assistant_id: int, db: Session = Depends(get_db), user: User = Depends(require_manager)):
|
||||||
|
existing = db.query(AssistantAssignment).filter(
|
||||||
|
AssistantAssignment.primary_waiter_id == waiter_id,
|
||||||
|
AssistantAssignment.assistant_waiter_id == assistant_id,
|
||||||
|
).first()
|
||||||
|
if existing:
|
||||||
|
raise HTTPException(status_code=400, detail="Assignment already exists")
|
||||||
|
assignment = AssistantAssignment(primary_waiter_id=waiter_id, assistant_waiter_id=assistant_id)
|
||||||
|
db.add(assignment)
|
||||||
|
db.commit()
|
||||||
|
db.refresh(assignment)
|
||||||
|
return assignment
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/{waiter_id}/assistant", status_code=status.HTTP_204_NO_CONTENT)
|
||||||
|
def remove_assistant(waiter_id: int, db: Session = Depends(get_db), user: User = Depends(require_manager)):
|
||||||
|
assignment = db.query(AssistantAssignment).filter(
|
||||||
|
AssistantAssignment.primary_waiter_id == waiter_id
|
||||||
|
).first()
|
||||||
|
if not assignment:
|
||||||
|
raise HTTPException(status_code=404, detail="Assignment not found")
|
||||||
|
db.delete(assignment)
|
||||||
|
db.commit()
|
||||||
0
local_backend/schemas/__init__.py
Normal file
0
local_backend/schemas/__init__.py
Normal file
12
local_backend/schemas/auth.py
Normal file
12
local_backend/schemas/auth.py
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
from pydantic import BaseModel
|
||||||
|
from schemas.user import UserOut
|
||||||
|
|
||||||
|
|
||||||
|
class LoginRequest(BaseModel):
|
||||||
|
username: str
|
||||||
|
pin: str
|
||||||
|
|
||||||
|
|
||||||
|
class TokenResponse(BaseModel):
|
||||||
|
access_token: str
|
||||||
|
user: UserOut
|
||||||
58
local_backend/schemas/order.py
Normal file
58
local_backend/schemas/order.py
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
from pydantic import BaseModel
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import Optional, List
|
||||||
|
|
||||||
|
|
||||||
|
class OrderItemInput(BaseModel):
|
||||||
|
product_id: int
|
||||||
|
quantity: int
|
||||||
|
selected_options: Optional[List[int]] = None
|
||||||
|
removed_ingredients: Optional[List[int]] = None
|
||||||
|
notes: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
|
class AddItemsRequest(BaseModel):
|
||||||
|
items: List[OrderItemInput]
|
||||||
|
|
||||||
|
|
||||||
|
class OrderItemOut(BaseModel):
|
||||||
|
id: int
|
||||||
|
order_id: int
|
||||||
|
product_id: int
|
||||||
|
added_by: int
|
||||||
|
quantity: int
|
||||||
|
unit_price: float
|
||||||
|
selected_options: Optional[str] = None
|
||||||
|
removed_ingredients: Optional[str] = None
|
||||||
|
notes: Optional[str] = None
|
||||||
|
status: str
|
||||||
|
added_at: datetime
|
||||||
|
printed: bool
|
||||||
|
|
||||||
|
model_config = {"from_attributes": True}
|
||||||
|
|
||||||
|
|
||||||
|
class OrderCreate(BaseModel):
|
||||||
|
table_id: int
|
||||||
|
|
||||||
|
|
||||||
|
class PayItemsRequest(BaseModel):
|
||||||
|
item_ids: List[int]
|
||||||
|
|
||||||
|
|
||||||
|
class AssignWaiterRequest(BaseModel):
|
||||||
|
waiter_id: int
|
||||||
|
|
||||||
|
|
||||||
|
class OrderOut(BaseModel):
|
||||||
|
id: int
|
||||||
|
table_id: int
|
||||||
|
opened_by: int
|
||||||
|
opened_at: datetime
|
||||||
|
status: str
|
||||||
|
closed_at: Optional[datetime] = None
|
||||||
|
closed_by: Optional[int] = None
|
||||||
|
notes: Optional[str] = None
|
||||||
|
items: List[OrderItemOut] = []
|
||||||
|
|
||||||
|
model_config = {"from_attributes": True}
|
||||||
22
local_backend/schemas/printer.py
Normal file
22
local_backend/schemas/printer.py
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
from pydantic import BaseModel
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
|
||||||
|
class PrinterBase(BaseModel):
|
||||||
|
name: str
|
||||||
|
ip_address: str
|
||||||
|
port: int = 9100
|
||||||
|
is_active: bool = True
|
||||||
|
|
||||||
|
|
||||||
|
class PrinterUpdate(BaseModel):
|
||||||
|
name: Optional[str] = None
|
||||||
|
ip_address: Optional[str] = None
|
||||||
|
port: Optional[int] = None
|
||||||
|
is_active: Optional[bool] = None
|
||||||
|
|
||||||
|
|
||||||
|
class PrinterOut(PrinterBase):
|
||||||
|
id: int
|
||||||
|
|
||||||
|
model_config = {"from_attributes": True}
|
||||||
84
local_backend/schemas/product.py
Normal file
84
local_backend/schemas/product.py
Normal file
@@ -0,0 +1,84 @@
|
|||||||
|
from pydantic import BaseModel
|
||||||
|
from typing import Optional, List
|
||||||
|
|
||||||
|
|
||||||
|
class CategoryBase(BaseModel):
|
||||||
|
name: str
|
||||||
|
color: Optional[str] = None
|
||||||
|
sort_order: int = 0
|
||||||
|
|
||||||
|
|
||||||
|
class CategoryCreate(CategoryBase):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class CategoryUpdate(BaseModel):
|
||||||
|
name: Optional[str] = None
|
||||||
|
color: Optional[str] = None
|
||||||
|
sort_order: Optional[int] = None
|
||||||
|
|
||||||
|
|
||||||
|
class CategoryOut(CategoryBase):
|
||||||
|
id: int
|
||||||
|
|
||||||
|
model_config = {"from_attributes": True}
|
||||||
|
|
||||||
|
|
||||||
|
class ProductOptionBase(BaseModel):
|
||||||
|
name: str
|
||||||
|
extra_cost: float = 0.0
|
||||||
|
|
||||||
|
|
||||||
|
class ProductOptionCreate(ProductOptionBase):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class ProductOptionOut(ProductOptionBase):
|
||||||
|
id: int
|
||||||
|
product_id: int
|
||||||
|
|
||||||
|
model_config = {"from_attributes": True}
|
||||||
|
|
||||||
|
|
||||||
|
class ProductIngredientBase(BaseModel):
|
||||||
|
name: str
|
||||||
|
|
||||||
|
|
||||||
|
class ProductIngredientCreate(ProductIngredientBase):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class ProductIngredientOut(ProductIngredientBase):
|
||||||
|
id: int
|
||||||
|
product_id: int
|
||||||
|
|
||||||
|
model_config = {"from_attributes": True}
|
||||||
|
|
||||||
|
|
||||||
|
class ProductBase(BaseModel):
|
||||||
|
name: str
|
||||||
|
category_id: Optional[int] = None
|
||||||
|
base_price: float
|
||||||
|
is_available: bool = True
|
||||||
|
printer_zone_id: Optional[int] = None
|
||||||
|
|
||||||
|
|
||||||
|
class ProductCreate(ProductBase):
|
||||||
|
options: List[ProductOptionCreate] = []
|
||||||
|
ingredients: List[ProductIngredientCreate] = []
|
||||||
|
|
||||||
|
|
||||||
|
class ProductUpdate(BaseModel):
|
||||||
|
name: Optional[str] = None
|
||||||
|
category_id: Optional[int] = None
|
||||||
|
base_price: Optional[float] = None
|
||||||
|
is_available: Optional[bool] = None
|
||||||
|
printer_zone_id: Optional[int] = None
|
||||||
|
|
||||||
|
|
||||||
|
class ProductOut(ProductBase):
|
||||||
|
id: int
|
||||||
|
options: List[ProductOptionOut] = []
|
||||||
|
ingredients: List[ProductIngredientOut] = []
|
||||||
|
|
||||||
|
model_config = {"from_attributes": True}
|
||||||
31
local_backend/schemas/table.py
Normal file
31
local_backend/schemas/table.py
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
from pydantic import BaseModel
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
|
||||||
|
class TableBase(BaseModel):
|
||||||
|
number: int
|
||||||
|
label: Optional[str] = None
|
||||||
|
is_active: bool = True
|
||||||
|
|
||||||
|
|
||||||
|
class TableCreate(TableBase):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class TableUpdate(BaseModel):
|
||||||
|
number: Optional[int] = None
|
||||||
|
label: Optional[str] = None
|
||||||
|
is_active: Optional[bool] = None
|
||||||
|
|
||||||
|
|
||||||
|
class TableFloorplanUpdate(BaseModel):
|
||||||
|
floor_x: float
|
||||||
|
floor_y: float
|
||||||
|
|
||||||
|
|
||||||
|
class TableOut(TableBase):
|
||||||
|
id: int
|
||||||
|
floor_x: Optional[float] = None
|
||||||
|
floor_y: Optional[float] = None
|
||||||
|
|
||||||
|
model_config = {"from_attributes": True}
|
||||||
35
local_backend/schemas/user.py
Normal file
35
local_backend/schemas/user.py
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
from pydantic import BaseModel
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
|
||||||
|
class UserBase(BaseModel):
|
||||||
|
username: str
|
||||||
|
role: str
|
||||||
|
is_active: bool = True
|
||||||
|
|
||||||
|
|
||||||
|
class UserCreate(UserBase):
|
||||||
|
pin: str
|
||||||
|
|
||||||
|
|
||||||
|
class UserUpdate(BaseModel):
|
||||||
|
username: Optional[str] = None
|
||||||
|
role: Optional[str] = None
|
||||||
|
is_active: Optional[bool] = None
|
||||||
|
|
||||||
|
|
||||||
|
class UserOut(UserBase):
|
||||||
|
id: int
|
||||||
|
created_at: datetime
|
||||||
|
|
||||||
|
model_config = {"from_attributes": True}
|
||||||
|
|
||||||
|
|
||||||
|
class AssistantAssignmentOut(BaseModel):
|
||||||
|
id: int
|
||||||
|
primary_waiter_id: int
|
||||||
|
assistant_waiter_id: int
|
||||||
|
assigned_at: datetime
|
||||||
|
|
||||||
|
model_config = {"from_attributes": True}
|
||||||
0
local_backend/services/__init__.py
Normal file
0
local_backend/services/__init__.py
Normal file
82
local_backend/services/cloud_sync.py
Normal file
82
local_backend/services/cloud_sync.py
Normal file
@@ -0,0 +1,82 @@
|
|||||||
|
"""
|
||||||
|
Periodic cloud check-in. Runs every 6 hours as an asyncio background task.
|
||||||
|
If cloud is unreachable, falls back to last known state + grace period.
|
||||||
|
"""
|
||||||
|
import asyncio
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
from datetime import datetime, timedelta, timezone
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
|
||||||
|
from config import settings
|
||||||
|
from middleware.license_check import license_state
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
SYNC_INTERVAL_SECONDS = 6 * 60 * 60 # 6 hours
|
||||||
|
STATE_FILE = Path("license_state.json")
|
||||||
|
|
||||||
|
|
||||||
|
def _load_persisted_state():
|
||||||
|
if STATE_FILE.exists():
|
||||||
|
try:
|
||||||
|
data = json.loads(STATE_FILE.read_text())
|
||||||
|
license_state.update(data)
|
||||||
|
logger.info("Loaded persisted license state: %s", data)
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning("Could not load license state file: %s", e)
|
||||||
|
|
||||||
|
|
||||||
|
def _persist_state():
|
||||||
|
try:
|
||||||
|
STATE_FILE.write_text(json.dumps(license_state))
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning("Could not persist license state: %s", e)
|
||||||
|
|
||||||
|
|
||||||
|
async def _sync_once():
|
||||||
|
if not settings.SITE_ID or not settings.CLOUD_URL:
|
||||||
|
logger.debug("No SITE_ID/CLOUD_URL configured — skipping cloud sync")
|
||||||
|
return
|
||||||
|
|
||||||
|
try:
|
||||||
|
async with httpx.AsyncClient(timeout=10) as client:
|
||||||
|
resp = await client.post(
|
||||||
|
f"{settings.CLOUD_URL}/api/sites/heartbeat",
|
||||||
|
json={"site_id": settings.SITE_ID},
|
||||||
|
)
|
||||||
|
resp.raise_for_status()
|
||||||
|
data = resp.json()
|
||||||
|
license_state["licensed"] = data.get("licensed", True)
|
||||||
|
license_state["locked"] = data.get("locked", False)
|
||||||
|
license_state["expires_at"] = data.get("expires_at")
|
||||||
|
license_state["last_sync"] = datetime.now(timezone.utc).isoformat()
|
||||||
|
license_state["sync_failed"] = False
|
||||||
|
_persist_state()
|
||||||
|
logger.info("Cloud sync OK: %s", data)
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning("Cloud sync failed: %s", e)
|
||||||
|
license_state["sync_failed"] = True
|
||||||
|
# Check grace period
|
||||||
|
last_sync_str = license_state.get("last_sync")
|
||||||
|
if last_sync_str:
|
||||||
|
last_sync = datetime.fromisoformat(last_sync_str)
|
||||||
|
grace_expires = last_sync + timedelta(hours=settings.LICENSE_GRACE_HOURS)
|
||||||
|
if datetime.now(timezone.utc) > grace_expires:
|
||||||
|
logger.error("License grace period expired — marking as unlicensed")
|
||||||
|
license_state["licensed"] = False
|
||||||
|
|
||||||
|
|
||||||
|
async def _sync_loop():
|
||||||
|
_load_persisted_state()
|
||||||
|
while True:
|
||||||
|
await _sync_once()
|
||||||
|
await asyncio.sleep(SYNC_INTERVAL_SECONDS)
|
||||||
|
|
||||||
|
|
||||||
|
async def start_cloud_sync() -> asyncio.Task:
|
||||||
|
task = asyncio.create_task(_sync_loop())
|
||||||
|
return task
|
||||||
226
local_backend/services/printer_service.py
Normal file
226
local_backend/services/printer_service.py
Normal file
@@ -0,0 +1,226 @@
|
|||||||
|
"""
|
||||||
|
ESC/POS printer service — Jolimark TP850UE confirmed configuration.
|
||||||
|
|
||||||
|
Key findings from printer testing:
|
||||||
|
- Code page n=29 (CP737) is the only working Greek code page on this model.
|
||||||
|
- All Greek text MUST be sent as raw CP737 bytes via p._raw() — never p.text().
|
||||||
|
- Set the code page immediately after connecting, before any output.
|
||||||
|
- 80mm paper = 48 chars wide at standard font. Double-height keeps 48-char width.
|
||||||
|
"""
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
import socket
|
||||||
|
import datetime
|
||||||
|
from typing import Tuple, List
|
||||||
|
|
||||||
|
from escpos.printer import Network
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from database import SessionLocal
|
||||||
|
from models.order import Order, OrderItem, PrintLog
|
||||||
|
from models.printer import Printer
|
||||||
|
from models.product import Product
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
LINE_WIDTH = 48
|
||||||
|
PRINTER_TIMEOUT = 5
|
||||||
|
|
||||||
|
|
||||||
|
# ── Low-level helpers ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def _get_printer(ip: str, port: int) -> Network:
|
||||||
|
p = Network(ip, port, timeout=PRINTER_TIMEOUT)
|
||||||
|
p._raw(b'\x1b\x40') # ESC @ — reset printer
|
||||||
|
p._raw(b'\x1b\x74\x1d') # ESC t 29 — select CP737 (Greek) — confirmed n=29
|
||||||
|
return p
|
||||||
|
|
||||||
|
|
||||||
|
def _gr(text: str) -> bytes:
|
||||||
|
"""Encode text to CP737 bytes. Replaces unknown chars instead of crashing."""
|
||||||
|
return text.encode('cp737', errors='replace')
|
||||||
|
|
||||||
|
|
||||||
|
def _raw_text(p: Network, text: str):
|
||||||
|
"""Send text as raw CP737 bytes — the ONLY safe way to print Greek."""
|
||||||
|
p._raw(_gr(text))
|
||||||
|
|
||||||
|
|
||||||
|
def _divider(p: Network):
|
||||||
|
p._raw(b'\x1b\x61\x00')
|
||||||
|
p._raw(_gr("-" * LINE_WIDTH + "\n"))
|
||||||
|
|
||||||
|
|
||||||
|
def _item_line(name: str, qty: int) -> str:
|
||||||
|
"""Build a dot-leader line: 'Club Sandwich . . . . 1' at 48 chars."""
|
||||||
|
qty_str = str(qty)
|
||||||
|
gap = LINE_WIDTH - len(name) - len(qty_str)
|
||||||
|
if gap < 3:
|
||||||
|
return f"{name} {qty_str}"
|
||||||
|
dots = (". " * ((gap // 2) + 1))[:gap]
|
||||||
|
return f"{name}{dots}{qty_str}"
|
||||||
|
|
||||||
|
|
||||||
|
def check_printer(ip: str, port: int) -> bool:
|
||||||
|
"""Quick TCP connect check — no data sent."""
|
||||||
|
try:
|
||||||
|
s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
||||||
|
s.settimeout(2)
|
||||||
|
s.connect((ip, port))
|
||||||
|
s.close()
|
||||||
|
return True
|
||||||
|
except OSError:
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def send_test_print(ip: str, port: int, name: str) -> Tuple[bool, str]:
|
||||||
|
try:
|
||||||
|
p = _get_printer(ip, port)
|
||||||
|
p._raw(b'\x1b\x61\x01')
|
||||||
|
p._raw(b'\x1b\x21\x30')
|
||||||
|
_raw_text(p, f"TEST — {name}\n")
|
||||||
|
p._raw(b'\x1b\x21\x00')
|
||||||
|
now = datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S")
|
||||||
|
_raw_text(p, f"{now}\n")
|
||||||
|
p._raw(b'\n\n\n')
|
||||||
|
p.cut()
|
||||||
|
p.close()
|
||||||
|
return True, ""
|
||||||
|
except Exception as e:
|
||||||
|
logger.error("Test print failed for %s:%s — %s", ip, port, e)
|
||||||
|
return False, str(e)
|
||||||
|
|
||||||
|
|
||||||
|
# ── Receipt formatting ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def _print_kitchen_ticket(p: Network, order: Order, items: List[OrderItem], db: Session):
|
||||||
|
# Header
|
||||||
|
p._raw(b'\x1b\x61\x01')
|
||||||
|
p._raw(b'\x1b\x21\x38') # bold + double height + double width
|
||||||
|
_raw_text(p, f"Παραγγελια #{order.id}\n")
|
||||||
|
p._raw(b'\x1b\x21\x00')
|
||||||
|
_divider(p)
|
||||||
|
|
||||||
|
# Meta
|
||||||
|
p._raw(b'\x1b\x61\x00')
|
||||||
|
p._raw(b'\x1b\x21\x10') # double height only — keeps 48-char width
|
||||||
|
now = datetime.datetime.now().strftime("%Y-%m-%d %H:%M")
|
||||||
|
_raw_text(p, f"Date: {now}\n")
|
||||||
|
_raw_text(p, f"Table: {order.table_id}\n")
|
||||||
|
_raw_text(p, f"Waiter: {order.opened_by}\n")
|
||||||
|
p._raw(b'\x1b\x21\x00')
|
||||||
|
_divider(p)
|
||||||
|
|
||||||
|
# Items
|
||||||
|
for item in items:
|
||||||
|
product = db.query(Product).filter(Product.id == item.product_id).first()
|
||||||
|
name = product.name if product else f"Product #{item.product_id}"
|
||||||
|
|
||||||
|
p._raw(b'\x1b\x21\x10')
|
||||||
|
p._raw(b'\x1b\x45\x01') # bold on
|
||||||
|
_raw_text(p, _item_line(name, item.quantity) + "\n")
|
||||||
|
p._raw(b'\x1b\x45\x00') # bold off
|
||||||
|
|
||||||
|
if item.removed_ingredients:
|
||||||
|
try:
|
||||||
|
removed_ids = json.loads(item.removed_ingredients)
|
||||||
|
if removed_ids:
|
||||||
|
_raw_text(p, f" - χωρις: {', '.join(str(i) for i in removed_ids)}\n")
|
||||||
|
except (json.JSONDecodeError, TypeError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
if item.selected_options:
|
||||||
|
try:
|
||||||
|
option_ids = json.loads(item.selected_options)
|
||||||
|
if option_ids:
|
||||||
|
_raw_text(p, f" + επιλογες: {', '.join(str(i) for i in option_ids)}\n")
|
||||||
|
except (json.JSONDecodeError, TypeError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
if item.notes:
|
||||||
|
_raw_text(p, f" (i) {item.notes}\n")
|
||||||
|
|
||||||
|
p._raw(b'\x1b\x21\x00')
|
||||||
|
|
||||||
|
_divider(p)
|
||||||
|
|
||||||
|
if order.notes:
|
||||||
|
p._raw(b'\x1b\x21\x30')
|
||||||
|
_raw_text(p, "Σημειωσεις:\n")
|
||||||
|
p._raw(b'\x1b\x21\x10')
|
||||||
|
_raw_text(p, f"{order.notes}\n")
|
||||||
|
p._raw(b'\x1b\x21\x00')
|
||||||
|
_divider(p)
|
||||||
|
|
||||||
|
p._raw(b'\x1b\x61\x01')
|
||||||
|
p._raw(b'\x1b\x21\x30')
|
||||||
|
_raw_text(p, "Τελος Παραγγελιας\n")
|
||||||
|
p._raw(b'\x1b\x21\x00')
|
||||||
|
p._raw(b'\n\n\n')
|
||||||
|
p.cut()
|
||||||
|
|
||||||
|
|
||||||
|
# ── Routing logic ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def route_and_print(order_id: int, item_ids: List[int]):
|
||||||
|
"""
|
||||||
|
Background task: group items by printer zone, send to each printer.
|
||||||
|
Printer failures are logged but never raise — order is already saved.
|
||||||
|
"""
|
||||||
|
db: Session = SessionLocal()
|
||||||
|
try:
|
||||||
|
order = db.query(Order).filter(Order.id == order_id).first()
|
||||||
|
if not order:
|
||||||
|
logger.error("route_and_print: order %s not found", order_id)
|
||||||
|
return
|
||||||
|
|
||||||
|
items = db.query(OrderItem).filter(OrderItem.id.in_(item_ids)).all()
|
||||||
|
|
||||||
|
# Group items by printer zone
|
||||||
|
zone_map: dict[int, List[OrderItem]] = {}
|
||||||
|
unzoned: List[OrderItem] = []
|
||||||
|
for item in items:
|
||||||
|
product = db.query(Product).filter(Product.id == item.product_id).first()
|
||||||
|
if product and product.printer_zone_id:
|
||||||
|
zone_map.setdefault(product.printer_zone_id, []).append(item)
|
||||||
|
else:
|
||||||
|
unzoned.append(item)
|
||||||
|
|
||||||
|
if unzoned:
|
||||||
|
logger.warning("order %s has %d item(s) with no printer zone — skipped", order_id, len(unzoned))
|
||||||
|
|
||||||
|
for printer_id, zone_items in zone_map.items():
|
||||||
|
printer = db.query(Printer).filter(Printer.id == printer_id, Printer.is_active == True).first()
|
||||||
|
if not printer:
|
||||||
|
logger.warning("Printer %s not found or inactive", printer_id)
|
||||||
|
continue
|
||||||
|
|
||||||
|
success = False
|
||||||
|
error_msg = None
|
||||||
|
try:
|
||||||
|
p = _get_printer(printer.ip_address, printer.port)
|
||||||
|
_print_kitchen_ticket(p, order, zone_items, db)
|
||||||
|
p.close()
|
||||||
|
success = True
|
||||||
|
# Mark items as printed
|
||||||
|
for item in zone_items:
|
||||||
|
item.printed = True
|
||||||
|
db.commit()
|
||||||
|
except Exception as e:
|
||||||
|
error_msg = str(e)
|
||||||
|
logger.error("Print failed for printer %s (%s:%s): %s", printer.name, printer.ip_address, printer.port, e)
|
||||||
|
|
||||||
|
log = PrintLog(
|
||||||
|
order_id=order_id,
|
||||||
|
printer_id=printer_id,
|
||||||
|
item_ids=json.dumps([i.id for i in zone_items]),
|
||||||
|
success=success,
|
||||||
|
error_message=error_msg,
|
||||||
|
)
|
||||||
|
db.add(log)
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.exception("Unexpected error in route_and_print for order %s: %s", order_id, e)
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
Reference in New Issue
Block a user