- 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>
431 lines
17 KiB
Python
431 lines
17 KiB
Python
# -*- 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()
|