feat: initial commit — local services (backend + manager dashboard + waiter PWA)
Includes all work to date: - local_backend: FastAPI backend with products, orders, tables, shifts, cloud sync - manager_dashboard: React manager UI with product/category management, reports, settings - waiter_pwa: React PWA for waiter devices - Category reparent endpoint and UI - Waiter domain: local_ip sent on heartbeat, waiter_domain persisted from cloud response - QR code modal in AppInfoTab for waiter domain - Product form: number input spinners removed, category pre-selected on new product - Category row: count badge moved to far right Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
7
local_backend/.dockerignore
Normal file
7
local_backend/.dockerignore
Normal file
@@ -0,0 +1,7 @@
|
||||
pos.db
|
||||
license_state.json
|
||||
__pycache__
|
||||
*.pyc
|
||||
*.pyo
|
||||
.env
|
||||
data/
|
||||
10
local_backend/Dockerfile
Normal file
10
local_backend/Dockerfile
Normal file
@@ -0,0 +1,10 @@
|
||||
FROM python:3.12-slim
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY requirements.txt .
|
||||
RUN pip install --no-cache-dir -r requirements.txt
|
||||
|
||||
COPY . .
|
||||
|
||||
CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000"]
|
||||
27
local_backend/config.py
Normal file
27
local_backend/config.py
Normal file
@@ -0,0 +1,27 @@
|
||||
import os
|
||||
from pathlib import Path
|
||||
from pydantic_settings import BaseSettings
|
||||
|
||||
_HERE = Path(__file__).parent # always points to local_backend/
|
||||
|
||||
# Use AppData on Windows (avoids Controlled Folder Access blocks),
|
||||
# fall back to local_backend/ on Linux/Mac/Docker
|
||||
if os.name == "nt":
|
||||
_DB_DEFAULT = Path(os.environ.get("LOCALAPPDATA", Path.home() / "AppData" / "Local")) / "pos" / "pos.db"
|
||||
_DB_DEFAULT.parent.mkdir(parents=True, exist_ok=True)
|
||||
else:
|
||||
_DB_DEFAULT = _HERE / "pos.db"
|
||||
|
||||
|
||||
class Settings(BaseSettings):
|
||||
SITE_ID: str = ""
|
||||
SITE_KEY: str = ""
|
||||
CLOUD_URL: str = ""
|
||||
SECRET_KEY: str = "change-me-generate-a-long-random-string"
|
||||
DATABASE_URL: str = f"sqlite:///{_DB_DEFAULT.as_posix()}"
|
||||
VERSION: str = "0.0.0"
|
||||
|
||||
model_config = {"env_file": str(_HERE / ".env"), "env_file_encoding": "utf-8"}
|
||||
|
||||
|
||||
settings = Settings()
|
||||
20
local_backend/database.py
Normal file
20
local_backend/database.py
Normal file
@@ -0,0 +1,20 @@
|
||||
from sqlalchemy import create_engine
|
||||
from sqlalchemy.orm import declarative_base, sessionmaker
|
||||
from config import settings
|
||||
|
||||
engine = create_engine(
|
||||
settings.DATABASE_URL,
|
||||
connect_args={"check_same_thread": False}, # needed for SQLite
|
||||
)
|
||||
|
||||
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
|
||||
|
||||
Base = declarative_base()
|
||||
|
||||
|
||||
def get_db():
|
||||
db = SessionLocal()
|
||||
try:
|
||||
yield db
|
||||
finally:
|
||||
db.close()
|
||||
277
local_backend/main.py
Normal file
277
local_backend/main.py
Normal file
@@ -0,0 +1,277 @@
|
||||
import os
|
||||
from contextlib import asynccontextmanager
|
||||
from fastapi import FastAPI
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
|
||||
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 — also registers WaiterZone
|
||||
import models.table # noqa: F401
|
||||
import models.printer # noqa: F401
|
||||
import models.product # noqa: F401
|
||||
import models.order # noqa: F401 — also registers OrderAuditLog, OrderDiscount
|
||||
import models.business_day # noqa: F401
|
||||
import models.shift # noqa: F401 — registers WaiterShift, ShiftBreak
|
||||
import models.settings # noqa: F401
|
||||
import models.flag # noqa: F401 — registers TableFlagDef, TableFlagAssignment
|
||||
import models.message # noqa: F401 — registers StaffMessage, StaffMessageAck, QuickMessageTemplate
|
||||
|
||||
from routers import auth, tables, products, orders, waiters, reports, system, setup as setup_router
|
||||
from routers import business_day as business_day_router
|
||||
from routers import shifts as shifts_router
|
||||
from routers import settings as settings_router
|
||||
from routers import flags as flags_router
|
||||
from routers import messages as messages_router
|
||||
from routers import sse as sse_router
|
||||
from routers import data_transfer as data_transfer_router
|
||||
|
||||
|
||||
def _run_migrations():
|
||||
"""Apply additive schema changes that create_all won't handle.
|
||||
Each migration gets its own connection so a no-op (column already exists)
|
||||
doesn't leave a dirty transaction that blocks subsequent migrations."""
|
||||
from sqlalchemy import text
|
||||
|
||||
migrations = [
|
||||
"ALTER TABLE product_ingredients ADD COLUMN extra_cost REAL NOT NULL DEFAULT 0.0",
|
||||
"ALTER TABLE products ADD COLUMN image_url VARCHAR",
|
||||
"ALTER TABLE tables ADD COLUMN group_id INTEGER REFERENCES table_groups(id)",
|
||||
"ALTER TABLE table_groups ADD COLUMN prefix VARCHAR",
|
||||
"ALTER TABLE table_groups ADD COLUMN color VARCHAR",
|
||||
"ALTER TABLE products ADD COLUMN sort_order INTEGER NOT NULL DEFAULT 0",
|
||||
"ALTER TABLE product_preference_sets ADD COLUMN default_choice_id INTEGER",
|
||||
"ALTER TABLE product_preference_choices ADD COLUMN sub_choices TEXT",
|
||||
"ALTER TABLE product_preference_choices ADD COLUMN disables_subset INTEGER NOT NULL DEFAULT 0",
|
||||
"ALTER TABLE product_preference_sets ADD COLUMN shared_subset TEXT",
|
||||
"ALTER TABLE product_options ADD COLUMN sub_choices TEXT",
|
||||
# Zone-based access control
|
||||
"""CREATE TABLE IF NOT EXISTS waiter_zones (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
waiter_id INTEGER NOT NULL REFERENCES users(id),
|
||||
group_id INTEGER REFERENCES table_groups(id),
|
||||
assigned_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||
)""",
|
||||
# Payment tracking on items
|
||||
"ALTER TABLE order_items ADD COLUMN paid_by INTEGER REFERENCES users(id)",
|
||||
"ALTER TABLE order_items ADD COLUMN paid_at DATETIME",
|
||||
"ALTER TABLE order_items ADD COLUMN payment_method VARCHAR",
|
||||
# Full audit log
|
||||
"""CREATE TABLE IF NOT EXISTS order_audit_log (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
order_id INTEGER NOT NULL REFERENCES orders(id),
|
||||
event_type VARCHAR NOT NULL,
|
||||
waiter_id INTEGER REFERENCES users(id),
|
||||
item_ids TEXT,
|
||||
amount REAL,
|
||||
payment_method VARCHAR,
|
||||
note TEXT,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||
)""",
|
||||
# Waiter profile fields
|
||||
"ALTER TABLE users ADD COLUMN full_name VARCHAR",
|
||||
"ALTER TABLE users ADD COLUMN nickname VARCHAR",
|
||||
"ALTER TABLE users ADD COLUMN mobile_phone VARCHAR",
|
||||
"ALTER TABLE users ADD COLUMN avatar_url VARCHAR",
|
||||
# Quick options (flat, allow_multiple)
|
||||
"""CREATE TABLE IF NOT EXISTS product_quick_options (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
product_id INTEGER NOT NULL REFERENCES products(id),
|
||||
name VARCHAR NOT NULL,
|
||||
price REAL NOT NULL DEFAULT 0.0,
|
||||
allow_multiple INTEGER NOT NULL DEFAULT 0,
|
||||
sort_order INTEGER NOT NULL DEFAULT 0
|
||||
)""",
|
||||
# allow_multiple flag on extras (product_options)
|
||||
"ALTER TABLE product_options ADD COLUMN allow_multiple INTEGER NOT NULL DEFAULT 0",
|
||||
# Discounts table (future-proofed, schema ready now)
|
||||
"""CREATE TABLE IF NOT EXISTS order_discounts (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
order_id INTEGER NOT NULL REFERENCES orders(id),
|
||||
item_id INTEGER REFERENCES order_items(id),
|
||||
discount_type VARCHAR NOT NULL,
|
||||
discount_value REAL NOT NULL,
|
||||
applied_by INTEGER NOT NULL REFERENCES users(id),
|
||||
applied_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
reason TEXT
|
||||
)""",
|
||||
# Business day scoping on orders
|
||||
"ALTER TABLE orders ADD COLUMN business_day_id INTEGER REFERENCES business_days(id)",
|
||||
# Shift attribution on paid items
|
||||
"ALTER TABLE order_items ADD COLUMN paid_in_shift_id INTEGER REFERENCES waiter_shifts(id)",
|
||||
# Seed default POS settings (INSERT OR IGNORE = no-op if already exists)
|
||||
"INSERT OR IGNORE INTO pos_settings (key, value, updated_at) VALUES ('shifts.waiter_self_start', 'true', CURRENT_TIMESTAMP)",
|
||||
"INSERT OR IGNORE INTO pos_settings (key, value, updated_at) VALUES ('shifts.waiter_self_end', 'true', CURRENT_TIMESTAMP)",
|
||||
"INSERT OR IGNORE INTO pos_settings (key, value, updated_at) VALUES ('business_day.force_close_allowed', 'true', CURRENT_TIMESTAMP)",
|
||||
"INSERT OR IGNORE INTO pos_settings (key, value, updated_at) VALUES ('flags.display_mode', 'both', CURRENT_TIMESTAMP)",
|
||||
# Table flags
|
||||
"""CREATE TABLE IF NOT EXISTS table_flag_defs (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
name VARCHAR NOT NULL,
|
||||
emoji VARCHAR,
|
||||
color VARCHAR DEFAULT '#6b7280',
|
||||
text_color VARCHAR DEFAULT NULL,
|
||||
sort_order INTEGER NOT NULL DEFAULT 0,
|
||||
is_active INTEGER NOT NULL DEFAULT 1,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||
)""",
|
||||
# Migration: add text_color if upgrading from older schema
|
||||
"ALTER TABLE table_flag_defs ADD COLUMN text_color VARCHAR DEFAULT NULL",
|
||||
"""CREATE TABLE IF NOT EXISTS table_flag_assignments (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
table_id INTEGER NOT NULL REFERENCES tables(id),
|
||||
flag_id INTEGER NOT NULL REFERENCES table_flag_defs(id),
|
||||
assigned_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
assigned_by INTEGER REFERENCES users(id)
|
||||
)""",
|
||||
# Staff messaging
|
||||
"""CREATE TABLE IF NOT EXISTS quick_message_templates (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
body VARCHAR NOT NULL,
|
||||
sort_order INTEGER NOT NULL DEFAULT 0,
|
||||
is_active INTEGER NOT NULL DEFAULT 1,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||
)""",
|
||||
"""CREATE TABLE IF NOT EXISTS staff_messages (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
sender_id INTEGER NOT NULL REFERENCES users(id),
|
||||
body TEXT NOT NULL,
|
||||
target_waiter_ids TEXT NOT NULL DEFAULT '[]',
|
||||
table_ids TEXT NOT NULL DEFAULT '[]',
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||
)""",
|
||||
"""CREATE TABLE IF NOT EXISTS staff_message_acks (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
message_id INTEGER NOT NULL REFERENCES staff_messages(id),
|
||||
waiter_id INTEGER NOT NULL REFERENCES users(id),
|
||||
acked_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||
)""",
|
||||
# Seed default flag definitions
|
||||
"INSERT OR IGNORE INTO table_flag_defs (id, name, emoji, color, sort_order) VALUES (1, 'Χρειάζεται καθάρισμα', '🧹', '#ef4444', 1)",
|
||||
"INSERT OR IGNORE INTO table_flag_defs (id, name, emoji, color, sort_order) VALUES (2, 'Χρειάζεται Βοήθεια', '🆘', '#f97316', 2)",
|
||||
"INSERT OR IGNORE INTO table_flag_defs (id, name, emoji, color, sort_order) VALUES (3, 'Χρειάζεται Σερβιτόρο', '🔔', '#eab308', 3)",
|
||||
"INSERT OR IGNORE INTO table_flag_defs (id, name, emoji, color, sort_order) VALUES (4, 'Περιμένει να πληρώσει', '💳', '#3b82f6', 4)",
|
||||
"INSERT OR IGNORE INTO table_flag_defs (id, name, emoji, color, sort_order) VALUES (5, 'VIP', '⭐', '#8b5cf6', 5)",
|
||||
"INSERT OR IGNORE INTO table_flag_defs (id, name, emoji, color, sort_order) VALUES (6, 'Ευγενικός Πελάτης', '😊', '#22c55e', 6)",
|
||||
"INSERT OR IGNORE INTO table_flag_defs (id, name, emoji, color, sort_order) VALUES (7, 'Αγενής Πελάτης', '😤', '#dc2626', 7)",
|
||||
"INSERT OR IGNORE INTO table_flag_defs (id, name, emoji, color, sort_order) VALUES (8, 'Αλλεργίες', '⚠️', '#f59e0b', 8)",
|
||||
"INSERT OR IGNORE INTO table_flag_defs (id, name, emoji, color, sort_order) VALUES (9, 'Παιδιά στο τραπέζι', '👶', '#06b6d4', 9)",
|
||||
"INSERT OR IGNORE INTO table_flag_defs (id, name, emoji, color, sort_order) VALUES (10, 'Επέτειος / Γενέθλια', '🎂', '#ec4899', 10)",
|
||||
# Seed default quick message templates
|
||||
"INSERT OR IGNORE INTO quick_message_templates (id, body, sort_order) VALUES (1, 'Σε χρειάζομαι τώρα', 1)",
|
||||
"INSERT OR IGNORE INTO quick_message_templates (id, body, sort_order) VALUES (2, 'Πάρε διάλειμμα', 2)",
|
||||
"INSERT OR IGNORE INTO quick_message_templates (id, body, sort_order) VALUES (3, 'Ετοιμάσου για κλείσιμο', 3)",
|
||||
"INSERT OR IGNORE INTO quick_message_templates (id, body, sort_order) VALUES (4, 'Ήρθε νέος πελάτης', 4)",
|
||||
"INSERT OR IGNORE INTO quick_message_templates (id, body, sort_order) VALUES (5, 'Ο πελάτης περιμένει να πληρώσει', 5)",
|
||||
# Product lifecycle status (active / archived)
|
||||
"ALTER TABLE products ADD COLUMN lifecycle_status VARCHAR NOT NULL DEFAULT 'active'",
|
||||
# Favorite flags + ordering on all product sub-item types
|
||||
"ALTER TABLE product_quick_options ADD COLUMN is_favorite INTEGER NOT NULL DEFAULT 0",
|
||||
"ALTER TABLE product_quick_options ADD COLUMN favorite_sort_order INTEGER NOT NULL DEFAULT 0",
|
||||
"ALTER TABLE product_options ADD COLUMN is_favorite INTEGER NOT NULL DEFAULT 0",
|
||||
"ALTER TABLE product_options ADD COLUMN favorite_sort_order INTEGER NOT NULL DEFAULT 0",
|
||||
"ALTER TABLE product_ingredients ADD COLUMN is_favorite INTEGER NOT NULL DEFAULT 0",
|
||||
"ALTER TABLE product_ingredients ADD COLUMN favorite_sort_order INTEGER NOT NULL DEFAULT 0",
|
||||
"ALTER TABLE product_preference_sets ADD COLUMN is_favorite INTEGER NOT NULL DEFAULT 0",
|
||||
"ALTER TABLE product_preference_sets ADD COLUMN favorite_sort_order INTEGER NOT NULL DEFAULT 0",
|
||||
# Sub-category support
|
||||
"ALTER TABLE categories ADD COLUMN parent_id INTEGER REFERENCES categories(id)",
|
||||
"ALTER TABLE categories ADD COLUMN general_sort_order INTEGER NOT NULL DEFAULT 0",
|
||||
# Auto-expand flag for sub-categories on the PWA accordion
|
||||
"ALTER TABLE categories ADD COLUMN auto_expanded INTEGER NOT NULL DEFAULT 0",
|
||||
# Printer protocol field
|
||||
"ALTER TABLE printers ADD COLUMN protocol VARCHAR NOT NULL DEFAULT 'escpos_tcp'",
|
||||
# Compact (half-width) display flag for quick options
|
||||
"ALTER TABLE product_quick_options ADD COLUMN is_compact INTEGER NOT NULL DEFAULT 0",
|
||||
# Print layout + per-type font settings
|
||||
"INSERT OR IGNORE INTO pos_settings (key, value, updated_at) VALUES ('print.ticket_mode', 'detailed', CURRENT_TIMESTAMP)",
|
||||
"INSERT OR IGNORE INTO pos_settings (key, value, updated_at) VALUES ('print.font_order_number', '48:1:0', CURRENT_TIMESTAMP)",
|
||||
"INSERT OR IGNORE INTO pos_settings (key, value, updated_at) VALUES ('print.font_meta', '0:0:0', CURRENT_TIMESTAMP)",
|
||||
"INSERT OR IGNORE INTO pos_settings (key, value, updated_at) VALUES ('print.font_item_name', '16:1:0', CURRENT_TIMESTAMP)",
|
||||
"INSERT OR IGNORE INTO pos_settings (key, value, updated_at) VALUES ('print.font_quick', '0:0:0', CURRENT_TIMESTAMP)",
|
||||
"INSERT OR IGNORE INTO pos_settings (key, value, updated_at) VALUES ('print.font_pref', '0:0:0', CURRENT_TIMESTAMP)",
|
||||
"INSERT OR IGNORE INTO pos_settings (key, value, updated_at) VALUES ('print.font_extra', '0:0:0', CURRENT_TIMESTAMP)",
|
||||
"INSERT OR IGNORE INTO pos_settings (key, value, updated_at) VALUES ('print.font_ingredient', '0:0:0', CURRENT_TIMESTAMP)",
|
||||
"INSERT OR IGNORE INTO pos_settings (key, value, updated_at) VALUES ('print.font_item_note', '0:0:0', CURRENT_TIMESTAMP)",
|
||||
"INSERT OR IGNORE INTO pos_settings (key, value, updated_at) VALUES ('print.font_order_note', '0:1:0', CURRENT_TIMESTAMP)",
|
||||
# Offline/emergency payment tracking
|
||||
"ALTER TABLE order_audit_log ADD COLUMN offline_uuid VARCHAR",
|
||||
"ALTER TABLE order_audit_log ADD COLUMN offline_at VARCHAR",
|
||||
"ALTER TABLE order_audit_log ADD COLUMN is_duplicate INTEGER NOT NULL DEFAULT 0",
|
||||
# Cancellation tracking on order items (for reports)
|
||||
"ALTER TABLE order_items ADD COLUMN cancelled_by INTEGER REFERENCES users(id)",
|
||||
"ALTER TABLE order_items ADD COLUMN cancel_reason TEXT",
|
||||
"ALTER TABLE order_items ADD COLUMN cancelled_at DATETIME",
|
||||
# Manager account fields (added for setup wizard / future password login)
|
||||
"ALTER TABLE users ADD COLUMN password_hash VARCHAR",
|
||||
"ALTER TABLE users ADD COLUMN email VARCHAR",
|
||||
# Venue identity settings
|
||||
"INSERT OR IGNORE INTO pos_settings (key, value, updated_at) VALUES ('venue.name', '', CURRENT_TIMESTAMP)",
|
||||
"INSERT OR IGNORE INTO pos_settings (key, value, updated_at) VALUES ('venue.type', '', CURRENT_TIMESTAMP)",
|
||||
# Security settings
|
||||
"INSERT OR IGNORE INTO pos_settings (key, value, updated_at) VALUES ('security.login_method', 'password', CURRENT_TIMESTAMP)",
|
||||
"INSERT OR IGNORE INTO pos_settings (key, value, updated_at) VALUES ('security.autofill_username', 'true', CURRENT_TIMESTAMP)",
|
||||
"INSERT OR IGNORE INTO pos_settings (key, value, updated_at) VALUES ('security.auto_lock', 'false', CURRENT_TIMESTAMP)",
|
||||
"INSERT OR IGNORE INTO pos_settings (key, value, updated_at) VALUES ('security.auto_lock_seconds', '300', CURRENT_TIMESTAMP)",
|
||||
"INSERT OR IGNORE INTO pos_settings (key, value, updated_at) VALUES ('security.auto_logout', 'false', CURRENT_TIMESTAMP)",
|
||||
"INSERT OR IGNORE INTO pos_settings (key, value, updated_at) VALUES ('security.auto_logout_seconds', '1800', CURRENT_TIMESTAMP)",
|
||||
]
|
||||
for sql in migrations:
|
||||
try:
|
||||
with engine.connect() as conn:
|
||||
conn.execute(text(sql))
|
||||
conn.commit()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI):
|
||||
import asyncio
|
||||
from services.sse_bus import init_loop
|
||||
init_loop(asyncio.get_running_loop())
|
||||
Base.metadata.create_all(bind=engine)
|
||||
_run_migrations()
|
||||
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)
|
||||
|
||||
# Serve product images as static files
|
||||
IMAGE_DIR = "/app/data/product_images"
|
||||
os.makedirs(IMAGE_DIR, exist_ok=True)
|
||||
app.mount("/static/product_images", StaticFiles(directory=IMAGE_DIR), name="product_images")
|
||||
|
||||
# Serve waiter avatars as static files
|
||||
AVATAR_DIR = "/app/data/avatars"
|
||||
os.makedirs(AVATAR_DIR, exist_ok=True)
|
||||
app.mount("/static/avatars", StaticFiles(directory=AVATAR_DIR), name="avatars")
|
||||
|
||||
app.include_router(setup_router.router, prefix="/api/setup", tags=["setup"])
|
||||
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"])
|
||||
app.include_router(business_day_router.router, prefix="/api/business-day", tags=["business-day"])
|
||||
app.include_router(shifts_router.router, prefix="/api/shifts", tags=["shifts"])
|
||||
app.include_router(settings_router.router, prefix="/api/settings", tags=["settings"])
|
||||
app.include_router(flags_router.router, prefix="/api/flags", tags=["flags"])
|
||||
app.include_router(messages_router.router, prefix="/api/messages", tags=["messages"])
|
||||
app.include_router(sse_router.router, prefix="/api/sse", tags=["sse"])
|
||||
app.include_router(data_transfer_router.router, prefix="/api/data-transfer", tags=["data-transfer"])
|
||||
0
local_backend/middleware/__init__.py
Normal file
0
local_backend/middleware/__init__.py
Normal file
69
local_backend/middleware/license_check.py
Normal file
69
local_backend/middleware/license_check.py
Normal file
@@ -0,0 +1,69 @@
|
||||
from fastapi import Request, Response
|
||||
from starlette.middleware.base import BaseHTTPMiddleware
|
||||
|
||||
# Shared mutable state — updated by cloud_sync.py
|
||||
# Fields:
|
||||
# licensed bool — False only after 72h offline OR after expiry grace passes
|
||||
# locked bool — True once lock_pending is enforced at workday close
|
||||
# lock_pending bool — Cloud requested lock; waiting for workday to close
|
||||
# expires_at str|None — ISO timestamp from cloud
|
||||
# days_until_expiry int|None — negative when expired
|
||||
# grace_expires_at str|None — ISO timestamp of expiry + 5 days
|
||||
# last_sync str|None — ISO timestamp of last successful heartbeat
|
||||
# sync_failed bool
|
||||
# latest_version str|None
|
||||
license_state: dict = {
|
||||
"licensed": True,
|
||||
"locked": False,
|
||||
"lock_pending": False,
|
||||
"expires_at": None,
|
||||
"days_until_expiry": None,
|
||||
"grace_expires_at": None,
|
||||
"last_sync": None,
|
||||
"sync_failed": False,
|
||||
"latest_version": None,
|
||||
}
|
||||
|
||||
# Paths that bypass all license checks (health probe)
|
||||
EXEMPT_PATHS = {"/api/system/health"}
|
||||
|
||||
# Paths that are always allowed so the frontend can read license status
|
||||
# and managers can still log in / close the workday when restricted
|
||||
STATUS_ALLOWED_PATHS = {
|
||||
"/api/system/status",
|
||||
"/api/system/sync-license",
|
||||
"/api/auth/login",
|
||||
"/api/auth/me",
|
||||
"/api/business-day/current",
|
||||
"/api/business-day/close",
|
||||
}
|
||||
|
||||
|
||||
class LicenseCheckMiddleware(BaseHTTPMiddleware):
|
||||
async def dispatch(self, request: Request, call_next):
|
||||
path = request.url.path
|
||||
|
||||
if path in EXEMPT_PATHS or path in STATUS_ALLOWED_PATHS:
|
||||
return await call_next(request)
|
||||
|
||||
# Hard block: licensed=False means either 72h offline grace expired
|
||||
# OR expiry grace period (5 days) has passed. In both cases the
|
||||
# business_day router already prevented opening a new workday, so
|
||||
# existing operations can still complete — we only block new ones.
|
||||
# The business_day /open endpoint has its own detailed error message.
|
||||
if not license_state.get("licensed", True):
|
||||
return Response(
|
||||
content='{"detail":"license_expired","code":"LICENSE_EXPIRED"}',
|
||||
status_code=402,
|
||||
media_type="application/json",
|
||||
)
|
||||
|
||||
# Hard block: locked=True (lock_pending was enforced at workday close)
|
||||
if license_state.get("locked"):
|
||||
return Response(
|
||||
content='{"detail":"system_locked","code":"SYSTEM_LOCKED"}',
|
||||
status_code=423,
|
||||
media_type="application/json",
|
||||
)
|
||||
|
||||
return await call_next(request)
|
||||
0
local_backend/models/__init__.py
Normal file
0
local_backend/models/__init__.py
Normal file
24
local_backend/models/business_day.py
Normal file
24
local_backend/models/business_day.py
Normal file
@@ -0,0 +1,24 @@
|
||||
from sqlalchemy import Column, Integer, String, DateTime, ForeignKey, Text
|
||||
from sqlalchemy.orm import relationship
|
||||
from datetime import datetime, timezone
|
||||
from database import Base
|
||||
|
||||
|
||||
def _utcnow():
|
||||
return datetime.now(timezone.utc)
|
||||
|
||||
|
||||
class BusinessDay(Base):
|
||||
__tablename__ = "business_days"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
status = Column(String, default="open", nullable=False) # 'open' | 'closed'
|
||||
opened_at = Column(DateTime(timezone=True), default=_utcnow, nullable=False)
|
||||
opened_by_id = Column(Integer, ForeignKey("users.id"), nullable=False)
|
||||
closed_at = Column(DateTime(timezone=True), nullable=True)
|
||||
closed_by_id = Column(Integer, ForeignKey("users.id"), nullable=True)
|
||||
notes = Column(Text, nullable=True)
|
||||
|
||||
opener = relationship("User", foreign_keys=[opened_by_id])
|
||||
closer = relationship("User", foreign_keys=[closed_by_id])
|
||||
shifts = relationship("WaiterShift", back_populates="business_day")
|
||||
38
local_backend/models/flag.py
Normal file
38
local_backend/models/flag.py
Normal file
@@ -0,0 +1,38 @@
|
||||
from sqlalchemy import Column, Integer, String, DateTime, ForeignKey, Boolean
|
||||
from sqlalchemy.orm import relationship
|
||||
from datetime import datetime, timezone
|
||||
from database import Base
|
||||
|
||||
|
||||
def _utcnow():
|
||||
return datetime.now(timezone.utc)
|
||||
|
||||
|
||||
class TableFlagDef(Base):
|
||||
"""Manager-configurable flag definitions (name, emoji, color)."""
|
||||
__tablename__ = "table_flag_defs"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
name = Column(String, nullable=False)
|
||||
emoji = Column(String, nullable=True)
|
||||
color = Column(String, nullable=True, default="#6b7280") # hex background
|
||||
text_color = Column(String, nullable=True, default=None) # hex text; None = white
|
||||
sort_order = Column(Integer, default=0, nullable=False)
|
||||
is_active = Column(Boolean, default=True, nullable=False)
|
||||
created_at = Column(DateTime(timezone=True), default=_utcnow)
|
||||
|
||||
assignments = relationship("TableFlagAssignment", back_populates="flag_def", cascade="all, delete-orphan")
|
||||
|
||||
|
||||
class TableFlagAssignment(Base):
|
||||
"""Active flag on a specific table."""
|
||||
__tablename__ = "table_flag_assignments"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
table_id = Column(Integer, ForeignKey("tables.id"), nullable=False)
|
||||
flag_id = Column(Integer, ForeignKey("table_flag_defs.id"), nullable=False)
|
||||
assigned_at = Column(DateTime(timezone=True), default=_utcnow)
|
||||
assigned_by = Column(Integer, ForeignKey("users.id"), nullable=True)
|
||||
|
||||
flag_def = relationship("TableFlagDef", back_populates="assignments")
|
||||
assigned_by_user = relationship("User")
|
||||
48
local_backend/models/message.py
Normal file
48
local_backend/models/message.py
Normal file
@@ -0,0 +1,48 @@
|
||||
from sqlalchemy import Column, Integer, String, DateTime, ForeignKey, Text, Boolean
|
||||
from sqlalchemy.orm import relationship
|
||||
from datetime import datetime, timezone
|
||||
from database import Base
|
||||
|
||||
|
||||
def _utcnow():
|
||||
return datetime.now(timezone.utc)
|
||||
|
||||
|
||||
class QuickMessageTemplate(Base):
|
||||
"""Manager-configurable quick message templates."""
|
||||
__tablename__ = "quick_message_templates"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
body = Column(String, nullable=False)
|
||||
sort_order = Column(Integer, default=0, nullable=False)
|
||||
is_active = Column(Boolean, default=True, nullable=False)
|
||||
created_at = Column(DateTime(timezone=True), default=_utcnow)
|
||||
|
||||
|
||||
class StaffMessage(Base):
|
||||
"""A message sent from a manager to one or more waiters."""
|
||||
__tablename__ = "staff_messages"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
sender_id = Column(Integer, ForeignKey("users.id"), nullable=False)
|
||||
body = Column(Text, nullable=False)
|
||||
# JSON arrays stored as text: "[1,2,3]" for waiter ids, "[5,6]" for table ids
|
||||
target_waiter_ids = Column(Text, nullable=False, default="[]")
|
||||
table_ids = Column(Text, nullable=False, default="[]")
|
||||
created_at = Column(DateTime(timezone=True), default=_utcnow)
|
||||
|
||||
sender = relationship("User", foreign_keys=[sender_id])
|
||||
acks = relationship("StaffMessageAck", back_populates="message", cascade="all, delete-orphan")
|
||||
|
||||
|
||||
class StaffMessageAck(Base):
|
||||
"""Acknowledgement by a specific waiter for a specific message."""
|
||||
__tablename__ = "staff_message_acks"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
message_id = Column(Integer, ForeignKey("staff_messages.id"), nullable=False)
|
||||
waiter_id = Column(Integer, ForeignKey("users.id"), nullable=False)
|
||||
acked_at = Column(DateTime(timezone=True), default=_utcnow)
|
||||
|
||||
message = relationship("StaffMessage", back_populates="acks")
|
||||
waiter = relationship("User")
|
||||
131
local_backend/models/order.py
Normal file
131
local_backend/models/order.py
Normal file
@@ -0,0 +1,131 @@
|
||||
from sqlalchemy import Column, Integer, String, Boolean, Float, DateTime, ForeignKey, Text
|
||||
from sqlalchemy.orm import relationship
|
||||
from datetime import datetime, timezone
|
||||
from database import Base
|
||||
|
||||
|
||||
def _utcnow():
|
||||
return datetime.now(timezone.utc)
|
||||
|
||||
|
||||
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(timezone=True), default=_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)
|
||||
business_day_id = Column(Integer, ForeignKey("business_days.id"), 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", cascade="all, delete-orphan")
|
||||
audit_logs = relationship("OrderAuditLog", back_populates="order", cascade="all, delete-orphan")
|
||||
discounts = relationship("OrderDiscount", back_populates="order", cascade="all, delete-orphan")
|
||||
|
||||
|
||||
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(timezone=True), default=_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(timezone=True), default=_utcnow)
|
||||
printed = Column(Boolean, default=False, nullable=False)
|
||||
|
||||
# Payment tracking
|
||||
paid_by = Column(Integer, ForeignKey("users.id"), nullable=True)
|
||||
paid_at = Column(DateTime, nullable=True)
|
||||
payment_method = Column(String, nullable=True) # 'cash'|'card'|'other' — future use
|
||||
paid_in_shift_id = Column(Integer, ForeignKey("waiter_shifts.id"), nullable=True)
|
||||
|
||||
order = relationship("Order", back_populates="items")
|
||||
product = relationship("Product", back_populates="order_items")
|
||||
added_by_user = relationship("User", foreign_keys=[added_by], back_populates="order_items")
|
||||
paid_by_user = relationship("User", foreign_keys=[paid_by], back_populates="items_paid")
|
||||
|
||||
|
||||
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(timezone=True), default=_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")
|
||||
|
||||
|
||||
class OrderAuditLog(Base):
|
||||
"""Immutable append-only audit trail for every action on an order."""
|
||||
__tablename__ = "order_audit_log"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
order_id = Column(Integer, ForeignKey("orders.id"), nullable=False)
|
||||
event_type = Column(String, nullable=False)
|
||||
# ORDER_OPENED | ITEMS_ADDED | PAYMENT | PAYMENT_OFFLINE | ORDER_CLOSED | ORDER_CANCELLED | ITEM_CANCELLED
|
||||
waiter_id = Column(Integer, ForeignKey("users.id"), nullable=True)
|
||||
item_ids = Column(Text, nullable=True) # JSON list of OrderItem ids
|
||||
amount = Column(Float, nullable=True) # total value for PAYMENT events
|
||||
payment_method = Column(String, nullable=True)
|
||||
note = Column(Text, nullable=True)
|
||||
created_at = Column(DateTime(timezone=True), default=_utcnow)
|
||||
# Emergency offline payment fields
|
||||
offline_uuid = Column(String, nullable=True) # client-generated UUID for dedup
|
||||
offline_at = Column(String, nullable=True) # ISO timestamp from client
|
||||
is_duplicate = Column(Integer, nullable=False, default=0) # 1 = duplicate payment flagged
|
||||
|
||||
order = relationship("Order", back_populates="audit_logs")
|
||||
waiter = relationship("User")
|
||||
|
||||
@property
|
||||
def waiter_name(self):
|
||||
return self.waiter.username if self.waiter else None
|
||||
|
||||
|
||||
class OrderDiscount(Base):
|
||||
"""Records a discount applied to an order or a specific item."""
|
||||
__tablename__ = "order_discounts"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
order_id = Column(Integer, ForeignKey("orders.id"), nullable=False)
|
||||
item_id = Column(Integer, ForeignKey("order_items.id"), nullable=True) # NULL = whole-order discount
|
||||
discount_type = Column(String, nullable=False) # 'percent' | 'fixed'
|
||||
discount_value = Column(Float, nullable=False) # e.g. 10.0 = 10% or €10.00
|
||||
applied_by = Column(Integer, ForeignKey("users.id"), nullable=False)
|
||||
applied_at = Column(DateTime(timezone=True), default=_utcnow)
|
||||
reason = Column(Text, nullable=True)
|
||||
|
||||
order = relationship("Order", back_populates="discounts")
|
||||
item = relationship("OrderItem")
|
||||
applied_by_user = relationship("User")
|
||||
17
local_backend/models/printer.py
Normal file
17
local_backend/models/printer.py
Normal file
@@ -0,0 +1,17 @@
|
||||
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)
|
||||
protocol = Column(String, default="escpos_tcp", nullable=False)
|
||||
|
||||
products = relationship("Product", back_populates="printer_zone")
|
||||
print_logs = relationship("PrintLog", back_populates="printer")
|
||||
123
local_backend/models/product.py
Normal file
123
local_backend/models/product.py
Normal file
@@ -0,0 +1,123 @@
|
||||
from sqlalchemy import Column, Integer, String, Boolean, Float, ForeignKey, Text
|
||||
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)
|
||||
# self-referential: null = top-level, non-null = sub-category of parent
|
||||
parent_id = Column(Integer, ForeignKey("categories.id"), nullable=True)
|
||||
# position of the "General" group (direct products) among sub-categories
|
||||
general_sort_order = Column(Integer, default=0, nullable=False)
|
||||
# sub-categories only: if True, the accordion section is expanded by default on the PWA
|
||||
auto_expanded = Column(Boolean, default=False, nullable=False)
|
||||
|
||||
products = relationship("Product", back_populates="category")
|
||||
subcategories = relationship("Category", back_populates="parent", foreign_keys="Category.parent_id")
|
||||
parent = relationship("Category", back_populates="subcategories", remote_side="Category.id", foreign_keys="Category.parent_id")
|
||||
|
||||
|
||||
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)
|
||||
# "active" | "archived" — archived products are kept for order history but hidden from active use
|
||||
lifecycle_status = Column(String, default="active", nullable=False)
|
||||
printer_zone_id = Column(Integer, ForeignKey("printers.id"), nullable=True)
|
||||
image_url = Column(String, nullable=True)
|
||||
sort_order = Column(Integer, default=0, nullable=False)
|
||||
|
||||
category = relationship("Category", back_populates="products")
|
||||
printer_zone = relationship("Printer", back_populates="products")
|
||||
quick_options = relationship("ProductQuickOption", back_populates="product", cascade="all, delete-orphan")
|
||||
options = relationship("ProductOption", back_populates="product", cascade="all, delete-orphan")
|
||||
ingredients = relationship("ProductIngredient", back_populates="product", cascade="all, delete-orphan")
|
||||
preference_sets = relationship("ProductPreferenceSet", 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)
|
||||
allow_multiple = Column(Boolean, default=False, nullable=False)
|
||||
# JSON array [{name, extra_cost, is_default}] — sub-options shown when this option is checked
|
||||
sub_choices = Column(Text, nullable=True)
|
||||
is_favorite = Column(Boolean, default=False, nullable=False)
|
||||
favorite_sort_order = Column(Integer, default=0, nullable=False)
|
||||
|
||||
product = relationship("Product", back_populates="options")
|
||||
|
||||
|
||||
class ProductQuickOption(Base):
|
||||
__tablename__ = "product_quick_options"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
product_id = Column(Integer, ForeignKey("products.id"), nullable=False)
|
||||
name = Column(String, nullable=False)
|
||||
price = Column(Float, default=0.0, nullable=False)
|
||||
allow_multiple = Column(Boolean, default=False, nullable=False)
|
||||
sort_order = Column(Integer, default=0, nullable=False)
|
||||
is_favorite = Column(Boolean, default=False, nullable=False)
|
||||
favorite_sort_order = Column(Integer, default=0, nullable=False)
|
||||
is_compact = Column(Boolean, default=False, nullable=False)
|
||||
|
||||
product = relationship("Product", back_populates="quick_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)
|
||||
extra_cost = Column(Float, default=0.0)
|
||||
is_favorite = Column(Boolean, default=False, nullable=False)
|
||||
favorite_sort_order = Column(Integer, default=0, nullable=False)
|
||||
|
||||
product = relationship("Product", back_populates="ingredients")
|
||||
|
||||
|
||||
class ProductPreferenceSet(Base):
|
||||
__tablename__ = "product_preference_sets"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
product_id = Column(Integer, ForeignKey("products.id"), nullable=False)
|
||||
name = Column(String, nullable=False)
|
||||
default_choice_id = Column(Integer, nullable=True)
|
||||
# JSON: {name, default_choice_index, choices:[{name,extra_cost,is_default}]}
|
||||
# Shared sub-set shown for all choices that don't have disables_subset=True
|
||||
shared_subset = Column(Text, nullable=True)
|
||||
is_favorite = Column(Boolean, default=False, nullable=False)
|
||||
favorite_sort_order = Column(Integer, default=0, nullable=False)
|
||||
|
||||
product = relationship("Product", back_populates="preference_sets")
|
||||
choices = relationship("ProductPreferenceChoice", back_populates="set", cascade="all, delete-orphan")
|
||||
|
||||
|
||||
class ProductPreferenceChoice(Base):
|
||||
__tablename__ = "product_preference_choices"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
set_id = Column(Integer, ForeignKey("product_preference_sets.id"), nullable=False)
|
||||
name = Column(String, nullable=False)
|
||||
extra_cost = Column(Float, default=0.0)
|
||||
# JSON array of sub-choice objects: [{name, extra_cost, is_default}]
|
||||
# Per-choice inline sub-preference shown only when this choice is selected.
|
||||
sub_choices = Column(Text, nullable=True)
|
||||
# When True this choice hides the set-level shared_subset on the PWA.
|
||||
disables_subset = Column(Boolean, default=False, nullable=False)
|
||||
|
||||
set = relationship("ProductPreferenceSet", back_populates="choices")
|
||||
19
local_backend/models/settings.py
Normal file
19
local_backend/models/settings.py
Normal file
@@ -0,0 +1,19 @@
|
||||
from sqlalchemy import Column, Integer, String, DateTime, ForeignKey
|
||||
from sqlalchemy.orm import relationship
|
||||
from datetime import datetime, timezone
|
||||
from database import Base
|
||||
|
||||
|
||||
def _utcnow():
|
||||
return datetime.now(timezone.utc)
|
||||
|
||||
|
||||
class PosSettings(Base):
|
||||
__tablename__ = "pos_settings"
|
||||
|
||||
key = Column(String, primary_key=True)
|
||||
value = Column(String, nullable=False)
|
||||
updated_at = Column(DateTime(timezone=True), default=_utcnow, onupdate=_utcnow, nullable=False)
|
||||
updated_by_id = Column(Integer, ForeignKey("users.id"), nullable=True)
|
||||
|
||||
updated_by = relationship("User", foreign_keys=[updated_by_id])
|
||||
36
local_backend/models/shift.py
Normal file
36
local_backend/models/shift.py
Normal file
@@ -0,0 +1,36 @@
|
||||
from sqlalchemy import Column, Integer, Float, DateTime, ForeignKey, Text
|
||||
from sqlalchemy.orm import relationship
|
||||
from datetime import datetime, timezone
|
||||
from database import Base
|
||||
|
||||
|
||||
def _utcnow():
|
||||
return datetime.now(timezone.utc)
|
||||
|
||||
|
||||
class WaiterShift(Base):
|
||||
__tablename__ = "waiter_shifts"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
waiter_id = Column(Integer, ForeignKey("users.id"), nullable=False)
|
||||
business_day_id = Column(Integer, ForeignKey("business_days.id"), nullable=False)
|
||||
started_at = Column(DateTime(timezone=True), default=_utcnow, nullable=False)
|
||||
ended_at = Column(DateTime(timezone=True), nullable=True)
|
||||
starting_cash = Column(Float, nullable=True)
|
||||
total_collected = Column(Float, nullable=True) # snapshot written at shift end
|
||||
notes = Column(Text, nullable=True)
|
||||
|
||||
waiter = relationship("User", foreign_keys=[waiter_id])
|
||||
business_day = relationship("BusinessDay", back_populates="shifts")
|
||||
breaks = relationship("ShiftBreak", back_populates="shift", cascade="all, delete-orphan")
|
||||
|
||||
|
||||
class ShiftBreak(Base):
|
||||
__tablename__ = "shift_breaks"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
shift_id = Column(Integer, ForeignKey("waiter_shifts.id"), nullable=False)
|
||||
started_at = Column(DateTime(timezone=True), default=_utcnow, nullable=False)
|
||||
ended_at = Column(DateTime(timezone=True), nullable=True)
|
||||
|
||||
shift = relationship("WaiterShift", back_populates="breaks")
|
||||
31
local_backend/models/table.py
Normal file
31
local_backend/models/table.py
Normal file
@@ -0,0 +1,31 @@
|
||||
from sqlalchemy import Column, Integer, String, Boolean, Float, ForeignKey
|
||||
from sqlalchemy.orm import relationship
|
||||
from database import Base
|
||||
|
||||
|
||||
class TableGroup(Base):
|
||||
__tablename__ = "table_groups"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
name = Column(String, nullable=False, unique=True)
|
||||
prefix = Column(String, nullable=True)
|
||||
sort_order = Column(Integer, default=0)
|
||||
color = Column(String, nullable=True)
|
||||
|
||||
tables = relationship("Table", back_populates="group")
|
||||
waiter_zones = relationship("WaiterZone", back_populates="group")
|
||||
|
||||
|
||||
class Table(Base):
|
||||
__tablename__ = "tables"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
number = Column(Integer, nullable=False)
|
||||
label = Column(String, nullable=True)
|
||||
group_id = Column(Integer, ForeignKey("table_groups.id"), nullable=True)
|
||||
is_active = Column(Boolean, default=True, nullable=False)
|
||||
floor_x = Column(Float, nullable=True)
|
||||
floor_y = Column(Float, nullable=True)
|
||||
|
||||
group = relationship("TableGroup", back_populates="tables")
|
||||
orders = relationship("Order", back_populates="table")
|
||||
70
local_backend/models/user.py
Normal file
70
local_backend/models/user.py
Normal file
@@ -0,0 +1,70 @@
|
||||
from sqlalchemy import Column, Integer, String, Boolean, DateTime, ForeignKey
|
||||
from sqlalchemy.orm import relationship
|
||||
from datetime import datetime, timezone
|
||||
from database import Base
|
||||
|
||||
|
||||
def _utcnow():
|
||||
return datetime.now(timezone.utc)
|
||||
|
||||
|
||||
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)
|
||||
password_hash = Column(String, nullable=True)
|
||||
email = Column(String, nullable=True)
|
||||
role = Column(String, nullable=False) # 'waiter' | 'manager' | 'sysadmin'
|
||||
is_active = Column(Boolean, default=True, nullable=False)
|
||||
full_name = Column(String, nullable=True)
|
||||
nickname = Column(String, nullable=True)
|
||||
mobile_phone = Column(String, nullable=True)
|
||||
avatar_url = Column(String, nullable=True)
|
||||
created_at = Column(DateTime(timezone=True), default=_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", foreign_keys="OrderItem.added_by", back_populates="added_by_user")
|
||||
items_paid = relationship("OrderItem", foreign_keys="OrderItem.paid_by", back_populates="paid_by_user")
|
||||
order_assignments = relationship("OrderWaiter", back_populates="waiter")
|
||||
zone_assignments = relationship("WaiterZone", back_populates="waiter", cascade="all, delete-orphan")
|
||||
|
||||
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 WaiterZone(Base):
|
||||
"""Maps a waiter to a table group they are allowed to operate in.
|
||||
If a waiter has NO rows here, they see NOTHING.
|
||||
A sentinel row with group_id=NULL means 'all zones'."""
|
||||
__tablename__ = "waiter_zones"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
waiter_id = Column(Integer, ForeignKey("users.id"), nullable=False)
|
||||
group_id = Column(Integer, ForeignKey("table_groups.id"), nullable=True) # NULL = all zones
|
||||
assigned_at = Column(DateTime(timezone=True), default=_utcnow)
|
||||
|
||||
waiter = relationship("User", back_populates="zone_assignments")
|
||||
group = relationship("TableGroup", back_populates="waiter_zones")
|
||||
|
||||
|
||||
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(timezone=True), default=_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")
|
||||
137
local_backend/print_size_test.py
Normal file
137
local_backend/print_size_test.py
Normal file
@@ -0,0 +1,137 @@
|
||||
"""
|
||||
Font size comparison test — Jolimark TP850UE
|
||||
Usage: python print_size_test.py [IP] [PORT]
|
||||
Default: 10.98.20.25:9100
|
||||
|
||||
Prints a single page showing all available size options side by side,
|
||||
to help decide which sizes to expose in the settings UI.
|
||||
|
||||
Hardware facts:
|
||||
ESC ! (0x1B 0x21 n):
|
||||
0x10 = double-height only (tall + narrow — breaks aspect ratio)
|
||||
0x20 = double-width only (short + wide — breaks aspect ratio)
|
||||
0x30 = double-height + double-width (2x in both axes — correct aspect ratio)
|
||||
There is NO 1.5x in ESC/POS hardware.
|
||||
GS ! (0x1D 0x21 n) can go 3x, 4x … 8x but they are extremely large.
|
||||
"""
|
||||
import sys
|
||||
|
||||
PRINTER_IP = sys.argv[1] if len(sys.argv) > 1 else "10.98.20.25"
|
||||
PRINTER_PORT = int(sys.argv[2]) if len(sys.argv) > 2 else 9100
|
||||
|
||||
try:
|
||||
from escpos.printer import Network
|
||||
except ImportError:
|
||||
print("escpos not installed. Run: pip install python-escpos")
|
||||
sys.exit(1)
|
||||
|
||||
def gr(text):
|
||||
return text.encode('cp737', errors='replace')
|
||||
|
||||
def raw(p, b):
|
||||
p._raw(b)
|
||||
|
||||
def section(p, title):
|
||||
raw(p, b'\x1b\x21\x00')
|
||||
raw(p, b'\x1b\x45\x00')
|
||||
raw(p, b'\x1b\x61\x01')
|
||||
p._raw(gr(f"--- {title} ---\n"))
|
||||
raw(p, b'\x1b\x61\x00')
|
||||
|
||||
def print_sample(p, esc_bang, gs_size, label_en, label_gr):
|
||||
"""Print one size sample with label."""
|
||||
# Label at normal size
|
||||
raw(p, b'\x1b\x21\x00')
|
||||
raw(p, b'\x1b\x45\x00')
|
||||
p._raw(gr(f"{label_en}:\n"))
|
||||
|
||||
# Apply size via ESC ! and/or GS !
|
||||
if gs_size is not None:
|
||||
raw(p, bytes([0x1d, 0x21, gs_size]))
|
||||
raw(p, bytes([0x1b, 0x21, esc_bang]))
|
||||
|
||||
p._raw(gr(f"Club Sandwich. x1\n"))
|
||||
p._raw(gr(f"* Χωρις αλατι\n"))
|
||||
p._raw(gr(f"+ Extra Bacon x2\n"))
|
||||
|
||||
# Reset
|
||||
raw(p, b'\x1d\x21\x00')
|
||||
raw(p, b'\x1b\x21\x00')
|
||||
raw(p, b'\n')
|
||||
|
||||
def divider(p):
|
||||
raw(p, b'\x1b\x21\x00')
|
||||
p._raw(gr("-" * 48 + "\n"))
|
||||
|
||||
print(f"Connecting to {PRINTER_IP}:{PRINTER_PORT}...")
|
||||
p = Network(PRINTER_IP, PRINTER_PORT, timeout=10)
|
||||
raw(p, b'\x1b\x40') # ESC @ reset
|
||||
raw(p, b'\x1b\x74\x1d') # CP737 Greek
|
||||
|
||||
raw(p, b'\x1b\x61\x01')
|
||||
raw(p, b'\x1b\x21\x30')
|
||||
raw(p, b'\x1b\x45\x01')
|
||||
p._raw(gr("SIZE COMPARISON TEST\n"))
|
||||
raw(p, b'\x1b\x21\x00')
|
||||
raw(p, b'\x1b\x45\x00')
|
||||
raw(p, b'\x1b\x61\x00')
|
||||
p._raw(gr("Which sizes look good for ticket printing?\n\n"))
|
||||
|
||||
# ── Section 1: The two aspect-ratio-correct options ───────────────────────
|
||||
section(p, "CORRECT ASPECT RATIO")
|
||||
p._raw(gr("\n"))
|
||||
|
||||
print_sample(p,
|
||||
esc_bang=0x00, gs_size=None,
|
||||
label_en="[1] SMALL (1x1 — normal)",
|
||||
label_gr="")
|
||||
|
||||
print_sample(p,
|
||||
esc_bang=0x30, gs_size=None,
|
||||
label_en="[2] LARGE (2x2 — double height+width)",
|
||||
label_gr="")
|
||||
|
||||
# ── Section 2: The broken single-axis options (for comparison) ────────────
|
||||
divider(p)
|
||||
section(p, "BROKEN ASPECT RATIO (for comparison)")
|
||||
p._raw(gr("These scale only ONE axis — shown so\nyou can confirm they look wrong.\n\n"))
|
||||
|
||||
print_sample(p,
|
||||
esc_bang=0x10, gs_size=None,
|
||||
label_en="[3] Tall only (2x height, 1x width)",
|
||||
label_gr="")
|
||||
|
||||
print_sample(p,
|
||||
esc_bang=0x20, gs_size=None,
|
||||
label_en="[4] Wide only (1x height, 2x width)",
|
||||
label_gr="")
|
||||
|
||||
# ── Section 3: GS ! options — 3x and beyond ──────────────────────────────
|
||||
divider(p)
|
||||
section(p, "GS! LARGER SIZES (3x3, 4x4)")
|
||||
p._raw(gr("These are technically available but\nvery large. Shown for completeness.\n\n"))
|
||||
|
||||
print_sample(p,
|
||||
esc_bang=0x00, gs_size=0x22,
|
||||
label_en="[5] GS! 3x3",
|
||||
label_gr="")
|
||||
|
||||
print_sample(p,
|
||||
esc_bang=0x00, gs_size=0x33,
|
||||
label_en="[6] GS! 4x4",
|
||||
label_gr="")
|
||||
|
||||
# ── Conclusion ────────────────────────────────────────────────────────────
|
||||
divider(p)
|
||||
raw(p, b'\x1b\x61\x01')
|
||||
raw(p, b'\x1b\x21\x00')
|
||||
p._raw(gr("CONCLUSION:\n"))
|
||||
p._raw(gr("[1] Small = use for modifiers/notes\n"))
|
||||
p._raw(gr("[2] Large = use for item names/headers\n"))
|
||||
p._raw(gr("No true 1.5x exists in hardware.\n"))
|
||||
p._raw(gr("GS! 3x3/4x4 available if desired.\n"))
|
||||
|
||||
raw(p, b'\n\n\n')
|
||||
p.cut()
|
||||
p.close()
|
||||
print("Done.")
|
||||
343
local_backend/print_test.py
Normal file
343
local_backend/print_test.py
Normal file
@@ -0,0 +1,343 @@
|
||||
"""
|
||||
Printer comprehensive test script — Jolimark TP850UE
|
||||
Usage: python print_test.py [IP] [PORT]
|
||||
Default: 10.98.20.25:9100
|
||||
|
||||
Prints 6 pages:
|
||||
Page 1 — ESC ! modes, Font A, English
|
||||
Page 2 — ESC ! modes, Font B, English
|
||||
Page 3 — ESC ! modes, Font A, Greek
|
||||
Page 4 — ESC ! modes, Font B, Greek
|
||||
Page 5 — GS ! character size multipliers (both fonts)
|
||||
Page 6 — Beep tests + misc (underline, invert, symbols)
|
||||
|
||||
ESC ! (0x1B 0x21 n) correct bit map for TP850UE:
|
||||
Bit 0 (0x01) — Font B instead of Font A
|
||||
Bit 3 (0x08) — Emphasize / Bold
|
||||
Bit 4 (0x10) — Double-height
|
||||
Bit 5 (0x20) — Double-width
|
||||
Bit 7 (0x80) — Underline
|
||||
|
||||
GS ! (0x1D 0x21 n) character size multiplier:
|
||||
Low nibble (bits 0-3): height multiplier (0=1x, 1=2x, 2=3x … 7=8x)
|
||||
High nibble (bits 4-7): width multiplier (0=1x, 1=2x, 2=3x … 7=8x)
|
||||
e.g. n=0x00 → 1×1, n=0x11 → 2×2, n=0x22 → 3×3, n=0x77 → 8×8
|
||||
"""
|
||||
import sys
|
||||
import time
|
||||
|
||||
PRINTER_IP = sys.argv[1] if len(sys.argv) > 1 else "10.98.20.25"
|
||||
PRINTER_PORT = int(sys.argv[2]) if len(sys.argv) > 2 else 9100
|
||||
|
||||
from escpos.printer import Network
|
||||
|
||||
|
||||
# ── Low-level helpers ──────────────────────────────────────────────────────────
|
||||
|
||||
def _gr(text: str) -> bytes:
|
||||
return text.encode('cp737', errors='replace')
|
||||
|
||||
def _open():
|
||||
p = Network(PRINTER_IP, PRINTER_PORT, timeout=10)
|
||||
p._raw(b'\x1b\x40') # ESC @ — full reset
|
||||
p._raw(b'\x1b\x74\x1d') # ESC t 29 — CP737 Greek code page
|
||||
return p
|
||||
|
||||
def _t(p, text: str):
|
||||
p._raw(_gr(text))
|
||||
|
||||
def _reset(p):
|
||||
"""Reset to: Font A, normal size, no bold, left-align."""
|
||||
p._raw(b'\x1b\x4d\x00') # ESC M 0 — Font A
|
||||
p._raw(b'\x1b\x21\x00') # ESC ! 0 — normal
|
||||
p._raw(b'\x1d\x21\x00') # GS ! 0 — 1×1 size
|
||||
p._raw(b'\x1b\x45\x00') # ESC E 0 — bold off
|
||||
p._raw(b'\x1b\x61\x00') # ESC a 0 — left align
|
||||
|
||||
def _center(p): p._raw(b'\x1b\x61\x01')
|
||||
def _left(p): p._raw(b'\x1b\x61\x00')
|
||||
|
||||
def _divider(p, char="-", width=48):
|
||||
_left(p)
|
||||
_t(p, char * width + "\n")
|
||||
|
||||
def _page_header(p, title: str):
|
||||
_center(p)
|
||||
p._raw(b'\x1b\x21\x28') # double-width + bold (bits 3+5 = 0x28)
|
||||
_t(p, title + "\n")
|
||||
_reset(p)
|
||||
_divider(p, "=")
|
||||
|
||||
|
||||
# ── ESC ! mode table ───────────────────────────────────────────────────────────
|
||||
#
|
||||
# Each entry: (esc_bang_byte, esc_e_bold, label)
|
||||
# esc_bang_byte sets the mode via ESC ! n
|
||||
# esc_e_bold adds ESC E on top (independent bold layer)
|
||||
# We test every useful combination so you can see the exact visual result.
|
||||
|
||||
ESC_BANG_MODES = [
|
||||
# (byte, extra_bold, label)
|
||||
(0x00, False, "0x00 Normal"),
|
||||
(0x00, True, "0x00 +ESC E Normal + Bold (ESC E)"),
|
||||
(0x08, False, "0x08 Bold only (bit3)"),
|
||||
(0x10, False, "0x10 Double-height (bit4)"),
|
||||
(0x10, True, "0x10 +ESC E Double-height + Bold"),
|
||||
(0x18, False, "0x18 Double-height + Bold (bits 3+4)"),
|
||||
(0x20, False, "0x20 Double-width (bit5)"),
|
||||
(0x20, True, "0x20 +ESC E Double-width + Bold"),
|
||||
(0x28, False, "0x28 Double-width + Bold (bits 3+5)"),
|
||||
(0x30, False, "0x30 Double-width + Double-height (bits 4+5)"),
|
||||
(0x38, False, "0x38 Double-width + Double-height + Bold (bits 3+4+5)"),
|
||||
]
|
||||
|
||||
|
||||
def _esc_bang_section(p, english: bool):
|
||||
lang = "EN" if english else "GR"
|
||||
sample_normal = "TEST PRINT Hello 123" if english else "ΔΟΚΙΜΗ ΕΚΤΥΠΩΣΗΣ"
|
||||
sample_lower = "test print hello 123" if english else "δοκιμη εκτυπωσης"
|
||||
|
||||
for (byte_val, extra_bold, label) in ESC_BANG_MODES:
|
||||
_left(p)
|
||||
# Print the label in small normal text first
|
||||
p._raw(b'\x1b\x21\x00')
|
||||
p._raw(b'\x1b\x45\x00')
|
||||
_t(p, f"[{label}]\n")
|
||||
|
||||
# Apply mode
|
||||
p._raw(bytes([0x1b, 0x21, byte_val]))
|
||||
if extra_bold:
|
||||
p._raw(b'\x1b\x45\x01')
|
||||
|
||||
_t(p, sample_normal + "\n")
|
||||
_t(p, sample_lower + "\n")
|
||||
|
||||
# Reset
|
||||
_reset(p)
|
||||
_t(p, "\n")
|
||||
|
||||
_divider(p)
|
||||
|
||||
|
||||
# ── Pages 1–4: ESC ! modes ────────────────────────────────────────────────────
|
||||
|
||||
def page_esc_bang(font_b: bool, english: bool):
|
||||
font_label = "Font B (8x16 small)" if font_b else "Font A (12x24 standard)"
|
||||
lang_label = "GREEK" if not english else "ENGLISH"
|
||||
p = _open()
|
||||
|
||||
# Select font
|
||||
p._raw(b'\x1b\x4d\x01' if font_b else b'\x1b\x4d\x00')
|
||||
|
||||
_page_header(p, f"ESC! MODES — {lang_label} — {font_label[:6]}")
|
||||
_t(p, f"Font: {font_label}\n")
|
||||
_divider(p)
|
||||
|
||||
_esc_bang_section(p, english)
|
||||
|
||||
p._raw(b'\n\n\n')
|
||||
p.cut()
|
||||
p.close()
|
||||
|
||||
|
||||
# ── Page 5: GS ! size multipliers ─────────────────────────────────────────────
|
||||
|
||||
# Combinations worth seeing: square multipliers + some asymmetric
|
||||
GS_SIZES = [
|
||||
(0x00, "1x1 normal"),
|
||||
(0x01, "1w x 2h"),
|
||||
(0x10, "2w x 1h"),
|
||||
(0x11, "2x2"),
|
||||
(0x22, "3x3"),
|
||||
(0x33, "4x4"),
|
||||
(0x44, "5x5"),
|
||||
(0x55, "6x6"),
|
||||
(0x02, "1w x 3h"),
|
||||
(0x20, "3w x 1h"),
|
||||
(0x21, "3w x 2h"),
|
||||
(0x12, "2w x 3h"),
|
||||
]
|
||||
|
||||
def page_gs_sizes():
|
||||
p = _open()
|
||||
_page_header(p, "GS! SIZE MULTIPLIERS")
|
||||
_t(p, "GS ! n (0x1D 0x21 n)\n")
|
||||
_t(p, "Low nibble=height, High nibble=width\n")
|
||||
_divider(p)
|
||||
|
||||
for (byte_val, label) in GS_SIZES:
|
||||
_left(p)
|
||||
# Label in tiny normal text
|
||||
p._raw(b'\x1b\x21\x00')
|
||||
p._raw(b'\x1d\x21\x00')
|
||||
_t(p, f"[n=0x{byte_val:02X} {label}]\n")
|
||||
|
||||
# Font A sample
|
||||
p._raw(b'\x1b\x4d\x00')
|
||||
p._raw(bytes([0x1d, 0x21, byte_val]))
|
||||
_t(p, "Aa SAMPLE\n")
|
||||
p._raw(b'\x1d\x21\x00')
|
||||
|
||||
# Font B sample on same size
|
||||
p._raw(b'\x1b\x4d\x01')
|
||||
p._raw(bytes([0x1d, 0x21, byte_val]))
|
||||
_t(p, "Bb SMALL\n")
|
||||
p._raw(b'\x1d\x21\x00')
|
||||
p._raw(b'\x1b\x4d\x00') # back to Font A
|
||||
|
||||
_t(p, "\n")
|
||||
|
||||
_divider(p)
|
||||
|
||||
# Also show GS ! combined with ESC ! bold
|
||||
_t(p, "\n")
|
||||
_divider(p, "=")
|
||||
p._raw(b'\x1b\x21\x00')
|
||||
_t(p, "GS! + ESC E bold combined:\n")
|
||||
_divider(p, "=")
|
||||
for (byte_val, label) in [(0x11,"2x2"), (0x22,"3x3"), (0x33,"4x4")]:
|
||||
p._raw(b'\x1b\x21\x00')
|
||||
p._raw(b'\x1d\x21\x00')
|
||||
_t(p, f"[{label} + bold]\n")
|
||||
p._raw(b'\x1b\x45\x01')
|
||||
p._raw(bytes([0x1d, 0x21, byte_val]))
|
||||
_t(p, "BOLD LARGE\n")
|
||||
p._raw(b'\x1b\x45\x00')
|
||||
p._raw(b'\x1d\x21\x00')
|
||||
_t(p, "\n")
|
||||
|
||||
p._raw(b'\n\n\n')
|
||||
p.cut()
|
||||
p.close()
|
||||
|
||||
|
||||
# ── Page 6: Beep + misc ────────────────────────────────────────────────────────
|
||||
|
||||
def page_beep_misc():
|
||||
p = _open()
|
||||
_page_header(p, "BEEP + MISC TESTS")
|
||||
|
||||
# ── Beep section ──
|
||||
_t(p, "BEEP TESTS\n")
|
||||
_divider(p, "-")
|
||||
_t(p, "Sending beeps now...\n\n")
|
||||
|
||||
# BEL — single beep (0x07)
|
||||
_t(p, "[1] BEL single beep (0x07)\n")
|
||||
p._raw(b'\x07')
|
||||
time.sleep(0.5)
|
||||
|
||||
# ESC BEL n1 n2 n3 — beep for appointment
|
||||
# n1=beep length (100ms units), n2=intermission (100ms), n3=count
|
||||
_t(p, "[2] ESC BEL: 1 beep, 200ms long\n")
|
||||
p._raw(bytes([0x1b, 0x07, 2, 2, 1])) # 200ms on, 200ms off, 1 beep
|
||||
time.sleep(0.8)
|
||||
|
||||
_t(p, "[3] ESC BEL: 3 short beeps\n")
|
||||
p._raw(bytes([0x1b, 0x07, 1, 1, 3])) # 100ms on, 100ms off, 3 beeps
|
||||
time.sleep(1.5)
|
||||
|
||||
_t(p, "[4] ESC BEL: 1 long beep (500ms)\n")
|
||||
p._raw(bytes([0x1b, 0x07, 5, 2, 1])) # 500ms on, 200ms off, 1 beep
|
||||
time.sleep(1.2)
|
||||
|
||||
_t(p, "[5] GS BEL: 2 beeps\n")
|
||||
p._raw(bytes([0x1d, 0x07, 2, 3, 2])) # 2 beeps, 300ms long, 200ms off
|
||||
time.sleep(1.5)
|
||||
|
||||
_t(p, "Beep tests done.\n")
|
||||
_divider(p)
|
||||
|
||||
# ── Underline ──
|
||||
_t(p, "\nUNDERLINE\n")
|
||||
_divider(p, "-")
|
||||
for ul in [1, 2]:
|
||||
p._raw(bytes([0x1b, 0x2d, ul]))
|
||||
_t(p, f"Underline mode {ul}: Hello World 123\n")
|
||||
p._raw(b'\x1b\x2d\x00')
|
||||
_t(p, "\n")
|
||||
_divider(p)
|
||||
|
||||
# ── White-on-black invert ──
|
||||
_t(p, "\nWHITE-ON-BLACK (GS B)\n")
|
||||
_divider(p, "-")
|
||||
p._raw(b'\x1d\x42\x01')
|
||||
_t(p, " INVERTED NORMAL \n")
|
||||
p._raw(b'\x1d\x21\x11') # 2x2 inverted
|
||||
_t(p, " INVERTED 2x2 \n")
|
||||
p._raw(b'\x1d\x21\x00')
|
||||
p._raw(b'\x1d\x42\x00')
|
||||
_t(p, "Normal after invert\n")
|
||||
_divider(p)
|
||||
|
||||
# ── 90-degree rotation ──
|
||||
_t(p, "\n90-DEGREE ROTATION (ESC V)\n")
|
||||
_divider(p, "-")
|
||||
p._raw(b'\x1b\x56\x01')
|
||||
_t(p, "ROTATED TEXT\n")
|
||||
p._raw(b'\x1b\x56\x00')
|
||||
_t(p, "Normal again\n")
|
||||
_divider(p)
|
||||
|
||||
# ── CP737 useful symbols at normal size ──
|
||||
_t(p, "\nUSEFUL CP737 SYMBOLS\n")
|
||||
_divider(p, "-")
|
||||
symbols = [
|
||||
(0xFB, "tick / checkmark"),
|
||||
(0xFE, "filled square"),
|
||||
(0xF9, "middle dot"),
|
||||
(0xFA, "small bullet"),
|
||||
(0xF8, "degree"),
|
||||
(0xDB, "full block"),
|
||||
(0xDC, "lower half block"),
|
||||
(0xDF, "upper half block"),
|
||||
(0xB0, "light shade"),
|
||||
(0xB1, "medium shade"),
|
||||
(0xB2, "dark shade"),
|
||||
(0xC4, "thin horiz line"),
|
||||
(0xCD, "double horiz line"),
|
||||
(0xBA, "vertical bar"),
|
||||
(0xC9, "top-left corner dbl"),
|
||||
(0xBB, "top-right corner dbl"),
|
||||
(0xC8, "bot-left corner dbl"),
|
||||
(0xBC, "bot-right corner dbl"),
|
||||
]
|
||||
for code, desc in symbols:
|
||||
p._raw(bytes([code, 0x20, code, 0x20, code, 0x20]))
|
||||
_t(p, f" {desc}\n")
|
||||
|
||||
_divider(p)
|
||||
|
||||
p._raw(b'\n\n\n')
|
||||
p.cut()
|
||||
p.close()
|
||||
|
||||
|
||||
# ── Main ───────────────────────────────────────────────────────────────────────
|
||||
|
||||
def main():
|
||||
print(f"Connecting to {PRINTER_IP}:{PRINTER_PORT}")
|
||||
print("Printing 6 pages...\n")
|
||||
|
||||
page_esc_bang(font_b=False, english=True)
|
||||
print("Page 1 done — ESC! modes, Font A, English")
|
||||
|
||||
page_esc_bang(font_b=True, english=True)
|
||||
print("Page 2 done — ESC! modes, Font B, English")
|
||||
|
||||
page_esc_bang(font_b=False, english=False)
|
||||
print("Page 3 done — ESC! modes, Font A, Greek")
|
||||
|
||||
page_esc_bang(font_b=True, english=False)
|
||||
print("Page 4 done — ESC! modes, Font B, Greek")
|
||||
|
||||
page_gs_sizes()
|
||||
print("Page 5 done — GS! size multipliers")
|
||||
|
||||
page_beep_misc()
|
||||
print("Page 6 done — Beep tests + misc")
|
||||
|
||||
print("\nAll done.")
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
10
local_backend/requirements.txt
Normal file
10
local_backend/requirements.txt
Normal file
@@ -0,0 +1,10 @@
|
||||
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
|
||||
python-multipart==0.0.9
|
||||
0
local_backend/routers/__init__.py
Normal file
0
local_backend/routers/__init__.py
Normal file
174
local_backend/routers/auth.py
Normal file
174
local_backend/routers/auth.py
Normal file
@@ -0,0 +1,174 @@
|
||||
import bcrypt
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from database import get_db
|
||||
from models.user import User
|
||||
from schemas.auth import LoginRequest, TokenResponse, UpdateMeRequest
|
||||
from pydantic import BaseModel as _PydanticBase
|
||||
|
||||
class LoginByIdRequest(_PydanticBase):
|
||||
waiter_id: int
|
||||
pin: str
|
||||
from schemas.user import UserOut
|
||||
from routers.deps import get_current_user, make_token, decode_token, blacklist_token
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
class NoAuthLoginRequest(_PydanticBase):
|
||||
username: str
|
||||
|
||||
|
||||
@router.post("/login-no-auth", response_model=TokenResponse)
|
||||
def login_no_auth(body: NoAuthLoginRequest, db: Session = Depends(get_db)):
|
||||
"""Login with no credentials — only works when security.login_method = 'none'."""
|
||||
from models.settings import PosSettings
|
||||
setting = db.query(PosSettings).filter(PosSettings.key == "security.login_method").first()
|
||||
if not setting or setting.value != "none":
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="No-auth login is not enabled.")
|
||||
user = db.query(User).filter(User.username == body.username, User.is_active == True).first()
|
||||
if not user or user.role not in ("manager", "sysadmin"):
|
||||
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("/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:
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid credentials")
|
||||
|
||||
authenticated = False
|
||||
if body.password and user.password_hash:
|
||||
authenticated = bcrypt.checkpw(body.password.encode(), user.password_hash.encode())
|
||||
elif body.pin and user.pin_hash:
|
||||
authenticated = bcrypt.checkpw(body.pin.encode(), user.pin_hash.encode())
|
||||
|
||||
if not authenticated:
|
||||
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("/login-by-id", response_model=TokenResponse)
|
||||
def login_by_id(body: LoginByIdRequest, db: Session = Depends(get_db)):
|
||||
"""Login using waiter id + PIN (used by the waiter-picker login screen)."""
|
||||
user = db.query(User).filter(User.id == body.waiter_id, 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="Λανθασμένο PIN")
|
||||
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")
|
||||
blacklist_token(token)
|
||||
new_token = make_token(user)
|
||||
return TokenResponse(access_token=new_token, user=UserOut.model_validate(user))
|
||||
|
||||
|
||||
@router.post("/logout")
|
||||
def logout(token: str):
|
||||
blacklist_token(token)
|
||||
return {"status": "logged out"}
|
||||
|
||||
|
||||
@router.get("/me", response_model=UserOut)
|
||||
def me(user: User = Depends(get_current_user)):
|
||||
return user
|
||||
|
||||
|
||||
# ─── Public manager list (login screen — no auth required) ───────────────────
|
||||
|
||||
class PublicManagerOut(_PydanticBase):
|
||||
id: int
|
||||
username: str
|
||||
full_name: str | None
|
||||
model_config = {"from_attributes": True}
|
||||
|
||||
|
||||
@router.get("/managers", response_model=list[PublicManagerOut])
|
||||
def public_manager_list(db: Session = Depends(get_db)):
|
||||
"""Public endpoint — returns active manager/sysadmin accounts for login screen."""
|
||||
managers = db.query(User).filter(
|
||||
User.role.in_(["manager", "sysadmin"]),
|
||||
User.is_active == True,
|
||||
).all()
|
||||
return [PublicManagerOut(id=m.id, username=m.username, full_name=m.full_name) for m in managers]
|
||||
|
||||
|
||||
# ─── Public waiter list (login screen — no auth required) ────────────────────
|
||||
|
||||
from pydantic import BaseModel as _BaseModel
|
||||
|
||||
class PublicWaiterOut(_BaseModel):
|
||||
id: int
|
||||
full_name: str | None
|
||||
nickname: str | None
|
||||
avatar_url: str | None
|
||||
on_shift: bool
|
||||
model_config = {"from_attributes": True}
|
||||
|
||||
|
||||
@router.get("/waiters", response_model=list[PublicWaiterOut])
|
||||
def public_waiter_list(db: Session = Depends(get_db)):
|
||||
"""Public endpoint — returns active waiters with on-shift flag. No auth required."""
|
||||
from models.shift import WaiterShift
|
||||
waiters = db.query(User).filter(User.role == "waiter", User.is_active == True).all()
|
||||
on_shift_ids = {
|
||||
row.waiter_id
|
||||
for row in db.query(WaiterShift).filter(WaiterShift.ended_at == None).all()
|
||||
}
|
||||
return [
|
||||
PublicWaiterOut(
|
||||
id=w.id,
|
||||
full_name=w.full_name,
|
||||
nickname=w.nickname,
|
||||
avatar_url=w.avatar_url,
|
||||
on_shift=w.id in on_shift_ids,
|
||||
)
|
||||
for w in waiters
|
||||
]
|
||||
|
||||
|
||||
@router.patch("/me", response_model=UserOut)
|
||||
def update_me(body: UpdateMeRequest, db: Session = Depends(get_db), user: User = Depends(get_current_user)):
|
||||
# Password change — requires current_password verification
|
||||
if body.new_password is not None:
|
||||
if not body.current_password:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="current_password is required to set a new password")
|
||||
if not user.password_hash:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Account has no password set")
|
||||
if not bcrypt.checkpw(body.current_password.encode(), user.password_hash.encode()):
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Current password is incorrect")
|
||||
if len(body.new_password) < 6:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="New password must be at least 6 characters")
|
||||
user.password_hash = bcrypt.hashpw(body.new_password.encode(), bcrypt.gensalt()).decode()
|
||||
|
||||
# PIN change — no current PIN required (already authenticated)
|
||||
if body.new_pin is not None:
|
||||
if len(body.new_pin) < 4:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="PIN must be at least 4 digits")
|
||||
user.pin_hash = bcrypt.hashpw(body.new_pin.encode(), bcrypt.gensalt()).decode()
|
||||
|
||||
# Username change — check uniqueness
|
||||
if body.username is not None and body.username != user.username:
|
||||
conflict = db.query(User).filter(User.username == body.username, User.id != user.id).first()
|
||||
if conflict:
|
||||
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="Username already taken")
|
||||
user.username = body.username
|
||||
|
||||
# Display name
|
||||
if body.full_name is not None:
|
||||
user.full_name = body.full_name
|
||||
|
||||
db.commit()
|
||||
db.refresh(user)
|
||||
return user
|
||||
195
local_backend/routers/business_day.py
Normal file
195
local_backend/routers/business_day.py
Normal file
@@ -0,0 +1,195 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from sqlalchemy import func
|
||||
from sqlalchemy.orm import Session
|
||||
from typing import Optional
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from database import get_db
|
||||
from models.business_day import BusinessDay
|
||||
from models.order import Order, OrderItem, OrderAuditLog
|
||||
from models.shift import WaiterShift
|
||||
from models.flag import TableFlagAssignment
|
||||
from models.message import StaffMessage, StaffMessageAck
|
||||
from schemas.business_day import BusinessDayOut, OpenBusinessDayRequest, CloseBusinessDayRequest
|
||||
from routers.deps import get_current_user, require_manager
|
||||
from models.user import User
|
||||
from middleware.license_check import license_state
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
def _dt(dt):
|
||||
if dt is None:
|
||||
return None
|
||||
return (dt.isoformat() + "Z") if dt.tzinfo is None else dt.isoformat()
|
||||
|
||||
|
||||
@router.get("/current", response_model=Optional[BusinessDayOut])
|
||||
def get_current_business_day(
|
||||
db: Session = Depends(get_db),
|
||||
user: User = Depends(get_current_user),
|
||||
):
|
||||
return db.query(BusinessDay).filter(BusinessDay.status == "open").first()
|
||||
|
||||
|
||||
@router.post("/open", response_model=BusinessDayOut, status_code=status.HTTP_201_CREATED)
|
||||
def open_business_day(
|
||||
body: OpenBusinessDayRequest,
|
||||
db: Session = Depends(get_db),
|
||||
user: User = Depends(require_manager),
|
||||
):
|
||||
existing = db.query(BusinessDay).filter(BusinessDay.status == "open").first()
|
||||
if existing:
|
||||
raise HTTPException(status_code=400, detail="A business day is already open")
|
||||
|
||||
# Gate: admin lock (already enforced or pending)
|
||||
if license_state.get("locked") or license_state.get("lock_pending"):
|
||||
raise HTTPException(
|
||||
status_code=423,
|
||||
detail={
|
||||
"code": "SYSTEM_LOCKED",
|
||||
"message": "Το σύστημα έχει κλειδωθεί από διαχειριστή. Επικοινωνήστε με την υποστήριξη.",
|
||||
},
|
||||
)
|
||||
|
||||
# Gate: license expired and expiry grace period also over
|
||||
if not license_state.get("licensed", True):
|
||||
expires_at = license_state.get("expires_at", "")
|
||||
raise HTTPException(
|
||||
status_code=402,
|
||||
detail={
|
||||
"code": "LICENSE_EXPIRED",
|
||||
"message": "Η άδεια χρήσης έχει λήξει. Ανανεώστε την άδεια ή επικοινωνήστε με την υποστήριξη.",
|
||||
"expires_at": expires_at,
|
||||
},
|
||||
)
|
||||
|
||||
day = BusinessDay(opened_by_id=user.id, notes=body.notes)
|
||||
db.add(day)
|
||||
db.commit()
|
||||
db.refresh(day)
|
||||
return day
|
||||
|
||||
|
||||
@router.post("/close", response_model=BusinessDayOut)
|
||||
def close_business_day(
|
||||
body: CloseBusinessDayRequest,
|
||||
db: Session = Depends(get_db),
|
||||
user: User = Depends(require_manager),
|
||||
):
|
||||
day = db.query(BusinessDay).filter(BusinessDay.status == "open").first()
|
||||
if not day:
|
||||
raise HTTPException(status_code=404, detail="No open business day")
|
||||
|
||||
open_orders = db.query(Order).filter(
|
||||
Order.business_day_id == day.id,
|
||||
Order.status.in_(["open", "partially_paid"]),
|
||||
).all()
|
||||
|
||||
if open_orders and not body.force:
|
||||
# Count orders that have at least one active (unpaid) item — covers both
|
||||
# "open" (fully unpaid) and "partially_paid" (partially unpaid) orders.
|
||||
with_pending = sum(
|
||||
1 for o in open_orders
|
||||
if db.query(OrderItem).filter(
|
||||
OrderItem.order_id == o.id,
|
||||
OrderItem.status == "active",
|
||||
).first() is not None
|
||||
)
|
||||
raise HTTPException(
|
||||
status_code=409,
|
||||
detail={
|
||||
"message": f"{len(open_orders)} table(s) still open, {with_pending} with unpaid items.",
|
||||
"open_orders": len(open_orders),
|
||||
"partially_paid": with_pending,
|
||||
},
|
||||
)
|
||||
|
||||
now = datetime.now(timezone.utc)
|
||||
|
||||
# Close all non-terminal orders for this business day (open, partially_paid, paid)
|
||||
all_unclosed = db.query(Order).filter(
|
||||
Order.business_day_id == day.id,
|
||||
Order.status.in_(["open", "partially_paid", "paid"]),
|
||||
).all()
|
||||
for order in all_unclosed:
|
||||
was_unpaid = order.status in ("open", "partially_paid")
|
||||
order.status = "closed"
|
||||
order.closed_at = now
|
||||
order.closed_by = user.id
|
||||
if was_unpaid:
|
||||
db.add(OrderAuditLog(
|
||||
order_id=order.id,
|
||||
event_type="ORDER_CLOSED",
|
||||
waiter_id=user.id,
|
||||
note="Force-closed at end of business day",
|
||||
))
|
||||
|
||||
active_shifts = db.query(WaiterShift).filter(
|
||||
WaiterShift.business_day_id == day.id,
|
||||
WaiterShift.ended_at == None,
|
||||
).all()
|
||||
for shift in active_shifts:
|
||||
items = db.query(OrderItem).filter(
|
||||
OrderItem.paid_in_shift_id == shift.id,
|
||||
OrderItem.status == "paid",
|
||||
).all()
|
||||
shift.total_collected = sum(i.unit_price * i.quantity for i in items)
|
||||
shift.ended_at = now
|
||||
|
||||
# Clear all table flags and staff messages — fresh slate for the next day
|
||||
db.query(TableFlagAssignment).delete(synchronize_session=False)
|
||||
db.query(StaffMessageAck).delete(synchronize_session=False)
|
||||
db.query(StaffMessage).delete(synchronize_session=False)
|
||||
|
||||
day.status = "closed"
|
||||
day.closed_at = now
|
||||
day.closed_by_id = user.id
|
||||
if body.notes:
|
||||
day.notes = body.notes
|
||||
|
||||
db.commit()
|
||||
db.refresh(day)
|
||||
|
||||
# Deferred lock: if cloud requested a lock while the workday was open,
|
||||
# enforce it now that the day has closed.
|
||||
if license_state.get("lock_pending"):
|
||||
license_state["lock_pending"] = False
|
||||
license_state["locked"] = True
|
||||
from services.cloud_sync import _persist_state
|
||||
_persist_state()
|
||||
|
||||
return day
|
||||
|
||||
|
||||
@router.get("/history")
|
||||
def business_day_history(
|
||||
db: Session = Depends(get_db),
|
||||
user: User = Depends(require_manager),
|
||||
):
|
||||
days = db.query(BusinessDay).order_by(BusinessDay.opened_at.desc()).all()
|
||||
result = []
|
||||
for day in days:
|
||||
order_count = db.query(Order).filter(Order.business_day_id == day.id).count()
|
||||
revenue = (
|
||||
db.query(func.sum(OrderItem.unit_price * OrderItem.quantity))
|
||||
.join(Order)
|
||||
.filter(Order.business_day_id == day.id, OrderItem.status == "paid")
|
||||
.scalar() or 0.0
|
||||
)
|
||||
w_opener = day.opener
|
||||
w_closer = day.closer
|
||||
result.append({
|
||||
"id": day.id,
|
||||
"status": day.status,
|
||||
"opened_at": _dt(day.opened_at),
|
||||
"closed_at": _dt(day.closed_at),
|
||||
"opened_by_id": day.opened_by_id,
|
||||
"opened_by_name": (w_opener.full_name or w_opener.username) if w_opener else None,
|
||||
"closed_by_id": day.closed_by_id,
|
||||
"closed_by_name": (w_closer.full_name or w_closer.username) if w_closer else None,
|
||||
"notes": day.notes,
|
||||
"order_count": order_count,
|
||||
"revenue": round(revenue, 2),
|
||||
})
|
||||
return {"business_days": result}
|
||||
342
local_backend/routers/data_transfer.py
Normal file
342
local_backend/routers/data_transfer.py
Normal file
@@ -0,0 +1,342 @@
|
||||
import json
|
||||
from datetime import datetime, timezone
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from fastapi.responses import JSONResponse
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from database import get_db
|
||||
from models.product import (
|
||||
Category, Product, ProductOption, ProductQuickOption,
|
||||
ProductIngredient, ProductPreferenceSet, ProductPreferenceChoice,
|
||||
)
|
||||
from models.table import Table, TableGroup
|
||||
from models.user import User
|
||||
from routers.deps import require_manager
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
EXPORT_VERSION = 1
|
||||
|
||||
|
||||
def _serialize_product(p: Product) -> dict:
|
||||
quick_options = [
|
||||
{"name": qo.name, "price": qo.price, "allow_multiple": qo.allow_multiple,
|
||||
"sort_order": qo.sort_order, "is_favorite": qo.is_favorite,
|
||||
"favorite_sort_order": qo.favorite_sort_order, "is_compact": qo.is_compact}
|
||||
for qo in p.quick_options
|
||||
]
|
||||
options = []
|
||||
for opt in p.options:
|
||||
sub = json.loads(opt.sub_choices) if opt.sub_choices else []
|
||||
options.append({"name": opt.name, "extra_cost": opt.extra_cost,
|
||||
"allow_multiple": opt.allow_multiple, "sub_choices": sub,
|
||||
"is_favorite": opt.is_favorite, "favorite_sort_order": opt.favorite_sort_order})
|
||||
ingredients = [
|
||||
{"name": ing.name, "extra_cost": ing.extra_cost,
|
||||
"is_favorite": ing.is_favorite, "favorite_sort_order": ing.favorite_sort_order}
|
||||
for ing in p.ingredients
|
||||
]
|
||||
preference_sets = []
|
||||
for ps in p.preference_sets:
|
||||
shared = json.loads(ps.shared_subset) if ps.shared_subset else None
|
||||
default_index = None
|
||||
choices = []
|
||||
for i, ch in enumerate(ps.choices):
|
||||
if ch.id == ps.default_choice_id:
|
||||
default_index = i
|
||||
sub = json.loads(ch.sub_choices) if ch.sub_choices else []
|
||||
choices.append({"name": ch.name, "extra_cost": ch.extra_cost,
|
||||
"sub_choices": sub, "disables_subset": ch.disables_subset})
|
||||
preference_sets.append({
|
||||
"name": ps.name, "choices": choices,
|
||||
"default_choice_index": default_index, "shared_subset": shared,
|
||||
"is_favorite": ps.is_favorite, "favorite_sort_order": ps.favorite_sort_order,
|
||||
})
|
||||
return {
|
||||
"name": p.name, "base_price": p.base_price, "is_available": p.is_available,
|
||||
"lifecycle_status": p.lifecycle_status, "sort_order": p.sort_order,
|
||||
"printer_zone_id": None, # always stripped on export
|
||||
"quick_options": quick_options, "options": options,
|
||||
"ingredients": ingredients, "preference_sets": preference_sets,
|
||||
}
|
||||
|
||||
|
||||
def _serialize_category(cat: Category) -> dict:
|
||||
products = [_serialize_product(p) for p in cat.products if p.lifecycle_status != "archived"]
|
||||
return {
|
||||
"name": cat.name, "color": cat.color, "sort_order": cat.sort_order,
|
||||
"parent_name": cat.parent.name if cat.parent else None,
|
||||
"general_sort_order": cat.general_sort_order, "auto_expanded": cat.auto_expanded,
|
||||
"products": products,
|
||||
}
|
||||
|
||||
|
||||
@router.get("/export/catalog")
|
||||
def export_catalog(db: Session = Depends(get_db), user: User = Depends(require_manager)):
|
||||
categories = db.query(Category).order_by(Category.sort_order).all()
|
||||
orphan_products = (
|
||||
db.query(Product)
|
||||
.filter(Product.category_id == None, Product.lifecycle_status != "archived")
|
||||
.order_by(Product.sort_order)
|
||||
.all()
|
||||
)
|
||||
data = {
|
||||
"categories": [_serialize_category(c) for c in categories],
|
||||
"uncategorized_products": [_serialize_product(p) for p in orphan_products],
|
||||
}
|
||||
payload = {
|
||||
"xenia_export_version": EXPORT_VERSION,
|
||||
"bundle": "catalog",
|
||||
"exported_at": datetime.now(timezone.utc).isoformat(),
|
||||
"data": data,
|
||||
}
|
||||
today = datetime.now(timezone.utc).strftime("%Y-%m-%d")
|
||||
return JSONResponse(
|
||||
content=payload,
|
||||
headers={"Content-Disposition": f'attachment; filename="xenia-catalog-{today}.json"'},
|
||||
)
|
||||
|
||||
|
||||
@router.get("/export/tables")
|
||||
def export_tables(db: Session = Depends(get_db), user: User = Depends(require_manager)):
|
||||
groups = db.query(TableGroup).order_by(TableGroup.sort_order).all()
|
||||
table_groups = []
|
||||
for g in groups:
|
||||
tables = [
|
||||
{"number": t.number, "label": t.label, "is_active": t.is_active,
|
||||
"floor_x": t.floor_x, "floor_y": t.floor_y}
|
||||
for t in sorted(g.tables, key=lambda t: t.number)
|
||||
]
|
||||
table_groups.append({
|
||||
"name": g.name, "prefix": g.prefix, "sort_order": g.sort_order,
|
||||
"color": g.color, "tables": tables,
|
||||
})
|
||||
ungrouped = (
|
||||
db.query(Table)
|
||||
.filter(Table.group_id == None)
|
||||
.order_by(Table.number)
|
||||
.all()
|
||||
)
|
||||
ungrouped_tables = [
|
||||
{"number": t.number, "label": t.label, "is_active": t.is_active,
|
||||
"floor_x": t.floor_x, "floor_y": t.floor_y}
|
||||
for t in ungrouped
|
||||
]
|
||||
payload = {
|
||||
"xenia_export_version": EXPORT_VERSION,
|
||||
"bundle": "tables",
|
||||
"exported_at": datetime.now(timezone.utc).isoformat(),
|
||||
"data": {"table_groups": table_groups, "ungrouped_tables": ungrouped_tables},
|
||||
}
|
||||
today = datetime.now(timezone.utc).strftime("%Y-%m-%d")
|
||||
return JSONResponse(
|
||||
content=payload,
|
||||
headers={"Content-Disposition": f'attachment; filename="xenia-tables-{today}.json"'},
|
||||
)
|
||||
|
||||
|
||||
@router.post("/import/catalog")
|
||||
def import_catalog(payload: dict, db: Session = Depends(get_db), user: User = Depends(require_manager)):
|
||||
if payload.get("bundle") != "catalog":
|
||||
raise HTTPException(status_code=400, detail="Wrong bundle type. Expected 'catalog'.")
|
||||
if payload.get("xenia_export_version", 0) != EXPORT_VERSION:
|
||||
raise HTTPException(status_code=400, detail="Unsupported export version.")
|
||||
|
||||
data = payload.get("data", {})
|
||||
categories_data = data.get("categories", [])
|
||||
|
||||
def _upsert_category(cat_data: dict, parent_id=None) -> Category:
|
||||
existing = db.query(Category).filter(
|
||||
Category.name == cat_data["name"],
|
||||
Category.parent_id == parent_id,
|
||||
).first()
|
||||
if existing:
|
||||
existing.color = cat_data.get("color")
|
||||
existing.sort_order = cat_data.get("sort_order", 0)
|
||||
existing.general_sort_order = cat_data.get("general_sort_order", 0)
|
||||
existing.auto_expanded = cat_data.get("auto_expanded", False)
|
||||
db.flush()
|
||||
return existing
|
||||
else:
|
||||
cat = Category(
|
||||
name=cat_data["name"],
|
||||
color=cat_data.get("color"),
|
||||
sort_order=cat_data.get("sort_order", 0),
|
||||
parent_id=parent_id,
|
||||
general_sort_order=cat_data.get("general_sort_order", 0),
|
||||
auto_expanded=cat_data.get("auto_expanded", False),
|
||||
)
|
||||
db.add(cat)
|
||||
db.flush()
|
||||
return cat
|
||||
|
||||
def _upsert_product(prod_data: dict, category_id=None):
|
||||
existing = db.query(Product).filter(Product.name == prod_data["name"]).first()
|
||||
if existing:
|
||||
existing.base_price = prod_data["base_price"]
|
||||
existing.is_available = prod_data.get("is_available", True)
|
||||
existing.lifecycle_status = prod_data.get("lifecycle_status", "active")
|
||||
existing.sort_order = prod_data.get("sort_order", 0)
|
||||
existing.category_id = category_id
|
||||
existing.printer_zone_id = None
|
||||
db.flush()
|
||||
product = existing
|
||||
else:
|
||||
product = Product(
|
||||
name=prod_data["name"],
|
||||
base_price=prod_data["base_price"],
|
||||
is_available=prod_data.get("is_available", True),
|
||||
lifecycle_status=prod_data.get("lifecycle_status", "active"),
|
||||
sort_order=prod_data.get("sort_order", 0),
|
||||
category_id=category_id,
|
||||
printer_zone_id=None,
|
||||
)
|
||||
db.add(product)
|
||||
db.flush()
|
||||
|
||||
# Replace sub-items (safe: sub-items have no direct order history references)
|
||||
for qo in list(product.quick_options):
|
||||
db.delete(qo)
|
||||
for opt in list(product.options):
|
||||
db.delete(opt)
|
||||
for ing in list(product.ingredients):
|
||||
db.delete(ing)
|
||||
for ps in list(product.preference_sets):
|
||||
db.delete(ps)
|
||||
db.flush()
|
||||
|
||||
for qo in prod_data.get("quick_options", []):
|
||||
db.add(ProductQuickOption(product_id=product.id, **qo))
|
||||
|
||||
for opt in prod_data.get("options", []):
|
||||
sub_json = json.dumps(opt.get("sub_choices", []))
|
||||
db.add(ProductOption(
|
||||
product_id=product.id, name=opt["name"], extra_cost=opt.get("extra_cost", 0.0),
|
||||
allow_multiple=opt.get("allow_multiple", False), sub_choices=sub_json,
|
||||
is_favorite=opt.get("is_favorite", False),
|
||||
favorite_sort_order=opt.get("favorite_sort_order", 0),
|
||||
))
|
||||
|
||||
for ing in prod_data.get("ingredients", []):
|
||||
db.add(ProductIngredient(product_id=product.id, **ing))
|
||||
|
||||
for ps_data in prod_data.get("preference_sets", []):
|
||||
shared_json = json.dumps(ps_data["shared_subset"]) if ps_data.get("shared_subset") else None
|
||||
ps = ProductPreferenceSet(
|
||||
product_id=product.id, name=ps_data["name"],
|
||||
shared_subset=shared_json,
|
||||
is_favorite=ps_data.get("is_favorite", False),
|
||||
favorite_sort_order=ps_data.get("favorite_sort_order", 0),
|
||||
)
|
||||
db.add(ps)
|
||||
db.flush()
|
||||
created_choices = []
|
||||
for ch in ps_data.get("choices", []):
|
||||
sub_json = json.dumps(ch.get("sub_choices", []))
|
||||
choice = ProductPreferenceChoice(
|
||||
set_id=ps.id, name=ch["name"], extra_cost=ch.get("extra_cost", 0.0),
|
||||
sub_choices=sub_json, disables_subset=ch.get("disables_subset", False),
|
||||
)
|
||||
db.add(choice)
|
||||
db.flush()
|
||||
created_choices.append(choice)
|
||||
idx = ps_data.get("default_choice_index")
|
||||
if idx is not None and 0 <= idx < len(created_choices):
|
||||
ps.default_choice_id = created_choices[idx].id
|
||||
|
||||
# First pass: top-level categories (parent_name is None)
|
||||
for cat_data in categories_data:
|
||||
if cat_data.get("parent_name") is None:
|
||||
cat = _upsert_category(cat_data, parent_id=None)
|
||||
for prod_data in cat_data.get("products", []):
|
||||
_upsert_product(prod_data, category_id=cat.id)
|
||||
|
||||
# Second pass: sub-categories (parent must already exist from first pass)
|
||||
for cat_data in categories_data:
|
||||
if cat_data.get("parent_name") is not None:
|
||||
parent = db.query(Category).filter(
|
||||
Category.name == cat_data["parent_name"],
|
||||
Category.parent_id == None,
|
||||
).first()
|
||||
parent_id = parent.id if parent else None
|
||||
cat = _upsert_category(cat_data, parent_id=parent_id)
|
||||
for prod_data in cat_data.get("products", []):
|
||||
_upsert_product(prod_data, category_id=cat.id)
|
||||
|
||||
# Uncategorized products (no category)
|
||||
for prod_data in data.get("uncategorized_products", []):
|
||||
_upsert_product(prod_data, category_id=None)
|
||||
|
||||
db.commit()
|
||||
return {"ok": True, "message": "Catalog imported successfully."}
|
||||
|
||||
|
||||
@router.post("/import/tables")
|
||||
def import_tables(payload: dict, db: Session = Depends(get_db), user: User = Depends(require_manager)):
|
||||
if payload.get("bundle") != "tables":
|
||||
raise HTTPException(status_code=400, detail="Wrong bundle type. Expected 'tables'.")
|
||||
if payload.get("xenia_export_version", 0) != EXPORT_VERSION:
|
||||
raise HTTPException(status_code=400, detail="Unsupported export version.")
|
||||
|
||||
data = payload.get("data", {})
|
||||
for group_data in data.get("table_groups", []):
|
||||
existing_group = db.query(TableGroup).filter(TableGroup.name == group_data["name"]).first()
|
||||
if existing_group:
|
||||
existing_group.prefix = group_data.get("prefix")
|
||||
existing_group.sort_order = group_data.get("sort_order", 0)
|
||||
existing_group.color = group_data.get("color")
|
||||
db.flush()
|
||||
group = existing_group
|
||||
else:
|
||||
group = TableGroup(
|
||||
name=group_data["name"],
|
||||
prefix=group_data.get("prefix"),
|
||||
sort_order=group_data.get("sort_order", 0),
|
||||
color=group_data.get("color"),
|
||||
)
|
||||
db.add(group)
|
||||
db.flush()
|
||||
|
||||
for table_data in group_data.get("tables", []):
|
||||
existing_table = db.query(Table).filter(
|
||||
Table.number == table_data["number"],
|
||||
Table.group_id == group.id,
|
||||
).first()
|
||||
if existing_table:
|
||||
existing_table.label = table_data.get("label")
|
||||
existing_table.is_active = table_data.get("is_active", True)
|
||||
existing_table.floor_x = table_data.get("floor_x")
|
||||
existing_table.floor_y = table_data.get("floor_y")
|
||||
else:
|
||||
db.add(Table(
|
||||
number=table_data["number"],
|
||||
label=table_data.get("label"),
|
||||
group_id=group.id,
|
||||
is_active=table_data.get("is_active", True),
|
||||
floor_x=table_data.get("floor_x"),
|
||||
floor_y=table_data.get("floor_y"),
|
||||
))
|
||||
|
||||
# Ungrouped tables (no zone)
|
||||
for table_data in data.get("ungrouped_tables", []):
|
||||
existing_table = db.query(Table).filter(
|
||||
Table.number == table_data["number"],
|
||||
Table.group_id == None,
|
||||
).first()
|
||||
if existing_table:
|
||||
existing_table.label = table_data.get("label")
|
||||
existing_table.is_active = table_data.get("is_active", True)
|
||||
existing_table.floor_x = table_data.get("floor_x")
|
||||
existing_table.floor_y = table_data.get("floor_y")
|
||||
else:
|
||||
db.add(Table(
|
||||
number=table_data["number"],
|
||||
label=table_data.get("label"),
|
||||
group_id=None,
|
||||
is_active=table_data.get("is_active", True),
|
||||
floor_x=table_data.get("floor_x"),
|
||||
floor_y=table_data.get("floor_y"),
|
||||
))
|
||||
|
||||
db.commit()
|
||||
return {"ok": True, "message": "Tables imported successfully."}
|
||||
64
local_backend/routers/deps.py
Normal file
64
local_backend/routers/deps.py
Normal file
@@ -0,0 +1,64 @@
|
||||
import jwt
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from fastapi import Depends, HTTPException, status
|
||||
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from database import get_db
|
||||
from config import settings
|
||||
from models.user import User
|
||||
|
||||
bearer = HTTPBearer()
|
||||
|
||||
# In-memory token blacklist (cleared on restart — acceptable for local use)
|
||||
_blacklisted_tokens: set[str] = set()
|
||||
|
||||
TOKEN_EXPIRY_HOURS = 8
|
||||
|
||||
|
||||
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")
|
||||
|
||||
|
||||
def blacklist_token(token: str):
|
||||
_blacklisted_tokens.add(token)
|
||||
|
||||
|
||||
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
|
||||
165
local_backend/routers/flags.py
Normal file
165
local_backend/routers/flags.py
Normal file
@@ -0,0 +1,165 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from sqlalchemy.orm import Session
|
||||
from typing import List
|
||||
|
||||
from database import get_db
|
||||
from models.flag import TableFlagDef, TableFlagAssignment
|
||||
from schemas.flag import FlagDefCreate, FlagDefUpdate, FlagDefOut, FlagAssignmentOut, SetTableFlagsRequest
|
||||
from routers.deps import get_current_user, require_manager
|
||||
from models.user import User
|
||||
from services.sse_bus import broadcast_sync
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
# ─── Flag definitions (manager only) ─────────────────────────────────────────
|
||||
|
||||
@router.get("/defs", response_model=List[FlagDefOut])
|
||||
def list_flag_defs(
|
||||
include_inactive: bool = False,
|
||||
db: Session = Depends(get_db),
|
||||
user: User = Depends(get_current_user),
|
||||
):
|
||||
q = db.query(TableFlagDef)
|
||||
if not include_inactive:
|
||||
q = q.filter(TableFlagDef.is_active == True)
|
||||
return q.order_by(TableFlagDef.sort_order, TableFlagDef.id).all()
|
||||
|
||||
|
||||
@router.post("/defs", response_model=FlagDefOut, status_code=status.HTTP_201_CREATED)
|
||||
def create_flag_def(
|
||||
body: FlagDefCreate,
|
||||
db: Session = Depends(get_db),
|
||||
user: User = Depends(require_manager),
|
||||
):
|
||||
flag = TableFlagDef(**body.model_dump())
|
||||
db.add(flag)
|
||||
db.commit()
|
||||
db.refresh(flag)
|
||||
return flag
|
||||
|
||||
|
||||
@router.put("/defs/{flag_id}", response_model=FlagDefOut)
|
||||
def update_flag_def(
|
||||
flag_id: int,
|
||||
body: FlagDefUpdate,
|
||||
db: Session = Depends(get_db),
|
||||
user: User = Depends(require_manager),
|
||||
):
|
||||
flag = db.query(TableFlagDef).filter(TableFlagDef.id == flag_id).first()
|
||||
if not flag:
|
||||
raise HTTPException(status_code=404, detail="Flag not found")
|
||||
for k, v in body.model_dump(exclude_unset=True).items():
|
||||
setattr(flag, k, v)
|
||||
db.commit()
|
||||
db.refresh(flag)
|
||||
return flag
|
||||
|
||||
|
||||
@router.patch("/defs/{flag_id}/toggle-active", response_model=FlagDefOut)
|
||||
def toggle_flag_active(
|
||||
flag_id: int,
|
||||
db: Session = Depends(get_db),
|
||||
user: User = Depends(require_manager),
|
||||
):
|
||||
flag = db.query(TableFlagDef).filter(TableFlagDef.id == flag_id).first()
|
||||
if not flag:
|
||||
raise HTTPException(status_code=404, detail="Flag not found")
|
||||
flag.is_active = not flag.is_active
|
||||
db.commit()
|
||||
db.refresh(flag)
|
||||
return flag
|
||||
|
||||
|
||||
@router.delete("/defs/{flag_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||
def delete_flag_def(
|
||||
flag_id: int,
|
||||
db: Session = Depends(get_db),
|
||||
user: User = Depends(require_manager),
|
||||
):
|
||||
flag = db.query(TableFlagDef).filter(TableFlagDef.id == flag_id).first()
|
||||
if not flag:
|
||||
raise HTTPException(status_code=404, detail="Flag not found")
|
||||
in_use = db.query(TableFlagAssignment).filter(TableFlagAssignment.flag_id == flag_id).count()
|
||||
if in_use:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_409_CONFLICT,
|
||||
detail=f"Η σήμανση χρησιμοποιείται σε {in_use} τραπέζι{'α' if in_use != 1 else ''}. Αφαιρέστε την πρώτα.",
|
||||
)
|
||||
db.delete(flag)
|
||||
db.commit()
|
||||
|
||||
|
||||
# ─── All assignments (bulk endpoint for manager views) ───────────────────────
|
||||
|
||||
@router.get("/assignments", response_model=List[FlagAssignmentOut])
|
||||
def get_all_assignments(
|
||||
db: Session = Depends(get_db),
|
||||
user: User = Depends(get_current_user),
|
||||
):
|
||||
"""All active flag assignments across all tables (for manager dashboard bulk load)."""
|
||||
return db.query(TableFlagAssignment).all()
|
||||
|
||||
|
||||
# ─── Table flag assignments ───────────────────────────────────────────────────
|
||||
|
||||
@router.get("/table/{table_id}", response_model=List[FlagAssignmentOut])
|
||||
def get_table_flags(
|
||||
table_id: int,
|
||||
db: Session = Depends(get_db),
|
||||
user: User = Depends(get_current_user),
|
||||
):
|
||||
return db.query(TableFlagAssignment).filter(
|
||||
TableFlagAssignment.table_id == table_id
|
||||
).all()
|
||||
|
||||
|
||||
@router.put("/table/{table_id}", response_model=List[FlagAssignmentOut])
|
||||
def set_table_flags(
|
||||
table_id: int,
|
||||
body: SetTableFlagsRequest,
|
||||
db: Session = Depends(get_db),
|
||||
user: User = Depends(get_current_user),
|
||||
):
|
||||
"""Replace all flags on a table with the given set of flag_ids."""
|
||||
# Validate all flag_ids exist and are active
|
||||
if body.flag_ids:
|
||||
valid = db.query(TableFlagDef).filter(
|
||||
TableFlagDef.id.in_(body.flag_ids),
|
||||
TableFlagDef.is_active == True,
|
||||
).count()
|
||||
if valid != len(body.flag_ids):
|
||||
raise HTTPException(status_code=400, detail="One or more flag IDs are invalid")
|
||||
|
||||
# Delete existing assignments for this table
|
||||
db.query(TableFlagAssignment).filter(
|
||||
TableFlagAssignment.table_id == table_id
|
||||
).delete(synchronize_session=False)
|
||||
|
||||
# Insert new assignments
|
||||
for flag_id in body.flag_ids:
|
||||
db.add(TableFlagAssignment(
|
||||
table_id=table_id,
|
||||
flag_id=flag_id,
|
||||
assigned_by=user.id,
|
||||
))
|
||||
|
||||
db.commit()
|
||||
result = db.query(TableFlagAssignment).filter(
|
||||
TableFlagAssignment.table_id == table_id
|
||||
).all()
|
||||
broadcast_sync("table_flags_changed", {"table_id": table_id, "flag_ids": body.flag_ids})
|
||||
return result
|
||||
|
||||
|
||||
@router.delete("/table/{table_id}/all", status_code=status.HTTP_204_NO_CONTENT)
|
||||
def clear_table_flags(
|
||||
table_id: int,
|
||||
db: Session = Depends(get_db),
|
||||
user: User = Depends(get_current_user),
|
||||
):
|
||||
db.query(TableFlagAssignment).filter(
|
||||
TableFlagAssignment.table_id == table_id
|
||||
).delete(synchronize_session=False)
|
||||
db.commit()
|
||||
broadcast_sync("table_flags_changed", {"table_id": table_id, "flag_ids": []})
|
||||
215
local_backend/routers/messages.py
Normal file
215
local_backend/routers/messages.py
Normal file
@@ -0,0 +1,215 @@
|
||||
import json
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from sqlalchemy.orm import Session, joinedload
|
||||
from typing import List
|
||||
|
||||
from database import get_db
|
||||
from models.message import StaffMessage, StaffMessageAck, QuickMessageTemplate
|
||||
from models.user import User
|
||||
from schemas.message import (
|
||||
SendMessageRequest, StaffMessageOut,
|
||||
QuickTemplateCreate, QuickTemplateUpdate, QuickTemplateOut,
|
||||
)
|
||||
from routers.deps import get_current_user, require_manager
|
||||
from services.sse_bus import broadcast_sync
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
def _load_msg(db: Session, msg_id: int) -> StaffMessage:
|
||||
"""Reload a message with sender and acks eagerly loaded."""
|
||||
return db.query(StaffMessage).options(
|
||||
joinedload(StaffMessage.sender),
|
||||
joinedload(StaffMessage.acks),
|
||||
).filter(StaffMessage.id == msg_id).one()
|
||||
|
||||
|
||||
def _message_out(msg: StaffMessage) -> StaffMessageOut:
|
||||
sender_name = None
|
||||
try:
|
||||
sender_name = msg.sender.username if msg.sender else None
|
||||
except Exception:
|
||||
pass
|
||||
return StaffMessageOut(
|
||||
id=msg.id,
|
||||
sender_id=msg.sender_id,
|
||||
sender_name=sender_name,
|
||||
body=msg.body,
|
||||
target_waiter_ids=msg.target_waiter_ids,
|
||||
table_ids=msg.table_ids,
|
||||
created_at=msg.created_at,
|
||||
acked_by=[ack.waiter_id for ack in msg.acks],
|
||||
)
|
||||
|
||||
|
||||
# ─── Quick templates ──────────────────────────────────────────────────────────
|
||||
|
||||
@router.get("/templates", response_model=List[QuickTemplateOut])
|
||||
def list_templates(
|
||||
db: Session = Depends(get_db),
|
||||
user: User = Depends(get_current_user),
|
||||
):
|
||||
return db.query(QuickMessageTemplate).filter(
|
||||
QuickMessageTemplate.is_active == True
|
||||
).order_by(QuickMessageTemplate.sort_order, QuickMessageTemplate.id).all()
|
||||
|
||||
|
||||
@router.post("/templates", response_model=QuickTemplateOut, status_code=status.HTTP_201_CREATED)
|
||||
def create_template(
|
||||
body: QuickTemplateCreate,
|
||||
db: Session = Depends(get_db),
|
||||
user: User = Depends(require_manager),
|
||||
):
|
||||
t = QuickMessageTemplate(**body.model_dump())
|
||||
db.add(t)
|
||||
db.commit()
|
||||
db.refresh(t)
|
||||
return t
|
||||
|
||||
|
||||
@router.put("/templates/{template_id}", response_model=QuickTemplateOut)
|
||||
def update_template(
|
||||
template_id: int,
|
||||
body: QuickTemplateUpdate,
|
||||
db: Session = Depends(get_db),
|
||||
user: User = Depends(require_manager),
|
||||
):
|
||||
t = db.query(QuickMessageTemplate).filter(QuickMessageTemplate.id == template_id).first()
|
||||
if not t:
|
||||
raise HTTPException(status_code=404, detail="Template not found")
|
||||
for k, v in body.model_dump(exclude_unset=True).items():
|
||||
setattr(t, k, v)
|
||||
db.commit()
|
||||
db.refresh(t)
|
||||
return t
|
||||
|
||||
|
||||
@router.delete("/templates/{template_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||
def delete_template(
|
||||
template_id: int,
|
||||
db: Session = Depends(get_db),
|
||||
user: User = Depends(require_manager),
|
||||
):
|
||||
t = db.query(QuickMessageTemplate).filter(QuickMessageTemplate.id == template_id).first()
|
||||
if not t:
|
||||
raise HTTPException(status_code=404, detail="Template not found")
|
||||
t.is_active = False
|
||||
db.commit()
|
||||
|
||||
|
||||
# ─── Staff messages ───────────────────────────────────────────────────────────
|
||||
|
||||
@router.post("/send", response_model=StaffMessageOut, status_code=status.HTTP_201_CREATED)
|
||||
def send_message(
|
||||
body: SendMessageRequest,
|
||||
db: Session = Depends(get_db),
|
||||
user: User = Depends(require_manager),
|
||||
):
|
||||
msg = StaffMessage(
|
||||
sender_id=user.id,
|
||||
body=body.body,
|
||||
target_waiter_ids=json.dumps(body.target_waiter_ids),
|
||||
table_ids=json.dumps(body.table_ids or []),
|
||||
)
|
||||
db.add(msg)
|
||||
db.commit()
|
||||
msg = _load_msg(db, msg.id)
|
||||
out = _message_out(msg)
|
||||
# Broadcast to targeted users (empty list = all connected users)
|
||||
target_ids = body.target_waiter_ids if body.target_waiter_ids else None
|
||||
broadcast_sync(
|
||||
"message_sent",
|
||||
{
|
||||
"id": out.id,
|
||||
"sender_id": out.sender_id,
|
||||
"sender_name": out.sender_name,
|
||||
"body": out.body,
|
||||
"table_ids": out.table_ids,
|
||||
"created_at": out.created_at.isoformat() if out.created_at else None,
|
||||
},
|
||||
user_ids=target_ids,
|
||||
)
|
||||
return out
|
||||
|
||||
|
||||
@router.get("/unread", response_model=List[StaffMessageOut])
|
||||
def get_unread_messages(
|
||||
db: Session = Depends(get_db),
|
||||
user: User = Depends(get_current_user),
|
||||
):
|
||||
"""
|
||||
Returns messages targeting this waiter that they haven't acked yet.
|
||||
A message targets a waiter if their ID is in target_waiter_ids,
|
||||
OR if target_waiter_ids is empty (broadcast to all).
|
||||
"""
|
||||
all_msgs = db.query(StaffMessage).options(
|
||||
joinedload(StaffMessage.sender),
|
||||
joinedload(StaffMessage.acks),
|
||||
).order_by(StaffMessage.created_at.desc()).limit(200).all()
|
||||
acked_ids = {
|
||||
ack.message_id
|
||||
for ack in db.query(StaffMessageAck).filter(StaffMessageAck.waiter_id == user.id).all()
|
||||
}
|
||||
|
||||
result = []
|
||||
for msg in all_msgs:
|
||||
if msg.id in acked_ids:
|
||||
continue
|
||||
targets = json.loads(msg.target_waiter_ids or "[]")
|
||||
# Empty list = broadcast to all
|
||||
if not targets or user.id in targets:
|
||||
result.append(_message_out(msg))
|
||||
|
||||
return result
|
||||
|
||||
|
||||
@router.get("/recent", response_model=List[StaffMessageOut])
|
||||
def get_recent_messages(
|
||||
limit: int = 10,
|
||||
db: Session = Depends(get_db),
|
||||
user: User = Depends(get_current_user),
|
||||
):
|
||||
"""Last N messages targeting this user (for notification history drawer)."""
|
||||
all_msgs = db.query(StaffMessage).options(
|
||||
joinedload(StaffMessage.sender),
|
||||
joinedload(StaffMessage.acks),
|
||||
).order_by(StaffMessage.created_at.desc()).limit(200).all()
|
||||
result = []
|
||||
for msg in all_msgs:
|
||||
targets = json.loads(msg.target_waiter_ids or "[]")
|
||||
if not targets or user.id in targets:
|
||||
result.append(_message_out(msg))
|
||||
if len(result) >= limit:
|
||||
break
|
||||
return result
|
||||
|
||||
|
||||
@router.post("/{message_id}/ack", status_code=status.HTTP_204_NO_CONTENT)
|
||||
def ack_message(
|
||||
message_id: int,
|
||||
db: Session = Depends(get_db),
|
||||
user: User = Depends(get_current_user),
|
||||
):
|
||||
msg = db.query(StaffMessage).filter(StaffMessage.id == message_id).first()
|
||||
if not msg:
|
||||
raise HTTPException(status_code=404, detail="Message not found")
|
||||
existing = db.query(StaffMessageAck).filter(
|
||||
StaffMessageAck.message_id == message_id,
|
||||
StaffMessageAck.waiter_id == user.id,
|
||||
).first()
|
||||
if not existing:
|
||||
db.add(StaffMessageAck(message_id=message_id, waiter_id=user.id))
|
||||
db.commit()
|
||||
|
||||
|
||||
@router.get("/all", response_model=List[StaffMessageOut])
|
||||
def list_all_messages(
|
||||
limit: int = 50,
|
||||
db: Session = Depends(get_db),
|
||||
user: User = Depends(require_manager),
|
||||
):
|
||||
msgs = db.query(StaffMessage).options(
|
||||
joinedload(StaffMessage.sender),
|
||||
joinedload(StaffMessage.acks),
|
||||
).order_by(StaffMessage.created_at.desc()).limit(limit).all()
|
||||
return [_message_out(m) for m in msgs]
|
||||
803
local_backend/routers/orders.py
Normal file
803
local_backend/routers/orders.py
Normal file
@@ -0,0 +1,803 @@
|
||||
import json
|
||||
from datetime import datetime, timezone
|
||||
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, OrderAuditLog
|
||||
from models.user import User, WaiterZone
|
||||
from models.table import Table
|
||||
from models.product import Product
|
||||
from schemas.order import OrderCreate, OrderOut, OrderItemOut, AddItemsRequest, AddItemsResponse, PayItemsRequest, OfflinePaymentRequest, AssignWaiterRequest, OrderWaiterOut
|
||||
from pydantic import BaseModel
|
||||
|
||||
class PrintOrderRequest(BaseModel):
|
||||
printer_id: int
|
||||
|
||||
class TransferOrderRequest(BaseModel):
|
||||
target_table_id: int
|
||||
|
||||
class MergeOrderRequest(BaseModel):
|
||||
target_order_id: int
|
||||
|
||||
class SplitItemRequest(BaseModel):
|
||||
quantity: int # how many to split off into a new item row
|
||||
|
||||
class PrintSynopsisRequest(BaseModel):
|
||||
printer_id: int
|
||||
|
||||
class MoveItemsRequest(BaseModel):
|
||||
item_ids: List[int]
|
||||
target_order_id: int
|
||||
|
||||
from routers.deps import get_current_user, require_manager
|
||||
from services.printer_service import route_and_print, route_and_print_sync, print_order_receipt, print_order_synopsis
|
||||
from services.sse_bus import broadcast_sync
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
def _can_access_order(order: Order, user: User, db: Session) -> bool:
|
||||
"""Zone-based access: any waiter whose zone covers the order's table group may act on it."""
|
||||
if user.role in ("manager", "sysadmin"):
|
||||
return True
|
||||
zones = db.query(WaiterZone).filter(WaiterZone.waiter_id == user.id).all()
|
||||
if not zones:
|
||||
return False
|
||||
if any(z.group_id is None for z in zones):
|
||||
return True
|
||||
table = db.query(Table).filter(Table.id == order.table_id).first()
|
||||
if not table:
|
||||
return False
|
||||
allowed_group_ids = {z.group_id for z in zones}
|
||||
return table.group_id in allowed_group_ids
|
||||
|
||||
|
||||
def _audit(db: Session, order_id: int, event_type: str, waiter_id: int = None,
|
||||
item_ids: list = None, amount: float = None, payment_method: str = None, note: str = None):
|
||||
db.add(OrderAuditLog(
|
||||
order_id=order_id,
|
||||
event_type=event_type,
|
||||
waiter_id=waiter_id,
|
||||
item_ids=json.dumps(item_ids) if item_ids is not None else None,
|
||||
amount=amount,
|
||||
payment_method=payment_method,
|
||||
note=note,
|
||||
))
|
||||
|
||||
|
||||
@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]
|
||||
|
||||
|
||||
class ActiveOrderSlim(BaseModel):
|
||||
id: int
|
||||
table_id: int
|
||||
status: str
|
||||
waiter_ids: List[int]
|
||||
model_config = {"from_attributes": True}
|
||||
|
||||
|
||||
@router.get("/active", response_model=List[ActiveOrderSlim])
|
||||
def list_active_orders(db: Session = Depends(get_db), user: User = Depends(get_current_user)):
|
||||
"""All currently open/partially-paid/paid orders (lightweight). Accessible to all staff."""
|
||||
orders = db.query(Order).filter(Order.status.in_(["open", "partially_paid", "paid"])).all()
|
||||
return [
|
||||
ActiveOrderSlim(
|
||||
id=o.id,
|
||||
table_id=o.table_id,
|
||||
status=o.status,
|
||||
waiter_ids=[w.waiter_id for w in o.waiters],
|
||||
)
|
||||
for o in orders
|
||||
]
|
||||
|
||||
|
||||
@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)):
|
||||
from models.business_day import BusinessDay
|
||||
from models.shift import WaiterShift
|
||||
|
||||
active_day = db.query(BusinessDay).filter(BusinessDay.status == "open").first()
|
||||
if not active_day:
|
||||
raise HTTPException(status_code=403, detail="Restaurant is not open — manager must open the business day first")
|
||||
|
||||
if user.role == "waiter":
|
||||
active_shift = db.query(WaiterShift).filter(
|
||||
WaiterShift.waiter_id == user.id,
|
||||
WaiterShift.ended_at == None,
|
||||
).first()
|
||||
if not active_shift:
|
||||
raise HTTPException(status_code=403, detail="You do not have an active shift")
|
||||
|
||||
existing = db.query(Order).filter(
|
||||
Order.table_id == body.table_id,
|
||||
Order.status.in_(["open", "partially_paid", "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, business_day_id=active_day.id)
|
||||
db.add(order)
|
||||
db.flush()
|
||||
db.add(OrderWaiter(order_id=order.id, waiter_id=user.id))
|
||||
_audit(db, order.id, "ORDER_OPENED", waiter_id=user.id)
|
||||
db.commit()
|
||||
db.refresh(order)
|
||||
broadcast_sync("order_updated", {"order_id": order.id, "table_id": order.table_id, "status": order.status, "action": "opened"})
|
||||
return order
|
||||
|
||||
|
||||
@router.post("/{order_id}/items", response_model=AddItemsResponse)
|
||||
def add_items(
|
||||
order_id: int,
|
||||
body: AddItemsRequest,
|
||||
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", "paid"):
|
||||
raise HTTPException(status_code=400, detail="Order is not open")
|
||||
|
||||
# Adding items to a fully-paid order reopens it — partially_paid since prior items were paid
|
||||
if order.status == "paid":
|
||||
order.status = "partially_paid"
|
||||
|
||||
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")
|
||||
extra_cost = sum(
|
||||
(o.price_delta or o.extra_cost or 0.0)
|
||||
for o in (item_in.selected_options or [])
|
||||
)
|
||||
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 + extra_cost,
|
||||
selected_options=json.dumps([o.model_dump() for o in 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)
|
||||
|
||||
_audit(db, order_id, "ITEMS_ADDED", waiter_id=user.id, item_ids=new_item_ids)
|
||||
db.commit()
|
||||
db.refresh(order)
|
||||
|
||||
print_results = route_and_print_sync(order_id, new_item_ids, db)
|
||||
broadcast_sync("order_updated", {"order_id": order.id, "table_id": order.table_id, "status": order.status, "action": "items_added", "item_ids": new_item_ids})
|
||||
return {"order": order, "print_results": print_results}
|
||||
|
||||
|
||||
@router.post("/{order_id}/retry-print", response_model=AddItemsResponse)
|
||||
def retry_print(
|
||||
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")
|
||||
|
||||
unprinted_ids = [item.id for item in order.items if not item.printed and item.status == "active"]
|
||||
if not unprinted_ids:
|
||||
return {"order": order, "print_results": []}
|
||||
|
||||
print_results = route_and_print_sync(order_id, unprinted_ids, db)
|
||||
db.refresh(order)
|
||||
return {"order": order, "print_results": print_results}
|
||||
|
||||
|
||||
@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"
|
||||
_audit(db, order_id, "ITEM_CANCELLED", waiter_id=user.id, item_ids=[item_id])
|
||||
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")
|
||||
|
||||
from models.shift import WaiterShift
|
||||
|
||||
items = db.query(OrderItem).filter(
|
||||
OrderItem.id.in_(body.item_ids),
|
||||
OrderItem.order_id == order_id,
|
||||
OrderItem.status == "active",
|
||||
).all()
|
||||
now = datetime.now(timezone.utc)
|
||||
active_shift = db.query(WaiterShift).filter(
|
||||
WaiterShift.waiter_id == user.id,
|
||||
WaiterShift.ended_at == None,
|
||||
).first()
|
||||
total_paid = 0.0
|
||||
for item in items:
|
||||
item.status = "paid"
|
||||
item.paid_by = user.id
|
||||
item.paid_at = now
|
||||
item.payment_method = body.payment_method
|
||||
item.paid_in_shift_id = active_shift.id if active_shift else None
|
||||
total_paid += item.unit_price * item.quantity
|
||||
|
||||
db.flush() # write item status changes before counting, since autoflush=False
|
||||
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"
|
||||
|
||||
paid_ids = [i.id for i in items]
|
||||
_audit(db, order_id, "PAYMENT", waiter_id=user.id, item_ids=paid_ids,
|
||||
amount=total_paid, payment_method=body.payment_method)
|
||||
db.commit()
|
||||
broadcast_sync("order_paid", {"order_id": order_id, "table_id": order.table_id, "status": order.status, "paid_item_ids": paid_ids, "amount": total_paid, "payment_method": body.payment_method})
|
||||
return {"status": order.status, "paid_item_ids": paid_ids}
|
||||
|
||||
|
||||
@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.now(timezone.utc)
|
||||
order.closed_by = user.id
|
||||
_audit(db, order_id, "ORDER_CLOSED", waiter_id=user.id)
|
||||
db.commit()
|
||||
broadcast_sync("order_closed", {"order_id": order_id, "table_id": order.table_id})
|
||||
return {"status": "closed"}
|
||||
|
||||
|
||||
@router.post("/{order_id}/pay-offline")
|
||||
def pay_items_offline(
|
||||
order_id: int,
|
||||
body: OfflinePaymentRequest,
|
||||
db: Session = Depends(get_db),
|
||||
user: User = Depends(get_current_user),
|
||||
):
|
||||
"""
|
||||
Sync an emergency payment that was taken while the server was offline.
|
||||
The UUID prevents double-processing. If a payment with the same UUID already
|
||||
exists on this order, the duplicate is logged in red (is_duplicate=1) rather
|
||||
than silently dropped — so managers can reconcile.
|
||||
"""
|
||||
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")
|
||||
|
||||
# Check for duplicate UUID on this order
|
||||
existing_uuid = db.query(OrderAuditLog).filter(
|
||||
OrderAuditLog.order_id == order_id,
|
||||
OrderAuditLog.offline_uuid == body.uuid,
|
||||
).first()
|
||||
is_duplicate = existing_uuid is not None
|
||||
|
||||
from models.shift import WaiterShift
|
||||
items = db.query(OrderItem).filter(
|
||||
OrderItem.id.in_(body.item_ids),
|
||||
OrderItem.order_id == order_id,
|
||||
OrderItem.status == "active",
|
||||
).all()
|
||||
|
||||
# Reject empty payments — client had no offline snapshot for this table
|
||||
if not items and not is_duplicate:
|
||||
raise HTTPException(status_code=400, detail="No active items found — payment rejected")
|
||||
|
||||
# Use the client-recorded offline timestamp as paid_at so audit reflects real payment time
|
||||
try:
|
||||
paid_at = datetime.fromisoformat(body.offline_at.replace("Z", "+00:00")) if body.offline_at else datetime.now(timezone.utc)
|
||||
except (ValueError, AttributeError):
|
||||
paid_at = datetime.now(timezone.utc)
|
||||
|
||||
active_shift = db.query(WaiterShift).filter(
|
||||
WaiterShift.waiter_id == user.id,
|
||||
WaiterShift.ended_at == None,
|
||||
).first()
|
||||
|
||||
total_paid = 0.0
|
||||
paid_ids = []
|
||||
if not is_duplicate:
|
||||
for item in items:
|
||||
item.status = "paid"
|
||||
item.paid_by = user.id
|
||||
item.paid_at = paid_at
|
||||
item.payment_method = body.payment_method
|
||||
item.paid_in_shift_id = active_shift.id if active_shift else None
|
||||
total_paid += item.unit_price * item.quantity
|
||||
paid_ids.append(item.id)
|
||||
|
||||
db.flush()
|
||||
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"
|
||||
else:
|
||||
# Duplicate — compute total for audit record without changing item state
|
||||
total_paid = sum(i.unit_price * i.quantity for i in items)
|
||||
paid_ids = [i.id for i in items]
|
||||
|
||||
# Always write audit log — duplicate flag makes it visible in red in manager dashboard
|
||||
db.add(OrderAuditLog(
|
||||
order_id=order_id,
|
||||
event_type="PAYMENT_OFFLINE",
|
||||
waiter_id=user.id,
|
||||
item_ids=json.dumps(paid_ids),
|
||||
amount=total_paid,
|
||||
payment_method=body.payment_method,
|
||||
note=f"Emergency offline payment (uuid={body.uuid}){' — DUPLICATE' if is_duplicate else ''}",
|
||||
offline_uuid=body.uuid,
|
||||
offline_at=body.offline_at,
|
||||
is_duplicate=1 if is_duplicate else 0,
|
||||
))
|
||||
db.commit()
|
||||
|
||||
if not is_duplicate:
|
||||
broadcast_sync("order_paid", {"order_id": order_id, "table_id": order.table_id, "status": order.status, "paid_item_ids": paid_ids, "amount": total_paid, "payment_method": body.payment_method})
|
||||
|
||||
return {
|
||||
"status": order.status if not is_duplicate else "duplicate",
|
||||
"paid_item_ids": paid_ids,
|
||||
"is_duplicate": is_duplicate,
|
||||
}
|
||||
|
||||
|
||||
@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.now(timezone.utc)
|
||||
order.closed_by = user.id
|
||||
_audit(db, order_id, "ORDER_CANCELLED", waiter_id=user.id)
|
||||
db.commit()
|
||||
broadcast_sync("order_closed", {"order_id": order_id, "table_id": order.table_id})
|
||||
|
||||
|
||||
@router.put("/{order_id}/assign-waiter")
|
||||
def assign_waiter(order_id: int, body: AssignWaiterRequest, 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")
|
||||
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()
|
||||
|
||||
|
||||
@router.post("/{order_id}/print")
|
||||
def print_order(
|
||||
order_id: int,
|
||||
body: PrintOrderRequest,
|
||||
background_tasks: BackgroundTasks,
|
||||
db: Session = Depends(get_db),
|
||||
user: User = Depends(require_manager),
|
||||
):
|
||||
from models.printer import Printer
|
||||
|
||||
order = db.query(Order).filter(Order.id == order_id).first()
|
||||
if not order:
|
||||
raise HTTPException(status_code=404, detail="Order not found")
|
||||
|
||||
printer = db.query(Printer).filter(Printer.id == body.printer_id, Printer.is_active == True).first()
|
||||
if not printer:
|
||||
raise HTTPException(status_code=404, detail="Printer not found or inactive")
|
||||
|
||||
table = db.query(Table).filter(Table.id == order.table_id).first()
|
||||
table_name = (table.label or f"T{table.number}") if table else f"#{order.table_id}"
|
||||
|
||||
opener = db.query(User).filter(User.id == order.opened_by).first()
|
||||
waiter_name = opener.username if opener else f"#{order.opened_by}"
|
||||
|
||||
items_data = []
|
||||
for item in order.items:
|
||||
if item.status == "cancelled":
|
||||
continue
|
||||
product_name = item.product.name if item.product else f"#{item.product_id}"
|
||||
items_data.append({
|
||||
"name": product_name,
|
||||
"quantity": item.quantity,
|
||||
"unit_price": item.unit_price,
|
||||
"total": item.unit_price * item.quantity,
|
||||
"status": item.status,
|
||||
})
|
||||
|
||||
grand_total = sum(i["total"] for i in items_data)
|
||||
|
||||
receipt = {
|
||||
"order_id": order.id,
|
||||
"table_name": table_name,
|
||||
"waiter_name": waiter_name,
|
||||
"opened_at": order.opened_at.strftime("%d/%m/%Y %H:%M"),
|
||||
"closed_at": order.closed_at.strftime("%d/%m/%Y %H:%M") if order.closed_at else None,
|
||||
"status": order.status,
|
||||
"items": items_data,
|
||||
"total": grand_total,
|
||||
"notes": order.notes,
|
||||
}
|
||||
|
||||
background_tasks.add_task(print_order_receipt, printer.ip_address, printer.port, receipt)
|
||||
return {"status": "printing"}
|
||||
|
||||
|
||||
# ─── Transfer order to a different table ─────────────────────────────────────
|
||||
|
||||
@router.post("/{order_id}/transfer")
|
||||
def transfer_order(
|
||||
order_id: int,
|
||||
body: TransferOrderRequest,
|
||||
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", "paid"):
|
||||
raise HTTPException(status_code=400, detail="Order is not active")
|
||||
|
||||
target_table = db.query(Table).filter(Table.id == body.target_table_id, Table.is_active == True).first()
|
||||
if not target_table:
|
||||
raise HTTPException(status_code=404, detail="Target table not found")
|
||||
if body.target_table_id == order.table_id:
|
||||
raise HTTPException(status_code=400, detail="Table is already assigned to this order")
|
||||
|
||||
conflict = db.query(Order).filter(
|
||||
Order.table_id == body.target_table_id,
|
||||
Order.status.in_(["open", "partially_paid", "paid"]),
|
||||
).first()
|
||||
if conflict:
|
||||
raise HTTPException(status_code=400, detail="Target table already has an active order")
|
||||
|
||||
old_table_id = order.table_id
|
||||
order.table_id = body.target_table_id
|
||||
_audit(db, order_id, "TABLE_TRANSFER", waiter_id=user.id,
|
||||
note=f"Transferred from table {old_table_id} to table {body.target_table_id}")
|
||||
db.commit()
|
||||
db.refresh(order)
|
||||
broadcast_sync("order_updated", {"order_id": order.id, "table_id": order.table_id, "old_table_id": old_table_id, "status": order.status, "action": "transferred"})
|
||||
return order
|
||||
|
||||
|
||||
# ─── Merge another order into this one ───────────────────────────────────────
|
||||
|
||||
@router.post("/{order_id}/merge")
|
||||
def merge_order(
|
||||
order_id: int,
|
||||
body: MergeOrderRequest,
|
||||
db: Session = Depends(get_db),
|
||||
user: User = Depends(get_current_user),
|
||||
):
|
||||
"""
|
||||
Merge source order (order_id) INTO target order (body.target_order_id).
|
||||
All items (paid + active) from the source are reassigned to the target.
|
||||
Source waiters are added to the target if not already there.
|
||||
Source order is cancelled with audit note.
|
||||
"""
|
||||
source = db.query(Order).filter(Order.id == order_id).first()
|
||||
if not source:
|
||||
raise HTTPException(status_code=404, detail="Source order not found")
|
||||
if not _can_access_order(source, user, db):
|
||||
raise HTTPException(status_code=403, detail="Access denied")
|
||||
if source.status not in ("open", "partially_paid", "paid"):
|
||||
raise HTTPException(status_code=400, detail="Source order is not active")
|
||||
|
||||
target = db.query(Order).filter(Order.id == body.target_order_id).first()
|
||||
if not target:
|
||||
raise HTTPException(status_code=404, detail="Target order not found")
|
||||
if not _can_access_order(target, user, db):
|
||||
raise HTTPException(status_code=403, detail="Access denied to target order")
|
||||
if target.status not in ("open", "partially_paid", "paid"):
|
||||
raise HTTPException(status_code=400, detail="Target order is not active")
|
||||
if source.id == target.id:
|
||||
raise HTTPException(status_code=400, detail="Cannot merge an order with itself")
|
||||
|
||||
# Move all items to target order
|
||||
moved_item_ids = []
|
||||
for item in source.items:
|
||||
item.order_id = target.id
|
||||
moved_item_ids.append(item.id)
|
||||
|
||||
# Copy source waiters to target (no duplicates)
|
||||
existing_waiter_ids = {w.waiter_id for w in target.waiters}
|
||||
for ow in source.waiters:
|
||||
if ow.waiter_id not in existing_waiter_ids:
|
||||
db.add(OrderWaiter(order_id=target.id, waiter_id=ow.waiter_id))
|
||||
|
||||
# Recompute target status after flush
|
||||
db.flush()
|
||||
active_remaining = db.query(OrderItem).filter(
|
||||
OrderItem.order_id == target.id, OrderItem.status == "active"
|
||||
).count()
|
||||
paid_exists = db.query(OrderItem).filter(
|
||||
OrderItem.order_id == target.id, OrderItem.status == "paid"
|
||||
).count()
|
||||
if active_remaining > 0:
|
||||
target.status = "partially_paid" if paid_exists > 0 else "open"
|
||||
else:
|
||||
target.status = "paid"
|
||||
|
||||
# Cancel source order
|
||||
source.status = "cancelled"
|
||||
source.closed_at = datetime.now(timezone.utc)
|
||||
source.closed_by = user.id
|
||||
|
||||
_audit(db, source.id, "ORDER_CANCELLED", waiter_id=user.id,
|
||||
note=f"Merged into order #{target.id} (table {target.table_id})")
|
||||
_audit(db, target.id, "ITEMS_ADDED", waiter_id=user.id, item_ids=moved_item_ids,
|
||||
note=f"Items merged from order #{source.id} (table {source.table_id})")
|
||||
|
||||
db.commit()
|
||||
db.refresh(target)
|
||||
broadcast_sync("order_updated", {"order_id": target.id, "table_id": target.table_id, "status": target.status, "action": "merged"})
|
||||
broadcast_sync("order_closed", {"order_id": source.id, "table_id": source.table_id})
|
||||
return target
|
||||
|
||||
|
||||
# ─── Split a stacked item into two rows ──────────────────────────────────────
|
||||
|
||||
@router.post("/{order_id}/items/{item_id}/split", response_model=List[OrderItemOut])
|
||||
def split_item(
|
||||
order_id: int,
|
||||
item_id: int,
|
||||
body: SplitItemRequest,
|
||||
db: Session = Depends(get_db),
|
||||
user: User = Depends(get_current_user),
|
||||
):
|
||||
"""
|
||||
Split qty units off item_id into a new item row.
|
||||
Both rows share all properties (product, price, options, notes).
|
||||
Only active items can be split.
|
||||
"""
|
||||
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 item.status != "active":
|
||||
raise HTTPException(status_code=400, detail="Only active items can be split")
|
||||
if body.quantity <= 0 or body.quantity >= item.quantity:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"Split quantity must be between 1 and {item.quantity - 1}"
|
||||
)
|
||||
|
||||
order = db.query(Order).filter(Order.id == order_id).first()
|
||||
if not _can_access_order(order, user, db):
|
||||
raise HTTPException(status_code=403, detail="Access denied")
|
||||
|
||||
# Reduce original item
|
||||
item.quantity -= body.quantity
|
||||
|
||||
# Create split-off item
|
||||
new_item = OrderItem(
|
||||
order_id=order_id,
|
||||
product_id=item.product_id,
|
||||
added_by=item.added_by,
|
||||
quantity=body.quantity,
|
||||
unit_price=item.unit_price,
|
||||
selected_options=item.selected_options,
|
||||
removed_ingredients=item.removed_ingredients,
|
||||
notes=item.notes,
|
||||
status="active",
|
||||
printed=item.printed,
|
||||
)
|
||||
db.add(new_item)
|
||||
db.commit()
|
||||
db.refresh(item)
|
||||
db.refresh(new_item)
|
||||
return [item, new_item]
|
||||
|
||||
|
||||
# ─── Move selected items to another order ────────────────────────────────────
|
||||
|
||||
@router.post("/{order_id}/move-items")
|
||||
def move_items(
|
||||
order_id: int,
|
||||
body: MoveItemsRequest,
|
||||
db: Session = Depends(get_db),
|
||||
user: User = Depends(get_current_user),
|
||||
):
|
||||
"""Move specific active items from this order to another open order."""
|
||||
source = db.query(Order).filter(Order.id == order_id).first()
|
||||
if not source:
|
||||
raise HTTPException(status_code=404, detail="Source order not found")
|
||||
if not _can_access_order(source, user, db):
|
||||
raise HTTPException(status_code=403, detail="Access denied")
|
||||
if source.status not in ("open", "partially_paid"):
|
||||
raise HTTPException(status_code=400, detail="Source order is not active")
|
||||
|
||||
target = db.query(Order).filter(Order.id == body.target_order_id).first()
|
||||
if not target:
|
||||
raise HTTPException(status_code=404, detail="Target order not found")
|
||||
if not _can_access_order(target, user, db):
|
||||
raise HTTPException(status_code=403, detail="Access denied to target order")
|
||||
if target.status not in ("open", "partially_paid"):
|
||||
raise HTTPException(status_code=400, detail="Target order is not active")
|
||||
if source.id == target.id:
|
||||
raise HTTPException(status_code=400, detail="Source and target orders are the same")
|
||||
|
||||
items = db.query(OrderItem).filter(
|
||||
OrderItem.id.in_(body.item_ids),
|
||||
OrderItem.order_id == order_id,
|
||||
OrderItem.status == "active",
|
||||
).all()
|
||||
if not items:
|
||||
raise HTTPException(status_code=400, detail="No active items found to move")
|
||||
|
||||
moved_ids = []
|
||||
for item in items:
|
||||
item.order_id = target.id
|
||||
moved_ids.append(item.id)
|
||||
|
||||
# Recompute source status
|
||||
db.flush()
|
||||
src_active = db.query(OrderItem).filter(OrderItem.order_id == source.id, OrderItem.status == "active").count()
|
||||
src_paid = db.query(OrderItem).filter(OrderItem.order_id == source.id, OrderItem.status == "paid").count()
|
||||
if src_active == 0 and src_paid == 0:
|
||||
source.status = "open"
|
||||
elif src_active == 0:
|
||||
source.status = "paid"
|
||||
else:
|
||||
source.status = "partially_paid" if src_paid > 0 else "open"
|
||||
|
||||
# Recompute target status
|
||||
tgt_active = db.query(OrderItem).filter(OrderItem.order_id == target.id, OrderItem.status == "active").count()
|
||||
tgt_paid = db.query(OrderItem).filter(OrderItem.order_id == target.id, OrderItem.status == "paid").count()
|
||||
target.status = "partially_paid" if (tgt_active > 0 and tgt_paid > 0) else ("paid" if tgt_active == 0 else "open")
|
||||
|
||||
_audit(db, source.id, "ITEMS_MOVED_OUT", waiter_id=user.id, item_ids=moved_ids,
|
||||
note=f"Moved to order #{target.id} (table {target.table_id})")
|
||||
_audit(db, target.id, "ITEMS_MOVED_IN", waiter_id=user.id, item_ids=moved_ids,
|
||||
note=f"Moved from order #{source.id} (table {source.table_id})")
|
||||
|
||||
db.commit()
|
||||
db.refresh(source)
|
||||
return {"moved_item_ids": moved_ids, "source_status": source.status, "target_status": target.status}
|
||||
|
||||
|
||||
# ─── Print order synopsis ─────────────────────────────────────────────────────
|
||||
|
||||
@router.post("/{order_id}/print-synopsis")
|
||||
def print_synopsis(
|
||||
order_id: int,
|
||||
body: PrintSynopsisRequest,
|
||||
background_tasks: BackgroundTasks,
|
||||
db: Session = Depends(get_db),
|
||||
user: User = Depends(get_current_user),
|
||||
):
|
||||
from models.printer import Printer
|
||||
|
||||
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")
|
||||
|
||||
printer = db.query(Printer).filter(Printer.id == body.printer_id, Printer.is_active == True).first()
|
||||
if not printer:
|
||||
raise HTTPException(status_code=404, detail="Printer not found or inactive")
|
||||
|
||||
table = db.query(Table).filter(Table.id == order.table_id).first()
|
||||
table_name = (table.label or f"T{table.number}") if table else f"#{order.table_id}"
|
||||
opener = db.query(User).filter(User.id == order.opened_by).first()
|
||||
waiter_name = (opener.nickname or opener.username) if opener else f"#{order.opened_by}"
|
||||
|
||||
items_data = []
|
||||
for item in order.items:
|
||||
if item.status == "cancelled":
|
||||
continue
|
||||
product_name = item.product.name if item.product else f"#{item.product_id}"
|
||||
items_data.append({
|
||||
"name": product_name,
|
||||
"quantity": item.quantity,
|
||||
"unit_price": item.unit_price,
|
||||
"total": item.unit_price * item.quantity,
|
||||
"status": item.status,
|
||||
})
|
||||
|
||||
total = sum(i["total"] for i in items_data)
|
||||
paid_total = sum(i["total"] for i in items_data if i["status"] == "paid")
|
||||
|
||||
synopsis = {
|
||||
"order_id": order.id,
|
||||
"table_name": table_name,
|
||||
"waiter_name": waiter_name,
|
||||
"opened_at": order.opened_at.strftime("%d/%m/%Y %H:%M"),
|
||||
"items": items_data,
|
||||
"total": total,
|
||||
"paid_total": paid_total,
|
||||
"remaining": total - paid_total,
|
||||
}
|
||||
|
||||
background_tasks.add_task(print_order_synopsis, printer.ip_address, printer.port, synopsis)
|
||||
return {"status": "printing"}
|
||||
331
local_backend/routers/products.py
Normal file
331
local_backend/routers/products.py
Normal file
@@ -0,0 +1,331 @@
|
||||
import os
|
||||
import uuid
|
||||
import json
|
||||
from fastapi import APIRouter, Depends, HTTPException, status, UploadFile, File
|
||||
from sqlalchemy.orm import Session
|
||||
from typing import List
|
||||
|
||||
from database import get_db
|
||||
from models.product import Product, Category, ProductOption, ProductQuickOption, ProductIngredient, ProductPreferenceSet, ProductPreferenceChoice
|
||||
from models.order import OrderItem
|
||||
from models.user import User
|
||||
from schemas.product import (
|
||||
ProductCreate, ProductUpdate, ProductOut, ProductReorderItem,
|
||||
CategoryCreate, CategoryUpdate, CategoryOut, CategoryReorderItem,
|
||||
SubcategoryReorderItem, ParentGeneralReorderItem,
|
||||
PreferenceSetCreate, ProductQuickOptionCreate,
|
||||
CategoryReparentRequest,
|
||||
)
|
||||
from routers.deps import get_current_user, require_manager
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
IMAGE_DIR = "/app/data/product_images"
|
||||
|
||||
|
||||
def _replace_quick_options(db, product, quick_options):
|
||||
for qo in product.quick_options:
|
||||
db.delete(qo)
|
||||
db.flush()
|
||||
for i, qo in enumerate(quick_options):
|
||||
db.add(ProductQuickOption(
|
||||
product_id=product.id,
|
||||
name=qo.name,
|
||||
price=qo.price,
|
||||
allow_multiple=qo.allow_multiple,
|
||||
sort_order=qo.sort_order if qo.sort_order else i,
|
||||
is_favorite=qo.is_favorite,
|
||||
favorite_sort_order=qo.favorite_sort_order,
|
||||
is_compact=qo.is_compact,
|
||||
))
|
||||
|
||||
|
||||
def _replace_options(db, product, options):
|
||||
for opt in product.options:
|
||||
db.delete(opt)
|
||||
db.flush()
|
||||
for opt in options:
|
||||
sub_json = json.dumps([s.model_dump() for s in opt.sub_choices]) if opt.sub_choices else None
|
||||
db.add(ProductOption(
|
||||
product_id=product.id,
|
||||
name=opt.name,
|
||||
extra_cost=opt.extra_cost,
|
||||
allow_multiple=opt.allow_multiple,
|
||||
sub_choices=sub_json,
|
||||
is_favorite=opt.is_favorite,
|
||||
favorite_sort_order=opt.favorite_sort_order,
|
||||
))
|
||||
|
||||
|
||||
def _replace_ingredients(db, product, ingredients):
|
||||
for ing in product.ingredients:
|
||||
db.delete(ing)
|
||||
db.flush()
|
||||
for ing in ingredients:
|
||||
db.add(ProductIngredient(product_id=product.id, **ing.model_dump()))
|
||||
|
||||
|
||||
def _replace_preference_sets(db, product, sets: List[PreferenceSetCreate]):
|
||||
for ps in product.preference_sets:
|
||||
db.delete(ps)
|
||||
db.flush()
|
||||
for ps in sets:
|
||||
shared_json = json.dumps(ps.shared_subset.model_dump()) if ps.shared_subset else None
|
||||
new_set = ProductPreferenceSet(
|
||||
product_id=product.id,
|
||||
name=ps.name,
|
||||
shared_subset=shared_json,
|
||||
is_favorite=ps.is_favorite,
|
||||
favorite_sort_order=ps.favorite_sort_order,
|
||||
)
|
||||
db.add(new_set)
|
||||
db.flush()
|
||||
created_choices = []
|
||||
for ch in ps.choices:
|
||||
sub_json = json.dumps([s.model_dump() for s in ch.sub_choices]) if ch.sub_choices else None
|
||||
choice = ProductPreferenceChoice(
|
||||
set_id=new_set.id,
|
||||
name=ch.name,
|
||||
extra_cost=ch.extra_cost,
|
||||
sub_choices=sub_json,
|
||||
disables_subset=ch.disables_subset,
|
||||
)
|
||||
db.add(choice)
|
||||
db.flush()
|
||||
created_choices.append(choice)
|
||||
if ps.default_choice_index is not None and 0 <= ps.default_choice_index < len(created_choices):
|
||||
new_set.default_choice_id = created_choices[ps.default_choice_index].id
|
||||
|
||||
|
||||
# ── 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)):
|
||||
# sort_order is among siblings (same parent_id level)
|
||||
sibling_count = db.query(Category).filter(Category.parent_id == body.parent_id).count()
|
||||
cat = Category(
|
||||
name=body.name,
|
||||
color=body.color,
|
||||
sort_order=sibling_count,
|
||||
parent_id=body.parent_id,
|
||||
general_sort_order=body.general_sort_order,
|
||||
)
|
||||
db.add(cat)
|
||||
db.commit()
|
||||
db.refresh(cat)
|
||||
return cat
|
||||
|
||||
|
||||
@router.put("/categories/reorder", status_code=status.HTTP_204_NO_CONTENT)
|
||||
def reorder_categories(items: List[CategoryReorderItem], db: Session = Depends(get_db), user: User = Depends(require_manager)):
|
||||
for item in items:
|
||||
cat = db.query(Category).filter(Category.id == item.id).first()
|
||||
if cat:
|
||||
cat.sort_order = item.sort_order
|
||||
db.commit()
|
||||
|
||||
|
||||
@router.put("/categories/reorder-subcategories", status_code=status.HTTP_204_NO_CONTENT)
|
||||
def reorder_subcategories(items: List[SubcategoryReorderItem], db: Session = Depends(get_db), user: User = Depends(require_manager)):
|
||||
"""Reorder sub-categories within their parent (sort_order among siblings)."""
|
||||
for item in items:
|
||||
cat = db.query(Category).filter(Category.id == item.id).first()
|
||||
if cat:
|
||||
cat.sort_order = item.sort_order
|
||||
db.commit()
|
||||
|
||||
|
||||
@router.put("/categories/reorder-general", status_code=status.HTTP_204_NO_CONTENT)
|
||||
def reorder_general(items: List[ParentGeneralReorderItem], db: Session = Depends(get_db), user: User = Depends(require_manager)):
|
||||
"""Update general_sort_order on parent categories (position of the General group)."""
|
||||
for item in items:
|
||||
cat = db.query(Category).filter(Category.id == item.id).first()
|
||||
if cat:
|
||||
cat.general_sort_order = item.general_sort_order
|
||||
db.commit()
|
||||
|
||||
|
||||
@router.put("/categories/{category_id}/reparent", response_model=CategoryOut)
|
||||
def reparent_category(category_id: int, body: CategoryReparentRequest, db: Session = Depends(get_db), user: User = Depends(require_manager)):
|
||||
"""Move a category to a new parent (or promote to top-level if parent_id is null).
|
||||
All products assigned to this category follow it automatically (no product updates needed).
|
||||
"""
|
||||
cat = db.query(Category).filter(Category.id == category_id).first()
|
||||
if not cat:
|
||||
raise HTTPException(status_code=404, detail="Category not found")
|
||||
if body.parent_id is not None:
|
||||
new_parent = db.query(Category).filter(Category.id == body.parent_id).first()
|
||||
if not new_parent:
|
||||
raise HTTPException(status_code=404, detail="Target parent category not found")
|
||||
if new_parent.parent_id is not None:
|
||||
raise HTTPException(status_code=400, detail="Cannot nest more than two levels deep")
|
||||
if body.parent_id == category_id:
|
||||
raise HTTPException(status_code=400, detail="A category cannot be its own parent")
|
||||
# If cat currently has children and is being made a sub, block it
|
||||
has_children = db.query(Category).filter(Category.parent_id == category_id).count() > 0
|
||||
if has_children and body.parent_id is not None:
|
||||
raise HTTPException(status_code=400, detail="Cannot nest a category that has subcategories")
|
||||
# Assign new sort_order at the end of the destination level
|
||||
sibling_count = db.query(Category).filter(Category.parent_id == body.parent_id).count()
|
||||
cat.parent_id = body.parent_id
|
||||
cat.sort_order = sibling_count
|
||||
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(all: bool = False, db: Session = Depends(get_db), user: User = Depends(get_current_user)):
|
||||
q = db.query(Product)
|
||||
if not all or user.role not in ("manager", "sysadmin"):
|
||||
# Waiters only see active, available products
|
||||
q = q.filter(Product.is_available == True, Product.lifecycle_status == "active")
|
||||
return q.order_by(Product.sort_order, Product.id).all()
|
||||
|
||||
|
||||
@router.put("/reorder", status_code=status.HTTP_204_NO_CONTENT)
|
||||
def reorder_products(items: List[ProductReorderItem], db: Session = Depends(get_db), user: User = Depends(require_manager)):
|
||||
for item in items:
|
||||
product = db.query(Product).filter(Product.id == item.id).first()
|
||||
if product:
|
||||
product.sort_order = item.sort_order
|
||||
db.commit()
|
||||
|
||||
|
||||
@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={"quick_options", "options", "ingredients", "preference_sets"})
|
||||
if data.get("sort_order") == 0:
|
||||
data["sort_order"] = db.query(Product).count()
|
||||
product = Product(**data)
|
||||
db.add(product)
|
||||
db.flush()
|
||||
for i, qo in enumerate(body.quick_options):
|
||||
db.add(ProductQuickOption(
|
||||
product_id=product.id,
|
||||
name=qo.name,
|
||||
price=qo.price,
|
||||
allow_multiple=qo.allow_multiple,
|
||||
sort_order=qo.sort_order if qo.sort_order else i,
|
||||
is_favorite=qo.is_favorite,
|
||||
favorite_sort_order=qo.favorite_sort_order,
|
||||
is_compact=qo.is_compact,
|
||||
))
|
||||
for opt in body.options:
|
||||
sub_json = json.dumps([s.model_dump() for s in opt.sub_choices]) if opt.sub_choices else None
|
||||
db.add(ProductOption(
|
||||
product_id=product.id,
|
||||
name=opt.name,
|
||||
extra_cost=opt.extra_cost,
|
||||
allow_multiple=opt.allow_multiple,
|
||||
sub_choices=sub_json,
|
||||
is_favorite=opt.is_favorite,
|
||||
favorite_sort_order=opt.favorite_sort_order,
|
||||
))
|
||||
for ing in body.ingredients:
|
||||
db.add(ProductIngredient(product_id=product.id, **ing.model_dump()))
|
||||
_replace_preference_sets(db, product, body.preference_sets)
|
||||
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, exclude={"quick_options", "options", "ingredients", "preference_sets"}).items():
|
||||
setattr(product, field, value)
|
||||
if body.quick_options is not None:
|
||||
_replace_quick_options(db, product, body.quick_options)
|
||||
if body.options is not None:
|
||||
_replace_options(db, product, body.options)
|
||||
if body.ingredients is not None:
|
||||
_replace_ingredients(db, product, body.ingredients)
|
||||
if body.preference_sets is not None:
|
||||
_replace_preference_sets(db, product, body.preference_sets)
|
||||
db.commit()
|
||||
db.refresh(product)
|
||||
return product
|
||||
|
||||
|
||||
@router.post("/{product_id}/image", response_model=ProductOut)
|
||||
async def upload_product_image(product_id: int, file: UploadFile = File(...), 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")
|
||||
|
||||
if not file.content_type.startswith("image/"):
|
||||
raise HTTPException(status_code=400, detail="File must be an image")
|
||||
|
||||
os.makedirs(IMAGE_DIR, exist_ok=True)
|
||||
|
||||
if product.image_url:
|
||||
old_path = os.path.join(IMAGE_DIR, os.path.basename(product.image_url))
|
||||
if os.path.exists(old_path):
|
||||
os.remove(old_path)
|
||||
|
||||
ext = file.filename.rsplit(".", 1)[-1].lower() if "." in file.filename else "jpg"
|
||||
filename = f"{product_id}_{uuid.uuid4().hex[:8]}.{ext}"
|
||||
filepath = os.path.join(IMAGE_DIR, filename)
|
||||
|
||||
contents = await file.read()
|
||||
with open(filepath, "wb") as f:
|
||||
f.write(contents)
|
||||
|
||||
product.image_url = f"/static/product_images/{filename}"
|
||||
db.commit()
|
||||
db.refresh(product)
|
||||
return product
|
||||
|
||||
|
||||
@router.delete("/{product_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||
def delete_product(product_id: int, hard: bool = False, 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")
|
||||
if hard:
|
||||
has_orders = db.query(OrderItem).filter(OrderItem.product_id == product_id).first()
|
||||
if has_orders:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="Cannot permanently delete a product that appears in past orders. Archive it instead."
|
||||
)
|
||||
db.delete(product)
|
||||
else:
|
||||
# If product has order history, archive it; otherwise hard delete
|
||||
has_orders = db.query(OrderItem).filter(OrderItem.product_id == product_id).first()
|
||||
if has_orders:
|
||||
product.lifecycle_status = "archived"
|
||||
else:
|
||||
db.delete(product)
|
||||
db.commit()
|
||||
1160
local_backend/routers/reports.py
Normal file
1160
local_backend/routers/reports.py
Normal file
File diff suppressed because it is too large
Load Diff
105
local_backend/routers/settings.py
Normal file
105
local_backend/routers/settings.py
Normal file
@@ -0,0 +1,105 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from sqlalchemy.orm import Session
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from database import get_db
|
||||
from models.settings import PosSettings
|
||||
from schemas.settings import UpdateSettingRequest
|
||||
from routers.deps import get_current_user, require_manager
|
||||
from models.user import User
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
VALID_SETTINGS = {
|
||||
# Security / auth
|
||||
"security.login_method": "How managers authenticate on first login: 'password' | 'pin' | 'none'",
|
||||
"security.autofill_username": "Auto-fill username when only one manager exists: 'true' | 'false'",
|
||||
"security.auto_lock": "Lock screen after inactivity: 'true' | 'false'",
|
||||
"security.auto_lock_seconds": "Seconds of inactivity before locking (0 = disabled)",
|
||||
"security.auto_logout": "Log out after inactivity: 'true' | 'false'",
|
||||
"security.auto_logout_seconds":"Seconds of inactivity before logging out (0 = disabled)",
|
||||
"shifts.waiter_self_start": "Allow waiters to start their own shifts without manager action",
|
||||
"shifts.waiter_self_end": "Allow waiters to end their own shifts without manager action",
|
||||
"business_day.force_close_allowed": "Allow force-closing business day with open tables",
|
||||
"system.timezone": "IANA timezone name used by the backend container (e.g. Europe/Athens). Requires container restart to take effect.",
|
||||
"ui.table_colours": "JSON blob of table card colour scheme (light + dark modes) for the Waiter PWA.",
|
||||
"dev.spoof_printing": "When enabled, all print jobs are silently dropped. Devices behave as if printing succeeded.",
|
||||
# Print layout
|
||||
"print.ticket_mode": "Kitchen ticket layout mode: 'detailed' or 'compact'",
|
||||
"print.divider_style": "Divider character used between sections: dash, equals, star, or empty",
|
||||
# Print font settings — values are "SIZE:BOLD:CAPS" where SIZE is ESC ! base byte (0/16/32/48), BOLD 0|1, CAPS 0|1
|
||||
"print.font_order_number": "Font for order number header: SIZE:BOLD:CAPS",
|
||||
"print.font_meta": "Font for table/waiter/time header block: SIZE:BOLD:CAPS",
|
||||
"print.font_item_name": "Font for item name lines: SIZE:BOLD:CAPS",
|
||||
"print.font_quick": "Font for quick option lines (* marker): SIZE:BOLD:CAPS",
|
||||
"print.font_pref": "Font for preference choice lines (> marker): SIZE:BOLD:CAPS",
|
||||
"print.font_extra": "Font for extra/option lines (+ marker): SIZE:BOLD:CAPS",
|
||||
"print.font_ingredient": "Font for removed ingredient lines (- marker): SIZE:BOLD:CAPS",
|
||||
"print.font_item_note": "Font for per-item note lines: SIZE:BOLD:CAPS",
|
||||
"print.font_order_note": "Font for order-level notes: SIZE:BOLD:CAPS",
|
||||
}
|
||||
|
||||
DEFAULTS = {
|
||||
"security.login_method": "password",
|
||||
"security.autofill_username": "true",
|
||||
"security.auto_lock": "false",
|
||||
"security.auto_lock_seconds": "300",
|
||||
"security.auto_logout": "false",
|
||||
"security.auto_logout_seconds": "1800",
|
||||
"shifts.waiter_self_start": "true",
|
||||
"shifts.waiter_self_end": "true",
|
||||
"business_day.force_close_allowed": "true",
|
||||
"system.timezone": "Europe/Athens",
|
||||
"ui.table_colours": "",
|
||||
"dev.spoof_printing": "false",
|
||||
"print.ticket_mode": "detailed",
|
||||
"print.divider_style": "dash",
|
||||
"print.font_order_number": "48:1:0",
|
||||
"print.font_meta": "0:0:0",
|
||||
"print.font_item_name": "16:1:0",
|
||||
"print.font_quick": "0:0:0",
|
||||
"print.font_pref": "0:0:0",
|
||||
"print.font_extra": "0:0:0",
|
||||
"print.font_ingredient": "0:0:0",
|
||||
"print.font_item_note": "0:0:0",
|
||||
"print.font_order_note": "0:1:0",
|
||||
}
|
||||
|
||||
|
||||
@router.get("/")
|
||||
def get_all_settings(
|
||||
db: Session = Depends(get_db),
|
||||
user: User = Depends(get_current_user),
|
||||
):
|
||||
stored = {s.key: s.value for s in db.query(PosSettings).all()}
|
||||
result = {}
|
||||
for key, description in VALID_SETTINGS.items():
|
||||
result[key] = {
|
||||
"value": stored.get(key, DEFAULTS.get(key, "true")),
|
||||
"description": description,
|
||||
}
|
||||
return result
|
||||
|
||||
|
||||
@router.put("/{key}")
|
||||
def update_setting(
|
||||
key: str,
|
||||
body: UpdateSettingRequest,
|
||||
db: Session = Depends(get_db),
|
||||
user: User = Depends(require_manager),
|
||||
):
|
||||
if key not in VALID_SETTINGS:
|
||||
raise HTTPException(status_code=400, detail=f"Unknown setting key: {key}")
|
||||
|
||||
setting = db.query(PosSettings).filter(PosSettings.key == key).first()
|
||||
if setting:
|
||||
setting.value = body.value
|
||||
setting.updated_at = datetime.now(timezone.utc)
|
||||
setting.updated_by_id = user.id
|
||||
else:
|
||||
setting = PosSettings(key=key, value=body.value, updated_by_id=user.id)
|
||||
db.add(setting)
|
||||
|
||||
db.commit()
|
||||
db.refresh(setting)
|
||||
return {"key": setting.key, "value": setting.value}
|
||||
116
local_backend/routers/setup.py
Normal file
116
local_backend/routers/setup.py
Normal file
@@ -0,0 +1,116 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from sqlalchemy.orm import Session
|
||||
from pydantic import BaseModel, EmailStr
|
||||
from typing import Optional
|
||||
import bcrypt
|
||||
|
||||
from database import get_db
|
||||
from models.user import User
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
class SetupStatusResponse(BaseModel):
|
||||
needs_setup: bool
|
||||
|
||||
|
||||
class SetupInitRequest(BaseModel):
|
||||
username: str
|
||||
password: str
|
||||
email: Optional[str] = None
|
||||
full_name: Optional[str] = None
|
||||
venue_type: Optional[str] = None
|
||||
venue_name: Optional[str] = None
|
||||
pin: Optional[str] = None
|
||||
|
||||
|
||||
class SetupInitResponse(BaseModel):
|
||||
ok: bool
|
||||
|
||||
|
||||
class SecurityConfigResponse(BaseModel):
|
||||
login_method: str
|
||||
autofill_username: bool
|
||||
|
||||
|
||||
@router.get("/security-config", response_model=SecurityConfigResponse)
|
||||
def security_config(db: Session = Depends(get_db)):
|
||||
"""Public endpoint — returns only the security settings needed by the login page."""
|
||||
from models.settings import PosSettings
|
||||
rows = {r.key: r.value for r in db.query(PosSettings).filter(
|
||||
PosSettings.key.in_(["security.login_method", "security.autofill_username"])
|
||||
).all()}
|
||||
return SecurityConfigResponse(
|
||||
login_method=rows.get("security.login_method", "password"),
|
||||
autofill_username=rows.get("security.autofill_username", "true") == "true",
|
||||
)
|
||||
|
||||
|
||||
@router.get("/status", response_model=SetupStatusResponse)
|
||||
def setup_status(db: Session = Depends(get_db)):
|
||||
has_manager = db.query(User).filter(
|
||||
User.role.in_(["manager", "sysadmin"]),
|
||||
User.is_active == True,
|
||||
).first()
|
||||
return SetupStatusResponse(needs_setup=has_manager is None)
|
||||
|
||||
|
||||
@router.post("/init", response_model=SetupInitResponse)
|
||||
def setup_init(body: SetupInitRequest, db: Session = Depends(get_db)):
|
||||
has_manager = db.query(User).filter(
|
||||
User.role.in_(["manager", "sysadmin"]),
|
||||
User.is_active == True,
|
||||
).first()
|
||||
if has_manager:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_409_CONFLICT,
|
||||
detail="Setup already completed — a manager account already exists.",
|
||||
)
|
||||
|
||||
if not body.username.strip():
|
||||
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail="Username is required.")
|
||||
if len(body.password) < 6:
|
||||
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail="Password must be at least 6 characters.")
|
||||
|
||||
existing = db.query(User).filter(User.username == body.username.strip()).first()
|
||||
if existing:
|
||||
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="Username already taken.")
|
||||
|
||||
password_hash = bcrypt.hashpw(body.password.encode(), bcrypt.gensalt()).decode()
|
||||
|
||||
raw_pin = body.pin if (body.pin and body.pin.isdigit() and 4 <= len(body.pin) <= 6) else "0000"
|
||||
pin_hash = bcrypt.hashpw(raw_pin.encode(), bcrypt.gensalt()).decode()
|
||||
|
||||
user = User(
|
||||
username=body.username.strip(),
|
||||
pin_hash=pin_hash,
|
||||
password_hash=password_hash,
|
||||
email=body.email,
|
||||
full_name=body.full_name,
|
||||
role="manager",
|
||||
is_active=True,
|
||||
)
|
||||
db.add(user)
|
||||
|
||||
# Persist venue settings if provided
|
||||
if body.venue_name or body.venue_type:
|
||||
from models.settings import PosSettings
|
||||
from datetime import datetime, timezone
|
||||
now = datetime.now(timezone.utc)
|
||||
if body.venue_name:
|
||||
setting = db.query(PosSettings).filter(PosSettings.key == "venue.name").first()
|
||||
if setting:
|
||||
setting.value = body.venue_name
|
||||
setting.updated_at = now
|
||||
else:
|
||||
db.add(PosSettings(key="venue.name", value=body.venue_name, updated_at=now))
|
||||
if body.venue_type:
|
||||
setting = db.query(PosSettings).filter(PosSettings.key == "venue.type").first()
|
||||
if setting:
|
||||
setting.value = body.venue_type
|
||||
setting.updated_at = now
|
||||
else:
|
||||
db.add(PosSettings(key="venue.type", value=body.venue_type, updated_at=now))
|
||||
|
||||
db.commit()
|
||||
return SetupInitResponse(ok=True)
|
||||
359
local_backend/routers/shifts.py
Normal file
359
local_backend/routers/shifts.py
Normal file
@@ -0,0 +1,359 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from sqlalchemy.orm import Session
|
||||
from typing import Optional
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from database import get_db
|
||||
from models.shift import WaiterShift, ShiftBreak
|
||||
from models.business_day import BusinessDay
|
||||
from models.order import OrderItem
|
||||
from models.settings import PosSettings
|
||||
from models.user import User
|
||||
from schemas.shift import StartShiftRequest, EndShiftRequest
|
||||
from routers.deps import get_current_user, require_manager
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
def _dt(dt):
|
||||
"""Serialize a naive-UTC datetime to ISO string with Z so JS parses it as UTC."""
|
||||
if dt is None:
|
||||
return None
|
||||
return (dt.isoformat() + "Z") if dt.tzinfo is None else dt.isoformat()
|
||||
|
||||
|
||||
def _get_setting(db: Session, key: str, default: str = "true") -> str:
|
||||
s = db.query(PosSettings).filter(PosSettings.key == key).first()
|
||||
return s.value if s else default
|
||||
|
||||
|
||||
def compute_shift_total(shift_id: int, db: Session) -> float:
|
||||
items = db.query(OrderItem).filter(
|
||||
OrderItem.paid_in_shift_id == shift_id,
|
||||
OrderItem.status == "paid",
|
||||
).all()
|
||||
return round(sum(i.unit_price * i.quantity for i in items), 2)
|
||||
|
||||
|
||||
def _enrich_shift(shift: WaiterShift, db: Session) -> dict:
|
||||
w = shift.waiter
|
||||
wname = (w.full_name or w.username) if w else f"#{shift.waiter_id}"
|
||||
total = compute_shift_total(shift.id, db) if shift.ended_at is None else (shift.total_collected or 0.0)
|
||||
return {
|
||||
"id": shift.id,
|
||||
"waiter_id": shift.waiter_id,
|
||||
"waiter_name": wname,
|
||||
"business_day_id": shift.business_day_id,
|
||||
"started_at": _dt(shift.started_at),
|
||||
"ended_at": _dt(shift.ended_at),
|
||||
"starting_cash": shift.starting_cash,
|
||||
"total_collected": total,
|
||||
"net_to_deliver": round(total + (shift.starting_cash or 0.0), 2),
|
||||
"is_active": shift.ended_at is None,
|
||||
"notes": shift.notes,
|
||||
"breaks": [
|
||||
{"id": b.id, "shift_id": b.shift_id, "started_at": _dt(b.started_at), "ended_at": _dt(b.ended_at)}
|
||||
for b in shift.breaks
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
@router.get("/my")
|
||||
def my_shift(db: Session = Depends(get_db), user: User = Depends(get_current_user)):
|
||||
shift = db.query(WaiterShift).filter(
|
||||
WaiterShift.waiter_id == user.id,
|
||||
WaiterShift.ended_at == None,
|
||||
).first()
|
||||
return _enrich_shift(shift, db) if shift else None
|
||||
|
||||
|
||||
@router.post("/start", status_code=status.HTTP_201_CREATED)
|
||||
def start_shift(
|
||||
body: StartShiftRequest,
|
||||
db: Session = Depends(get_db),
|
||||
user: User = Depends(get_current_user),
|
||||
):
|
||||
target_id = body.waiter_id
|
||||
|
||||
if target_id and target_id != user.id:
|
||||
if user.role not in ("manager", "sysadmin"):
|
||||
raise HTTPException(status_code=403, detail="Only managers can start shifts for other waiters")
|
||||
target = db.query(User).filter(User.id == target_id, User.is_active == True).first()
|
||||
if not target:
|
||||
raise HTTPException(status_code=404, detail="Waiter not found")
|
||||
else:
|
||||
target_id = user.id
|
||||
if user.role == "waiter" and _get_setting(db, "shifts.waiter_self_start") != "true":
|
||||
raise HTTPException(status_code=403, detail="Shift start requires manager confirmation")
|
||||
|
||||
active_day = db.query(BusinessDay).filter(BusinessDay.status == "open").first()
|
||||
if not active_day:
|
||||
raise HTTPException(status_code=400, detail="No open business day — manager must open the restaurant first")
|
||||
|
||||
existing = db.query(WaiterShift).filter(
|
||||
WaiterShift.waiter_id == target_id,
|
||||
WaiterShift.ended_at == None,
|
||||
).first()
|
||||
if existing:
|
||||
raise HTTPException(status_code=400, detail="Waiter already has an active shift")
|
||||
|
||||
shift = WaiterShift(
|
||||
waiter_id=target_id,
|
||||
business_day_id=active_day.id,
|
||||
starting_cash=body.starting_cash,
|
||||
)
|
||||
db.add(shift)
|
||||
db.commit()
|
||||
db.refresh(shift)
|
||||
return _enrich_shift(shift, db)
|
||||
|
||||
|
||||
@router.post("/end")
|
||||
def end_shift(
|
||||
body: EndShiftRequest,
|
||||
db: Session = Depends(get_db),
|
||||
user: User = Depends(get_current_user),
|
||||
):
|
||||
if user.role == "waiter" and _get_setting(db, "shifts.waiter_self_end") != "true":
|
||||
raise HTTPException(status_code=403, detail="Shift end requires manager confirmation")
|
||||
|
||||
shift = db.query(WaiterShift).filter(
|
||||
WaiterShift.waiter_id == user.id,
|
||||
WaiterShift.ended_at == None,
|
||||
).first()
|
||||
if not shift:
|
||||
raise HTTPException(status_code=404, detail="No active shift found")
|
||||
|
||||
now = datetime.now(timezone.utc)
|
||||
shift.total_collected = compute_shift_total(shift.id, db)
|
||||
shift.ended_at = now
|
||||
if body.notes:
|
||||
shift.notes = body.notes
|
||||
|
||||
open_break = db.query(ShiftBreak).filter(
|
||||
ShiftBreak.shift_id == shift.id, ShiftBreak.ended_at == None
|
||||
).first()
|
||||
if open_break:
|
||||
open_break.ended_at = now
|
||||
|
||||
db.commit()
|
||||
db.refresh(shift)
|
||||
return _enrich_shift(shift, db)
|
||||
|
||||
|
||||
@router.post("/manager/start", status_code=status.HTTP_201_CREATED)
|
||||
def manager_start_shift(
|
||||
body: StartShiftRequest,
|
||||
db: Session = Depends(get_db),
|
||||
user: User = Depends(require_manager),
|
||||
):
|
||||
if not body.waiter_id:
|
||||
raise HTTPException(status_code=400, detail="waiter_id is required")
|
||||
|
||||
target = db.query(User).filter(User.id == body.waiter_id, User.is_active == True).first()
|
||||
if not target:
|
||||
raise HTTPException(status_code=404, detail="Waiter not found")
|
||||
|
||||
active_day = db.query(BusinessDay).filter(BusinessDay.status == "open").first()
|
||||
if not active_day:
|
||||
raise HTTPException(status_code=400, detail="No open business day")
|
||||
|
||||
existing = db.query(WaiterShift).filter(
|
||||
WaiterShift.waiter_id == body.waiter_id,
|
||||
WaiterShift.ended_at == None,
|
||||
).first()
|
||||
if existing:
|
||||
raise HTTPException(status_code=400, detail="Waiter already has an active shift")
|
||||
|
||||
shift = WaiterShift(
|
||||
waiter_id=body.waiter_id,
|
||||
business_day_id=active_day.id,
|
||||
starting_cash=body.starting_cash,
|
||||
)
|
||||
db.add(shift)
|
||||
db.commit()
|
||||
db.refresh(shift)
|
||||
return _enrich_shift(shift, db)
|
||||
|
||||
|
||||
@router.post("/manager/end/{shift_id}")
|
||||
def manager_end_shift(
|
||||
shift_id: int,
|
||||
body: EndShiftRequest,
|
||||
db: Session = Depends(get_db),
|
||||
user: User = Depends(require_manager),
|
||||
):
|
||||
shift = db.query(WaiterShift).filter(
|
||||
WaiterShift.id == shift_id,
|
||||
WaiterShift.ended_at == None,
|
||||
).first()
|
||||
if not shift:
|
||||
raise HTTPException(status_code=404, detail="Active shift not found")
|
||||
|
||||
now = datetime.now(timezone.utc)
|
||||
shift.total_collected = compute_shift_total(shift.id, db)
|
||||
shift.ended_at = now
|
||||
if body.notes:
|
||||
shift.notes = body.notes
|
||||
|
||||
open_break = db.query(ShiftBreak).filter(
|
||||
ShiftBreak.shift_id == shift.id, ShiftBreak.ended_at == None
|
||||
).first()
|
||||
if open_break:
|
||||
open_break.ended_at = now
|
||||
|
||||
db.commit()
|
||||
db.refresh(shift)
|
||||
return _enrich_shift(shift, db)
|
||||
|
||||
|
||||
@router.post("/{shift_id}/break/start")
|
||||
def start_break(
|
||||
shift_id: int,
|
||||
db: Session = Depends(get_db),
|
||||
user: User = Depends(get_current_user),
|
||||
):
|
||||
shift = db.query(WaiterShift).filter(WaiterShift.id == shift_id).first()
|
||||
if not shift:
|
||||
raise HTTPException(status_code=404, detail="Shift not found")
|
||||
if shift.waiter_id != user.id and user.role not in ("manager", "sysadmin"):
|
||||
raise HTTPException(status_code=403, detail="Access denied")
|
||||
if shift.ended_at:
|
||||
raise HTTPException(status_code=400, detail="Shift already ended")
|
||||
|
||||
open_break = db.query(ShiftBreak).filter(
|
||||
ShiftBreak.shift_id == shift_id, ShiftBreak.ended_at == None
|
||||
).first()
|
||||
if open_break:
|
||||
raise HTTPException(status_code=400, detail="Break already in progress")
|
||||
|
||||
b = ShiftBreak(shift_id=shift_id)
|
||||
db.add(b)
|
||||
db.commit()
|
||||
db.refresh(b)
|
||||
return {"id": b.id, "shift_id": b.shift_id, "started_at": _dt(b.started_at), "ended_at": _dt(b.ended_at)}
|
||||
|
||||
|
||||
@router.post("/{shift_id}/break/end")
|
||||
def end_break(
|
||||
shift_id: int,
|
||||
db: Session = Depends(get_db),
|
||||
user: User = Depends(get_current_user),
|
||||
):
|
||||
shift = db.query(WaiterShift).filter(WaiterShift.id == shift_id).first()
|
||||
if not shift:
|
||||
raise HTTPException(status_code=404, detail="Shift not found")
|
||||
if shift.waiter_id != user.id and user.role not in ("manager", "sysadmin"):
|
||||
raise HTTPException(status_code=403, detail="Access denied")
|
||||
|
||||
open_break = db.query(ShiftBreak).filter(
|
||||
ShiftBreak.shift_id == shift_id, ShiftBreak.ended_at == None
|
||||
).first()
|
||||
if not open_break:
|
||||
raise HTTPException(status_code=404, detail="No active break found")
|
||||
|
||||
open_break.ended_at = datetime.now(timezone.utc)
|
||||
db.commit()
|
||||
db.refresh(open_break)
|
||||
return {"id": open_break.id, "shift_id": open_break.shift_id, "started_at": _dt(open_break.started_at), "ended_at": _dt(open_break.ended_at)}
|
||||
|
||||
|
||||
@router.get("/")
|
||||
def list_shifts(
|
||||
waiter_id: Optional[int] = None,
|
||||
business_day_id: Optional[int] = None,
|
||||
active_only: bool = False,
|
||||
db: Session = Depends(get_db),
|
||||
user: User = Depends(require_manager),
|
||||
):
|
||||
q = db.query(WaiterShift)
|
||||
if waiter_id:
|
||||
q = q.filter(WaiterShift.waiter_id == waiter_id)
|
||||
if business_day_id:
|
||||
q = q.filter(WaiterShift.business_day_id == business_day_id)
|
||||
if active_only:
|
||||
q = q.filter(WaiterShift.ended_at == None)
|
||||
shifts = q.order_by(WaiterShift.started_at.desc()).all()
|
||||
return {"shifts": [_enrich_shift(s, db) for s in shifts]}
|
||||
|
||||
|
||||
@router.get("/{shift_id}")
|
||||
def get_shift(
|
||||
shift_id: int,
|
||||
db: Session = Depends(get_db),
|
||||
user: User = Depends(require_manager),
|
||||
):
|
||||
shift = db.query(WaiterShift).filter(WaiterShift.id == shift_id).first()
|
||||
if not shift:
|
||||
raise HTTPException(status_code=404, detail="Shift not found")
|
||||
return _enrich_shift(shift, db)
|
||||
|
||||
|
||||
@router.get("/{shift_id}/summary")
|
||||
def get_shift_summary(
|
||||
shift_id: int,
|
||||
db: Session = Depends(get_db),
|
||||
user: User = Depends(require_manager),
|
||||
):
|
||||
"""Full shift summary: enriched shift data + paid items grouped by order."""
|
||||
from models.order import Order
|
||||
from sqlalchemy.orm import joinedload
|
||||
|
||||
shift = db.query(WaiterShift).filter(WaiterShift.id == shift_id).first()
|
||||
if not shift:
|
||||
raise HTTPException(status_code=404, detail="Shift not found")
|
||||
|
||||
from models.table import Table
|
||||
items = db.query(OrderItem).options(
|
||||
joinedload(OrderItem.product),
|
||||
joinedload(OrderItem.order),
|
||||
).filter(
|
||||
OrderItem.paid_in_shift_id == shift_id,
|
||||
OrderItem.status == "paid",
|
||||
).all()
|
||||
|
||||
# Build table_id -> display name map for all referenced tables
|
||||
table_ids = {item.order.table_id for item in items if item.order and item.order.table_id}
|
||||
tables_map: dict[int, str] = {}
|
||||
if table_ids:
|
||||
tbl_rows = db.query(Table).filter(Table.id.in_(table_ids)).all()
|
||||
for t in tbl_rows:
|
||||
prefix = (t.group.prefix if t.group and t.group.prefix else "") if t.group else ""
|
||||
tables_map[t.id] = t.label if t.label else f"{prefix}{t.number}"
|
||||
|
||||
orders_seen = {}
|
||||
for item in items:
|
||||
oid = item.order_id
|
||||
if oid not in orders_seen:
|
||||
o = item.order
|
||||
tid = o.table_id if o else None
|
||||
orders_seen[oid] = {
|
||||
"order_id": oid,
|
||||
"table_id": tid,
|
||||
"table_name": tables_map.get(tid) if tid else None,
|
||||
"opened_at": _dt(o.opened_at) if o else None,
|
||||
"items": [],
|
||||
}
|
||||
orders_seen[oid]["items"].append({
|
||||
"id": item.id,
|
||||
"product_name": item.product.name if item.product else f"#{item.product_id}",
|
||||
"quantity": item.quantity,
|
||||
"unit_price": float(item.unit_price),
|
||||
"subtotal": round(float(item.unit_price) * item.quantity, 2),
|
||||
"paid_at": _dt(item.paid_at),
|
||||
})
|
||||
|
||||
# Compute hours worked
|
||||
started = shift.started_at
|
||||
ended = shift.ended_at
|
||||
duration_minutes = None
|
||||
if started and ended:
|
||||
duration_minutes = int((ended - started).total_seconds() / 60)
|
||||
elif started:
|
||||
from datetime import datetime, timezone as tz
|
||||
duration_minutes = int((datetime.now(tz.utc) - started.replace(tzinfo=tz.utc) if started.tzinfo is None else datetime.now(tz.utc) - started).total_seconds() / 60)
|
||||
|
||||
enriched = _enrich_shift(shift, db)
|
||||
enriched["orders"] = list(orders_seen.values())
|
||||
enriched["duration_minutes"] = duration_minutes
|
||||
return enriched
|
||||
60
local_backend/routers/sse.py
Normal file
60
local_backend/routers/sse.py
Normal file
@@ -0,0 +1,60 @@
|
||||
"""
|
||||
SSE stream endpoint — one long-lived GET per connected phone.
|
||||
|
||||
Authentication: token passed as query param ?token=<jwt>
|
||||
(EventSource API in browsers cannot set custom headers, so query param is the standard pattern.)
|
||||
|
||||
The client receives a stream of JSON lines:
|
||||
data: {"type": "...", "data": {...}}\n\n
|
||||
|
||||
A keepalive comment (": ping") is sent every 25 seconds to prevent proxy timeouts.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
from fastapi import APIRouter, Query
|
||||
from fastapi.responses import StreamingResponse
|
||||
|
||||
from routers.deps import decode_token
|
||||
from services.sse_bus import subscribe, unsubscribe
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
KEEPALIVE_INTERVAL = 25 # seconds
|
||||
|
||||
|
||||
async def _event_stream(user_id: int):
|
||||
q = await subscribe(user_id)
|
||||
try:
|
||||
while True:
|
||||
try:
|
||||
payload = await asyncio.wait_for(q.get(), timeout=KEEPALIVE_INTERVAL)
|
||||
yield f"data: {payload}\n\n"
|
||||
except asyncio.TimeoutError:
|
||||
# keepalive — prevents nginx/proxies from closing idle connections
|
||||
yield ": ping\n\n"
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
finally:
|
||||
await unsubscribe(user_id, q)
|
||||
|
||||
|
||||
@router.get("/stream")
|
||||
async def sse_stream(token: str = Query(...)):
|
||||
"""
|
||||
Open an SSE stream for the authenticated user.
|
||||
The phone connects once on login and stays connected.
|
||||
On reconnect (after network drop) it does a full GET first, then reconnects here.
|
||||
"""
|
||||
# decode_token raises HTTPException on invalid/expired — no manual check needed
|
||||
payload = decode_token(token)
|
||||
user_id: int = int(payload["sub"])
|
||||
|
||||
return StreamingResponse(
|
||||
_event_stream(user_id),
|
||||
media_type="text/event-stream",
|
||||
headers={
|
||||
"Cache-Control": "no-cache",
|
||||
"X-Accel-Buffering": "no", # disable nginx buffering
|
||||
"Connection": "keep-alive",
|
||||
},
|
||||
)
|
||||
179
local_backend/routers/system.py
Normal file
179
local_backend/routers/system.py
Normal file
@@ -0,0 +1,179 @@
|
||||
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 PrinterCreate, PrinterUpdate, PrinterOut
|
||||
from routers.deps import get_current_user, require_manager, require_sysadmin
|
||||
from models.user import User
|
||||
from models.product import Category, Product
|
||||
from models.table import Table, TableGroup
|
||||
from services import printer_service
|
||||
from services.cloud_sync import _sync_once
|
||||
from middleware.license_check import license_state
|
||||
from config import settings
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
_start_time = time.time()
|
||||
|
||||
|
||||
@router.get("/health")
|
||||
def health():
|
||||
return {"status": "ok", "version": settings.VERSION}
|
||||
|
||||
|
||||
@router.get("/status")
|
||||
def system_status(db: Session = Depends(get_db), user: User = Depends(get_current_user)):
|
||||
from datetime import datetime, timezone
|
||||
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})
|
||||
|
||||
licensed = license_state.get("licensed", True)
|
||||
locked = license_state.get("locked", False)
|
||||
lock_pending = license_state.get("lock_pending", False)
|
||||
expires_at = license_state.get("expires_at")
|
||||
days_until_expiry = license_state.get("days_until_expiry")
|
||||
grace_expires_at = license_state.get("grace_expires_at")
|
||||
|
||||
# Determine lock_reason for the frontend banner logic
|
||||
# "admin" — locked by sysadmin (immediately or deferred)
|
||||
# "expired" — license grace period over, site is blocked
|
||||
# None — all good
|
||||
lock_reason = None
|
||||
if locked or lock_pending:
|
||||
lock_reason = "admin"
|
||||
elif not licensed:
|
||||
lock_reason = "expired"
|
||||
|
||||
# Grace days remaining (only meaningful while in expiry grace period)
|
||||
grace_days_remaining = None
|
||||
if grace_expires_at:
|
||||
try:
|
||||
grace_dt = datetime.fromisoformat(grace_expires_at)
|
||||
if grace_dt.tzinfo is None:
|
||||
grace_dt = grace_dt.replace(tzinfo=timezone.utc)
|
||||
grace_days_remaining = max(0, (grace_dt - datetime.now(timezone.utc)).days)
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
return {
|
||||
"uptime_seconds": int(time.time() - _start_time),
|
||||
"version": settings.VERSION,
|
||||
"latest_version": license_state.get("latest_version"),
|
||||
"licensed": licensed,
|
||||
"locked": locked,
|
||||
"lock_pending": lock_pending,
|
||||
"lock_reason": lock_reason,
|
||||
"expires_at": expires_at,
|
||||
"days_until_expiry": days_until_expiry,
|
||||
"grace_expires_at": grace_expires_at,
|
||||
"grace_days_remaining": grace_days_remaining,
|
||||
"sync_failed": license_state.get("sync_failed", False),
|
||||
"last_sync": license_state.get("last_sync"),
|
||||
"waiter_domain": license_state.get("waiter_domain"),
|
||||
"printers": printer_statuses,
|
||||
}
|
||||
|
||||
|
||||
@router.post("/sync-license")
|
||||
async def sync_license_now(user: User = Depends(require_manager)):
|
||||
"""Trigger an immediate cloud heartbeat and return the fresh license state."""
|
||||
await _sync_once()
|
||||
return {
|
||||
"licensed": license_state.get("licensed", True),
|
||||
"locked": license_state.get("locked", False),
|
||||
"lock_pending": license_state.get("lock_pending", False),
|
||||
"lock_reason": (
|
||||
"admin" if (license_state.get("locked") or license_state.get("lock_pending"))
|
||||
else "expired" if not license_state.get("licensed", True)
|
||||
else None
|
||||
),
|
||||
"expires_at": license_state.get("expires_at"),
|
||||
"days_until_expiry": license_state.get("days_until_expiry"),
|
||||
"sync_failed": license_state.get("sync_failed", False),
|
||||
"last_sync": license_state.get("last_sync"),
|
||||
}
|
||||
|
||||
|
||||
@router.get("/printers", response_model=List[PrinterOut])
|
||||
def list_printers(db: Session = Depends(get_db), user: User = Depends(require_manager)):
|
||||
return db.query(Printer).all()
|
||||
|
||||
|
||||
@router.post("/printers", response_model=PrinterOut)
|
||||
def create_printer(body: PrinterCreate, db: Session = Depends(get_db), user: User = Depends(require_manager)):
|
||||
printer = Printer(**body.model_dump())
|
||||
db.add(printer)
|
||||
db.commit()
|
||||
db.refresh(printer)
|
||||
return printer
|
||||
|
||||
|
||||
@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.post("/printers/test-order")
|
||||
def test_order_print(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_order_print(printer.ip_address, printer.port, db)
|
||||
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_manager)):
|
||||
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.delete("/printers/{printer_id}")
|
||||
def delete_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")
|
||||
db.delete(printer)
|
||||
db.commit()
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
@router.get("/stats")
|
||||
def system_stats(db: Session = Depends(get_db), user: User = Depends(get_current_user)):
|
||||
return {
|
||||
"categories": db.query(Category).count(),
|
||||
"products": db.query(Product).filter(Product.lifecycle_status == "active").count(),
|
||||
"tables": db.query(Table).filter(Table.is_active == True).count(),
|
||||
"table_groups": db.query(TableGroup).count(),
|
||||
"managers": db.query(User).filter(User.role == "manager", User.is_active == True).count(),
|
||||
"waiters": db.query(User).filter(User.role == "waiter", User.is_active == True).count(),
|
||||
}
|
||||
|
||||
|
||||
@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"}
|
||||
227
local_backend/routers/tables.py
Normal file
227
local_backend/routers/tables.py
Normal file
@@ -0,0 +1,227 @@
|
||||
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, TableGroup
|
||||
from models.order import Order
|
||||
from models.user import User, WaiterZone
|
||||
from schemas.table import (
|
||||
TableCreate, TableUpdate, TableFloorplanUpdate, TableOut,
|
||||
TableGroupCreate, TableGroupUpdate, TableGroupOut,
|
||||
TableBatchCreate, MAX_TABLE_NAME_LENGTH,
|
||||
)
|
||||
from routers.deps import get_current_user, require_manager
|
||||
from services.sse_bus import broadcast_sync
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
# ── Table Groups ──────────────────────────────────────────────────────────────
|
||||
|
||||
@router.get("/groups", response_model=List[TableGroupOut])
|
||||
def list_groups(db: Session = Depends(get_db), user: User = Depends(get_current_user)):
|
||||
return db.query(TableGroup).order_by(TableGroup.sort_order).all()
|
||||
|
||||
|
||||
@router.post("/groups", response_model=TableGroupOut, status_code=status.HTTP_201_CREATED)
|
||||
def create_group(body: TableGroupCreate, db: Session = Depends(get_db), user: User = Depends(require_manager)):
|
||||
if db.query(TableGroup).filter(TableGroup.name == body.name).first():
|
||||
raise HTTPException(status_code=400, detail="Group name already exists")
|
||||
sort_order = db.query(TableGroup).count()
|
||||
group = TableGroup(name=body.name, prefix=body.prefix, color=body.color, sort_order=sort_order)
|
||||
db.add(group)
|
||||
db.commit()
|
||||
db.refresh(group)
|
||||
return group
|
||||
|
||||
|
||||
@router.put("/groups/{group_id}", response_model=TableGroupOut)
|
||||
def update_group(group_id: int, body: TableGroupUpdate, db: Session = Depends(get_db), user: User = Depends(require_manager)):
|
||||
group = db.query(TableGroup).filter(TableGroup.id == group_id).first()
|
||||
if not group:
|
||||
raise HTTPException(status_code=404, detail="Group not found")
|
||||
for field, value in body.model_dump(exclude_none=True).items():
|
||||
setattr(group, field, value)
|
||||
db.commit()
|
||||
db.refresh(group)
|
||||
return group
|
||||
|
||||
|
||||
@router.delete("/groups/{group_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||
def delete_group(group_id: int, db: Session = Depends(get_db), user: User = Depends(require_manager)):
|
||||
group = db.query(TableGroup).filter(TableGroup.id == group_id).first()
|
||||
if not group:
|
||||
raise HTTPException(status_code=404, detail="Group not found")
|
||||
db.query(Table).filter(Table.group_id == group_id).update({"group_id": None})
|
||||
db.delete(group)
|
||||
db.commit()
|
||||
|
||||
|
||||
# ── Tables ────────────────────────────────────────────────────────────────────
|
||||
|
||||
def _next_global_number(db: Session) -> int:
|
||||
last = db.query(Table).order_by(Table.number.desc()).first()
|
||||
return (last.number + 1) if last else 1
|
||||
|
||||
|
||||
@router.get("/", response_model=List[TableOut])
|
||||
def list_tables(include_inactive: bool = False, db: Session = Depends(get_db), user: User = Depends(get_current_user)):
|
||||
q = db.query(Table)
|
||||
if not include_inactive:
|
||||
q = q.filter(Table.is_active == True)
|
||||
|
||||
# Zone-based filtering for waiters
|
||||
if user.role not in ("manager", "sysadmin"):
|
||||
zones = db.query(WaiterZone).filter(WaiterZone.waiter_id == user.id).all()
|
||||
# No zone rows → sees nothing
|
||||
if not zones:
|
||||
return []
|
||||
# Any row with group_id=None → sees all tables (all-zones sentinel)
|
||||
has_all_zones = any(z.group_id is None for z in zones)
|
||||
if not has_all_zones:
|
||||
allowed_group_ids = [z.group_id for z in zones]
|
||||
q = q.filter(Table.group_id.in_(allowed_group_ids))
|
||||
|
||||
tables = q.order_by(Table.group_id, Table.number).all()
|
||||
|
||||
active_table_ids = {
|
||||
row[0] for row in db.query(Order.table_id).filter(
|
||||
Order.status.in_(["open", "partially_paid", "paid"])
|
||||
).all()
|
||||
}
|
||||
|
||||
result = []
|
||||
for t in tables:
|
||||
out = TableOut.model_validate(t)
|
||||
out.has_active_order = t.id in active_table_ids
|
||||
result.append(out)
|
||||
return result
|
||||
|
||||
|
||||
@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)):
|
||||
number = _next_global_number(db)
|
||||
table = Table(number=number, label=body.label, group_id=body.group_id, is_active=True)
|
||||
db.add(table)
|
||||
db.commit()
|
||||
db.refresh(table)
|
||||
broadcast_sync("table_list_changed", {"action": "created", "table_id": table.id})
|
||||
return table
|
||||
|
||||
|
||||
@router.post("/batch", response_model=List[TableOut], status_code=status.HTTP_201_CREATED)
|
||||
def batch_create_tables(body: TableBatchCreate, db: Session = Depends(get_db), user: User = Depends(require_manager)):
|
||||
if body.count < 1 or body.count > 200:
|
||||
raise HTTPException(status_code=400, detail="Count must be between 1 and 200")
|
||||
|
||||
# Group-local label numbering: find highest suffix already used in this group
|
||||
existing_in_group = (
|
||||
db.query(Table)
|
||||
.filter(Table.group_id == body.group_id)
|
||||
.all()
|
||||
) if body.group_id else []
|
||||
|
||||
# Extract trailing integers from existing labels that start with this prefix
|
||||
used = []
|
||||
for t in existing_in_group:
|
||||
if t.label and t.label.startswith(body.name_prefix):
|
||||
suffix = t.label[len(body.name_prefix):]
|
||||
if suffix.isdigit():
|
||||
used.append(int(suffix))
|
||||
start_label_n = (max(used) + 1) if used else 1
|
||||
|
||||
# Guard: worst-case label is prefix + highest number that will be generated
|
||||
last_n = start_label_n + body.count - 1
|
||||
worst_case = f"{body.name_prefix}{last_n}"
|
||||
if len(worst_case) > MAX_TABLE_NAME_LENGTH:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"Table name '{worst_case}' would exceed {MAX_TABLE_NAME_LENGTH} characters. Shorten the prefix or reduce the count.",
|
||||
)
|
||||
|
||||
created = []
|
||||
for i in range(body.count):
|
||||
label_n = start_label_n + i
|
||||
global_number = _next_global_number(db)
|
||||
table = Table(
|
||||
number=global_number,
|
||||
label=f"{body.name_prefix}{label_n}",
|
||||
group_id=body.group_id,
|
||||
is_active=True,
|
||||
)
|
||||
db.add(table)
|
||||
db.flush()
|
||||
created.append(table)
|
||||
db.commit()
|
||||
for t in created:
|
||||
db.refresh(t)
|
||||
return created
|
||||
|
||||
|
||||
@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 delete_table(table_id: int, hard: bool = False, 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")
|
||||
active_order = db.query(Order).filter(
|
||||
Order.table_id == table_id,
|
||||
Order.status.in_(["open", "partially_paid", "paid"])
|
||||
).first()
|
||||
if active_order:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="Cannot delete or deactivate a table with an active order"
|
||||
)
|
||||
if hard:
|
||||
# Delete all past (non-active) orders for this table so FK constraint doesn't block deletion.
|
||||
# Active orders are already blocked above. Items/waiters/print_logs cascade via ORM.
|
||||
past_orders = db.query(Order).filter(Order.table_id == table_id).all()
|
||||
for order in past_orders:
|
||||
db.delete(order)
|
||||
db.flush()
|
||||
db.delete(table)
|
||||
else:
|
||||
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", "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
|
||||
186
local_backend/routers/waiters.py
Normal file
186
local_backend/routers/waiters.py
Normal file
@@ -0,0 +1,186 @@
|
||||
import os
|
||||
import uuid
|
||||
import bcrypt
|
||||
from fastapi import APIRouter, Depends, HTTPException, status, UploadFile, File
|
||||
from sqlalchemy.orm import Session
|
||||
from typing import List
|
||||
|
||||
from database import get_db
|
||||
from models.user import User, AssistantAssignment, WaiterZone
|
||||
from models.shift import WaiterShift
|
||||
from schemas.user import UserCreate, UserUpdate, UserOut, AssistantAssignmentOut, SetZonesRequest
|
||||
from routers.deps import require_manager, get_current_user
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
AVATAR_DIR = "/app/data/avatars"
|
||||
|
||||
|
||||
# ── Helpers ───────────────────────────────────────────────────────────────────
|
||||
|
||||
def _waiter_or_404(waiter_id: int, db: Session) -> User:
|
||||
w = db.query(User).filter(User.id == waiter_id).first()
|
||||
if not w:
|
||||
raise HTTPException(status_code=404, detail="Waiter not found")
|
||||
return w
|
||||
|
||||
|
||||
# ── CRUD ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
@router.get("/on-shift", response_model=List[UserOut])
|
||||
def list_waiters_on_shift(db: Session = Depends(get_db), user: User = Depends(get_current_user)):
|
||||
"""Waiters with an active (not-ended) shift. Accessible to all staff."""
|
||||
waiter_ids = db.query(WaiterShift.waiter_id).filter(WaiterShift.ended_at == None).subquery()
|
||||
return db.query(User).filter(User.id.in_(waiter_ids), User.role == "waiter", User.is_active == True).all()
|
||||
|
||||
|
||||
@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,
|
||||
full_name=body.full_name,
|
||||
nickname=body.nickname,
|
||||
mobile_phone=body.mobile_phone,
|
||||
)
|
||||
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 = _waiter_or_404(waiter_id, db)
|
||||
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 = _waiter_or_404(waiter_id, db)
|
||||
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 = _waiter_or_404(waiter_id, db)
|
||||
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 = _waiter_or_404(waiter_id, db)
|
||||
db.delete(waiter)
|
||||
db.commit()
|
||||
|
||||
|
||||
# ── Avatar upload / delete ───────────────────────────────────────────────────
|
||||
|
||||
@router.post("/{waiter_id}/avatar", response_model=UserOut)
|
||||
async def upload_avatar(waiter_id: int, file: UploadFile = File(...), db: Session = Depends(get_db), user: User = Depends(require_manager)):
|
||||
waiter = _waiter_or_404(waiter_id, db)
|
||||
if not file.content_type.startswith("image/"):
|
||||
raise HTTPException(status_code=400, detail="File must be an image")
|
||||
|
||||
# Delete old avatar file if present
|
||||
if waiter.avatar_url:
|
||||
old_path = os.path.join(AVATAR_DIR, os.path.basename(waiter.avatar_url))
|
||||
if os.path.exists(old_path):
|
||||
os.remove(old_path)
|
||||
|
||||
ext = os.path.splitext(file.filename or "")[1] or ".jpg"
|
||||
filename = f"waiter_{waiter_id}_{uuid.uuid4().hex[:8]}{ext}"
|
||||
dest = os.path.join(AVATAR_DIR, filename)
|
||||
os.makedirs(AVATAR_DIR, exist_ok=True)
|
||||
content = await file.read()
|
||||
with open(dest, "wb") as f:
|
||||
f.write(content)
|
||||
|
||||
waiter.avatar_url = f"/static/avatars/{filename}"
|
||||
db.commit()
|
||||
db.refresh(waiter)
|
||||
return waiter
|
||||
|
||||
|
||||
@router.delete("/{waiter_id}/avatar", response_model=UserOut)
|
||||
def delete_avatar(waiter_id: int, db: Session = Depends(get_db), user: User = Depends(require_manager)):
|
||||
waiter = _waiter_or_404(waiter_id, db)
|
||||
if waiter.avatar_url:
|
||||
old_path = os.path.join(AVATAR_DIR, os.path.basename(waiter.avatar_url))
|
||||
if os.path.exists(old_path):
|
||||
os.remove(old_path)
|
||||
waiter.avatar_url = None
|
||||
db.commit()
|
||||
db.refresh(waiter)
|
||||
return waiter
|
||||
|
||||
|
||||
# ── Zone assignments ──────────────────────────────────────────────────────────
|
||||
|
||||
@router.put("/{waiter_id}/zones")
|
||||
def set_zones(waiter_id: int, body: SetZonesRequest, db: Session = Depends(get_db), user: User = Depends(require_manager)):
|
||||
"""Replace all zone assignments for a waiter atomically.
|
||||
|
||||
- all_zones=True → single NULL group_id row (sees everything)
|
||||
- group_ids=[1,2] → rows for groups 1 and 2 only
|
||||
- group_ids=[] → no rows at all (sees nothing)
|
||||
"""
|
||||
_waiter_or_404(waiter_id, db)
|
||||
# Wipe existing assignments
|
||||
db.query(WaiterZone).filter(WaiterZone.waiter_id == waiter_id).delete()
|
||||
|
||||
if body.all_zones:
|
||||
db.add(WaiterZone(waiter_id=waiter_id, group_id=None))
|
||||
elif body.group_ids:
|
||||
for gid in body.group_ids:
|
||||
db.add(WaiterZone(waiter_id=waiter_id, group_id=gid))
|
||||
|
||||
db.commit()
|
||||
zones = db.query(WaiterZone).filter(WaiterZone.waiter_id == waiter_id).all()
|
||||
return {"waiter_id": waiter_id, "zones": [{"id": z.id, "group_id": z.group_id} for z in zones]}
|
||||
|
||||
|
||||
# ── Assistant assignments (kept for backwards compat) ─────────────────────────
|
||||
|
||||
@router.post("/{waiter_id}/assign-assistant", response_model=AssistantAssignmentOut)
|
||||
def assign_assistant(waiter_id: int, assistant_id: int, db: Session = Depends(get_db), user: User = Depends(require_manager)):
|
||||
existing = db.query(AssistantAssignment).filter(
|
||||
AssistantAssignment.primary_waiter_id == waiter_id,
|
||||
AssistantAssignment.assistant_waiter_id == assistant_id,
|
||||
).first()
|
||||
if existing:
|
||||
raise HTTPException(status_code=400, detail="Assignment already exists")
|
||||
assignment = AssistantAssignment(primary_waiter_id=waiter_id, assistant_waiter_id=assistant_id)
|
||||
db.add(assignment)
|
||||
db.commit()
|
||||
db.refresh(assignment)
|
||||
return assignment
|
||||
|
||||
|
||||
@router.delete("/{waiter_id}/assistant", status_code=status.HTTP_204_NO_CONTENT)
|
||||
def remove_assistant(waiter_id: int, db: Session = Depends(get_db), user: User = Depends(require_manager)):
|
||||
assignment = db.query(AssistantAssignment).filter(
|
||||
AssistantAssignment.primary_waiter_id == waiter_id
|
||||
).first()
|
||||
if not assignment:
|
||||
raise HTTPException(status_code=404, detail="Assignment not found")
|
||||
db.delete(assignment)
|
||||
db.commit()
|
||||
0
local_backend/schemas/__init__.py
Normal file
0
local_backend/schemas/__init__.py
Normal file
21
local_backend/schemas/auth.py
Normal file
21
local_backend/schemas/auth.py
Normal file
@@ -0,0 +1,21 @@
|
||||
from pydantic import BaseModel
|
||||
from schemas.user import UserOut
|
||||
|
||||
|
||||
class LoginRequest(BaseModel):
|
||||
username: str
|
||||
pin: str | None = None
|
||||
password: str | None = None
|
||||
|
||||
|
||||
class TokenResponse(BaseModel):
|
||||
access_token: str
|
||||
user: UserOut
|
||||
|
||||
|
||||
class UpdateMeRequest(BaseModel):
|
||||
full_name: str | None = None
|
||||
username: str | None = None
|
||||
current_password: str | None = None
|
||||
new_password: str | None = None
|
||||
new_pin: str | None = None
|
||||
14
local_backend/schemas/base.py
Normal file
14
local_backend/schemas/base.py
Normal file
@@ -0,0 +1,14 @@
|
||||
from datetime import datetime
|
||||
from typing import Annotated
|
||||
from pydantic import PlainSerializer
|
||||
|
||||
# SQLite strips tzinfo on read-back, so naive datetimes from DB are actually UTC.
|
||||
# This serializer appends "Z" so browsers parse them correctly as UTC.
|
||||
UTCDatetime = Annotated[
|
||||
datetime,
|
||||
PlainSerializer(
|
||||
lambda dt: (dt.isoformat() + "Z") if dt.tzinfo is None else dt.isoformat(),
|
||||
return_type=str,
|
||||
when_used="json",
|
||||
),
|
||||
]
|
||||
24
local_backend/schemas/business_day.py
Normal file
24
local_backend/schemas/business_day.py
Normal file
@@ -0,0 +1,24 @@
|
||||
from pydantic import BaseModel
|
||||
from typing import Optional
|
||||
from schemas.base import UTCDatetime
|
||||
|
||||
|
||||
class BusinessDayOut(BaseModel):
|
||||
id: int
|
||||
status: str
|
||||
opened_at: UTCDatetime
|
||||
opened_by_id: int
|
||||
closed_at: Optional[UTCDatetime] = None
|
||||
closed_by_id: Optional[int] = None
|
||||
notes: Optional[str] = None
|
||||
|
||||
model_config = {"from_attributes": True}
|
||||
|
||||
|
||||
class OpenBusinessDayRequest(BaseModel):
|
||||
notes: Optional[str] = None
|
||||
|
||||
|
||||
class CloseBusinessDayRequest(BaseModel):
|
||||
force: bool = False
|
||||
notes: Optional[str] = None
|
||||
47
local_backend/schemas/flag.py
Normal file
47
local_backend/schemas/flag.py
Normal file
@@ -0,0 +1,47 @@
|
||||
from pydantic import BaseModel
|
||||
from typing import Optional, List
|
||||
from datetime import datetime
|
||||
|
||||
|
||||
class FlagDefCreate(BaseModel):
|
||||
name: str
|
||||
emoji: Optional[str] = None
|
||||
color: Optional[str] = "#6b7280"
|
||||
text_color: Optional[str] = None
|
||||
sort_order: Optional[int] = 0
|
||||
|
||||
|
||||
class FlagDefUpdate(BaseModel):
|
||||
name: Optional[str] = None
|
||||
emoji: Optional[str] = None
|
||||
color: Optional[str] = None
|
||||
text_color: Optional[str] = None
|
||||
sort_order: Optional[int] = None
|
||||
is_active: Optional[bool] = None
|
||||
|
||||
|
||||
class FlagDefOut(BaseModel):
|
||||
id: int
|
||||
name: str
|
||||
emoji: Optional[str] = None
|
||||
color: Optional[str] = None
|
||||
text_color: Optional[str] = None
|
||||
sort_order: int
|
||||
is_active: bool
|
||||
|
||||
model_config = {"from_attributes": True}
|
||||
|
||||
|
||||
class FlagAssignmentOut(BaseModel):
|
||||
id: int
|
||||
table_id: int
|
||||
flag_id: int
|
||||
flag_def: Optional[FlagDefOut] = None
|
||||
assigned_at: datetime
|
||||
assigned_by: Optional[int] = None
|
||||
|
||||
model_config = {"from_attributes": True}
|
||||
|
||||
|
||||
class SetTableFlagsRequest(BaseModel):
|
||||
flag_ids: List[int]
|
||||
42
local_backend/schemas/message.py
Normal file
42
local_backend/schemas/message.py
Normal file
@@ -0,0 +1,42 @@
|
||||
from pydantic import BaseModel
|
||||
from typing import Optional, List
|
||||
from datetime import datetime
|
||||
|
||||
|
||||
class QuickTemplateCreate(BaseModel):
|
||||
body: str
|
||||
sort_order: Optional[int] = 0
|
||||
|
||||
|
||||
class QuickTemplateUpdate(BaseModel):
|
||||
body: Optional[str] = None
|
||||
sort_order: Optional[int] = None
|
||||
is_active: Optional[bool] = None
|
||||
|
||||
|
||||
class QuickTemplateOut(BaseModel):
|
||||
id: int
|
||||
body: str
|
||||
sort_order: int
|
||||
is_active: bool
|
||||
|
||||
model_config = {"from_attributes": True}
|
||||
|
||||
|
||||
class SendMessageRequest(BaseModel):
|
||||
body: str
|
||||
target_waiter_ids: List[int] # empty = all active waiters
|
||||
table_ids: Optional[List[int]] = []
|
||||
|
||||
|
||||
class StaffMessageOut(BaseModel):
|
||||
id: int
|
||||
sender_id: int
|
||||
sender_name: Optional[str] = None
|
||||
body: str
|
||||
target_waiter_ids: str # raw JSON string — frontend parses
|
||||
table_ids: str
|
||||
created_at: datetime
|
||||
acked_by: List[int] = [] # waiter ids who have acked
|
||||
|
||||
model_config = {"from_attributes": True}
|
||||
126
local_backend/schemas/order.py
Normal file
126
local_backend/schemas/order.py
Normal file
@@ -0,0 +1,126 @@
|
||||
from pydantic import BaseModel
|
||||
from datetime import datetime
|
||||
from typing import Optional, List
|
||||
from schemas.base import UTCDatetime
|
||||
|
||||
|
||||
class SelectedOptionInput(BaseModel):
|
||||
id: Optional[int] = None
|
||||
name: Optional[str] = None
|
||||
price_delta: Optional[float] = None
|
||||
extra_cost: Optional[float] = None
|
||||
# type tags: "quick" | "pref" | "pref_sub" | "extra" | "extra_sub"
|
||||
# Omitted by old clients — print code falls back gracefully.
|
||||
type: Optional[str] = None
|
||||
|
||||
|
||||
class OrderItemInput(BaseModel):
|
||||
product_id: int
|
||||
quantity: int
|
||||
selected_options: Optional[List[SelectedOptionInput]] = None
|
||||
removed_ingredients: Optional[List[str]] = None
|
||||
notes: Optional[str] = None
|
||||
|
||||
|
||||
class AddItemsRequest(BaseModel):
|
||||
items: List[OrderItemInput]
|
||||
|
||||
|
||||
class ProductNameOut(BaseModel):
|
||||
id: int
|
||||
name: str
|
||||
model_config = {"from_attributes": True}
|
||||
|
||||
|
||||
class OrderItemOut(BaseModel):
|
||||
id: int
|
||||
order_id: int
|
||||
product_id: int
|
||||
product: Optional[ProductNameOut] = None
|
||||
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: UTCDatetime
|
||||
printed: bool
|
||||
paid_by: Optional[int] = None
|
||||
paid_at: Optional[UTCDatetime] = None
|
||||
payment_method: Optional[str] = None
|
||||
paid_in_shift_id: Optional[int] = None
|
||||
|
||||
model_config = {"from_attributes": True}
|
||||
|
||||
|
||||
class PrintResultOut(BaseModel):
|
||||
printer_name: str
|
||||
success: bool
|
||||
error: Optional[str] = None
|
||||
|
||||
|
||||
class AddItemsResponse(BaseModel):
|
||||
order: "OrderOut"
|
||||
print_results: List[PrintResultOut]
|
||||
|
||||
model_config = {"from_attributes": True}
|
||||
|
||||
|
||||
class OrderCreate(BaseModel):
|
||||
table_id: int
|
||||
|
||||
|
||||
class PayItemsRequest(BaseModel):
|
||||
item_ids: List[int]
|
||||
payment_method: Optional[str] = None # 'cash' | 'card' | 'other' — optional for now
|
||||
|
||||
|
||||
class OfflinePaymentRequest(BaseModel):
|
||||
uuid: str # client-generated UUID, used for duplicate detection
|
||||
item_ids: List[int]
|
||||
payment_method: Optional[str] = None
|
||||
offline_at: Optional[str] = None # ISO timestamp of when payment was taken offline
|
||||
|
||||
|
||||
class AssignWaiterRequest(BaseModel):
|
||||
waiter_id: int
|
||||
|
||||
|
||||
class OrderWaiterOut(BaseModel):
|
||||
waiter_id: int
|
||||
model_config = {"from_attributes": True}
|
||||
|
||||
|
||||
class AuditLogOut(BaseModel):
|
||||
id: int
|
||||
order_id: int
|
||||
event_type: str
|
||||
waiter_id: Optional[int] = None
|
||||
waiter_name: Optional[str] = None # resolved server-side
|
||||
item_ids: Optional[str] = None
|
||||
amount: Optional[float] = None
|
||||
payment_method: Optional[str] = None
|
||||
note: Optional[str] = None
|
||||
created_at: UTCDatetime
|
||||
offline_at: Optional[str] = None
|
||||
is_duplicate: int = 0
|
||||
|
||||
model_config = {"from_attributes": True}
|
||||
|
||||
|
||||
class OrderOut(BaseModel):
|
||||
id: int
|
||||
table_id: int
|
||||
opened_by: int
|
||||
opened_at: UTCDatetime
|
||||
status: str
|
||||
closed_at: Optional[UTCDatetime] = None
|
||||
closed_by: Optional[int] = None
|
||||
notes: Optional[str] = None
|
||||
business_day_id: Optional[int] = None
|
||||
items: List[OrderItemOut] = []
|
||||
waiters: List[OrderWaiterOut] = []
|
||||
audit_logs: List[AuditLogOut] = []
|
||||
|
||||
model_config = {"from_attributes": True}
|
||||
31
local_backend/schemas/printer.py
Normal file
31
local_backend/schemas/printer.py
Normal file
@@ -0,0 +1,31 @@
|
||||
from pydantic import BaseModel
|
||||
from typing import Optional
|
||||
|
||||
|
||||
PROTOCOLS = ["escpos_tcp"] # extend later as needed
|
||||
|
||||
|
||||
class PrinterBase(BaseModel):
|
||||
name: str
|
||||
ip_address: str
|
||||
port: int = 9100
|
||||
is_active: bool = True
|
||||
protocol: str = "escpos_tcp"
|
||||
|
||||
|
||||
class PrinterCreate(PrinterBase):
|
||||
pass
|
||||
|
||||
|
||||
class PrinterUpdate(BaseModel):
|
||||
name: Optional[str] = None
|
||||
ip_address: Optional[str] = None
|
||||
port: Optional[int] = None
|
||||
is_active: Optional[bool] = None
|
||||
protocol: Optional[str] = None
|
||||
|
||||
|
||||
class PrinterOut(PrinterBase):
|
||||
id: int
|
||||
|
||||
model_config = {"from_attributes": True}
|
||||
302
local_backend/schemas/product.py
Normal file
302
local_backend/schemas/product.py
Normal file
@@ -0,0 +1,302 @@
|
||||
import json
|
||||
from pydantic import BaseModel, model_validator, field_validator
|
||||
from typing import Optional, List, Any
|
||||
|
||||
|
||||
class CategoryCreate(BaseModel):
|
||||
name: str
|
||||
color: Optional[str] = None
|
||||
sort_order: int = 0
|
||||
parent_id: Optional[int] = None
|
||||
general_sort_order: int = 0
|
||||
auto_expanded: bool = False
|
||||
|
||||
|
||||
class CategoryUpdate(BaseModel):
|
||||
name: Optional[str] = None
|
||||
color: Optional[str] = None
|
||||
sort_order: Optional[int] = None
|
||||
parent_id: Optional[int] = None
|
||||
general_sort_order: Optional[int] = None
|
||||
auto_expanded: Optional[bool] = None
|
||||
|
||||
|
||||
class CategoryOut(BaseModel):
|
||||
id: int
|
||||
name: str
|
||||
color: Optional[str] = None
|
||||
sort_order: int = 0
|
||||
parent_id: Optional[int] = None
|
||||
general_sort_order: int = 0
|
||||
auto_expanded: bool = False
|
||||
|
||||
model_config = {"from_attributes": True}
|
||||
|
||||
|
||||
class CategoryReparentRequest(BaseModel):
|
||||
parent_id: Optional[int] = None
|
||||
|
||||
|
||||
class CategoryReorderItem(BaseModel):
|
||||
id: int
|
||||
sort_order: int
|
||||
|
||||
|
||||
class SubcategoryReorderItem(BaseModel):
|
||||
id: int
|
||||
sort_order: int # position among subcategories within the parent
|
||||
|
||||
|
||||
class ParentGeneralReorderItem(BaseModel):
|
||||
id: int # parent category id
|
||||
general_sort_order: int
|
||||
|
||||
|
||||
# ── Quick Options ─────────────────────────────────────────────────────────────
|
||||
|
||||
class ProductQuickOptionCreate(BaseModel):
|
||||
name: str
|
||||
price: float = 0.0
|
||||
allow_multiple: bool = False
|
||||
sort_order: int = 0
|
||||
is_favorite: bool = False
|
||||
favorite_sort_order: int = 0
|
||||
is_compact: bool = False
|
||||
|
||||
|
||||
class ProductQuickOptionOut(BaseModel):
|
||||
id: int
|
||||
product_id: int
|
||||
name: str
|
||||
price: float = 0.0
|
||||
allow_multiple: bool = False
|
||||
sort_order: int = 0
|
||||
is_favorite: bool = False
|
||||
favorite_sort_order: int = 0
|
||||
is_compact: bool = False
|
||||
|
||||
model_config = {"from_attributes": True}
|
||||
|
||||
|
||||
# ── Options ──────────────────────────────────────────────────────────────────
|
||||
|
||||
class OptionSubChoice(BaseModel):
|
||||
name: str
|
||||
extra_cost: float = 0.0
|
||||
is_default: bool = False
|
||||
|
||||
|
||||
class ProductOptionBase(BaseModel):
|
||||
name: str
|
||||
extra_cost: float = 0.0
|
||||
allow_multiple: bool = False
|
||||
is_favorite: bool = False
|
||||
favorite_sort_order: int = 0
|
||||
|
||||
|
||||
class ProductOptionCreate(ProductOptionBase):
|
||||
sub_choices: List[OptionSubChoice] = []
|
||||
|
||||
|
||||
class ProductOptionOut(ProductOptionBase):
|
||||
id: int
|
||||
product_id: int
|
||||
sub_choices: List[OptionSubChoice] = []
|
||||
|
||||
model_config = {"from_attributes": True}
|
||||
|
||||
@model_validator(mode='before')
|
||||
@classmethod
|
||||
def parse_option_sub_choices(cls, data: Any) -> Any:
|
||||
if hasattr(data, 'sub_choices'):
|
||||
raw = data.sub_choices
|
||||
parsed = json.loads(raw) if isinstance(raw, str) else []
|
||||
return {
|
||||
'id': data.id,
|
||||
'product_id': data.product_id,
|
||||
'name': data.name,
|
||||
'extra_cost': data.extra_cost,
|
||||
'allow_multiple': getattr(data, 'allow_multiple', False) or False,
|
||||
'sub_choices': parsed,
|
||||
'is_favorite': getattr(data, 'is_favorite', False) or False,
|
||||
'favorite_sort_order': getattr(data, 'favorite_sort_order', 0) or 0,
|
||||
}
|
||||
return data
|
||||
|
||||
|
||||
# ── Ingredients ───────────────────────────────────────────────────────────────
|
||||
|
||||
class ProductIngredientBase(BaseModel):
|
||||
name: str
|
||||
extra_cost: float = 0.0
|
||||
is_favorite: bool = False
|
||||
favorite_sort_order: int = 0
|
||||
|
||||
|
||||
class ProductIngredientCreate(ProductIngredientBase):
|
||||
pass
|
||||
|
||||
|
||||
class ProductIngredientOut(ProductIngredientBase):
|
||||
id: int
|
||||
product_id: int
|
||||
|
||||
model_config = {"from_attributes": True}
|
||||
|
||||
|
||||
# ── Sub-choices (nested under a preference choice) ────────────────────────────
|
||||
|
||||
class SubChoice(BaseModel):
|
||||
name: str
|
||||
extra_cost: float = 0.0
|
||||
is_default: bool = False
|
||||
|
||||
|
||||
# ── Shared subset (set-level, shown for all non-disabling choices) ─────────────
|
||||
|
||||
class SharedSubsetChoice(BaseModel):
|
||||
name: str
|
||||
extra_cost: float = 0.0
|
||||
is_default: bool = False
|
||||
|
||||
|
||||
class SharedSubset(BaseModel):
|
||||
name: str
|
||||
choices: List[SharedSubsetChoice] = []
|
||||
|
||||
|
||||
# ── Preferences ───────────────────────────────────────────────────────────────
|
||||
|
||||
class PreferenceChoiceCreate(BaseModel):
|
||||
name: str
|
||||
extra_cost: float = 0.0
|
||||
sub_choices: List[SubChoice] = []
|
||||
disables_subset: bool = False
|
||||
|
||||
|
||||
class PreferenceChoiceOut(BaseModel):
|
||||
id: int
|
||||
set_id: int
|
||||
name: str
|
||||
extra_cost: float = 0.0
|
||||
sub_choices: List[SubChoice] = []
|
||||
disables_subset: bool = False
|
||||
|
||||
model_config = {"from_attributes": True}
|
||||
|
||||
@model_validator(mode='before')
|
||||
@classmethod
|
||||
def parse_sub_choices(cls, data: Any) -> Any:
|
||||
if hasattr(data, 'sub_choices'):
|
||||
raw = data.sub_choices
|
||||
if isinstance(raw, str):
|
||||
try:
|
||||
parsed = json.loads(raw)
|
||||
except Exception:
|
||||
parsed = []
|
||||
else:
|
||||
parsed = []
|
||||
return {
|
||||
'id': data.id,
|
||||
'set_id': data.set_id,
|
||||
'name': data.name,
|
||||
'extra_cost': data.extra_cost,
|
||||
'sub_choices': parsed,
|
||||
'disables_subset': data.disables_subset or False,
|
||||
}
|
||||
return data
|
||||
|
||||
|
||||
class PreferenceSetCreate(BaseModel):
|
||||
name: str
|
||||
choices: List[PreferenceChoiceCreate] = []
|
||||
default_choice_index: Optional[int] = None # index into choices (0-based)
|
||||
shared_subset: Optional[SharedSubset] = None
|
||||
is_favorite: bool = False
|
||||
favorite_sort_order: int = 0
|
||||
|
||||
|
||||
class PreferenceSetOut(BaseModel):
|
||||
id: int
|
||||
product_id: int
|
||||
name: str
|
||||
choices: List[PreferenceChoiceOut] = []
|
||||
default_choice_id: Optional[int] = None
|
||||
shared_subset: Optional[SharedSubset] = None
|
||||
is_favorite: bool = False
|
||||
favorite_sort_order: int = 0
|
||||
|
||||
model_config = {"from_attributes": True}
|
||||
|
||||
@model_validator(mode='before')
|
||||
@classmethod
|
||||
def parse_shared_subset(cls, data: Any) -> Any:
|
||||
if hasattr(data, 'shared_subset'):
|
||||
raw = data.shared_subset
|
||||
if isinstance(raw, str):
|
||||
try:
|
||||
parsed = json.loads(raw)
|
||||
except Exception:
|
||||
parsed = None
|
||||
else:
|
||||
parsed = None
|
||||
return {
|
||||
'id': data.id,
|
||||
'product_id': data.product_id,
|
||||
'name': data.name,
|
||||
'choices': list(data.choices),
|
||||
'default_choice_id': data.default_choice_id,
|
||||
'shared_subset': parsed,
|
||||
'is_favorite': getattr(data, 'is_favorite', False) or False,
|
||||
'favorite_sort_order': getattr(data, 'favorite_sort_order', 0) or 0,
|
||||
}
|
||||
return data
|
||||
|
||||
|
||||
# ── Products ──────────────────────────────────────────────────────────────────
|
||||
|
||||
class ProductBase(BaseModel):
|
||||
name: str
|
||||
category_id: Optional[int] = None
|
||||
base_price: float
|
||||
is_available: bool = True
|
||||
lifecycle_status: str = "active"
|
||||
printer_zone_id: Optional[int] = None
|
||||
sort_order: int = 0
|
||||
|
||||
|
||||
class ProductCreate(ProductBase):
|
||||
quick_options: List[ProductQuickOptionCreate] = []
|
||||
options: List[ProductOptionCreate] = []
|
||||
ingredients: List[ProductIngredientCreate] = []
|
||||
preference_sets: List[PreferenceSetCreate] = []
|
||||
|
||||
|
||||
class ProductUpdate(BaseModel):
|
||||
name: Optional[str] = None
|
||||
category_id: Optional[int] = None
|
||||
base_price: Optional[float] = None
|
||||
is_available: Optional[bool] = None
|
||||
lifecycle_status: Optional[str] = None
|
||||
printer_zone_id: Optional[int] = None
|
||||
sort_order: Optional[int] = None
|
||||
quick_options: Optional[List[ProductQuickOptionCreate]] = None
|
||||
options: Optional[List[ProductOptionCreate]] = None
|
||||
ingredients: Optional[List[ProductIngredientCreate]] = None
|
||||
preference_sets: Optional[List[PreferenceSetCreate]] = None
|
||||
|
||||
|
||||
class ProductReorderItem(BaseModel):
|
||||
id: int
|
||||
sort_order: int
|
||||
|
||||
|
||||
class ProductOut(ProductBase):
|
||||
id: int
|
||||
quick_options: List[ProductQuickOptionOut] = []
|
||||
options: List[ProductOptionOut] = []
|
||||
ingredients: List[ProductIngredientOut] = []
|
||||
preference_sets: List[PreferenceSetOut] = []
|
||||
image_url: Optional[str] = None
|
||||
|
||||
model_config = {"from_attributes": True}
|
||||
16
local_backend/schemas/settings.py
Normal file
16
local_backend/schemas/settings.py
Normal file
@@ -0,0 +1,16 @@
|
||||
from pydantic import BaseModel
|
||||
from typing import Optional
|
||||
from schemas.base import UTCDatetime
|
||||
|
||||
|
||||
class PosSettingOut(BaseModel):
|
||||
key: str
|
||||
value: str
|
||||
updated_at: Optional[UTCDatetime] = None
|
||||
updated_by_id: Optional[int] = None
|
||||
|
||||
model_config = {"from_attributes": True}
|
||||
|
||||
|
||||
class UpdateSettingRequest(BaseModel):
|
||||
value: str
|
||||
38
local_backend/schemas/shift.py
Normal file
38
local_backend/schemas/shift.py
Normal file
@@ -0,0 +1,38 @@
|
||||
from pydantic import BaseModel
|
||||
from typing import Optional, List
|
||||
from schemas.base import UTCDatetime
|
||||
|
||||
|
||||
class ShiftBreakOut(BaseModel):
|
||||
id: int
|
||||
shift_id: int
|
||||
started_at: UTCDatetime
|
||||
ended_at: Optional[UTCDatetime] = None
|
||||
|
||||
model_config = {"from_attributes": True}
|
||||
|
||||
|
||||
class WaiterShiftOut(BaseModel):
|
||||
id: int
|
||||
waiter_id: int
|
||||
waiter_name: Optional[str] = None
|
||||
business_day_id: int
|
||||
started_at: UTCDatetime
|
||||
ended_at: Optional[UTCDatetime] = None
|
||||
starting_cash: Optional[float] = None
|
||||
total_collected: Optional[float] = None
|
||||
net_to_deliver: Optional[float] = None
|
||||
is_active: bool = True
|
||||
notes: Optional[str] = None
|
||||
breaks: List[ShiftBreakOut] = []
|
||||
|
||||
model_config = {"from_attributes": True}
|
||||
|
||||
|
||||
class StartShiftRequest(BaseModel):
|
||||
starting_cash: Optional[float] = None
|
||||
waiter_id: Optional[int] = None # manager use: start shift for a specific waiter
|
||||
|
||||
|
||||
class EndShiftRequest(BaseModel):
|
||||
notes: Optional[str] = None
|
||||
87
local_backend/schemas/table.py
Normal file
87
local_backend/schemas/table.py
Normal file
@@ -0,0 +1,87 @@
|
||||
from pydantic import BaseModel, field_validator
|
||||
from typing import Optional, List
|
||||
|
||||
MAX_TABLE_NAME_LENGTH = 6
|
||||
|
||||
|
||||
class TableGroupCreate(BaseModel):
|
||||
name: str
|
||||
prefix: Optional[str] = None
|
||||
color: Optional[str] = None
|
||||
|
||||
|
||||
class TableGroupUpdate(BaseModel):
|
||||
name: Optional[str] = None
|
||||
prefix: Optional[str] = None
|
||||
color: Optional[str] = None
|
||||
|
||||
|
||||
class TableGroupOut(BaseModel):
|
||||
id: int
|
||||
name: str
|
||||
prefix: Optional[str] = None
|
||||
sort_order: int = 0
|
||||
color: Optional[str] = None
|
||||
|
||||
model_config = {"from_attributes": True}
|
||||
|
||||
|
||||
class TableBase(BaseModel):
|
||||
label: Optional[str] = None
|
||||
group_id: Optional[int] = None
|
||||
is_active: bool = True
|
||||
|
||||
|
||||
class TableCreate(BaseModel):
|
||||
label: Optional[str] = None
|
||||
group_id: Optional[int] = None
|
||||
|
||||
@field_validator("label")
|
||||
@classmethod
|
||||
def label_max_length(cls, v):
|
||||
if v is not None:
|
||||
v = v.strip()
|
||||
if len(v) > MAX_TABLE_NAME_LENGTH:
|
||||
raise ValueError(f"Table name cannot exceed {MAX_TABLE_NAME_LENGTH} characters")
|
||||
return v
|
||||
|
||||
|
||||
class TableBatchCreate(BaseModel):
|
||||
group_id: Optional[int] = None
|
||||
count: int
|
||||
name_prefix: str # e.g. "TBL-" → TBL-1, TBL-2 ...
|
||||
# start_number is computed on the backend from existing tables in the group
|
||||
|
||||
|
||||
class TableUpdate(BaseModel):
|
||||
label: Optional[str] = None
|
||||
group_id: Optional[int] = None
|
||||
is_active: Optional[bool] = None
|
||||
|
||||
@field_validator("label")
|
||||
@classmethod
|
||||
def label_max_length(cls, v):
|
||||
if v is not None:
|
||||
v = v.strip()
|
||||
if len(v) > MAX_TABLE_NAME_LENGTH:
|
||||
raise ValueError(f"Table name cannot exceed {MAX_TABLE_NAME_LENGTH} characters")
|
||||
return v
|
||||
|
||||
|
||||
class TableFloorplanUpdate(BaseModel):
|
||||
floor_x: float
|
||||
floor_y: float
|
||||
|
||||
|
||||
class TableOut(BaseModel):
|
||||
id: int
|
||||
number: int
|
||||
label: Optional[str] = None
|
||||
group_id: Optional[int] = None
|
||||
is_active: bool = True
|
||||
floor_x: Optional[float] = None
|
||||
floor_y: Optional[float] = None
|
||||
group: Optional[TableGroupOut] = None
|
||||
has_active_order: bool = False
|
||||
|
||||
model_config = {"from_attributes": True}
|
||||
61
local_backend/schemas/user.py
Normal file
61
local_backend/schemas/user.py
Normal file
@@ -0,0 +1,61 @@
|
||||
from pydantic import BaseModel
|
||||
from datetime import datetime
|
||||
from typing import Optional, List
|
||||
from schemas.base import UTCDatetime
|
||||
|
||||
|
||||
class UserBase(BaseModel):
|
||||
username: str
|
||||
role: str
|
||||
is_active: bool = True
|
||||
full_name: Optional[str] = None
|
||||
nickname: Optional[str] = None
|
||||
mobile_phone: Optional[str] = None
|
||||
avatar_url: Optional[str] = None
|
||||
email: Optional[str] = None
|
||||
|
||||
|
||||
class UserCreate(UserBase):
|
||||
pin: str
|
||||
|
||||
|
||||
class UserUpdate(BaseModel):
|
||||
username: Optional[str] = None
|
||||
role: Optional[str] = None
|
||||
is_active: Optional[bool] = None
|
||||
full_name: Optional[str] = None
|
||||
nickname: Optional[str] = None
|
||||
mobile_phone: Optional[str] = None
|
||||
|
||||
|
||||
class WaiterZoneOut(BaseModel):
|
||||
id: int
|
||||
waiter_id: int
|
||||
group_id: Optional[int] = None # None = all zones
|
||||
|
||||
model_config = {"from_attributes": True}
|
||||
|
||||
|
||||
class UserOut(UserBase):
|
||||
id: int
|
||||
created_at: UTCDatetime
|
||||
zone_assignments: List[WaiterZoneOut] = []
|
||||
|
||||
model_config = {"from_attributes": True}
|
||||
|
||||
|
||||
class SetZonesRequest(BaseModel):
|
||||
"""Replace all zone assignments for a waiter in one call.
|
||||
group_ids=[] means remove all (sees nothing).
|
||||
group_ids=[null] or all_zones=True means the wildcard 'all zones' sentinel."""
|
||||
group_ids: Optional[List[Optional[int]]] = None # list of group ids; None in list = all-zones sentinel
|
||||
all_zones: bool = False # convenience flag: if True, set a single NULL-group_id row
|
||||
|
||||
|
||||
class AssistantAssignmentOut(BaseModel):
|
||||
id: int
|
||||
primary_waiter_id: int
|
||||
assistant_waiter_id: int
|
||||
assigned_at: UTCDatetime
|
||||
|
||||
model_config = {"from_attributes": True}
|
||||
45
local_backend/seed.py
Normal file
45
local_backend/seed.py
Normal file
@@ -0,0 +1,45 @@
|
||||
"""
|
||||
Run once to bootstrap the database with initial data.
|
||||
Usage: python seed.py
|
||||
"""
|
||||
import bcrypt
|
||||
from database import engine, Base, SessionLocal
|
||||
import models.user
|
||||
import models.table
|
||||
import models.printer
|
||||
import models.product
|
||||
import models.order
|
||||
|
||||
Base.metadata.create_all(bind=engine)
|
||||
|
||||
db = SessionLocal()
|
||||
|
||||
try:
|
||||
from models.user import User
|
||||
from models.printer import Printer
|
||||
|
||||
# ── Manager account ───────────────────────────────────────────────────────
|
||||
if not db.query(User).filter(User.username == "manager").first():
|
||||
db.add(User(
|
||||
username="manager",
|
||||
pin_hash=bcrypt.hashpw(b"1234", bcrypt.gensalt()).decode(),
|
||||
role="manager",
|
||||
))
|
||||
print("Created manager account (PIN: 1234)")
|
||||
else:
|
||||
print("Manager account already exists — skipping")
|
||||
|
||||
# ── Printers ──────────────────────────────────────────────────────────────
|
||||
if not db.query(Printer).filter(Printer.name == "Kitchen").first():
|
||||
db.add(Printer(name="Kitchen", ip_address="10.98.20.25", port=9100))
|
||||
print("Created Kitchen printer")
|
||||
|
||||
if not db.query(Printer).filter(Printer.name == "Bar").first():
|
||||
db.add(Printer(name="Bar", ip_address="10.98.20.25", port=9100))
|
||||
print("Created Bar printer (update IP when you have a second printer)")
|
||||
|
||||
db.commit()
|
||||
print("\nDone. Change the manager PIN after first login.")
|
||||
|
||||
finally:
|
||||
db.close()
|
||||
0
local_backend/services/__init__.py
Normal file
0
local_backend/services/__init__.py
Normal file
179
local_backend/services/cloud_sync.py
Normal file
179
local_backend/services/cloud_sync.py
Normal file
@@ -0,0 +1,179 @@
|
||||
"""
|
||||
Periodic cloud check-in. Runs every 5 minutes as an asyncio background task.
|
||||
Grace period: 72 hours (3 days) before marking unlicensed on connectivity failure.
|
||||
|
||||
Lock behaviour:
|
||||
- cloud sets locked=true → set lock_pending=true in state
|
||||
- lock_pending is enforced at workday-close time (see business_day router)
|
||||
- while a workday is open, the site keeps running; lock applies once it closes
|
||||
|
||||
License expiry behaviour:
|
||||
- 5 days before expiry → warning only (days_until_expiry in state)
|
||||
- on expiry → 5-day grace period begins (grace_expires_at in state)
|
||||
- after grace + no open workday → licensed=False enforced by business_day router
|
||||
"""
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
import socket
|
||||
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 = 5 * 60 # 5 minutes
|
||||
GRACE_HOURS = 72 # 3 days offline grace
|
||||
EXPIRY_GRACE_DAYS = 5 # days after expiry before blocking
|
||||
EXPIRY_WARNING_DAYS = 5 # days before expiry to show warning
|
||||
STATE_FILE = Path(__file__).parent.parent / "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)
|
||||
|
||||
|
||||
def _compute_expiry_fields(expires_at_str: str | None) -> dict:
|
||||
"""Return days_until_expiry and grace_expires_at derived from expires_at."""
|
||||
if not expires_at_str:
|
||||
return {"days_until_expiry": None, "grace_expires_at": None}
|
||||
|
||||
try:
|
||||
expires_at = datetime.fromisoformat(expires_at_str)
|
||||
if expires_at.tzinfo is None:
|
||||
expires_at = expires_at.replace(tzinfo=timezone.utc)
|
||||
except ValueError:
|
||||
return {"days_until_expiry": None, "grace_expires_at": None}
|
||||
|
||||
now = datetime.now(timezone.utc)
|
||||
days_until = (expires_at - now).days # negative once expired
|
||||
|
||||
grace_expires_at = None
|
||||
if days_until < 0:
|
||||
grace_expires_at = (expires_at + timedelta(days=EXPIRY_GRACE_DAYS)).isoformat()
|
||||
|
||||
return {
|
||||
"days_until_expiry": days_until,
|
||||
"grace_expires_at": grace_expires_at,
|
||||
}
|
||||
|
||||
|
||||
def _get_local_ip() -> str | None:
|
||||
"""Best-effort detection of the machine's LAN IP address."""
|
||||
try:
|
||||
with socket.socket(socket.AF_INET, socket.SOCK_DGRAM) as s:
|
||||
s.connect(("8.8.8.8", 80))
|
||||
return s.getsockname()[0]
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
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:
|
||||
local_ip = _get_local_ip()
|
||||
async with httpx.AsyncClient(timeout=10) as client:
|
||||
resp = await client.post(
|
||||
f"{settings.CLOUD_URL}/api/heartbeat/",
|
||||
headers={
|
||||
"X-Site-ID": settings.SITE_ID,
|
||||
"X-Site-Key": settings.SITE_KEY,
|
||||
},
|
||||
json={"version": settings.VERSION, "uptime_seconds": 0, "local_ip": local_ip},
|
||||
)
|
||||
resp.raise_for_status()
|
||||
data = resp.json()
|
||||
|
||||
licensed = data.get("licensed", True)
|
||||
cloud_locked = data.get("locked", False)
|
||||
expires_at = data.get("expires_at")
|
||||
expiry_fields = _compute_expiry_fields(expires_at)
|
||||
|
||||
# If cloud says locked, check whether a workday is currently open.
|
||||
# No open workday → lock immediately.
|
||||
# Open workday → defer to workday close (business_day router enforces it).
|
||||
if cloud_locked:
|
||||
from database import SessionLocal
|
||||
from models.business_day import BusinessDay
|
||||
db = SessionLocal()
|
||||
try:
|
||||
open_day = db.query(BusinessDay).filter(BusinessDay.status == "open").first()
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
if open_day:
|
||||
if not license_state.get("lock_pending"):
|
||||
license_state["lock_pending"] = True
|
||||
logger.info("Cloud requested lock — workday open, deferring to workday close")
|
||||
else:
|
||||
license_state["lock_pending"] = False
|
||||
license_state["locked"] = True
|
||||
logger.info("Cloud requested lock — no open workday, locking immediately")
|
||||
|
||||
# If cloud lifts the lock, clear pending too
|
||||
if not cloud_locked:
|
||||
license_state["lock_pending"] = False
|
||||
license_state["locked"] = False
|
||||
|
||||
license_state.update({
|
||||
"licensed": licensed,
|
||||
"expires_at": expires_at,
|
||||
"latest_version": data.get("latest_version"),
|
||||
"waiter_domain": data.get("waiter_domain"),
|
||||
"last_sync": datetime.now(timezone.utc).isoformat(),
|
||||
"sync_failed": False,
|
||||
**expiry_fields,
|
||||
})
|
||||
_persist_state()
|
||||
logger.info("Cloud sync OK: licensed=%s locked=%s expires_at=%s", licensed, cloud_locked, expires_at)
|
||||
|
||||
except Exception as e:
|
||||
logger.warning("Cloud sync failed: %s", e)
|
||||
license_state["sync_failed"] = True
|
||||
|
||||
last_sync_str = license_state.get("last_sync")
|
||||
if last_sync_str:
|
||||
try:
|
||||
last_sync = datetime.fromisoformat(last_sync_str)
|
||||
grace_expires = last_sync + timedelta(hours=GRACE_HOURS)
|
||||
if datetime.now(timezone.utc) > grace_expires:
|
||||
logger.error("72-hour offline grace period expired — marking unlicensed")
|
||||
license_state["licensed"] = False
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
# Recompute expiry fields from cached expires_at even when offline
|
||||
expiry_fields = _compute_expiry_fields(license_state.get("expires_at"))
|
||||
license_state.update(expiry_fields)
|
||||
|
||||
|
||||
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
|
||||
899
local_backend/services/printer_service.py
Normal file
899
local_backend/services/printer_service.py
Normal file
@@ -0,0 +1,899 @@
|
||||
"""
|
||||
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
|
||||
from models.settings import PosSettings
|
||||
|
||||
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))
|
||||
|
||||
|
||||
_DIVIDER_CHARS = {
|
||||
"dash": "-",
|
||||
"equals": "=",
|
||||
"star": "*",
|
||||
"empty": "",
|
||||
}
|
||||
|
||||
_PRINT_SETTING_KEYS = [
|
||||
"print.ticket_mode",
|
||||
"print.divider_style",
|
||||
"print.font_order_number",
|
||||
"print.font_meta",
|
||||
"print.font_item_name",
|
||||
"print.font_quick",
|
||||
"print.font_pref",
|
||||
"print.font_extra",
|
||||
"print.font_ingredient",
|
||||
"print.font_item_note",
|
||||
"print.font_order_note",
|
||||
]
|
||||
|
||||
_PRINT_SETTING_DEFAULTS = {
|
||||
"print.ticket_mode": "detailed",
|
||||
"print.divider_style": "dash",
|
||||
"print.font_order_number": "48:1:0",
|
||||
"print.font_meta": "0:0:0",
|
||||
"print.font_item_name": "16:1:0",
|
||||
"print.font_quick": "0:0:0",
|
||||
"print.font_pref": "0:0:0",
|
||||
"print.font_extra": "0:0:0",
|
||||
"print.font_ingredient": "0:0:0",
|
||||
"print.font_item_note": "0:0:0",
|
||||
"print.font_order_note": "0:1:0",
|
||||
}
|
||||
|
||||
# SIZE byte values (ESC ! base, no bold bit):
|
||||
# 0 = normal
|
||||
# 16 = double-height (bit4)
|
||||
# 32 = double-width (bit5)
|
||||
# 48 = double-height + double-width (bits 4+5)
|
||||
# Bold applied via ESC E, caps applied in software before encoding.
|
||||
|
||||
def _decode_font(value: str) -> tuple[int, bool, bool]:
|
||||
"""Parse 'SIZE:BOLD:CAPS' string → (esc_bang_byte, bold_flag, caps_flag)."""
|
||||
try:
|
||||
parts = str(value).split(":")
|
||||
size = int(parts[0])
|
||||
bold = len(parts) > 1 and parts[1] == "1"
|
||||
caps = len(parts) > 2 and parts[2] == "1"
|
||||
return size, bold, caps
|
||||
except (ValueError, AttributeError):
|
||||
return 0, False, False
|
||||
|
||||
|
||||
def _load_print_settings(db: Session) -> dict:
|
||||
rows = db.query(PosSettings).filter(
|
||||
PosSettings.key.in_(_PRINT_SETTING_KEYS)
|
||||
).all()
|
||||
settings = dict(_PRINT_SETTING_DEFAULTS)
|
||||
for row in rows:
|
||||
settings[row.key] = row.value
|
||||
return settings
|
||||
|
||||
|
||||
def _divider(p: Network, style: str = "dash"):
|
||||
char = _DIVIDER_CHARS.get(style, "-")
|
||||
p._raw(b'\x1b\x61\x00')
|
||||
if char:
|
||||
p._raw(_gr(char * LINE_WIDTH + "\n"))
|
||||
else:
|
||||
p._raw(b'\n')
|
||||
|
||||
|
||||
def _item_line(name: str, qty: int, line_width: int = LINE_WIDTH) -> str:
|
||||
"""Build a dot-leader line ending with 'xN'.
|
||||
line_width must reflect the effective width at the chosen font size
|
||||
(double-width fonts halve the available char count to 24)."""
|
||||
suffix = f"x{qty}"
|
||||
available = line_width - len(name) - len(suffix)
|
||||
if available < 2:
|
||||
# Name alone is too long — put qty on same line with a single space
|
||||
return f"{name} {suffix}"
|
||||
dots = (". " * ((available // 2) + 1))[:available]
|
||||
return f"{name}{dots}{suffix}"
|
||||
|
||||
|
||||
def _apply_font(p: Network, size: int, bold: bool):
|
||||
p._raw(bytes([0x1b, 0x21, size]))
|
||||
p._raw(b'\x1b\x45\x01' if bold else b'\x1b\x45\x00')
|
||||
|
||||
|
||||
def _reset_font(p: Network):
|
||||
p._raw(b'\x1b\x21\x00')
|
||||
p._raw(b'\x1b\x45\x00')
|
||||
|
||||
|
||||
def _print_line(p: Network, text: str, size: int, bold: bool, caps: bool,
|
||||
align: bytes = b'\x1b\x61\x00'):
|
||||
"""Apply font, optionally capitalize, print text + newline, reset font."""
|
||||
p._raw(align)
|
||||
_apply_font(p, size, bold)
|
||||
out = text.upper() if caps else text
|
||||
_raw_text(p, out + "\n")
|
||||
_reset_font(p)
|
||||
|
||||
|
||||
def _greek_date(dt: datetime.datetime) -> str:
|
||||
"""Return date/time string in Greek format: HH:MM DD-MM-YYYY"""
|
||||
return dt.strftime("%H:%M %d-%m-%Y")
|
||||
|
||||
|
||||
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 is_spoof_mode() -> bool:
|
||||
"""Stateless check — opens its own DB session. For use outside route_and_print."""
|
||||
db = SessionLocal()
|
||||
try:
|
||||
return _is_spoof_mode(db)
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
def send_test_print(ip: str, port: int, name: str) -> Tuple[bool, str]:
|
||||
if is_spoof_mode():
|
||||
logger.info("Spoof printing ON — dropping test print for %s", name)
|
||||
return True, ""
|
||||
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)
|
||||
|
||||
|
||||
def send_test_order_print(ip: str, port: int, db: Session) -> Tuple[bool, str]:
|
||||
"""Print a fake order using the current font/layout settings — for settings preview."""
|
||||
if _is_spoof_mode(db):
|
||||
logger.info("Spoof printing ON — dropping test order print")
|
||||
return True, ""
|
||||
|
||||
# ── Fake data structures (no DB writes) ──────────────────────────────────
|
||||
class _Table:
|
||||
label = "O2"
|
||||
number = 2
|
||||
|
||||
class _User:
|
||||
nickname = "bonamin"
|
||||
username = "bonamin"
|
||||
|
||||
class _Order:
|
||||
id = 99
|
||||
table = _Table()
|
||||
opener = _User()
|
||||
table_id = 2
|
||||
opened_by = 1
|
||||
notes = "Χωρις καψαλισμα παρακαλω"
|
||||
|
||||
class _Item:
|
||||
def __init__(self, product_id, quantity, selected_options, removed_ingredients, notes):
|
||||
self.product_id = product_id
|
||||
self.quantity = quantity
|
||||
self.selected_options = selected_options
|
||||
self.removed_ingredients = removed_ingredients
|
||||
self.notes = notes
|
||||
|
||||
import json as _json
|
||||
|
||||
items = [
|
||||
# Item 1: Freddo Espresso — quick options + preference + note
|
||||
_Item(
|
||||
product_id=1001,
|
||||
quantity=2,
|
||||
selected_options=_json.dumps([
|
||||
{"name": "Διπλος", "price_delta": 0.5, "type": "quick"},
|
||||
{"name": "Εξτρα ζαχαρη", "price_delta": 0.0, "type": "quick"},
|
||||
{"name": "Παγωμενος", "price_delta": 0.0, "type": "quick"},
|
||||
{"name": "Γαλα", "price_delta": 0.0, "type": "pref"},
|
||||
{"name": "Βρωμης", "price_delta": 0.3, "type": "pref_sub"},
|
||||
]),
|
||||
removed_ingredients=None,
|
||||
notes="Πολυ κρυο παρακαλω",
|
||||
),
|
||||
# Item 2: Club Sandwich — extra with sub + removed ingredients
|
||||
_Item(
|
||||
product_id=1002,
|
||||
quantity=1,
|
||||
selected_options=_json.dumps([
|
||||
{"name": "Extra Bacon", "price_delta": 1.5, "type": "extra"},
|
||||
{"name": "Τραγανο", "price_delta": 0.0, "type": "extra_sub"},
|
||||
{"name": "Extra Bacon", "price_delta": 1.5, "type": "extra"},
|
||||
{"name": "Τραγανο", "price_delta": 0.0, "type": "extra_sub"},
|
||||
{"name": "Ψωμι", "price_delta": 0.0, "type": "pref"},
|
||||
{"name": "Σικαλεως", "price_delta": 0.0, "type": "pref_sub"},
|
||||
]),
|
||||
removed_ingredients=_json.dumps(["Ντοματα", "Μουσταρδα"]),
|
||||
notes=None,
|
||||
),
|
||||
# Item 3: Margherita — quick + extra + removed
|
||||
_Item(
|
||||
product_id=1003,
|
||||
quantity=3,
|
||||
selected_options=_json.dumps([
|
||||
{"name": "Well Done", "price_delta": 0.0, "type": "quick"},
|
||||
{"name": "Extra Τυρι", "price_delta": 1.0, "type": "extra"},
|
||||
{"name": "Extra Τυρι", "price_delta": 1.0, "type": "extra"},
|
||||
{"name": "Extra Τυρι", "price_delta": 1.0, "type": "extra"},
|
||||
]),
|
||||
removed_ingredients=_json.dumps(["Ελιες", "Κρεμμυδι"]),
|
||||
notes=None,
|
||||
),
|
||||
]
|
||||
|
||||
# Patch product lookup so _print_kitchen_ticket gets real names
|
||||
_FAKE_NAMES = {1001: "Freddo Espresso", 1002: "Club Sandwich", 1003: "Margherita Pizza"}
|
||||
|
||||
# Monkey-patch db.query for Product only inside this call
|
||||
_orig_query = db.query
|
||||
|
||||
class _FakeQuery:
|
||||
def __init__(self, model):
|
||||
self._model = model
|
||||
self._filter_id = None
|
||||
def filter(self, *args):
|
||||
# extract id from the filter expression value
|
||||
for arg in args:
|
||||
try:
|
||||
self._filter_id = arg.right.value
|
||||
except Exception:
|
||||
pass
|
||||
return self
|
||||
def first(self):
|
||||
if self._model.__name__ == "Product" and self._filter_id in _FAKE_NAMES:
|
||||
class _P:
|
||||
name = _FAKE_NAMES[self._filter_id]
|
||||
return _P()
|
||||
return _orig_query(self._model).filter(self._model.id == self._filter_id).first()
|
||||
|
||||
class _PatchedDB:
|
||||
def query(self, model):
|
||||
from models.product import Product as _Product
|
||||
if model is _Product:
|
||||
return _FakeQuery(model)
|
||||
return _orig_query(model)
|
||||
# delegate everything else to real db
|
||||
def __getattr__(self, name):
|
||||
return getattr(db, name)
|
||||
|
||||
try:
|
||||
p = _get_printer(ip, port)
|
||||
_print_kitchen_ticket(p, _Order(), items, _PatchedDB())
|
||||
p.close()
|
||||
return True, ""
|
||||
except Exception as e:
|
||||
logger.error("Test order print failed for %s:%s — %s", ip, port, e)
|
||||
return False, str(e)
|
||||
|
||||
|
||||
# ── Receipt formatting ───────────────────────────────────────────────────────
|
||||
|
||||
def _parse_options(item: OrderItem) -> dict:
|
||||
"""
|
||||
Parse selected_options JSON into grouped dict:
|
||||
{ 'quick': [(name, qty)], 'pref': [(name, sub|None)],
|
||||
'extra': [(name, sub|None, qty)], 'unknown': [name] }
|
||||
Falls back gracefully when type tags are absent (old data).
|
||||
"""
|
||||
result = {"quick": [], "pref": [], "extra": [], "unknown": []}
|
||||
if not item.selected_options:
|
||||
return result
|
||||
|
||||
try:
|
||||
raw = json.loads(item.selected_options)
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
return result
|
||||
|
||||
if not isinstance(raw, list):
|
||||
return result
|
||||
|
||||
i = 0
|
||||
while i < len(raw):
|
||||
entry = raw[i]
|
||||
if not isinstance(entry, dict):
|
||||
i += 1
|
||||
continue
|
||||
name = entry.get("name") or ""
|
||||
etype = entry.get("type")
|
||||
|
||||
# Peek at next entry to collect sub-choice
|
||||
sub = None
|
||||
if i + 1 < len(raw):
|
||||
nxt = raw[i + 1]
|
||||
if isinstance(nxt, dict) and nxt.get("type") in ("pref_sub", "extra_sub"):
|
||||
sub = nxt.get("name") or ""
|
||||
i += 1 # consume sub
|
||||
|
||||
if etype == "quick":
|
||||
# Collapse repeated quick entries into a single (name, qty) tuple
|
||||
existing = next((q for q in result["quick"] if q[0] == name), None)
|
||||
if existing:
|
||||
result["quick"][result["quick"].index(existing)] = (name, existing[1] + 1)
|
||||
else:
|
||||
result["quick"].append((name, 1))
|
||||
elif etype == "pref":
|
||||
result["pref"].append((name, sub))
|
||||
elif etype == "extra":
|
||||
# Collapse repeated extra entries (same name+sub) → (name, sub, qty)
|
||||
existing = next((e for e in result["extra"] if e[0] == name and e[1] == sub), None)
|
||||
if existing:
|
||||
result["extra"][result["extra"].index(existing)] = (name, sub, existing[2] + 1)
|
||||
else:
|
||||
result["extra"].append((name, sub, 1))
|
||||
else:
|
||||
# Legacy data without type tag — treat as unknown, display plainly
|
||||
if name:
|
||||
result["unknown"].append(name + (f" · {sub}" if sub else ""))
|
||||
|
||||
i += 1
|
||||
|
||||
return result
|
||||
|
||||
|
||||
def _print_kitchen_ticket(p: Network, order: Order, items: List[OrderItem], db: Session):
|
||||
cfg = _load_print_settings(db)
|
||||
mode = cfg.get("print.ticket_mode", "detailed")
|
||||
div = cfg.get("print.divider_style", "dash")
|
||||
compact = (mode == "compact")
|
||||
|
||||
sz_ord, b_ord, c_ord = _decode_font(cfg["print.font_order_number"])
|
||||
sz_meta, b_meta, c_meta = _decode_font(cfg["print.font_meta"])
|
||||
sz_item, b_item, c_item = _decode_font(cfg["print.font_item_name"])
|
||||
sz_qk, b_qk, c_qk = _decode_font(cfg["print.font_quick"])
|
||||
sz_pr, b_pr, c_pr = _decode_font(cfg["print.font_pref"])
|
||||
sz_ex, b_ex, c_ex = _decode_font(cfg["print.font_extra"])
|
||||
sz_ing, b_ing, c_ing = _decode_font(cfg["print.font_ingredient"])
|
||||
sz_note, b_note, c_note = _decode_font(cfg["print.font_item_note"])
|
||||
sz_onote,b_onote,c_onote= _decode_font(cfg["print.font_order_note"])
|
||||
|
||||
# Resolve display names
|
||||
table_name = order.table.label or str(order.table.number) if order.table else str(order.table_id)
|
||||
waiter_nick = (order.opener.nickname or order.opener.username) if order.opener else str(order.opened_by)
|
||||
now_str = _greek_date(datetime.datetime.now())
|
||||
|
||||
# ── COMPACT header — single line ────────────────────────────────────────
|
||||
if compact:
|
||||
p._raw(b'\x1b\x61\x00')
|
||||
_apply_font(p, sz_ord, b_ord)
|
||||
header = f"Παρ. #{order.id} | Τρ. {table_name} | {now_str} | {waiter_nick}"
|
||||
_raw_text(p, (header.upper() if c_ord else header) + "\n")
|
||||
_reset_font(p)
|
||||
_divider(p, div)
|
||||
|
||||
# ── DETAILED header ──────────────────────────────────────────────────────
|
||||
else:
|
||||
_print_line(p, f"Παραγγελια #{order.id}", sz_ord, b_ord, c_ord,
|
||||
align=b'\x1b\x61\x01')
|
||||
_divider(p, div)
|
||||
p._raw(b'\x1b\x61\x00')
|
||||
_apply_font(p, sz_meta, b_meta)
|
||||
_raw_text(p, ("ΤΡΑΠΕΖΙ:" if c_meta else "Τραπεζι:") + f" Τραπεζι {table_name}\n")
|
||||
_raw_text(p, ("ΗΜΕΡΟΜΗΝΙΑ:" if c_meta else "Ημερομηνια:") + f" {now_str}\n")
|
||||
_raw_text(p, ("ΣΕΡΒΙΤΟΡΟΣ:" if c_meta else "Σερβιτορος:") + f" {waiter_nick}\n")
|
||||
_reset_font(p)
|
||||
_divider(p, div)
|
||||
|
||||
# ── Items ────────────────────────────────────────────────────────────────
|
||||
# Double-width fonts halve the effective character width
|
||||
item_line_width = LINE_WIDTH // 2 if sz_item in (32, 48) else LINE_WIDTH
|
||||
|
||||
for item in items:
|
||||
product = db.query(Product).filter(Product.id == item.product_id).first()
|
||||
raw_name = product.name if product else f"Product #{item.product_id}"
|
||||
item_name = raw_name.upper() if c_item else raw_name
|
||||
|
||||
p._raw(b'\x1b\x61\x00')
|
||||
_apply_font(p, sz_item, b_item)
|
||||
_raw_text(p, _item_line(item_name, item.quantity, item_line_width) + "\n")
|
||||
_reset_font(p)
|
||||
|
||||
opts = _parse_options(item)
|
||||
|
||||
# Quick options (* marker)
|
||||
if opts["quick"]:
|
||||
if compact:
|
||||
parts = []
|
||||
for name, qty in opts["quick"]:
|
||||
n = name.upper() if c_qk else name
|
||||
parts.append(f"{n} x{qty}" if qty > 1 else n)
|
||||
_apply_font(p, sz_qk, b_qk)
|
||||
_raw_text(p, "* " + " | ".join(parts) + "\n")
|
||||
_reset_font(p)
|
||||
else:
|
||||
for name, qty in opts["quick"]:
|
||||
n = name.upper() if c_qk else name
|
||||
line = f"* {n} x{qty}" if qty > 1 else f"* {n}"
|
||||
_apply_font(p, sz_qk, b_qk)
|
||||
_raw_text(p, line + "\n")
|
||||
_reset_font(p)
|
||||
|
||||
# Preferences (> marker)
|
||||
if opts["pref"]:
|
||||
if compact:
|
||||
parts = []
|
||||
for name, sub in opts["pref"]:
|
||||
n = name.upper() if c_pr else name
|
||||
s = (sub.upper() if c_pr else sub) if sub else None
|
||||
parts.append(f"{n} · {s}" if s else n)
|
||||
_apply_font(p, sz_pr, b_pr)
|
||||
_raw_text(p, "> " + " | ".join(parts) + "\n")
|
||||
_reset_font(p)
|
||||
else:
|
||||
for name, sub in opts["pref"]:
|
||||
n = name.upper() if c_pr else name
|
||||
s = (sub.upper() if c_pr else sub) if sub else None
|
||||
line = f"> {n} · {s}" if s else f"> {n}"
|
||||
_apply_font(p, sz_pr, b_pr)
|
||||
_raw_text(p, line + "\n")
|
||||
_reset_font(p)
|
||||
|
||||
# Extras (+ marker)
|
||||
if opts["extra"]:
|
||||
if compact:
|
||||
parts = []
|
||||
for name, sub, qty in opts["extra"]:
|
||||
n = name.upper() if c_ex else name
|
||||
s = (sub.upper() if c_ex else sub) if sub else None
|
||||
part = f"{n} · {s}" if s else n
|
||||
if qty > 1:
|
||||
part += f" · x{qty}"
|
||||
parts.append(part)
|
||||
_apply_font(p, sz_ex, b_ex)
|
||||
_raw_text(p, "+ " + " | ".join(parts) + "\n")
|
||||
_reset_font(p)
|
||||
else:
|
||||
for name, sub, qty in opts["extra"]:
|
||||
n = name.upper() if c_ex else name
|
||||
s = (sub.upper() if c_ex else sub) if sub else None
|
||||
line = f"+ {n}"
|
||||
if s:
|
||||
line += f" · {s}"
|
||||
if qty > 1:
|
||||
line += f" · x{qty}"
|
||||
_apply_font(p, sz_ex, b_ex)
|
||||
_raw_text(p, line + "\n")
|
||||
_reset_font(p)
|
||||
|
||||
# Legacy untagged options
|
||||
for entry in opts["unknown"]:
|
||||
_apply_font(p, sz_ex, b_ex)
|
||||
_raw_text(p, f"+ {entry}\n")
|
||||
_reset_font(p)
|
||||
|
||||
# Removed ingredients (- marker)
|
||||
if item.removed_ingredients:
|
||||
try:
|
||||
removed = json.loads(item.removed_ingredients)
|
||||
if removed:
|
||||
names = [n.upper() if c_ing else n for n in removed]
|
||||
joined = " · ".join(names)
|
||||
_apply_font(p, sz_ing, b_ing)
|
||||
_raw_text(p, f"- ΧΩΡΙΣ: {joined}\n")
|
||||
_reset_font(p)
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
pass
|
||||
|
||||
# Per-item note
|
||||
if item.notes:
|
||||
note_text = item.notes.upper() if c_note else item.notes
|
||||
_apply_font(p, sz_note, b_note)
|
||||
if compact:
|
||||
_raw_text(p, f"! {note_text}\n")
|
||||
else:
|
||||
_raw_text(p, f"\n(!) {note_text}\n\n")
|
||||
_reset_font(p)
|
||||
|
||||
# Blank line between items in detailed mode
|
||||
if not compact:
|
||||
p._raw(b'\n')
|
||||
|
||||
_divider(p, div)
|
||||
|
||||
# Order-level notes
|
||||
if order.notes:
|
||||
note_text = order.notes.upper() if c_onote else order.notes
|
||||
_apply_font(p, sz_onote, b_onote)
|
||||
_raw_text(p, f"Σημ: {note_text}\n")
|
||||
_reset_font(p)
|
||||
if not compact:
|
||||
_divider(p, div)
|
||||
|
||||
# Footer (detailed only)
|
||||
if not compact:
|
||||
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()
|
||||
|
||||
|
||||
# ── On-demand report / receipt prints ────────────────────────────────────────
|
||||
|
||||
def print_waiter_report(ip: str, port: int, report: dict, mode: str):
|
||||
"""Print a waiter shift/period report. mode='simple'|'extensive'."""
|
||||
if is_spoof_mode():
|
||||
logger.info("Spoof printing ON — dropping waiter report print")
|
||||
return
|
||||
try:
|
||||
p = _get_printer(ip, port)
|
||||
|
||||
p._raw(b'\x1b\x61\x01')
|
||||
p._raw(b'\x1b\x21\x30')
|
||||
_raw_text(p, "ΑΝΑΦΟΡΑ ΣΕΡΒΙΤΟΡΟΥ\n")
|
||||
p._raw(b'\x1b\x21\x00')
|
||||
_divider(p)
|
||||
|
||||
p._raw(b'\x1b\x61\x00')
|
||||
p._raw(b'\x1b\x21\x10')
|
||||
_raw_text(p, f"Σερβιτορος: {report['waiter_name']}\n")
|
||||
_raw_text(p, f"Απο: {report['from_dt']}\n")
|
||||
_raw_text(p, f"Εως: {report['to_dt']}\n")
|
||||
p._raw(b'\x1b\x21\x00')
|
||||
_divider(p)
|
||||
|
||||
p._raw(b'\x1b\x21\x10')
|
||||
_raw_text(p, f"Παραγγελιες: {report['orders']}\n")
|
||||
_raw_text(p, f"Αντικειμενα: {report['items']}\n")
|
||||
p._raw(b'\x1b\x21\x00')
|
||||
_divider(p)
|
||||
|
||||
p._raw(b'\x1b\x61\x01')
|
||||
p._raw(b'\x1b\x21\x30')
|
||||
_raw_text(p, f"ΣΥΝΟΛΟ: {report['total']:.2f}e\n")
|
||||
p._raw(b'\x1b\x21\x00')
|
||||
|
||||
if mode == "extensive" and report.get("order_data"):
|
||||
_divider(p)
|
||||
p._raw(b'\x1b\x61\x00')
|
||||
_raw_text(p, "ΑΝΑΛΥΤΙΚΑ\n")
|
||||
_divider(p)
|
||||
for od in report["order_data"]:
|
||||
# Build right-aligned total: "HH:MM - HH:MM - TABLE . . . 9.99e"
|
||||
time_open = od.get("time_open", "")
|
||||
time_close = od.get("time_close", "")
|
||||
table = od["table"]
|
||||
value = f"{od['total']:.2f}e"
|
||||
times_part = f"{time_open} - {time_close}" if time_close else time_open
|
||||
prefix = f"{times_part} - {table}"
|
||||
gap = LINE_WIDTH - len(prefix) - len(value)
|
||||
if gap < 3:
|
||||
line = f"{prefix} {value}"
|
||||
else:
|
||||
dots = (". " * ((gap // 2) + 1))[:gap]
|
||||
line = f"{prefix}{dots}{value}"
|
||||
_raw_text(p, line + "\n")
|
||||
|
||||
p._raw(b'\n\n\n')
|
||||
p.cut()
|
||||
p.close()
|
||||
except Exception as e:
|
||||
logger.error("print_waiter_report failed for %s:%s — %s", ip, port, e)
|
||||
|
||||
|
||||
def print_printer_report(ip: str, port: int, report: dict, mode: str):
|
||||
"""Print a per-printer totals report. mode='simple'|'extensive'."""
|
||||
if is_spoof_mode():
|
||||
logger.info("Spoof printing ON — dropping printer report print")
|
||||
return
|
||||
try:
|
||||
p = _get_printer(ip, port)
|
||||
|
||||
p._raw(b'\x1b\x61\x01')
|
||||
p._raw(b'\x1b\x21\x30')
|
||||
_raw_text(p, "ΑΝΑΦΟΡΑ ΕΚΤΥΠΩΤΗ\n")
|
||||
p._raw(b'\x1b\x21\x00')
|
||||
_divider(p)
|
||||
|
||||
p._raw(b'\x1b\x61\x00')
|
||||
p._raw(b'\x1b\x21\x10')
|
||||
_raw_text(p, f"Εκτυπωτης: {report['printer_name']}\n")
|
||||
_raw_text(p, f"Απο: {report['from_dt']}\n")
|
||||
_raw_text(p, f"Εως: {report['to_dt']}\n")
|
||||
p._raw(b'\x1b\x21\x00')
|
||||
_divider(p)
|
||||
|
||||
p._raw(b'\x1b\x21\x10')
|
||||
_raw_text(p, f"Εργασιες εκτ.: {report['print_jobs']}\n")
|
||||
_raw_text(p, f"Παραγγελιες: {report['orders']}\n")
|
||||
_raw_text(p, f"Αντικειμενα: {report['items']}\n")
|
||||
p._raw(b'\x1b\x21\x00')
|
||||
_divider(p)
|
||||
|
||||
p._raw(b'\x1b\x61\x01')
|
||||
p._raw(b'\x1b\x21\x30')
|
||||
_raw_text(p, f"ΣΥΝΟΛΟ: {report['total']:.2f}e\n")
|
||||
p._raw(b'\x1b\x21\x00')
|
||||
|
||||
if mode == "extensive" and report.get("order_data"):
|
||||
_divider(p)
|
||||
p._raw(b'\x1b\x61\x00')
|
||||
_raw_text(p, "ΑΝΑΛΥΤΙΚΑ\n")
|
||||
_divider(p)
|
||||
for od in report["order_data"]:
|
||||
# Header line: "HH:MM - TABLE . . . . 9.99e"
|
||||
prefix = f"{od['time']} - {od['table']}"
|
||||
value = f"{od['total']:.2f}e"
|
||||
gap = LINE_WIDTH - len(prefix) - len(value)
|
||||
if gap < 3:
|
||||
header_line = f"{prefix} {value}"
|
||||
else:
|
||||
dots = (". " * ((gap // 2) + 1))[:gap]
|
||||
header_line = f"{prefix}{dots}{value}"
|
||||
p._raw(b'\x1b\x45\x01')
|
||||
_raw_text(p, header_line + "\n")
|
||||
p._raw(b'\x1b\x45\x00')
|
||||
# Indented items
|
||||
for item in od.get("items", []):
|
||||
_raw_text(p, f" {item['quantity']} x {item['name']}\n")
|
||||
|
||||
p._raw(b'\n\n\n')
|
||||
p.cut()
|
||||
p.close()
|
||||
except Exception as e:
|
||||
logger.error("print_printer_report failed for %s:%s — %s", ip, port, e)
|
||||
|
||||
|
||||
def print_order_receipt(ip: str, port: int, receipt: dict):
|
||||
"""Print a manager-triggered order receipt."""
|
||||
if is_spoof_mode():
|
||||
logger.info("Spoof printing ON — dropping order receipt print")
|
||||
return
|
||||
try:
|
||||
p = _get_printer(ip, port)
|
||||
|
||||
p._raw(b'\x1b\x61\x01')
|
||||
p._raw(b'\x1b\x21\x30')
|
||||
_raw_text(p, f"ΠΑΡΑΓΓΕΛΙΑ #{receipt['order_id']}\n")
|
||||
p._raw(b'\x1b\x21\x00')
|
||||
_divider(p)
|
||||
|
||||
p._raw(b'\x1b\x61\x00')
|
||||
p._raw(b'\x1b\x21\x10')
|
||||
_raw_text(p, f"Τραπεζι: {receipt['table_name']}\n")
|
||||
_raw_text(p, f"Σερβιτορος: {receipt['waiter_name']}\n")
|
||||
_raw_text(p, f"Ανοιχτηκε: {receipt['opened_at']}\n")
|
||||
if receipt.get("closed_at"):
|
||||
_raw_text(p, f"Εκλεισε: {receipt['closed_at']}\n")
|
||||
p._raw(b'\x1b\x21\x00')
|
||||
_divider(p)
|
||||
|
||||
for item in receipt.get("items", []):
|
||||
p._raw(b'\x1b\x21\x10')
|
||||
_raw_text(p, _item_line(item["name"], item["quantity"]) + "\n")
|
||||
p._raw(b'\x1b\x21\x00')
|
||||
_raw_text(p, f" {item['unit_price']:.2f}e x{item['quantity']} = {item['total']:.2f}e\n")
|
||||
|
||||
_divider(p)
|
||||
|
||||
if receipt.get("notes"):
|
||||
p._raw(b'\x1b\x21\x10')
|
||||
_raw_text(p, f"Σημ: {receipt['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, f"ΣΥΝΟΛΟ: {receipt['total']:.2f}e\n")
|
||||
p._raw(b'\x1b\x21\x00')
|
||||
|
||||
p._raw(b'\n\n\n')
|
||||
p.cut()
|
||||
p.close()
|
||||
except Exception as e:
|
||||
logger.error("print_order_receipt failed for %s:%s — %s", ip, port, e)
|
||||
|
||||
|
||||
def print_order_synopsis(ip: str, port: int, synopsis: dict):
|
||||
"""Print a waiter-triggered order synopsis (not a kitchen ticket)."""
|
||||
if is_spoof_mode():
|
||||
logger.info("Spoof printing ON — dropping order synopsis print")
|
||||
return
|
||||
try:
|
||||
p = _get_printer(ip, port)
|
||||
|
||||
p._raw(b'\x1b\x61\x01')
|
||||
p._raw(b'\x1b\x21\x30')
|
||||
_raw_text(p, "ΣΥΝΟΨΗ ΠΑΡΑΓΓΕΛΙΑΣ\n")
|
||||
p._raw(b'\x1b\x21\x00')
|
||||
_divider(p)
|
||||
|
||||
p._raw(b'\x1b\x61\x00')
|
||||
p._raw(b'\x1b\x21\x10')
|
||||
_raw_text(p, f"Τραπεζι: {synopsis['table_name']}\n")
|
||||
_raw_text(p, f"Σερβιτορος: {synopsis['waiter_name']}\n")
|
||||
_raw_text(p, f"Ωρα: {synopsis['opened_at']}\n")
|
||||
p._raw(b'\x1b\x21\x00')
|
||||
_divider(p)
|
||||
|
||||
paid_items = [i for i in synopsis.get("items", []) if i["status"] == "paid"]
|
||||
active_items = [i for i in synopsis.get("items", []) if i["status"] == "active"]
|
||||
|
||||
if active_items:
|
||||
p._raw(b'\x1b\x21\x10')
|
||||
_raw_text(p, "ΕΚΚΡΕΜΗ:\n")
|
||||
p._raw(b'\x1b\x21\x00')
|
||||
for item in active_items:
|
||||
_raw_text(p, f" {_item_line(item['name'], item['quantity'])}\n")
|
||||
_raw_text(p, f" {item['unit_price']:.2f}e x{item['quantity']} = {item['total']:.2f}e\n")
|
||||
_divider(p)
|
||||
|
||||
if paid_items:
|
||||
p._raw(b'\x1b\x21\x10')
|
||||
_raw_text(p, "ΠΛΗΡΩΜΕΝΑ:\n")
|
||||
p._raw(b'\x1b\x21\x00')
|
||||
for item in paid_items:
|
||||
_raw_text(p, f" {_item_line(item['name'], item['quantity'])}\n")
|
||||
_raw_text(p, f" {item['unit_price']:.2f}e x{item['quantity']} = {item['total']:.2f}e\n")
|
||||
_divider(p)
|
||||
|
||||
p._raw(b'\x1b\x61\x01')
|
||||
p._raw(b'\x1b\x21\x30')
|
||||
_raw_text(p, f"ΣΥΝΟΛΟ: {synopsis['total']:.2f}e\n")
|
||||
if synopsis.get('paid_total', 0) > 0:
|
||||
p._raw(b'\x1b\x21\x10')
|
||||
_raw_text(p, f"Πληρωμενο: {synopsis['paid_total']:.2f}e\n")
|
||||
_raw_text(p, f"Εκκρεμει: {synopsis['remaining']:.2f}e\n")
|
||||
p._raw(b'\x1b\x21\x00')
|
||||
|
||||
p._raw(b'\n\n\n')
|
||||
p.cut()
|
||||
p.close()
|
||||
except Exception as e:
|
||||
logger.error("print_order_synopsis failed for %s:%s — %s", ip, port, e)
|
||||
|
||||
|
||||
# ── 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:
|
||||
_do_route_and_print(order_id, item_ids, db)
|
||||
except Exception as e:
|
||||
logger.exception("Unexpected error in route_and_print for order %s: %s", order_id, e)
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
def route_and_print_sync(order_id: int, item_ids: List[int], db: Session) -> List[dict]:
|
||||
"""
|
||||
Synchronous variant used when the caller needs print results.
|
||||
Returns a list of per-printer result dicts:
|
||||
{ printer_name, success, error }
|
||||
"""
|
||||
return _do_route_and_print(order_id, item_ids, db)
|
||||
|
||||
|
||||
def _is_spoof_mode(db: Session) -> bool:
|
||||
row = db.query(PosSettings).filter(PosSettings.key == "dev.spoof_printing").first()
|
||||
return row is not None and row.value == "true"
|
||||
|
||||
|
||||
def _do_route_and_print(order_id: int, item_ids: List[int], db: Session) -> List[dict]:
|
||||
if _is_spoof_mode(db):
|
||||
logger.info("Spoof printing ON — dropping print job for order %s", order_id)
|
||||
for item_id in item_ids:
|
||||
item = db.query(OrderItem).filter(OrderItem.id == item_id).first()
|
||||
if item:
|
||||
item.printed = True
|
||||
db.commit()
|
||||
return [{"printer_name": "spoof", "success": True, "error": None}]
|
||||
|
||||
results = []
|
||||
|
||||
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 results
|
||||
|
||||
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)
|
||||
results.append({"printer_name": f"#{printer_id}", "success": False, "error": "Printer not found or inactive"})
|
||||
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
|
||||
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()
|
||||
|
||||
results.append({"printer_name": printer.name, "success": success, "error": error_msg})
|
||||
|
||||
return results
|
||||
84
local_backend/services/sse_bus.py
Normal file
84
local_backend/services/sse_bus.py
Normal file
@@ -0,0 +1,84 @@
|
||||
"""
|
||||
SSE Event Bus — in-memory broadcaster for Server-Sent Events.
|
||||
|
||||
All routers import `broadcast_sync()` to push events from sync routes.
|
||||
The SSE endpoint imports `subscribe()` / `unsubscribe()` to manage per-client queues.
|
||||
|
||||
Event shape (JSON-serialisable dict):
|
||||
{ "type": "<event_type>", "data": { ... } }
|
||||
|
||||
Supported event types:
|
||||
order_updated — order created / item added / transferred / merged
|
||||
order_paid — items paid on an order
|
||||
order_closed — order closed or cancelled
|
||||
table_list_changed — table added/removed
|
||||
table_flags_changed — flags set/cleared on a table
|
||||
message_sent — new staff message (targeted or broadcast)
|
||||
shift_changed — shift started / ended by manager
|
||||
business_day_changed — business day opened / closed
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
from typing import Dict, Set
|
||||
|
||||
# Captured once at startup by init_loop() called from lifespan.
|
||||
# Sync route threads use this to schedule coroutines safely.
|
||||
_main_loop: asyncio.AbstractEventLoop | None = None
|
||||
|
||||
# waiter_id → set of asyncio.Queue (one per SSE connection for that user)
|
||||
_queues: Dict[int, Set[asyncio.Queue]] = {}
|
||||
|
||||
|
||||
def init_loop(loop: asyncio.AbstractEventLoop) -> None:
|
||||
"""Call once from the FastAPI lifespan (async context) to capture the event loop."""
|
||||
global _main_loop
|
||||
_main_loop = loop
|
||||
|
||||
|
||||
async def subscribe(user_id: int) -> asyncio.Queue:
|
||||
q: asyncio.Queue = asyncio.Queue(maxsize=256)
|
||||
if user_id not in _queues:
|
||||
_queues[user_id] = set()
|
||||
_queues[user_id].add(q)
|
||||
return q
|
||||
|
||||
|
||||
async def unsubscribe(user_id: int, q: asyncio.Queue) -> None:
|
||||
if user_id in _queues:
|
||||
_queues[user_id].discard(q)
|
||||
if not _queues[user_id]:
|
||||
del _queues[user_id]
|
||||
|
||||
|
||||
def broadcast_sync(event_type: str, data: dict, *, user_ids: list[int] | None = None) -> None:
|
||||
"""
|
||||
Fire-and-forget broadcast from a synchronous FastAPI route (thread-pool worker).
|
||||
Uses call_soon_threadsafe so the coroutine runs on the main event loop, not the thread.
|
||||
"""
|
||||
if _main_loop is None:
|
||||
return
|
||||
_main_loop.call_soon_threadsafe(
|
||||
_main_loop.create_task,
|
||||
broadcast(event_type, data, user_ids=user_ids),
|
||||
)
|
||||
|
||||
|
||||
async def broadcast(event_type: str, data: dict, *, user_ids: list[int] | None = None) -> None:
|
||||
"""
|
||||
Push an event to connected clients.
|
||||
user_ids=None → broadcast to ALL connected users
|
||||
user_ids=[...] → send only to those specific user IDs
|
||||
"""
|
||||
payload = json.dumps({"type": event_type, "data": data})
|
||||
targets = (
|
||||
{uid: qs for uid, qs in _queues.items() if uid in user_ids}
|
||||
if user_ids is not None
|
||||
else dict(_queues)
|
||||
)
|
||||
for qs in targets.values():
|
||||
for q in list(qs):
|
||||
try:
|
||||
q.put_nowait(payload)
|
||||
except asyncio.QueueFull:
|
||||
pass # slow client — drop rather than block
|
||||
Reference in New Issue
Block a user