Phase 1: scaffold local backend — models, schemas, routers, printer service, Docker

This commit is contained in:
2026-04-20 11:22:55 +03:00
commit 4ffe27df95
44 changed files with 2729 additions and 0 deletions

28
.gitignore vendored Normal file
View 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

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

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

View 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 (09) + 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

View 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 (5264px), 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

View 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

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

View 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)
```

0
Readme Normal file
View File

14
docker-compose.yml Normal file
View 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"

View 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
View 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
View 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
View 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
View 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"])

View File

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

View File

View 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")

View 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")

View 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")

View 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")

View 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")

View 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

View File

View 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"}

View 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

View 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()

View 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()

View 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

View 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"}

View 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

View 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()

View File

View 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

View 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}

View 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}

View 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}

View 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}

View 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}

View File

View 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

View 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()

BIN
logo.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 36 KiB