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