Snapshot of in-progress work across local_backend, manager_dashboard, and waiter_pwa (pricing, chat, fiscal, prep zones, recovery codes, CRM, inventory, permissions), plus the nginx/docker-compose deploy fixes for the Unraid + NPM reverse-proxy setup. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
879 lines
38 KiB
Python
879 lines
38 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 waiters with real Greek names
|
||
- 2 table groups with 13 tables total
|
||
- A full menu: 3 categories, ~20 products with costs
|
||
- 120 days of backdated history with realistic time distribution
|
||
- Online orders (source="online") scattered through history
|
||
- Order cancellations (~10% of items)
|
||
- Site notes and todos
|
||
- Contacts (suppliers) and expenses over the period
|
||
- Customers and tabs
|
||
- Scheduled shifts for the next 3 weeks
|
||
|
||
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 guard before inserting anything.
|
||
"""
|
||
import json
|
||
import random
|
||
import sys
|
||
from datetime import datetime, timedelta, date, 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.customers # noqa: F401 — must be before models.order (orders.customer_id FK)
|
||
import models.order
|
||
import models.business_day
|
||
import models.shift
|
||
import models.settings
|
||
import models.flag
|
||
import models.message
|
||
import models.notes # noqa: F401 — registers tables with Base.metadata
|
||
import models.expenses # noqa: F401
|
||
import models.tabs # noqa: F401
|
||
import models.schedule # noqa: F401
|
||
import models.waste # noqa: F401
|
||
import models.reservation # noqa: F401
|
||
|
||
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
|
||
from models.notes import SiteNote, SiteTodo
|
||
from models.expenses import Contact, Expense, ExpensePayment
|
||
from models.customers import Customer
|
||
from models.tabs import Tab, TabEntry, TabPayment
|
||
from models.schedule import ScheduledShift
|
||
|
||
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)
|
||
|
||
|
||
def _random_time_in_day(day_date: date, hour_weights: list) -> datetime:
|
||
"""
|
||
Returns a random datetime in day_date, biased toward peak hours.
|
||
hour_weights: list of (hour, weight) tuples covering the operating hours.
|
||
"""
|
||
hours = [h for h, _ in hour_weights]
|
||
weights = [w for _, w in hour_weights]
|
||
h = random.choices(hours, weights=weights)[0]
|
||
m = random.randint(0, 59)
|
||
return datetime(day_date.year, day_date.month, day_date.day, h, m)
|
||
|
||
|
||
# Operating hours weighted toward lunch (12-14) and dinner peaks (19-22),
|
||
# with lighter morning coffee traffic (10-11) and afternoon lulls (15-18).
|
||
HOUR_WEIGHTS = [
|
||
(10, 3), (11, 5),
|
||
(12, 12), (13, 15), (14, 10),
|
||
(15, 4), (16, 3), (17, 4),
|
||
(18, 6), (19, 14), (20, 16), (21, 13), (22, 8),
|
||
]
|
||
|
||
# Day-of-week multipliers (Mon=0 … Sun=6) — weekends busier
|
||
DOW_MULTIPLIER = {0: 0.75, 1: 0.75, 2: 0.80, 3: 0.85, 4: 1.00, 5: 1.20, 6: 1.10}
|
||
|
||
# ── Menu definition ───────────────────────────────────────────────────────────
|
||
# (category_name, color, [(product_name, price, cost), ...])
|
||
MENU = [
|
||
("Ορεκτικά", "#f97316", [
|
||
("Τζατζίκι", 4.50, 1.20),
|
||
("Ταραμοσαλάτα", 4.50, 1.10),
|
||
("Χωριάτικη Σαλάτα",7.50, 2.00),
|
||
("Κεφτεδάκια", 8.00, 2.80),
|
||
("Σαγανάκι", 6.50, 2.20),
|
||
("Χούμους", 5.00, 1.40),
|
||
]),
|
||
("Κυρίως Πιάτα", "#ef4444", [
|
||
("Μπριζόλα Χοιρινή", 12.00, 4.50),
|
||
("Μπριζόλα Μοσχαρίσια", 16.00, 7.00),
|
||
("Σουβλάκι Κοτόπουλο", 10.00, 3.20),
|
||
("Μουσακάς", 11.00, 3.80),
|
||
("Παστίτσιο", 10.00, 3.50),
|
||
("Σπαγγέτι Μπολονέζ", 9.50, 3.00),
|
||
("Γύρος Χοιρινός", 8.50, 2.60),
|
||
]),
|
||
("Ποτά & Αναψυκτικά", "#3b82f6", [
|
||
("Νερό 500ml", 1.00, 0.20),
|
||
("Αναψυκτικό", 2.50, 0.60),
|
||
("Μπύρα Φιάλη", 4.00, 1.20),
|
||
("Κρασί (ποτήρι)", 4.50, 1.00),
|
||
("Καφές Ελληνικός", 2.50, 0.45),
|
||
("Φραπέ", 3.00, 0.50),
|
||
("Χυμός Πορτοκάλι", 3.50, 0.90),
|
||
]),
|
||
]
|
||
|
||
# Weighted product picker: drinks ordered ~40% of the time, mains ~35%, starters ~25%
|
||
CATEGORY_WEIGHTS = [0.25, 0.35, 0.40]
|
||
|
||
WAITER_NAMES = [
|
||
("Νίκος", "Νίκος Παπαδόπουλος", "demo_nikos", "2201"),
|
||
("Μαρία", "Μαρία Κωνσταντίνου", "demo_maria", "3302"),
|
||
("Πέτρος", "Πέτρος Αναστασίου", "demo_petros", "4403"),
|
||
("Έλενα", "Έλενα Μιχαηλίδου", "demo_elena", "5504"),
|
||
]
|
||
|
||
PAYMENT_METHODS = ["cash", "cash", "cash", "card", "card"] # 60% cash / 40% card
|
||
|
||
CANCEL_REASONS = [
|
||
"Λάθος παραγγελία",
|
||
"Ο πελάτης άλλαξε γνώμη",
|
||
"Το προϊόν δεν είναι διαθέσιμο",
|
||
"Λάθος τραπέζι",
|
||
]
|
||
|
||
# ── Main ──────────────────────────────────────────────────────────────────────
|
||
|
||
db = SessionLocal()
|
||
|
||
try:
|
||
# ── Guard: abort if demo data already present ─────────────────────────────
|
||
existing = db.query(User).filter(User.username == "demo_nikos").first()
|
||
if existing:
|
||
print("Demo data already present (found demo_nikos 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:
|
||
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, username, pin in WAITER_NAMES:
|
||
w = db.query(User).filter(User.username == username).first()
|
||
if not w:
|
||
w = User(
|
||
username=username,
|
||
pin_hash=_hash_pin(pin),
|
||
role="waiter",
|
||
full_name=full_name,
|
||
nickname=nickname,
|
||
is_active=True,
|
||
)
|
||
db.add(w)
|
||
db.flush()
|
||
waiters.append(w)
|
||
print(f" created {len(waiters)} 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 ───────────────────────────────────────────────
|
||
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 = []
|
||
category_product_map = []
|
||
|
||
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, prod_cost) 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,
|
||
cost_simple=prod_cost,
|
||
is_available=True,
|
||
lifecycle_status="active",
|
||
sort_order=prod_sort,
|
||
)
|
||
db.add(p)
|
||
db.flush()
|
||
else:
|
||
p.cost_simple = prod_cost
|
||
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 (with costs)")
|
||
|
||
# ── Contacts (suppliers & utilities) ─────────────────────────────────────
|
||
contact_defs = [
|
||
("Κρεοπωλείο Παπαδόπουλος", "supplier", "+30 22310 41234", None, "Πιερία 12, Κατερίνη", "Κύριος προμηθευτής κρέατος"),
|
||
("Αλιεύματα Κόκκαλης", "supplier", "+30 22310 55678", None, "Λιμάνι, Κατερίνη", "Φρέσκα ψάρια και θαλασσινά"),
|
||
("Χονδρικό Ελαιόλαδο", "supplier", "+30 23310 22100", "oil@agrofarm.gr","Αγροτική Οδός 5", "Ελαιόλαδο και τυρί χύμα"),
|
||
("Αναψυκτικά ΑΕΒΕ", "supplier", "+30 210 9012345", "orders@aeve.gr", "Βιομηχανική Ζώνη, Αθήνα","Αναψυκτικά, νερά, μπύρες"),
|
||
("ΔΕΗ", "utility", "11500", None, None, "Ηλεκτρισμός"),
|
||
("ΕΥΔΑΠ / Τοπικό Δίκτυο", "utility", "11888", None, None, "Νερό"),
|
||
("Cosmote Επαγγελματικό", "utility", "13888", None, None, "Τηλεφωνία & internet"),
|
||
]
|
||
contacts = []
|
||
for c_name, c_type, c_phone, c_email, c_addr, c_notes in contact_defs:
|
||
c = db.query(Contact).filter(Contact.name == c_name).first()
|
||
if not c:
|
||
c = Contact(
|
||
name=c_name,
|
||
type=c_type,
|
||
phone=c_phone,
|
||
email=c_email,
|
||
address=c_addr,
|
||
notes=c_notes,
|
||
is_active=True,
|
||
)
|
||
db.add(c)
|
||
db.flush()
|
||
contacts.append(c)
|
||
print(f" created {len(contacts)} contacts")
|
||
|
||
# ── Customers ─────────────────────────────────────────────────────────────
|
||
customer_defs = [
|
||
("Γιώργος Σταθόπουλος", "Γιώργης", "+30 6944 111222", None, "Τακτικός πελάτης, κάθε Παρασκευή βράδυ"),
|
||
("Ελευθερία Νικολάου", "Ελευθερία","+30 6977 334455", "eleftheria@gmail.com", "Αλλεργία στη γλουτένη"),
|
||
("Δημήτρης Παππάς", "Μήτσος", "+30 6955 667788", None, "Προτιμά τραπέζι στη βεράντα"),
|
||
("Σοφία Αλεξίου", "Σοφία", "+30 6933 998877", "sofia.alex@hotmail.com", "VIP πελάτης"),
|
||
("Ομάδα Εργατών", None, "+30 6900 123456", None, "Ομάδα οικοδόμων — συχνά μεσημέρι"),
|
||
]
|
||
customers = []
|
||
for c_name, c_nick, c_phone, c_email, c_notes in customer_defs:
|
||
c = db.query(Customer).filter(Customer.name == c_name).first()
|
||
if not c:
|
||
c = Customer(
|
||
name=c_name,
|
||
nickname=c_nick,
|
||
phone=c_phone,
|
||
email=c_email,
|
||
notes=c_notes,
|
||
is_active=True,
|
||
created_by_id=manager.id,
|
||
)
|
||
db.add(c)
|
||
db.flush()
|
||
customers.append(c)
|
||
print(f" created {len(customers)} customers")
|
||
|
||
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...")
|
||
|
||
# Keep track of created business days (date → BusinessDay) for expense linking
|
||
bday_by_date: dict[date, BusinessDay] = {}
|
||
|
||
for days_ago in range(DEMO_DAYS, 0, -1):
|
||
day_date = today - timedelta(days=days_ago)
|
||
dow = day_date.weekday()
|
||
multiplier = DOW_MULTIPLIER[dow]
|
||
|
||
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()
|
||
bday_by_date[day_date] = bday
|
||
|
||
# 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 = {}
|
||
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 distributed realistically across the day
|
||
base_n = random.randint(*ORDERS_PER_DAY)
|
||
n_orders = max(5, int(base_n * multiplier))
|
||
|
||
# ~8% of days have 1-2 online orders
|
||
n_online = random.choices([0, 1, 2], weights=[0.60, 0.28, 0.12])[0]
|
||
n_pos = n_orders - n_online
|
||
|
||
order_times = []
|
||
for _ in range(n_pos):
|
||
order_times.append(_random_time_in_day(day_date, HOUR_WEIGHTS))
|
||
order_times.sort()
|
||
|
||
# Online orders land at lunch or dinner time
|
||
online_order_times = []
|
||
for _ in range(n_online):
|
||
h = random.choices([12, 13, 19, 20, 21], weights=[2, 2, 3, 3, 2])[0]
|
||
online_order_times.append(datetime(day_date.year, day_date.month, day_date.day,
|
||
h, random.randint(0, 59)))
|
||
|
||
table_busy_until: dict[int, datetime] = {}
|
||
|
||
# ── POS orders ────────────────────────────────────────────────────────
|
||
for opened_at_naive in order_times:
|
||
opened_at = _utc(opened_at_naive)
|
||
|
||
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
|
||
table = random.choice(free_tables)
|
||
|
||
waiter = random.choice(working_waiters)
|
||
shift = shifts[waiter.id]
|
||
|
||
duration_min = random.randint(20, 70)
|
||
closed_at = opened_at + timedelta(minutes=duration_min)
|
||
if closed_at > close_time:
|
||
closed_at = close_time - timedelta(minutes=2)
|
||
|
||
table_busy_until[table.id] = closed_at
|
||
|
||
# ~15% of orders linked to a known customer
|
||
order_customer = random.choice(customers) if random.random() < 0.15 else None
|
||
|
||
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",
|
||
customer_id=order_customer.id if order_customer else None,
|
||
)
|
||
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)
|
||
|
||
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]
|
||
|
||
# ~10% chance the item gets cancelled
|
||
is_cancelled = random.random() < 0.10
|
||
paid_at = closed_at - timedelta(minutes=random.randint(1, 5))
|
||
line_total = product.base_price * qty
|
||
|
||
item = OrderItem(
|
||
order_id=order.id,
|
||
product_id=product.id,
|
||
added_by=waiter.id,
|
||
quantity=qty,
|
||
unit_price=product.base_price,
|
||
unit_cost=product.cost_simple,
|
||
status="cancelled" if is_cancelled else "paid",
|
||
printed=True,
|
||
)
|
||
|
||
if is_cancelled:
|
||
cancel_at = items_added_at + timedelta(minutes=random.randint(1, 10))
|
||
item.cancelled_by = waiter.id
|
||
item.cancelled_at = _utc(cancel_at)
|
||
item.cancel_reason = random.choice(CANCEL_REASONS)
|
||
else:
|
||
payment_method = random.choice(PAYMENT_METHODS)
|
||
item.paid_by = waiter.id
|
||
item.paid_at = _utc(paid_at)
|
||
item.payment_method = payment_method
|
||
item.paid_in_shift_id = shift.id
|
||
order_total += line_total
|
||
shift_totals[shift.id] = round(shift_totals[shift.id] + line_total, 2)
|
||
|
||
item.added_at = _utc(items_added_at)
|
||
db.add(item)
|
||
db.flush()
|
||
items_added.append(item.id)
|
||
|
||
_audit(db, order.id, "ITEMS_ADDED",
|
||
waiter_id=waiter.id, item_ids=items_added, created_at=items_added_at)
|
||
|
||
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
|
||
|
||
# ── Online orders ─────────────────────────────────────────────────────
|
||
online_ref_counter = days_ago * 10 # rough unique ref per day
|
||
for ot in online_order_times:
|
||
opened_at = _utc(ot)
|
||
duration_min = random.randint(25, 50)
|
||
closed_at = opened_at + timedelta(minutes=duration_min)
|
||
if closed_at > close_time:
|
||
closed_at = close_time - timedelta(minutes=2)
|
||
|
||
online_type = random.choice(["delivery", "dine_in"])
|
||
online_names = ["Αλέξης Κ.", "Μαρία Π.", "Νίκος Σ.", "Αθηνά Λ.", "Θανάσης Β.", "Γιώτα Δ."]
|
||
online_phones = ["+30 694 111 0000", "+30 697 222 1111", "+30 693 333 2222",
|
||
"+30 699 444 3333", "+30 698 555 4444"]
|
||
o_name = random.choice(online_names)
|
||
o_phone = random.choice(online_phones)
|
||
o_status = random.choices(
|
||
["delivered", "delivered", "delivered", "rejected"],
|
||
weights=[0.85, 0.05, 0.05, 0.05]
|
||
)[0]
|
||
|
||
ref = f"ORD-{online_ref_counter:04d}"
|
||
online_ref_counter += 1
|
||
|
||
order = Order(
|
||
table_id=None,
|
||
opened_by=manager.id,
|
||
business_day_id=bday.id,
|
||
status="closed" if o_status == "delivered" else "cancelled",
|
||
closed_by=manager.id,
|
||
source="online",
|
||
online_order_ref=ref,
|
||
online_status=o_status,
|
||
online_customer_name=o_name,
|
||
online_customer_phone=o_phone,
|
||
online_order_type=online_type,
|
||
online_customer_notes=random.choice([None, None, "Χωρίς κρεμμύδι", "Γρήγορα παρακαλώ", None]),
|
||
order_type="delivery" if online_type == "delivery" else "here",
|
||
)
|
||
order.opened_at = opened_at
|
||
order.closed_at = closed_at
|
||
db.add(order)
|
||
db.flush()
|
||
|
||
_audit(db, order.id, "ORDER_OPENED", waiter_id=manager.id, created_at=opened_at)
|
||
|
||
n_items = random.randint(1, 4)
|
||
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=2)
|
||
|
||
for prod_list in chosen_category_lists:
|
||
product = random.choice(prod_list)
|
||
qty = random.choices([1, 2], weights=[0.8, 0.2])[0]
|
||
line_total = product.base_price * qty
|
||
order_total += line_total
|
||
|
||
item = OrderItem(
|
||
order_id=order.id,
|
||
product_id=product.id,
|
||
added_by=manager.id,
|
||
quantity=qty,
|
||
unit_price=product.base_price,
|
||
unit_cost=product.cost_simple,
|
||
status="paid" if o_status == "delivered" else "cancelled",
|
||
printed=True,
|
||
paid_by=manager.id if o_status == "delivered" else None,
|
||
paid_at=_utc(closed_at) if o_status == "delivered" else None,
|
||
payment_method="card" if o_status == "delivered" else None,
|
||
)
|
||
item.added_at = _utc(items_added_at)
|
||
db.add(item)
|
||
db.flush()
|
||
items_added.append(item.id)
|
||
|
||
if o_status == "delivered":
|
||
_audit(db, order.id, "PAYMENT",
|
||
waiter_id=manager.id, item_ids=items_added,
|
||
amount=round(order_total, 2), payment_method="card",
|
||
created_at=closed_at - timedelta(minutes=1))
|
||
total_revenue += order_total
|
||
_audit(db, order.id, "ORDER_CLOSED", waiter_id=manager.id, created_at=closed_at)
|
||
total_orders += 1
|
||
|
||
# Write total_collected onto shifts
|
||
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 (dow={dow})")
|
||
|
||
# ── Expenses ──────────────────────────────────────────────────────────────
|
||
print("\nAdding expenses...")
|
||
|
||
supplier_meat = contacts[0]
|
||
supplier_fish = contacts[1]
|
||
supplier_olive = contacts[2]
|
||
supplier_bev = contacts[3]
|
||
util_power = contacts[4]
|
||
util_water = contacts[5]
|
||
util_phone = contacts[6]
|
||
|
||
def _add_expense(description, category, contact, amount, paid_amount,
|
||
days_back, notes=None):
|
||
exp_date = today - timedelta(days=days_back)
|
||
bday_obj = bday_by_date.get(exp_date)
|
||
due = _utc(datetime(exp_date.year, exp_date.month, exp_date.day, 9, 0))
|
||
exp = Expense(
|
||
description=description,
|
||
category=category,
|
||
contact_id=contact.id if contact else None,
|
||
total_amount=amount,
|
||
paid_amount=paid_amount,
|
||
due_date=due,
|
||
business_day_id=bday_obj.id if bday_obj else None,
|
||
created_by_id=manager.id,
|
||
notes=notes,
|
||
)
|
||
exp.created_at = due
|
||
db.add(exp)
|
||
db.flush()
|
||
if paid_amount > 0:
|
||
pmt = ExpensePayment(
|
||
expense_id=exp.id,
|
||
amount=paid_amount,
|
||
paid_by_id=manager.id,
|
||
notes=None,
|
||
)
|
||
pmt.paid_at = due + timedelta(hours=1)
|
||
db.add(pmt)
|
||
db.flush()
|
||
|
||
# Recurring weekly: meat supplier (~every 5 days)
|
||
for i in range(0, DEMO_DAYS, 5):
|
||
_add_expense("Κρέας εβδομάδας", "supplier", supplier_meat,
|
||
round(random.uniform(180, 280), 2), 0,
|
||
DEMO_DAYS - i, "Παράδοση Δευτέρα πρωί")
|
||
|
||
# Recurring bi-weekly: fish
|
||
for i in range(0, DEMO_DAYS, 10):
|
||
_add_expense("Ψάρια & θαλασσινά", "supplier", supplier_fish,
|
||
round(random.uniform(90, 160), 2), 0,
|
||
DEMO_DAYS - i)
|
||
|
||
# Olive oil once a month
|
||
for i in range(0, DEMO_DAYS, 30):
|
||
_add_expense("Ελαιόλαδο & τυρί", "supplier", supplier_olive,
|
||
round(random.uniform(120, 200), 2), 0,
|
||
DEMO_DAYS - i)
|
||
|
||
# Beverages every 2 weeks
|
||
for i in range(0, DEMO_DAYS, 14):
|
||
amt = round(random.uniform(200, 350), 2)
|
||
_add_expense("Αναψυκτικά & μπύρες", "supplier", supplier_bev,
|
||
amt, amt, # fully paid
|
||
DEMO_DAYS - i)
|
||
|
||
# Monthly utility bills
|
||
for month_offset in range(4):
|
||
days_back = 30 * (month_offset + 1)
|
||
_add_expense("ΔΕΗ — μηνιαίος λογαριασμός", "utilities", util_power,
|
||
round(random.uniform(280, 420), 2), 0, days_back)
|
||
_add_expense("Νερό — μηνιαίος λογαριασμός", "utilities", util_water,
|
||
round(random.uniform(45, 90), 2), 0, days_back + 2)
|
||
_add_expense("Τηλεφωνία & Internet", "utilities", util_phone,
|
||
49.90, 49.90, days_back + 1)
|
||
|
||
# Rent once a month
|
||
for month_offset in range(4):
|
||
days_back = 30 * (month_offset + 1) - 5
|
||
_add_expense("Ενοίκιο καταστήματος", "rent", None,
|
||
1200.0, 1200.0, days_back,
|
||
"Πάντα στις αρχές του μήνα")
|
||
|
||
# One maintenance expense
|
||
_add_expense("Επισκευή ψυγείου", "maintenance", None,
|
||
320.0, 320.0, 45, "Συμπιεστής — Τεχνίτης Σωτήρης")
|
||
|
||
db.commit()
|
||
print(" expenses done")
|
||
|
||
# ── Tabs ──────────────────────────────────────────────────────────────────
|
||
print("Adding tabs...")
|
||
|
||
def _add_tab(customer, entries_desc, payments, is_closed=False):
|
||
"""entries_desc: list of (amount, description). payments: list of amounts."""
|
||
tab_opened = _utc(datetime.now(timezone.utc) - timedelta(days=random.randint(5, 30)))
|
||
tab = Tab(
|
||
customer_id=customer.id,
|
||
status="closed" if is_closed else "open",
|
||
opened_at=tab_opened,
|
||
closed_by_id=manager.id if is_closed else None,
|
||
closed_at=tab_opened + timedelta(days=random.randint(3, 15)) if is_closed else None,
|
||
notes=None,
|
||
)
|
||
db.add(tab)
|
||
db.flush()
|
||
|
||
entry_time = tab_opened
|
||
for amount, desc in entries_desc:
|
||
entry_time += timedelta(days=random.randint(1, 3))
|
||
e = TabEntry(
|
||
tab_id=tab.id,
|
||
order_id=None,
|
||
order_item_id=None,
|
||
amount=amount,
|
||
description=desc,
|
||
created_by_id=manager.id,
|
||
)
|
||
e.created_at = entry_time
|
||
db.add(e)
|
||
|
||
pay_time = entry_time + timedelta(days=1)
|
||
for pay_amount in payments:
|
||
pay_time += timedelta(hours=random.randint(1, 24))
|
||
p = TabPayment(
|
||
tab_id=tab.id,
|
||
amount=pay_amount,
|
||
payment_method=random.choice(["cash", "card"]),
|
||
received_by_id=manager.id,
|
||
notes=None,
|
||
)
|
||
p.created_at = pay_time
|
||
db.add(p)
|
||
|
||
db.flush()
|
||
|
||
# Open tab: Γιώργης hasn't settled his tab yet
|
||
_add_tab(customers[0], [
|
||
(35.50, "Δείπνο 3 ατόμων — 15/05"),
|
||
(28.00, "Μεσημεριανό — 22/05"),
|
||
(42.00, "Δείπνο 4 ατόμων — 28/05"),
|
||
], [20.00]) # partial payment
|
||
|
||
# Closed tab: Σοφία settled hers
|
||
_add_tab(customers[3], [
|
||
(55.00, "Εταιρικό δείπνο — 10/04"),
|
||
(30.00, "Μεσημεριανό — 18/04"),
|
||
], [85.00], is_closed=True)
|
||
|
||
# Open tab: Ομάδα εργατών — running tab
|
||
_add_tab(customers[4], [
|
||
(22.00, "Μεσημεριανό ομάδα — 01/06"),
|
||
(25.50, "Μεσημεριανό ομάδα — 05/06"),
|
||
(19.00, "Μεσημεριανό ομάδα — 09/06"),
|
||
], [30.00])
|
||
|
||
db.commit()
|
||
print(" tabs done")
|
||
|
||
# ── Site Notes & Todos ────────────────────────────────────────────────────
|
||
print("Adding notes & todos...")
|
||
|
||
notes_data = [
|
||
("Ο φούρνος χρειάζεται σέρβις — να κλείσουμε ραντεβού με τεχνίτη.", True),
|
||
("Νέα τιμή μπύρας από 1 Ιουλίου: Heineken 4.50€ (από 4.00€).", False),
|
||
("Παραγγελία ελαιολάδου: 10 κιλά εξαιρετικό παρθένο από Αλεξόπουλο.", False),
|
||
("ΔΕΗ: η επόμενη πληρωμή λήγει στις 20 Ιουλίου. Να μην ξεχαστεί!", True),
|
||
("Το κλιματιστικό βεράντας λειτουργεί πάλι μετά την επισκευή (Παρ. 07/06).", False),
|
||
]
|
||
for body, pinned in notes_data:
|
||
note = SiteNote(
|
||
body=body,
|
||
created_by_id=manager.id,
|
||
is_pinned=pinned,
|
||
)
|
||
db.add(note)
|
||
|
||
todos_data = [
|
||
("Ανανέωση τιμοκαταλόγου για καλοκαίρι", "high", False),
|
||
("Αγορά νέων ποτηριών κρασιού (χάλασαν 4)", "normal", False),
|
||
("Έλεγχος πυροσβεστήρων — Ιούλιος", "high", False),
|
||
("Εκπαίδευση νέου σερβιτόρου στο σύστημα", "normal", False),
|
||
("Ανανέωση άδειας λειτουργίας", "high", False),
|
||
("Παραγγελία νέων χαρτοπετσετών και υλικών bar", "normal", True),
|
||
("Φωτογράφιση νέων πιάτων για online menu", "normal", False),
|
||
]
|
||
for body, priority, is_done in todos_data:
|
||
todo = SiteTodo(
|
||
body=body,
|
||
priority=priority,
|
||
is_done=is_done,
|
||
created_by_id=manager.id,
|
||
done_by_id=manager.id if is_done else None,
|
||
done_at=_utc(datetime.now(timezone.utc) - timedelta(days=2)) if is_done else None,
|
||
)
|
||
db.add(todo)
|
||
|
||
db.commit()
|
||
print(" notes & todos done")
|
||
|
||
# ── Schedule (next 3 weeks) ───────────────────────────────────────────────
|
||
print("Adding schedule for next 3 weeks...")
|
||
|
||
# Each waiter works 5 days out of 7, with realistic shift patterns
|
||
# Mon-Fri: full shifts, Sat: both sessions, Sun: afternoon/evening only
|
||
shift_patterns = {
|
||
0: [("10:00", "16:00"), ("16:00", "23:30")], # Mon — two shifts
|
||
1: [("10:00", "16:00"), ("16:00", "23:30")], # Tue
|
||
2: [("10:00", "16:00"), ("16:00", "23:30")], # Wed
|
||
3: [("10:00", "16:00"), ("16:00", "23:30")], # Thu
|
||
4: [("10:00", "17:00"), ("17:00", "23:30")], # Fri
|
||
5: [("10:00", "16:00"), ("16:00", "23:30")], # Sat — busy day
|
||
6: [("11:00", "16:00"), ("16:00", "23:00")], # Sun
|
||
}
|
||
|
||
# Assign waiters to shifts: each day, 2 waiters per slot
|
||
for weeks_ahead in range(3):
|
||
for day_offset in range(7):
|
||
sched_date = today + timedelta(weeks=weeks_ahead, days=day_offset)
|
||
dow = sched_date.weekday()
|
||
day_patterns = shift_patterns[dow]
|
||
|
||
# Shuffle waiters and assign 2 per slot
|
||
waiter_pool = waiters[:]
|
||
random.shuffle(waiter_pool)
|
||
for slot_idx, (start_t, end_t) in enumerate(day_patterns):
|
||
# Pick 2 waiters per slot (rotate through pool)
|
||
slot_waiters = [waiter_pool[slot_idx % len(waiter_pool)],
|
||
waiter_pool[(slot_idx + 1) % len(waiter_pool)]]
|
||
for w in slot_waiters:
|
||
# Don't double-schedule same waiter on same day
|
||
already = db.query(ScheduledShift).filter(
|
||
ScheduledShift.user_id == w.id,
|
||
ScheduledShift.scheduled_date == sched_date,
|
||
).first()
|
||
if not already:
|
||
sched = ScheduledShift(
|
||
user_id=w.id,
|
||
scheduled_date=sched_date,
|
||
start_time=start_t,
|
||
end_time=end_t,
|
||
notes=None,
|
||
created_by_id=manager.id,
|
||
)
|
||
db.add(sched)
|
||
|
||
db.commit()
|
||
print(" schedule done")
|
||
|
||
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} | nickname: {w.nickname}")
|
||
|
||
finally:
|
||
db.close()
|