feat(local_backend): reservations, print analytics, workday summary, zone-PIN management

- New reservations module: model, schema, router (CRUD + status updates + upcoming alerts)
  and background task for auto-expiring stale reservations
- Reports: print_products, print_categories, print_tables analytics endpoints
  plus meta_products and business_day_summary for workday close/view flow
- printer_service: configurable font sizes/weights, donut/bar chart print layout helpers,
  analytics print blocks per printer
- tables/schemas: surfaced color, zone, and other new fields on Table, Product, User, Printer
- demo_seed.py for quick dev DB population; wipe_database.py utility

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-06-07 20:25:22 +03:00
parent aed71a18d8
commit 79bd3b0f41
25 changed files with 2017 additions and 146 deletions

430
local_backend/demo_seed.py Normal file
View File

@@ -0,0 +1,430 @@
# -*- coding: utf-8 -*-
"""
Populates the POS database with realistic demo data for customer presentations.
Generates:
- 1 manager account (username: manager / PIN: 1234 / password: password)
- 4 demo waiters
- 2 table groups with 12 tables total
- A full menu: 3 categories, ~20 products
- 45 days of backdated history (business days, shifts, orders, payments)
Usage:
python demo_seed.py
Must be run from the local_backend directory. Designed to run on a clean DB
(after wipe_database.py), but safe to run multiple times — checks for the
[DEMO] marker before inserting anything.
"""
import json
import random
import sys
from datetime import datetime, timedelta, timezone
import bcrypt
from sqlalchemy import text
from database import engine, Base, SessionLocal
import models.user
import models.table
import models.printer
import models.product
import models.order
import models.business_day
import models.shift
import models.settings
import models.flag
import models.message
from models.user import User, WaiterZone
from models.table import TableGroup, Table
from models.product import Category, Product
from models.order import Order, OrderItem, OrderWaiter, OrderAuditLog
from models.business_day import BusinessDay
from models.shift import WaiterShift
Base.metadata.create_all(bind=engine)
# ── Config ────────────────────────────────────────────────────────────────────
DEMO_DAYS = 120 # how many past days to generate (~4 months)
ORDERS_PER_DAY = (18, 35) # min/max orders per business day
# ── Helpers ───────────────────────────────────────────────────────────────────
def _utc(dt: datetime) -> datetime:
if dt.tzinfo is None:
return dt.replace(tzinfo=timezone.utc)
return dt
def _hash_pin(pin: str) -> str:
return bcrypt.hashpw(pin.encode(), bcrypt.gensalt()).decode()
def _hash_password(pw: str) -> str:
return bcrypt.hashpw(pw.encode(), bcrypt.gensalt()).decode()
def _audit(db, order_id, event_type, waiter_id=None, item_ids=None,
amount=None, payment_method=None, note=None, created_at=None):
log = 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,
)
if created_at:
log.created_at = _utc(created_at)
db.add(log)
# ── Menu definition ───────────────────────────────────────────────────────────
# (category_name, color, [(product_name, price), ...])
MENU = [
("Ορεκτικά", "#f97316", [
("Τζατζίκι", 4.50),
("Ταραμοσαλάτα", 4.50),
("Χωριάτικη Σαλάτα", 7.50),
("Κεφτεδάκια", 8.00),
("Σαγανάκι", 6.50),
("Χούμους", 5.00),
]),
("Κυρίως Πιάτα", "#ef4444", [
("Μπριζόλα Χοιρινή", 12.00),
("Μπριζόλα Μοσχαρίσια", 16.00),
("Σουβλάκι Κοτόπουλο", 10.00),
("Μουσακάς", 11.00),
("Παστίτσιο", 10.00),
("Σπαγγέτι Μπολονέζ", 9.50),
("Γύρος Χοιρινός", 8.50),
]),
("Ποτά & Αναψυκτικά", "#3b82f6", [
("Νερό 500ml", 1.00),
("Αναψυκτικό", 2.50),
("Μπύρα Φιάλη", 4.00),
("Κρασί (ποτήρι)", 4.50),
("Καφές Ελληνικός", 2.50),
("Φραπέ", 3.00),
("Χυμός Πορτοκάλι", 3.50),
]),
]
# Weighted product picker: drinks ordered ~40% of the time, mains ~35%, starters ~25%
CATEGORY_WEIGHTS = [0.25, 0.35, 0.40]
WAITER_NAMES = [
("Νίκος", "Νίκος Π."),
("Μαρία", "Μαρία Κ."),
("Πέτρος", "Πέτρος Α."),
("Έλενα", "Έλενα Μ."),
]
PAYMENT_METHODS = ["cash", "cash", "cash", "card", "card"] # 60% cash / 40% card
# ── Main ──────────────────────────────────────────────────────────────────────
db = SessionLocal()
try:
# ── Guard: abort if demo data already present ─────────────────────────────
existing = db.query(User).filter(User.full_name.like("[DEMO]%")).first()
if existing:
print("Demo data already present (found [DEMO] user). Nothing to do.")
print("Run python wipe_database.py first if you want a fresh seed.")
sys.exit(0)
print("Creating demo data...")
# ── Manager account ───────────────────────────────────────────────────────
manager = db.query(User).filter(User.username == "manager").first()
if not manager:
manager = User(
username="manager",
pin_hash=_hash_pin("1234"),
password_hash=_hash_password("password"),
role="manager",
full_name="Manager",
is_active=True,
)
db.add(manager)
db.flush()
print(" created manager (username: manager / PIN: 1234 / password: password)")
else:
# Ensure password_hash is set on existing manager
if not manager.password_hash:
manager.password_hash = _hash_password("password")
db.flush()
print(" manager account already exists — updated password to 'password'")
# ── Waiters ───────────────────────────────────────────────────────────────
waiters = []
for nickname, full_name in WAITER_NAMES:
username = f"demo_{nickname.lower()}"
w = db.query(User).filter(User.username == username).first()
if not w:
w = User(
username=username,
pin_hash=_hash_pin(str(random.randint(1000, 9999))),
role="waiter",
full_name=f"[DEMO] {full_name}",
nickname=nickname,
is_active=True,
)
db.add(w)
db.flush()
waiters.append(w)
print(f" created {len(waiters)} demo waiters")
# ── Table groups & tables ─────────────────────────────────────────────────
group_configs = [
("Κεντρική Αίθουσα", "Κ", "#6366f1", list(range(1, 9))), # tables 1-8
("Βεράντα", "Β", "#22c55e", list(range(1, 6))), # tables 1-5
]
all_tables = []
for g_name, g_prefix, g_color, table_numbers in group_configs:
grp = db.query(TableGroup).filter(TableGroup.name == g_name).first()
if not grp:
grp = TableGroup(name=g_name, prefix=g_prefix, color=g_color, sort_order=len(all_tables))
db.add(grp)
db.flush()
for n in table_numbers:
tbl = db.query(Table).filter(Table.group_id == grp.id, Table.number == n).first()
if not tbl:
tbl = Table(number=n, group_id=grp.id, is_active=True)
db.add(tbl)
db.flush()
all_tables.append(tbl)
print(f" created {len(all_tables)} tables across {len(group_configs)} groups")
# ── Waiter zone assignments (all waiters → all zones) ─────────────────────
for w in waiters:
existing_zone = db.query(WaiterZone).filter(
WaiterZone.waiter_id == w.id, WaiterZone.group_id.is_(None)
).first()
if not existing_zone:
db.add(WaiterZone(waiter_id=w.id, group_id=None))
db.flush()
# ── Menu ──────────────────────────────────────────────────────────────────
categories = []
all_products = [] # flat list for random picks
category_product_map = [] # parallel list of product sub-lists (for weighted pick)
for sort_idx, (cat_name, cat_color, items) in enumerate(MENU):
cat = db.query(Category).filter(Category.name == cat_name).first()
if not cat:
cat = Category(name=cat_name, color=cat_color, sort_order=sort_idx)
db.add(cat)
db.flush()
categories.append(cat)
cat_products = []
for prod_sort, (prod_name, prod_price) in enumerate(items):
p = db.query(Product).filter(Product.name == prod_name, Product.category_id == cat.id).first()
if not p:
p = Product(
name=prod_name,
category_id=cat.id,
base_price=prod_price,
is_available=True,
lifecycle_status="active",
sort_order=prod_sort,
)
db.add(p)
db.flush()
cat_products.append(p)
all_products.append(p)
category_product_map.append(cat_products)
print(f" created {len(categories)} categories with {len(all_products)} products")
db.commit()
# ── Historical days ───────────────────────────────────────────────────────
today = datetime.now(timezone.utc).date()
total_orders = 0
total_revenue = 0.0
print(f"\nGenerating {DEMO_DAYS} days of history...")
for days_ago in range(DEMO_DAYS, 0, -1):
day_date = today - timedelta(days=days_ago)
# Business day: opens at ~10:00, closes at ~23:30
open_time = _utc(datetime(day_date.year, day_date.month, day_date.day,
10, random.randint(0, 30)))
close_time = _utc(datetime(day_date.year, day_date.month, day_date.day,
23, random.randint(15, 45)))
bday = BusinessDay(
status="closed",
opened_at=open_time,
opened_by_id=manager.id,
closed_at=close_time,
closed_by_id=manager.id,
)
db.add(bday)
db.flush()
# 2-4 waiters work each day
working_waiters = random.sample(waiters, k=random.randint(2, len(waiters)))
STARTING_CASH_OPTIONS = [50.0, 80.0, 100.0, 110.0, 120.0, 150.0, 200.0]
shifts = {}
shift_totals = {} # shift_id → running revenue total, filled during order loop
for w in working_waiters:
shift_start = open_time + timedelta(minutes=random.randint(0, 30))
shift_end = close_time - timedelta(minutes=random.randint(0, 20))
shift = WaiterShift(
waiter_id=w.id,
business_day_id=bday.id,
started_at=shift_start,
ended_at=shift_end,
starting_cash=random.choice(STARTING_CASH_OPTIONS),
)
db.add(shift)
db.flush()
shifts[w.id] = shift
shift_totals[shift.id] = 0.0
# Orders — two rushes: lunch (12-15h) and dinner (19-23h)
n_orders = random.randint(*ORDERS_PER_DAY)
lunch_count = int(n_orders * 0.4)
dinner_count = n_orders - lunch_count
order_times = []
for _ in range(lunch_count):
h = random.randint(12, 14)
m = random.randint(0, 59)
order_times.append(datetime(day_date.year, day_date.month, day_date.day, h, m))
for _ in range(dinner_count):
h = random.randint(19, 22)
m = random.randint(0, 59)
order_times.append(datetime(day_date.year, day_date.month, day_date.day, h, m))
order_times.sort()
# Track which tables are "busy" at each point in time so we don't double-seat
# Format: {table_id: release_time}
table_busy_until: dict[int, datetime] = {}
for opened_at_naive in order_times:
opened_at = _utc(opened_at_naive)
# Pick a free table
free_tables = [
t for t in all_tables
if table_busy_until.get(t.id, datetime.min.replace(tzinfo=timezone.utc)) <= opened_at
]
if not free_tables:
free_tables = all_tables # all busy → just reuse (edge case)
table = random.choice(free_tables)
# Pick a waiter who is working today
waiter = random.choice(working_waiters)
shift = shifts[waiter.id]
# Order duration: 20-70 min
duration_min = random.randint(20, 70)
closed_at = opened_at + timedelta(minutes=duration_min)
# Cap to close_time
if closed_at > close_time:
closed_at = close_time - timedelta(minutes=2)
table_busy_until[table.id] = closed_at
order = Order(
table_id=table.id,
opened_by=waiter.id,
business_day_id=bday.id,
status="closed",
closed_by=manager.id,
notes=None,
source="pos",
)
order.opened_at = opened_at
order.closed_at = closed_at
db.add(order)
db.flush()
db.add(OrderWaiter(
order_id=order.id,
waiter_id=waiter.id,
assigned_at=opened_at,
))
_audit(db, order.id, "ORDER_OPENED", waiter_id=waiter.id, created_at=opened_at)
# Items: 2-6 items, weighted by category
n_items = random.randint(2, 6)
chosen_category_lists = random.choices(category_product_map, weights=CATEGORY_WEIGHTS, k=n_items)
items_added = []
order_total = 0.0
items_added_at = opened_at + timedelta(minutes=random.randint(2, 8))
for prod_list in chosen_category_lists:
product = random.choice(prod_list)
qty = random.choices([1, 2, 3], weights=[0.7, 0.2, 0.1])[0]
payment_method = random.choice(PAYMENT_METHODS)
paid_at = closed_at - timedelta(minutes=random.randint(1, 5))
line_total = product.base_price * qty
order_total += line_total
item = OrderItem(
order_id=order.id,
product_id=product.id,
added_by=waiter.id,
quantity=qty,
unit_price=product.base_price,
status="paid",
printed=True,
paid_by=waiter.id,
paid_at=_utc(paid_at),
payment_method=payment_method,
paid_in_shift_id=shift.id,
)
item.added_at = _utc(items_added_at)
db.add(item)
db.flush()
items_added.append(item.id)
shift_totals[shift.id] = round(shift_totals[shift.id] + line_total, 2)
_audit(db, order.id, "ITEMS_ADDED",
waiter_id=waiter.id, item_ids=items_added,
created_at=items_added_at)
# Payment audit (single payment per order for simplicity)
payment_method = random.choice(PAYMENT_METHODS)
pay_at = closed_at - timedelta(minutes=1)
_audit(db, order.id, "PAYMENT",
waiter_id=waiter.id, item_ids=items_added,
amount=round(order_total, 2), payment_method=payment_method,
created_at=pay_at)
_audit(db, order.id, "ORDER_CLOSED",
waiter_id=waiter.id, created_at=closed_at)
total_orders += 1
total_revenue += order_total
# Write total_collected snapshot onto each ended shift
for w in working_waiters:
s = shifts[w.id]
s.total_collected = shift_totals[s.id]
db.commit()
print(f" {day_date} {n_orders:2d} orders {len(working_waiters)} waiters")
print(f"\nDone.")
print(f" Total orders : {total_orders}")
print(f" Total revenue : €{total_revenue:,.2f}")
print(f"\nLogin credentials:")
print(f" Manager — username: manager | password: password | PIN: 1234")
for w in waiters:
print(f" Waiter — username: {w.username}")
finally:
db.close()