feat: bump client-services (accumulated feature work + deploy fixes)
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>
This commit is contained in:
@@ -4,22 +4,28 @@ 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)
|
||||
- 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] marker before inserting anything.
|
||||
DEMO guard before inserting anything.
|
||||
"""
|
||||
import json
|
||||
import random
|
||||
import sys
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from datetime import datetime, timedelta, date, timezone
|
||||
|
||||
import bcrypt
|
||||
from sqlalchemy import text
|
||||
@@ -30,12 +36,19 @@ 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
|
||||
@@ -43,6 +56,11 @@ 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)
|
||||
|
||||
@@ -83,34 +101,58 @@ def _audit(db, order_id, event_type, waiter_id=None, item_ids=None,
|
||||
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), ...])
|
||||
# (category_name, color, [(product_name, price, cost), ...])
|
||||
MENU = [
|
||||
("Ορεκτικά", "#f97316", [
|
||||
("Τζατζίκι", 4.50),
|
||||
("Ταραμοσαλάτα", 4.50),
|
||||
("Χωριάτικη Σαλάτα", 7.50),
|
||||
("Κεφτεδάκια", 8.00),
|
||||
("Σαγανάκι", 6.50),
|
||||
("Χούμους", 5.00),
|
||||
("Τζατζίκι", 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),
|
||||
("Μπριζόλα Μοσχαρίσια", 16.00),
|
||||
("Σουβλάκι Κοτόπουλο", 10.00),
|
||||
("Μουσακάς", 11.00),
|
||||
("Παστίτσιο", 10.00),
|
||||
("Σπαγγέτι Μπολονέζ", 9.50),
|
||||
("Γύρος Χοιρινός", 8.50),
|
||||
("Μπριζόλα Χοιρινή", 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),
|
||||
("Αναψυκτικό", 2.50),
|
||||
("Μπύρα Φιάλη", 4.00),
|
||||
("Κρασί (ποτήρι)", 4.50),
|
||||
("Καφές Ελληνικός", 2.50),
|
||||
("Φραπέ", 3.00),
|
||||
("Χυμός Πορτοκάλι", 3.50),
|
||||
("Νερό 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),
|
||||
]),
|
||||
]
|
||||
|
||||
@@ -118,23 +160,30 @@ MENU = [
|
||||
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.full_name.like("[DEMO]%")).first()
|
||||
existing = db.query(User).filter(User.username == "demo_nikos").first()
|
||||
if existing:
|
||||
print("Demo data already present (found [DEMO] user). Nothing to do.")
|
||||
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)
|
||||
|
||||
@@ -155,7 +204,6 @@ try:
|
||||
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()
|
||||
@@ -163,22 +211,21 @@ try:
|
||||
|
||||
# ── Waiters ───────────────────────────────────────────────────────────────
|
||||
waiters = []
|
||||
for nickname, full_name in WAITER_NAMES:
|
||||
username = f"demo_{nickname.lower()}"
|
||||
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(str(random.randint(1000, 9999))),
|
||||
pin_hash=_hash_pin(pin),
|
||||
role="waiter",
|
||||
full_name=f"[DEMO] {full_name}",
|
||||
full_name=full_name,
|
||||
nickname=nickname,
|
||||
is_active=True,
|
||||
)
|
||||
db.add(w)
|
||||
db.flush()
|
||||
waiters.append(w)
|
||||
print(f" created {len(waiters)} demo waiters")
|
||||
print(f" created {len(waiters)} waiters")
|
||||
|
||||
# ── Table groups & tables ─────────────────────────────────────────────────
|
||||
group_configs = [
|
||||
@@ -201,7 +248,7 @@ try:
|
||||
all_tables.append(tbl)
|
||||
print(f" created {len(all_tables)} tables across {len(group_configs)} groups")
|
||||
|
||||
# ── Waiter zone assignments (all waiters → all zones) ─────────────────────
|
||||
# ── Waiter zone assignments ───────────────────────────────────────────────
|
||||
for w in waiters:
|
||||
existing_zone = db.query(WaiterZone).filter(
|
||||
WaiterZone.waiter_id == w.id, WaiterZone.group_id.is_(None)
|
||||
@@ -212,8 +259,8 @@ try:
|
||||
|
||||
# ── Menu ──────────────────────────────────────────────────────────────────
|
||||
categories = []
|
||||
all_products = [] # flat list for random picks
|
||||
category_product_map = [] # parallel list of product sub-lists (for weighted pick)
|
||||
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()
|
||||
@@ -224,24 +271,82 @@ try:
|
||||
categories.append(cat)
|
||||
|
||||
cat_products = []
|
||||
for prod_sort, (prod_name, prod_price) in enumerate(items):
|
||||
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")
|
||||
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()
|
||||
|
||||
@@ -252,10 +357,14 @@ try:
|
||||
|
||||
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]
|
||||
|
||||
# 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,
|
||||
@@ -270,12 +379,13 @@ try:
|
||||
)
|
||||
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 = {} # shift_id → running revenue total, filled during order loop
|
||||
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))
|
||||
@@ -291,51 +401,53 @@ try:
|
||||
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
|
||||
# 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(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))
|
||||
for _ in range(n_pos):
|
||||
order_times.append(_random_time_in_day(day_date, HOUR_WEIGHTS))
|
||||
order_times.sort()
|
||||
|
||||
# Track which tables are "busy" at each point in time so we don't double-seat
|
||||
# Format: {table_id: release_time}
|
||||
# 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)
|
||||
|
||||
# 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)
|
||||
free_tables = all_tables
|
||||
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
|
||||
|
||||
# ~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,
|
||||
@@ -344,21 +456,16 @@ try:
|
||||
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,
|
||||
))
|
||||
|
||||
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 = []
|
||||
@@ -368,10 +475,11 @@ try:
|
||||
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)
|
||||
|
||||
# ~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
|
||||
order_total += line_total
|
||||
|
||||
item = OrderItem(
|
||||
order_id=order.id,
|
||||
@@ -379,44 +487,384 @@ try:
|
||||
added_by=waiter.id,
|
||||
quantity=qty,
|
||||
unit_price=product.base_price,
|
||||
status="paid",
|
||||
unit_cost=product.cost_simple,
|
||||
status="cancelled" if is_cancelled else "paid",
|
||||
printed=True,
|
||||
paid_by=waiter.id,
|
||||
paid_at=_utc(paid_at),
|
||||
payment_method=payment_method,
|
||||
paid_in_shift_id=shift.id,
|
||||
)
|
||||
|
||||
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)
|
||||
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)
|
||||
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)
|
||||
_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
|
||||
# ── 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")
|
||||
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}")
|
||||
@@ -424,7 +872,7 @@ try:
|
||||
print(f"\nLogin credentials:")
|
||||
print(f" Manager — username: manager | password: password | PIN: 1234")
|
||||
for w in waiters:
|
||||
print(f" Waiter — username: {w.username}")
|
||||
print(f" Waiter — username: {w.username} | nickname: {w.nickname}")
|
||||
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
Reference in New Issue
Block a user