commit 4ffe27df957edd8620a806ab6adb4af5edc89c51 Author: bonamin Date: Mon Apr 20 11:22:55 2026 +0300 Phase 1: scaffold local backend — models, schemas, routers, printer service, Docker 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 0000000..505d17d Binary files /dev/null and b/logo.png differ