From 4ffe27df957edd8620a806ab6adb4af5edc89c51 Mon Sep 17 00:00:00 2001 From: bonamin Date: Mon, 20 Apr 2026 11:22:55 +0300 Subject: [PATCH] =?UTF-8?q?Phase=201:=20scaffold=20local=20backend=20?= =?UTF-8?q?=E2=80=94=20models,=20schemas,=20routers,=20printer=20service,?= =?UTF-8?q?=20Docker?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 28 ++ PLANS AND STRATEGIES/00_PROJECT_OVERVIEW.md | 85 +++++ PLANS AND STRATEGIES/01_LOCAL_BACKEND.md | 307 ++++++++++++++++++ PLANS AND STRATEGIES/02_WAITER_PWA.md | 227 +++++++++++++ PLANS AND STRATEGIES/03_MANAGER_DASHBOARD.md | 172 ++++++++++ PLANS AND STRATEGIES/04_CLOUD_BACKEND.md | 113 +++++++ PLANS AND STRATEGIES/05_SYSADMIN_PANEL.md | 73 +++++ .../CLAUDE_CODE_INSTRUCTIONS.md | 68 ++++ Readme | 0 docker-compose.yml | 14 + local_backend/.env.example | 5 + local_backend/Dockerfile | 10 + local_backend/config.py | 15 + local_backend/database.py | 20 ++ local_backend/main.py | 43 +++ local_backend/middleware/__init__.py | 0 local_backend/middleware/license_check.py | 35 ++ local_backend/models/__init__.py | 0 local_backend/models/order.py | 72 ++++ local_backend/models/printer.py | 16 + local_backend/models/product.py | 52 +++ local_backend/models/table.py | 16 + local_backend/models/user.py | 43 +++ local_backend/requirements.txt | 9 + local_backend/routers/__init__.py | 0 local_backend/routers/auth.py | 64 ++++ local_backend/routers/deps.py | 32 ++ local_backend/routers/orders.py | 231 +++++++++++++ local_backend/routers/products.py | 94 ++++++ local_backend/routers/reports.py | 86 +++++ local_backend/routers/system.py | 71 ++++ local_backend/routers/tables.py | 78 +++++ local_backend/routers/waiters.py | 100 ++++++ local_backend/schemas/__init__.py | 0 local_backend/schemas/auth.py | 12 + local_backend/schemas/order.py | 58 ++++ local_backend/schemas/printer.py | 22 ++ local_backend/schemas/product.py | 84 +++++ local_backend/schemas/table.py | 31 ++ local_backend/schemas/user.py | 35 ++ local_backend/services/__init__.py | 0 local_backend/services/cloud_sync.py | 82 +++++ local_backend/services/printer_service.py | 226 +++++++++++++ logo.png | Bin 0 -> 36987 bytes 44 files changed, 2729 insertions(+) create mode 100644 .gitignore create mode 100644 PLANS AND STRATEGIES/00_PROJECT_OVERVIEW.md create mode 100644 PLANS AND STRATEGIES/01_LOCAL_BACKEND.md create mode 100644 PLANS AND STRATEGIES/02_WAITER_PWA.md create mode 100644 PLANS AND STRATEGIES/03_MANAGER_DASHBOARD.md create mode 100644 PLANS AND STRATEGIES/04_CLOUD_BACKEND.md create mode 100644 PLANS AND STRATEGIES/05_SYSADMIN_PANEL.md create mode 100644 PLANS AND STRATEGIES/CLAUDE_CODE_INSTRUCTIONS.md create mode 100644 Readme create mode 100644 docker-compose.yml create mode 100644 local_backend/.env.example create mode 100644 local_backend/Dockerfile create mode 100644 local_backend/config.py create mode 100644 local_backend/database.py create mode 100644 local_backend/main.py create mode 100644 local_backend/middleware/__init__.py create mode 100644 local_backend/middleware/license_check.py create mode 100644 local_backend/models/__init__.py create mode 100644 local_backend/models/order.py create mode 100644 local_backend/models/printer.py create mode 100644 local_backend/models/product.py create mode 100644 local_backend/models/table.py create mode 100644 local_backend/models/user.py create mode 100644 local_backend/requirements.txt create mode 100644 local_backend/routers/__init__.py create mode 100644 local_backend/routers/auth.py create mode 100644 local_backend/routers/deps.py create mode 100644 local_backend/routers/orders.py create mode 100644 local_backend/routers/products.py create mode 100644 local_backend/routers/reports.py create mode 100644 local_backend/routers/system.py create mode 100644 local_backend/routers/tables.py create mode 100644 local_backend/routers/waiters.py create mode 100644 local_backend/schemas/__init__.py create mode 100644 local_backend/schemas/auth.py create mode 100644 local_backend/schemas/order.py create mode 100644 local_backend/schemas/printer.py create mode 100644 local_backend/schemas/product.py create mode 100644 local_backend/schemas/table.py create mode 100644 local_backend/schemas/user.py create mode 100644 local_backend/services/__init__.py create mode 100644 local_backend/services/cloud_sync.py create mode 100644 local_backend/services/printer_service.py create mode 100644 logo.png diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..2872e64 --- /dev/null +++ b/.gitignore @@ -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 diff --git a/PLANS AND STRATEGIES/00_PROJECT_OVERVIEW.md b/PLANS AND STRATEGIES/00_PROJECT_OVERVIEW.md new file mode 100644 index 0000000..502c0ae --- /dev/null +++ b/PLANS AND STRATEGIES/00_PROJECT_OVERVIEW.md @@ -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. diff --git a/PLANS AND STRATEGIES/01_LOCAL_BACKEND.md b/PLANS AND STRATEGIES/01_LOCAL_BACKEND.md new file mode 100644 index 0000000..9a8577e --- /dev/null +++ b/PLANS AND STRATEGIES/01_LOCAL_BACKEND.md @@ -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). diff --git a/PLANS AND STRATEGIES/02_WAITER_PWA.md b/PLANS AND STRATEGIES/02_WAITER_PWA.md new file mode 100644 index 0000000..7b29be6 --- /dev/null +++ b/PLANS AND STRATEGIES/02_WAITER_PWA.md @@ -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 diff --git a/PLANS AND STRATEGIES/03_MANAGER_DASHBOARD.md b/PLANS AND STRATEGIES/03_MANAGER_DASHBOARD.md new file mode 100644 index 0000000..77d1c1b --- /dev/null +++ b/PLANS AND STRATEGIES/03_MANAGER_DASHBOARD.md @@ -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 diff --git a/PLANS AND STRATEGIES/04_CLOUD_BACKEND.md b/PLANS AND STRATEGIES/04_CLOUD_BACKEND.md new file mode 100644 index 0000000..504fd36 --- /dev/null +++ b/PLANS AND STRATEGIES/04_CLOUD_BACKEND.md @@ -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: + Header: X-Site-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 diff --git a/PLANS AND STRATEGIES/05_SYSADMIN_PANEL.md b/PLANS AND STRATEGIES/05_SYSADMIN_PANEL.md new file mode 100644 index 0000000..d25b9b2 --- /dev/null +++ b/PLANS AND STRATEGIES/05_SYSADMIN_PANEL.md @@ -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) diff --git a/PLANS AND STRATEGIES/CLAUDE_CODE_INSTRUCTIONS.md b/PLANS AND STRATEGIES/CLAUDE_CODE_INSTRUCTIONS.md new file mode 100644 index 0000000..3708ab8 --- /dev/null +++ b/PLANS AND STRATEGIES/CLAUDE_CODE_INSTRUCTIONS.md @@ -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) +``` diff --git a/Readme b/Readme new file mode 100644 index 0000000..e69de29 diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..6b3d243 --- /dev/null +++ b/docker-compose.yml @@ -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" diff --git a/local_backend/.env.example b/local_backend/.env.example new file mode 100644 index 0000000..8eb410b --- /dev/null +++ b/local_backend/.env.example @@ -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 diff --git a/local_backend/Dockerfile b/local_backend/Dockerfile new file mode 100644 index 0000000..617e0d7 --- /dev/null +++ b/local_backend/Dockerfile @@ -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"] diff --git a/local_backend/config.py b/local_backend/config.py new file mode 100644 index 0000000..5765029 --- /dev/null +++ b/local_backend/config.py @@ -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() diff --git a/local_backend/database.py b/local_backend/database.py new file mode 100644 index 0000000..a26623d --- /dev/null +++ b/local_backend/database.py @@ -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() diff --git a/local_backend/main.py b/local_backend/main.py new file mode 100644 index 0000000..f226679 --- /dev/null +++ b/local_backend/main.py @@ -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"]) diff --git a/local_backend/middleware/__init__.py b/local_backend/middleware/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/local_backend/middleware/license_check.py b/local_backend/middleware/license_check.py new file mode 100644 index 0000000..ca05991 --- /dev/null +++ b/local_backend/middleware/license_check.py @@ -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) diff --git a/local_backend/models/__init__.py b/local_backend/models/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/local_backend/models/order.py b/local_backend/models/order.py new file mode 100644 index 0000000..464c215 --- /dev/null +++ b/local_backend/models/order.py @@ -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") diff --git a/local_backend/models/printer.py b/local_backend/models/printer.py new file mode 100644 index 0000000..e609e51 --- /dev/null +++ b/local_backend/models/printer.py @@ -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") diff --git a/local_backend/models/product.py b/local_backend/models/product.py new file mode 100644 index 0000000..ced7274 --- /dev/null +++ b/local_backend/models/product.py @@ -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") diff --git a/local_backend/models/table.py b/local_backend/models/table.py new file mode 100644 index 0000000..dfbfa32 --- /dev/null +++ b/local_backend/models/table.py @@ -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") diff --git a/local_backend/models/user.py b/local_backend/models/user.py new file mode 100644 index 0000000..097e5b0 --- /dev/null +++ b/local_backend/models/user.py @@ -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") diff --git a/local_backend/requirements.txt b/local_backend/requirements.txt new file mode 100644 index 0000000..17f0335 --- /dev/null +++ b/local_backend/requirements.txt @@ -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 diff --git a/local_backend/routers/__init__.py b/local_backend/routers/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/local_backend/routers/auth.py b/local_backend/routers/auth.py new file mode 100644 index 0000000..f4868d7 --- /dev/null +++ b/local_backend/routers/auth.py @@ -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"} diff --git a/local_backend/routers/deps.py b/local_backend/routers/deps.py new file mode 100644 index 0000000..73e73a7 --- /dev/null +++ b/local_backend/routers/deps.py @@ -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 diff --git a/local_backend/routers/orders.py b/local_backend/routers/orders.py new file mode 100644 index 0000000..3e76777 --- /dev/null +++ b/local_backend/routers/orders.py @@ -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() diff --git a/local_backend/routers/products.py b/local_backend/routers/products.py new file mode 100644 index 0000000..2dbae69 --- /dev/null +++ b/local_backend/routers/products.py @@ -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() diff --git a/local_backend/routers/reports.py b/local_backend/routers/reports.py new file mode 100644 index 0000000..49f672c --- /dev/null +++ b/local_backend/routers/reports.py @@ -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 diff --git a/local_backend/routers/system.py b/local_backend/routers/system.py new file mode 100644 index 0000000..c86d87e --- /dev/null +++ b/local_backend/routers/system.py @@ -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"} diff --git a/local_backend/routers/tables.py b/local_backend/routers/tables.py new file mode 100644 index 0000000..b8435e7 --- /dev/null +++ b/local_backend/routers/tables.py @@ -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 diff --git a/local_backend/routers/waiters.py b/local_backend/routers/waiters.py new file mode 100644 index 0000000..54a4805 --- /dev/null +++ b/local_backend/routers/waiters.py @@ -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() diff --git a/local_backend/schemas/__init__.py b/local_backend/schemas/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/local_backend/schemas/auth.py b/local_backend/schemas/auth.py new file mode 100644 index 0000000..1321073 --- /dev/null +++ b/local_backend/schemas/auth.py @@ -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 diff --git a/local_backend/schemas/order.py b/local_backend/schemas/order.py new file mode 100644 index 0000000..5b7521d --- /dev/null +++ b/local_backend/schemas/order.py @@ -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} diff --git a/local_backend/schemas/printer.py b/local_backend/schemas/printer.py new file mode 100644 index 0000000..6958b3e --- /dev/null +++ b/local_backend/schemas/printer.py @@ -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} diff --git a/local_backend/schemas/product.py b/local_backend/schemas/product.py new file mode 100644 index 0000000..0ca4b12 --- /dev/null +++ b/local_backend/schemas/product.py @@ -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} diff --git a/local_backend/schemas/table.py b/local_backend/schemas/table.py new file mode 100644 index 0000000..7b2a989 --- /dev/null +++ b/local_backend/schemas/table.py @@ -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} diff --git a/local_backend/schemas/user.py b/local_backend/schemas/user.py new file mode 100644 index 0000000..8a75764 --- /dev/null +++ b/local_backend/schemas/user.py @@ -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} diff --git a/local_backend/services/__init__.py b/local_backend/services/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/local_backend/services/cloud_sync.py b/local_backend/services/cloud_sync.py new file mode 100644 index 0000000..55722d1 --- /dev/null +++ b/local_backend/services/cloud_sync.py @@ -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 diff --git a/local_backend/services/printer_service.py b/local_backend/services/printer_service.py new file mode 100644 index 0000000..1d857f7 --- /dev/null +++ b/local_backend/services/printer_service.py @@ -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() diff --git a/logo.png b/logo.png new file mode 100644 index 0000000000000000000000000000000000000000..505d17d6da9afeb17e0efd7e1c61407ca68464c1 GIT binary patch literal 36987 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 literal 0 HcmV?d00001