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

View File

@@ -7,4 +7,4 @@ RUN pip install --no-cache-dir -r requirements.txt
COPY . .
CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000"]
CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000", "--log-level", "info"]

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()

View File

@@ -19,6 +19,7 @@ import models.shift # noqa: F401 — registers WaiterShift, ShiftBreak
import models.settings # noqa: F401
import models.flag # noqa: F401 — registers TableFlagDef, TableFlagAssignment
import models.message # noqa: F401 — registers StaffMessage, StaffMessageAck, QuickMessageTemplate
import models.reservation # noqa: F401
from routers import auth, tables, products, orders, waiters, reports, system, setup as setup_router
from routers import business_day as business_day_router
@@ -29,6 +30,7 @@ from routers import messages as messages_router
from routers import sse as sse_router
from routers import data_transfer as data_transfer_router
from routers import connect_orders as connect_orders_router
from routers import reservations as reservations_router
def _run_migrations():
@@ -237,6 +239,29 @@ def _run_migrations():
"ALTER TABLE products ADD COLUMN digital_price REAL",
"ALTER TABLE products ADD COLUMN digital_discount REAL NOT NULL DEFAULT 0.0",
"ALTER TABLE products ADD COLUMN digital_image_url VARCHAR",
# Per-printer line width (chars per line) — default 48 for 80mm Jolimark
"ALTER TABLE printers ADD COLUMN line_width INTEGER NOT NULL DEFAULT 48",
# Product description (plain-text, for menu display)
"ALTER TABLE products ADD COLUMN description TEXT",
# Staff note (internal memo on a waiter's profile)
"ALTER TABLE users ADD COLUMN note VARCHAR",
# Reservations table
"""CREATE TABLE IF NOT EXISTS reservations (
id INTEGER PRIMARY KEY AUTOINCREMENT,
guest_name VARCHAR NOT NULL,
party_size INTEGER NOT NULL,
phone VARCHAR,
email VARCHAR,
note TEXT,
reserved_for DATETIME NOT NULL,
table_id INTEGER REFERENCES tables(id),
status VARCHAR NOT NULL DEFAULT 'pending',
source VARCHAR NOT NULL DEFAULT 'manager_app',
created_by_user_id INTEGER REFERENCES users(id),
online_customer_id VARCHAR,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
)""",
]
for sql in migrations:
try:
@@ -251,12 +276,15 @@ def _run_migrations():
async def lifespan(app: FastAPI):
import asyncio
from services.sse_bus import init_loop
from services.reservation_tasks import start_reservation_tasks
init_loop(asyncio.get_running_loop())
Base.metadata.create_all(bind=engine)
_run_migrations()
sync_task = await start_cloud_sync()
reservation_task = await start_reservation_tasks()
yield
sync_task.cancel()
reservation_task.cancel()
app = FastAPI(title="POS Local Backend", lifespan=lifespan)
@@ -295,3 +323,4 @@ app.include_router(messages_router.router, prefix="/api/messages", tag
app.include_router(sse_router.router, prefix="/api/sse", tags=["sse"])
app.include_router(data_transfer_router.router, prefix="/api/data-transfer", tags=["data-transfer"])
app.include_router(connect_orders_router.router, prefix="/api/connect", tags=["connect"])
app.include_router(reservations_router.router, prefix="/api/reservations", tags=["reservations"])

View File

@@ -12,6 +12,7 @@ class Printer(Base):
port = Column(Integer, default=9100, nullable=False)
is_active = Column(Boolean, default=True, nullable=False)
protocol = Column(String, default="escpos_tcp", nullable=False)
line_width = Column(Integer, default=48, nullable=False)
products = relationship("Product", back_populates="printer_zone")
print_logs = relationship("PrintLog", back_populates="printer")

View File

@@ -27,6 +27,7 @@ class Product(Base):
id = Column(Integer, primary_key=True, index=True)
name = Column(String, nullable=False)
description = Column(Text, nullable=True)
category_id = Column(Integer, ForeignKey("categories.id"), nullable=True)
base_price = Column(Float, nullable=False)
is_available = Column(Boolean, default=True, nullable=False)

View File

@@ -0,0 +1,42 @@
from sqlalchemy import Column, Integer, String, DateTime, ForeignKey, Text
from sqlalchemy.orm import relationship
from datetime import datetime, timezone
from database import Base
class Reservation(Base):
__tablename__ = "reservations"
id = Column(Integer, primary_key=True, index=True)
# Guest info
guest_name = Column(String, nullable=False)
party_size = Column(Integer, nullable=False)
phone = Column(String, nullable=True)
email = Column(String, nullable=True)
note = Column(Text, nullable=True)
# When the customer wants the table
reserved_for = Column(DateTime, nullable=False)
# Optional table assignment (assignable/editable later)
table_id = Column(Integer, ForeignKey("tables.id"), nullable=True)
# Lifecycle: pending | arrived | cancelled | no_show
status = Column(String, nullable=False, default="pending")
# Source: manager_app | waiter_app | online
source = Column(String, nullable=False, default="manager_app")
# Who entered this reservation (staff user)
created_by_user_id = Column(Integer, ForeignKey("users.id"), nullable=True)
# For future online reservations: cloud-side customer identifier
online_customer_id = Column(String, nullable=True)
created_at = Column(DateTime, default=lambda: datetime.now(timezone.utc))
updated_at = Column(DateTime, default=lambda: datetime.now(timezone.utc),
onupdate=lambda: datetime.now(timezone.utc))
table = relationship("Table", foreign_keys=[table_id])
created_by = relationship("User", foreign_keys=[created_by_user_id])

View File

@@ -21,6 +21,7 @@ class User(Base):
full_name = Column(String, nullable=True)
nickname = Column(String, nullable=True)
mobile_phone = Column(String, nullable=True)
note = Column(String, nullable=True)
avatar_url = Column(String, nullable=True)
created_at = Column(DateTime(timezone=True), default=_utcnow)

View File

@@ -6,10 +6,12 @@ from datetime import datetime, timezone
from database import get_db
from models.business_day import BusinessDay
from models.order import Order, OrderItem, OrderAuditLog
from models.order import Order, OrderItem, OrderAuditLog, PrintLog
from models.shift import WaiterShift
from models.flag import TableFlagAssignment
from models.message import StaffMessage, StaffMessageAck
from models.table import Table, TableGroup
from models.printer import Printer
from schemas.business_day import BusinessDayOut, OpenBusinessDayRequest, CloseBusinessDayRequest
from routers.deps import get_current_user, require_manager
from models.user import User
@@ -189,6 +191,219 @@ def delete_business_day(
db.commit()
@router.get("/summary")
def business_day_summary(
day_id: Optional[int] = None,
db: Session = Depends(get_db),
user: User = Depends(require_manager),
):
"""Full summary for a business day: per-waiter, per-zone, per-printer, and checks.
If day_id is omitted, uses the currently open business day."""
if day_id:
day = db.query(BusinessDay).filter(BusinessDay.id == day_id).first()
if not day:
raise HTTPException(status_code=404, detail="Business day not found")
else:
day = db.query(BusinessDay).filter(BusinessDay.status == "open").first()
if not day:
raise HTTPException(status_code=404, detail="No open business day")
orders = db.query(Order).filter(Order.business_day_id == day.id).all()
order_ids = [o.id for o in orders]
all_items = db.query(OrderItem).filter(OrderItem.order_id.in_(order_ids)).all() if order_ids else []
paid_items = [i for i in all_items if i.status == "paid"]
active_items = [i for i in all_items if i.status == "active"]
shifts = db.query(WaiterShift).filter(WaiterShift.business_day_id == day.id).all()
users_map = {u.id: u for u in db.query(User).all()}
tables_map = {t.id: t for t in db.query(Table).all()}
groups_map = {g.id: g for g in db.query(TableGroup).all()}
printers_map = {p.id: p for p in db.query(Printer).all()}
# ── Per-waiter summary ──────────────────────────────────────────────────
waiter_stats: dict = {}
def _waiter_entry(uid):
if uid not in waiter_stats:
u = users_map.get(uid)
name = (u.full_name or u.nickname or u.username) if u else f"#{uid}"
role = u.role if u else "waiter"
waiter_stats[uid] = {
"waiter_id": uid,
"waiter_name": name,
"role": role,
"orders_opened": 0,
"items_ordered": 0,
"total_items_value": 0.0,
"items_paid": 0,
"total_collected": 0.0,
"cash": 0.0,
"card": 0.0,
"transfer": 0.0,
"treat": 0.0,
"other": 0.0,
"shift_started_at": None,
"shift_ended_at": None,
"starting_cash": None,
}
return waiter_stats[uid]
for o in orders:
if o.status == "cancelled":
continue
e = _waiter_entry(o.opened_by)
e["orders_opened"] += 1
for item in all_items:
if item.status == "cancelled":
continue
e = _waiter_entry(item.added_by)
e["items_ordered"] += item.quantity
e["total_items_value"] += item.unit_price * item.quantity
for item in paid_items:
if item.paid_by:
e = _waiter_entry(item.paid_by)
val = item.unit_price * item.quantity
e["items_paid"] += item.quantity
e["total_collected"] += val
method = (item.payment_method or "cash").lower()
if method == "cash":
e["cash"] += val
elif method == "card":
e["card"] += val
elif method == "transfer":
e["transfer"] += val
elif method in ("treat", "comp"):
e["treat"] += val
else:
e["other"] += val
for shift in shifts:
e = _waiter_entry(shift.waiter_id)
if e["shift_started_at"] is None or (shift.started_at and _dt(shift.started_at) < e["shift_started_at"]):
e["shift_started_at"] = _dt(shift.started_at)
if shift.starting_cash is not None:
e["starting_cash"] = shift.starting_cash
if shift.ended_at:
ended = _dt(shift.ended_at)
if e["shift_ended_at"] is None or ended > e["shift_ended_at"]:
e["shift_ended_at"] = ended
# manager gets shift = day open/close
manager_opener = users_map.get(day.opened_by_id)
if manager_opener:
e = _waiter_entry(day.opened_by_id)
if e["shift_started_at"] is None:
e["shift_started_at"] = _dt(day.opened_at)
if e["shift_ended_at"] is None and day.closed_at:
e["shift_ended_at"] = _dt(day.closed_at)
# ── Per-zone summary ────────────────────────────────────────────────────
zone_stats: dict = {} # group_id → stats
def _zone_entry(gid, gname, gcolor):
if gid not in zone_stats:
zone_stats[gid] = {
"group_id": gid,
"group_name": gname,
"color": gcolor,
"orders": 0,
"items": 0,
"revenue": 0.0,
}
return zone_stats[gid]
for o in orders:
if o.status == "cancelled" or not o.table_id:
continue
table = tables_map.get(o.table_id)
group = groups_map.get(table.group_id) if table and table.group_id else None
gid = group.id if group else 0
gname = group.name if group else "Χωρίς Ζώνη"
gcolor = group.color if group else "#9ca3af"
entry = _zone_entry(gid, gname, gcolor)
entry["orders"] += 1
for item in o.items:
if item.status == "cancelled":
continue
entry["items"] += item.quantity
if item.status == "paid":
entry["revenue"] += item.unit_price * item.quantity
# ── Per-printer summary ─────────────────────────────────────────────────
# Build item_id → value lookup for revenue-per-printer calculation
items_value_map = {i.id: i.unit_price * i.quantity for i in all_items if i.status != "cancelled"}
printer_stats: dict = {}
if order_ids:
logs = db.query(PrintLog).filter(PrintLog.order_id.in_(order_ids)).all()
for log in logs:
pid = log.printer_id
if pid not in printer_stats:
pr = printers_map.get(pid)
printer_stats[pid] = {
"printer_id": pid,
"printer_name": pr.name if pr else f"#{pid}",
"ip_address": pr.ip_address if pr else "",
"jobs": 0,
"success": 0,
"failed": 0,
"items_value": 0.0,
}
printer_stats[pid]["jobs"] += 1
if log.success:
printer_stats[pid]["success"] += 1
try:
import json as _json
item_ids = _json.loads(log.item_ids) if log.item_ids else []
for iid in item_ids:
printer_stats[pid]["items_value"] += items_value_map.get(iid, 0.0)
except Exception:
pass
else:
printer_stats[pid]["failed"] += 1
for pid in printer_stats:
printer_stats[pid]["items_value"] = round(printer_stats[pid]["items_value"], 2)
# ── Checks ──────────────────────────────────────────────────────────────
open_orders = [o for o in orders if o.status in ("open", "partially_paid")]
with_unpaid_items = sum(
1 for o in open_orders
if any(i.status == "active" for i in o.items)
)
active_shift_count = sum(1 for s in shifts if s.ended_at is None)
total_revenue = sum(i.unit_price * i.quantity for i in paid_items)
total_items_ordered = sum(i.quantity for i in all_items if i.status != "cancelled")
total_orders = sum(1 for o in orders if o.status != "cancelled")
# Round float fields in waiter stats
for w in waiter_stats.values():
for key in ("total_collected", "total_items_value", "cash", "card", "transfer", "treat", "other"):
w[key] = round(w[key], 2)
return {
"day_id": day.id,
"status": day.status,
"opened_at": _dt(day.opened_at),
"closed_at": _dt(day.closed_at),
"total_revenue": round(total_revenue, 2),
"total_orders": total_orders,
"total_items_ordered": total_items_ordered,
"waiters": sorted(waiter_stats.values(), key=lambda x: -x["total_collected"]),
"zones": sorted(zone_stats.values(), key=lambda x: -x["revenue"]),
"printers": list(printer_stats.values()),
"checks": {
"open_orders": len(open_orders),
"with_unpaid_items": with_unpaid_items,
"active_shifts": active_shift_count,
},
}
@router.get("/history")
def business_day_history(
db: Session = Depends(get_db),

View File

@@ -67,16 +67,21 @@ def _audit(db: Session, order_id: int, event_type: str, waiter_id: int = None,
))
ACTIVE_STATUSES = ["open", "partially_paid", "paid"]
@router.get("/", response_model=List[OrderOut])
def list_orders(
order_status: Optional[str] = None,
waiter_id: Optional[int] = None,
all: bool = False,
db: Session = Depends(get_db),
user: User = Depends(require_manager),
):
q = db.query(Order)
if order_status:
q = q.filter(Order.status == order_status)
elif not all:
q = q.filter(Order.status.in_(ACTIVE_STATUSES))
if waiter_id:
q = q.join(OrderWaiter).filter(OrderWaiter.waiter_id == waiter_id)
return q.all()
@@ -183,9 +188,13 @@ def get_order(order_id: int, db: Session = Depends(get_db), user: User = Depends
"is_duplicate": l.is_duplicate,
}
table = db.query(Table).filter(Table.id == order.table_id).first() if order.table_id else None
table_name = (table.label or f"T{table.number}") if table else None
return {
"id": order.id,
"table_id": order.table_id,
"table_name": table_name,
"opened_by": order.opened_by,
"opened_at": order.opened_at,
"status": order.status,
@@ -347,12 +356,13 @@ def pay_items(order_id: int, body: PayItemsRequest, db: Session = Depends(get_db
WaiterShift.waiter_id == user.id,
WaiterShift.ended_at == None,
).first()
effective_method = body.payment_method or "cash"
total_paid = 0.0
for item in items:
item.status = "paid"
item.paid_by = user.id
item.paid_at = now
item.payment_method = body.payment_method
item.payment_method = effective_method
item.paid_in_shift_id = active_shift.id if active_shift else None
total_paid += item.unit_price * item.quantity
@@ -452,6 +462,7 @@ def pay_items_offline(
WaiterShift.ended_at == None,
).first()
effective_method = body.payment_method or "cash"
total_paid = 0.0
paid_ids = []
if not is_duplicate:
@@ -459,7 +470,7 @@ def pay_items_offline(
item.status = "paid"
item.paid_by = user.id
item.paid_at = paid_at
item.payment_method = body.payment_method
item.payment_method = effective_method
item.paid_in_shift_id = active_shift.id if active_shift else None
total_paid += item.unit_price * item.quantity
paid_ids.append(item.id)
@@ -605,7 +616,7 @@ def print_order(
"notes": order.notes,
}
background_tasks.add_task(print_order_receipt, printer.ip_address, printer.port, receipt)
background_tasks.add_task(print_order_receipt, printer.ip_address, printer.port, receipt, printer.line_width)
return {"status": "printing"}
@@ -900,5 +911,5 @@ def print_synopsis(
"remaining": total - paid_total,
}
background_tasks.add_task(print_order_synopsis, printer.ip_address, printer.port, synopsis)
background_tasks.add_task(print_order_synopsis, printer.ip_address, printer.port, synopsis, printer.line_width)
return {"status": "printing"}

View File

@@ -20,7 +20,10 @@ from models.business_day import BusinessDay
from schemas.order import OrderOut
from schemas.table import TableOut
from routers.deps import require_manager
from services.printer_service import print_waiter_report, print_printer_report, print_order_receipt
from services.printer_service import (
print_waiter_report, print_printer_report, print_order_receipt, load_divider_style,
print_products_report, print_categories_report, print_tables_report,
)
router = APIRouter()
@@ -416,17 +419,88 @@ class PrintWaiterReportBody(BaseModel):
class PrintPrinterReportBody(BaseModel):
# printer_target_id: 0 = all printers, >0 = specific printer id
printer_target_id: int
printer_id: int
mode: str # "simple" | "extensive"
from_dt: str
to_dt: str
# mode: "quick" | "orders" | "detailed"
mode: str
from_dt: Optional[str] = None
to_dt: Optional[str] = None
business_day_id: Optional[int] = None
item_breakdown: bool = False
class PrintOrderBody(BaseModel):
printer_id: int
def _build_printer_block(printer_id: int, printer_name: str, logs, tables, db, mode: str) -> dict:
"""
Build a report block for a single printer from its logs.
Returns dict with print_jobs, orders, items, total, order_data, item_breakdown.
item_breakdown is always computed (used for product analysis section when requested).
"""
order_map: dict = {}
item_breakdown: dict = {} # name → {"qty": int, "value": float}
items_count = 0
grand_total = 0.0
for log in logs:
oid = log.order_id
if oid not in order_map:
order = db.query(Order).filter(Order.id == oid).first()
if order:
order_map[oid] = {
"id": oid,
"time": log.printed_at.strftime("%H:%M"),
"table": tables.get(order.table_id, f"#{order.table_id}"),
"total": 0.0,
"items": [],
}
try:
item_ids = json.loads(log.item_ids)
except Exception:
item_ids = []
for item_id in item_ids:
item = db.query(OrderItem).filter(OrderItem.id == item_id).first()
if item and item.status in ("active", "paid"):
items_count += item.quantity
val = item.unit_price * item.quantity
grand_total += val
product_name = item.product.name if item.product else f"#{item.product_id}"
if oid in order_map:
order_map[oid]["total"] += val
order_map[oid]["items"].append({
"name": product_name,
"quantity": item.quantity,
"unit_price": float(item.unit_price),
"total": round(val, 2),
})
# Accumulate item breakdown (always, regardless of mode)
if product_name not in item_breakdown:
item_breakdown[product_name] = {"qty": 0, "value": 0.0}
item_breakdown[product_name]["qty"] += item.quantity
item_breakdown[product_name]["value"] = round(item_breakdown[product_name]["value"] + val, 2)
# Convert breakdown to sorted list for easy rendering
breakdown_list = sorted(
[{"name": k, "qty": v["qty"], "value": v["value"]} for k, v in item_breakdown.items()],
key=lambda x: x["qty"],
reverse=True,
)
return {
"printer_id": printer_id,
"printer_name": printer_name,
"print_jobs": len(logs),
"orders": len(order_map),
"items": items_count,
"total": round(grand_total, 2),
"order_data": list(order_map.values()) if mode in ("orders", "detailed") else [],
"item_breakdown": breakdown_list,
}
@router.post("/print/waiter")
def print_waiter(
body: PrintWaiterReportBody,
@@ -497,73 +571,293 @@ def print_printer_totals(
db: Session = Depends(get_db),
user: User = Depends(require_manager),
):
# The physical printer that will receive the paper
printer = db.query(Printer).filter(Printer.id == body.printer_id, Printer.is_active == True).first()
if not printer:
raise HTTPException(status_code=404, detail="Printer not found or inactive")
target_printer = db.query(Printer).filter(Printer.id == body.printer_target_id).first()
target_name = target_printer.name if target_printer else f"Printer #{body.printer_target_id}"
from_dt = datetime.fromisoformat(body.from_dt)
to_dt = datetime.fromisoformat(body.to_dt)
logs = db.query(PrintLog).filter(
PrintLog.printer_id == body.printer_target_id,
PrintLog.success == True,
PrintLog.printed_at >= from_dt,
PrintLog.printed_at <= to_dt,
).all()
tables = {t.id: (t.label or f"T{t.number}") for t in db.query(Table).all()}
# Build per-order entries keyed by order_id; each log may add more items
order_map: dict = {}
items_count = 0
grand_total = 0.0
for log in logs:
oid = log.order_id
if oid not in order_map:
order = db.query(Order).filter(Order.id == oid).first()
if order:
order_map[oid] = {
"id": oid,
"time": log.printed_at.strftime("%H:%M"),
"table": tables.get(order.table_id, f"#{order.table_id}"),
"total": 0.0,
"items": [],
}
try:
item_ids = json.loads(log.item_ids)
except Exception:
item_ids = []
for item_id in item_ids:
item = db.query(OrderItem).filter(OrderItem.id == item_id).first()
if item and item.status in ("active", "paid"):
items_count += item.quantity
val = item.unit_price * item.quantity
grand_total += val
if oid in order_map:
order_map[oid]["total"] += val
product_name = item.product.name if item.product else f"#{item.product_id}"
order_map[oid]["items"].append({"name": product_name, "quantity": item.quantity})
# Build time filter for logs
def _base_log_q():
q = db.query(PrintLog).filter(PrintLog.success == True)
if body.business_day_id:
q = q.join(Order).filter(Order.business_day_id == body.business_day_id)
elif body.from_dt and body.to_dt:
from_dt = datetime.fromisoformat(body.from_dt)
to_dt = datetime.fromisoformat(body.to_dt)
q = q.filter(PrintLog.printed_at >= from_dt, PrintLog.printed_at <= to_dt)
return q
order_data = list(order_map.values())
# Determine period label for the receipt header
if body.business_day_id:
bd = db.query(BusinessDay).filter(BusinessDay.id == body.business_day_id).first()
period_label = bd.opened_at.strftime("%d/%m/%Y") if bd else f"Workday #{body.business_day_id}"
period_is_workday = True
else:
from_dt = datetime.fromisoformat(body.from_dt) if body.from_dt else None
to_dt = datetime.fromisoformat(body.to_dt) if body.to_dt else None
period_label = (
f"{from_dt.strftime('%d/%m/%Y')} - {to_dt.strftime('%d/%m/%Y')}"
if from_dt and to_dt else ""
)
period_is_workday = False
if body.printer_target_id == 0:
# All printers — build one block per active printer that has logs
all_printers = db.query(Printer).filter(Printer.is_active == True).all()
printer_blocks = []
for p in all_printers:
logs = _base_log_q().filter(PrintLog.printer_id == p.id).all()
if not logs:
continue
block = _build_printer_block(p.id, p.name, logs, tables, db, body.mode)
printer_blocks.append(block)
else:
target_printer = db.query(Printer).filter(Printer.id == body.printer_target_id).first()
target_name = target_printer.name if target_printer else f"Printer #{body.printer_target_id}"
logs = _base_log_q().filter(PrintLog.printer_id == body.printer_target_id).all()
printer_blocks = [_build_printer_block(body.printer_target_id, target_name, logs, tables, db, body.mode)]
report = {
"printer_name": target_name,
"print_jobs": len(logs),
"orders": len(order_map),
"items": items_count,
"total": grand_total,
"order_data": order_data if body.mode == "extensive" else [],
"from_dt": from_dt.strftime("%d/%m/%Y %H:%M"),
"to_dt": to_dt.strftime("%d/%m/%Y %H:%M"),
"printer_blocks": printer_blocks,
"period_label": period_label,
"period_is_workday": period_is_workday,
"mode": body.mode,
"item_breakdown": body.item_breakdown,
"line_width": printer.line_width,
"div_style": load_divider_style(db),
}
background_tasks.add_task(print_printer_report, printer.ip_address, printer.port, report, body.mode)
return {"status": "printing"}
# ---------------------------------------------------------------------------
# Print: products / categories / tables
# ---------------------------------------------------------------------------
class PrintAnalyticsBody(BaseModel):
printer_id: int
mode: str # "smart" | "full"
from_dt: Optional[str] = None
to_dt: Optional[str] = None
business_day_id: Optional[int] = None
def _analytics_period(body: PrintAnalyticsBody, db) -> tuple[str, bool]:
"""Return (period_label, period_is_workday)."""
if body.business_day_id:
bd = db.query(BusinessDay).filter(BusinessDay.id == body.business_day_id).first()
label = bd.opened_at.strftime("%d/%m/%Y") if bd else f"Workday #{body.business_day_id}"
return label, True
from_dt = datetime.fromisoformat(body.from_dt) if body.from_dt else None
to_dt = datetime.fromisoformat(body.to_dt) if body.to_dt else None
label = (
f"{from_dt.strftime('%d/%m/%Y')} - {to_dt.strftime('%d/%m/%Y')}"
if from_dt and to_dt else ""
)
return label, False
@router.post("/print/products")
def print_products(
body: PrintAnalyticsBody,
background_tasks: BackgroundTasks,
db: Session = Depends(get_db),
user: User = Depends(require_manager),
):
from models.product import Product, Category
printer = db.query(Printer).filter(Printer.id == body.printer_id, Printer.is_active == True).first()
if not printer:
raise HTTPException(status_code=404, detail="Printer not found or inactive")
period_label, period_is_workday = _analytics_period(body, db)
# Build sold-item summary
q = db.query(OrderItem).filter(OrderItem.status.in_(["active", "paid"]))
if body.business_day_id:
q = q.join(Order).filter(Order.business_day_id == body.business_day_id)
elif body.from_dt and body.to_dt:
q = q.filter(
OrderItem.added_at >= datetime.fromisoformat(body.from_dt),
OrderItem.added_at <= datetime.fromisoformat(body.to_dt),
)
items = q.all()
sold: dict = {}
for item in items:
pid = item.product_id
if pid not in sold:
sold[pid] = {"qty": 0, "revenue": 0.0}
sold[pid]["qty"] += item.quantity
sold[pid]["revenue"] += item.unit_price * item.quantity
if body.mode == "full":
# All active products, 0-sold included
all_products = db.query(Product).filter(Product.lifecycle_status == "active").order_by(Product.name).all()
report_items = sorted(
[{"name": p.name, "qty": sold.get(p.id, {}).get("qty", 0), "revenue": round(sold.get(p.id, {}).get("revenue", 0.0), 2)} for p in all_products],
key=lambda x: x["qty"], reverse=True,
)
else:
# Smart: only sold
products_db = {p.id: p for p in db.query(Product).all()}
report_items = sorted(
[{"name": (products_db[pid].name if pid in products_db else f"#{pid}"), "qty": v["qty"], "revenue": round(v["revenue"], 2)} for pid, v in sold.items()],
key=lambda x: x["qty"], reverse=True,
)
report = {
"mode": body.mode,
"period_label": period_label,
"period_is_workday": period_is_workday,
"items": report_items,
"line_width": printer.line_width,
"div_style": load_divider_style(db),
}
background_tasks.add_task(print_products_report, printer.ip_address, printer.port, report)
return {"status": "printing"}
@router.post("/print/categories")
def print_categories(
body: PrintAnalyticsBody,
background_tasks: BackgroundTasks,
db: Session = Depends(get_db),
user: User = Depends(require_manager),
):
from models.product import Product, Category
printer = db.query(Printer).filter(Printer.id == body.printer_id, Printer.is_active == True).first()
if not printer:
raise HTTPException(status_code=404, detail="Printer not found or inactive")
period_label, period_is_workday = _analytics_period(body, db)
q = db.query(OrderItem).filter(OrderItem.status.in_(["active", "paid"]))
if body.business_day_id:
q = q.join(Order).filter(Order.business_day_id == body.business_day_id)
elif body.from_dt and body.to_dt:
q = q.filter(
OrderItem.added_at >= datetime.fromisoformat(body.from_dt),
OrderItem.added_at <= datetime.fromisoformat(body.to_dt),
)
items = q.all()
products_db = {p.id: p for p in db.query(Product).all()}
categories_db = {c.id: c for c in db.query(Category).all()}
# summary[cid] = {name, units_sold, revenue, products: {pid: {name, qty, revenue}}}
summary: dict = {}
for item in items:
product = products_db.get(item.product_id)
if not product:
continue
cid = product.category_id
pid = item.product_id
if cid not in summary:
cat = categories_db.get(cid)
summary[cid] = {"name": cat.name if cat else f"#{cid}", "units_sold": 0, "revenue": 0.0, "products": {}}
summary[cid]["units_sold"] += item.quantity
summary[cid]["revenue"] += item.unit_price * item.quantity
if pid not in summary[cid]["products"]:
summary[cid]["products"][pid] = {"name": product.name, "qty": 0, "revenue": 0.0}
summary[cid]["products"][pid]["qty"] += item.quantity
summary[cid]["products"][pid]["revenue"] += item.unit_price * item.quantity
total_rev = sum(v["revenue"] for v in summary.values())
total_qty = sum(v["units_sold"] for v in summary.values())
cat_list = []
for v in sorted(summary.values(), key=lambda x: x["revenue"], reverse=True):
cat_total_qty = v["units_sold"]
cat_total_rev = v["revenue"]
products_list = sorted(
[{"name": p["name"], "qty": p["qty"], "revenue": round(p["revenue"], 2),
"pct_qty": round(p["qty"] / cat_total_qty * 100, 1) if cat_total_qty else 0,
"pct_rev": round(p["revenue"] / cat_total_rev * 100, 1) if cat_total_rev else 0}
for p in v["products"].values()],
key=lambda x: x["qty"], reverse=True,
)
cat_list.append({
"name": v["name"],
"units_sold": v["units_sold"],
"revenue": round(v["revenue"], 2),
"pct_rev": round(v["revenue"] / total_rev * 100, 1) if total_rev else 0,
"pct_qty": round(v["units_sold"] / total_qty * 100, 1) if total_qty else 0,
"products": products_list,
})
report = {
"mode": body.mode,
"period_label": period_label,
"period_is_workday": period_is_workday,
"categories": cat_list,
"line_width": printer.line_width,
"div_style": load_divider_style(db),
}
background_tasks.add_task(print_categories_report, printer.ip_address, printer.port, report)
return {"status": "printing"}
@router.post("/print/tables")
def print_tables(
body: PrintAnalyticsBody,
background_tasks: BackgroundTasks,
db: Session = Depends(get_db),
user: User = Depends(require_manager),
):
printer = db.query(Printer).filter(Printer.id == body.printer_id, Printer.is_active == True).first()
if not printer:
raise HTTPException(status_code=404, detail="Printer not found or inactive")
period_label, period_is_workday = _analytics_period(body, db)
q = db.query(Order).filter(Order.status.in_(["closed", "paid"]))
if body.business_day_id:
q = q.filter(Order.business_day_id == body.business_day_id)
elif body.from_dt and body.to_dt:
q = q.filter(
Order.opened_at >= datetime.fromisoformat(body.from_dt),
Order.opened_at <= datetime.fromisoformat(body.to_dt),
)
orders = q.all()
tables_db = {t.id: t for t in db.query(Table).all()}
summary: dict = {}
for order in orders:
tid = order.table_id
if tid not in summary:
t = tables_db.get(tid)
summary[tid] = {
"name": (t.label or f"T{t.number}") if t else f"#{tid}",
"order_count": 0, "revenue": 0.0, "durations": [],
}
summary[tid]["order_count"] += 1
summary[tid]["revenue"] += sum(i.unit_price * i.quantity for i in order.items if i.status in ("active", "paid"))
if order.closed_at and order.opened_at:
summary[tid]["durations"].append((order.closed_at - order.opened_at).total_seconds() / 60)
table_list = []
for entry in summary.values():
durations = entry.pop("durations")
entry["avg_duration_minutes"] = round(sum(durations) / len(durations), 1) if durations else None
entry["revenue"] = round(entry["revenue"], 2)
table_list.append(entry)
table_list.sort(key=lambda x: x["revenue"], reverse=True)
report = {
"mode": body.mode,
"period_label": period_label,
"period_is_workday": period_is_workday,
"tables": table_list,
"line_width": printer.line_width,
"div_style": load_divider_style(db),
}
background_tasks.add_task(print_tables_report, printer.ip_address, printer.port, report)
return {"status": "printing"}
# ---------------------------------------------------------------------------
# Shift history report
# ---------------------------------------------------------------------------
@@ -1148,6 +1442,28 @@ def meta_printers(
return {"printers": [{"id": p.id, "name": p.name} for p in printers]}
@router.get("/meta/products")
def meta_products(
db: Session = Depends(get_db),
user: User = Depends(require_manager),
):
from models.product import Product, Category
products = db.query(Product).filter(Product.lifecycle_status == "active").order_by(Product.name).all()
categories_db = {c.id: c.name for c in db.query(Category).all()}
return {
"products": [
{
"id": p.id,
"name": p.name,
"category_id": p.category_id,
"category_name": categories_db.get(p.category_id, ""),
"price": float(p.base_price),
}
for p in products
]
}
# ---------------------------------------------------------------------------
# CSV export endpoints
# ---------------------------------------------------------------------------

View File

@@ -0,0 +1,171 @@
from fastapi import APIRouter, Depends, HTTPException, status, Query
from sqlalchemy.orm import Session
from typing import List, Optional
from datetime import datetime, timezone
from database import get_db
from models.reservation import Reservation
from models.table import Table
from schemas.reservation import (
ReservationCreate, ReservationUpdate, ReservationStatusUpdate, ReservationOut,
)
from routers.deps import get_current_user, require_manager
from models.user import User
router = APIRouter()
VALID_STATUSES = {"pending", "arrived", "cancelled", "no_show"}
def _enrich(r: Reservation) -> ReservationOut:
out = ReservationOut.model_validate(r)
if r.table:
out.table_label = r.table.label or f"T{r.table.number}"
if r.created_by:
out.created_by_name = (
r.created_by.full_name or r.created_by.nickname or r.created_by.username
)
return out
@router.post("/", response_model=ReservationOut, status_code=status.HTTP_201_CREATED)
def create_reservation(
body: ReservationCreate,
db: Session = Depends(get_db),
user: User = Depends(get_current_user),
):
if body.table_id:
if not db.query(Table).filter(Table.id == body.table_id, Table.is_active == True).first():
raise HTTPException(status_code=400, detail="Table not found or inactive")
r = Reservation(
guest_name=body.guest_name,
party_size=body.party_size,
phone=body.phone,
email=body.email,
note=body.note,
reserved_for=body.reserved_for,
table_id=body.table_id,
source=body.source,
online_customer_id=body.online_customer_id,
created_by_user_id=user.id,
status="pending",
)
db.add(r)
db.commit()
db.refresh(r)
return _enrich(r)
@router.get("/", response_model=List[ReservationOut])
def list_reservations(
tab: Optional[str] = Query(None), # "upcoming" | "history" — if omitted returns all
date: Optional[str] = Query(None), # ISO date string YYYY-MM-DD to filter by day
db: Session = Depends(get_db),
user: User = Depends(get_current_user),
):
q = db.query(Reservation)
if date:
try:
day = datetime.fromisoformat(date).date()
except ValueError:
raise HTTPException(status_code=400, detail="Invalid date format")
from sqlalchemy import func
q = q.filter(func.date(Reservation.reserved_for) == day)
if tab == "upcoming":
# pending + no_show (actionable), ordered soonest first
q = q.filter(Reservation.status.in_(["pending", "no_show"]))
q = q.order_by(Reservation.reserved_for.asc())
elif tab == "history":
# arrived, cancelled, plus no_show/pending that are clearly in the past
now = datetime.now(timezone.utc)
q = q.filter(
(Reservation.status.in_(["arrived", "cancelled"])) |
(
Reservation.status.in_(["no_show", "pending"]) &
(Reservation.reserved_for < now)
)
)
q = q.order_by(Reservation.reserved_for.desc())
else:
q = q.order_by(Reservation.reserved_for.asc())
reservations = q.all()
return [_enrich(r) for r in reservations]
@router.get("/{reservation_id}", response_model=ReservationOut)
def get_reservation(
reservation_id: int,
db: Session = Depends(get_db),
user: User = Depends(get_current_user),
):
r = db.query(Reservation).filter(Reservation.id == reservation_id).first()
if not r:
raise HTTPException(status_code=404, detail="Reservation not found")
return _enrich(r)
@router.patch("/{reservation_id}", response_model=ReservationOut)
def update_reservation(
reservation_id: int,
body: ReservationUpdate,
db: Session = Depends(get_db),
user: User = Depends(get_current_user),
):
r = db.query(Reservation).filter(Reservation.id == reservation_id).first()
if not r:
raise HTTPException(status_code=404, detail="Reservation not found")
updates = body.model_dump(exclude_none=True)
if "table_id" in updates and updates["table_id"] is not None:
if not db.query(Table).filter(Table.id == updates["table_id"], Table.is_active == True).first():
raise HTTPException(status_code=400, detail="Table not found or inactive")
if "status" in updates and updates["status"] not in VALID_STATUSES:
raise HTTPException(status_code=400, detail=f"Invalid status: {updates['status']}")
for field, value in updates.items():
setattr(r, field, value)
r.updated_at = datetime.now(timezone.utc)
db.commit()
db.refresh(r)
return _enrich(r)
@router.patch("/{reservation_id}/status", response_model=ReservationOut)
def update_reservation_status(
reservation_id: int,
body: ReservationStatusUpdate,
db: Session = Depends(get_db),
user: User = Depends(get_current_user),
):
if body.status not in VALID_STATUSES:
raise HTTPException(status_code=400, detail=f"Invalid status: {body.status}")
r = db.query(Reservation).filter(Reservation.id == reservation_id).first()
if not r:
raise HTTPException(status_code=404, detail="Reservation not found")
r.status = body.status
r.updated_at = datetime.now(timezone.utc)
db.commit()
db.refresh(r)
return _enrich(r)
@router.delete("/{reservation_id}", status_code=status.HTTP_204_NO_CONTENT)
def delete_reservation(
reservation_id: int,
db: Session = Depends(get_db),
user: User = Depends(require_manager),
):
r = db.query(Reservation).filter(Reservation.id == reservation_id).first()
if not r:
raise HTTPException(status_code=404, detail="Reservation not found")
db.delete(r)
db.commit()

View File

@@ -27,6 +27,7 @@ VALID_SETTINGS = {
# Print layout
"print.ticket_mode": "Kitchen ticket layout mode: 'detailed' or 'compact'",
"print.divider_style": "Divider character used between sections: dash, equals, star, or empty",
"print.dot_style": "Dot-leader style for item lines: STYLE:SIZE:BOLD where STYLE is dot_space|dot|dash_space|underscore, SIZE is ESC ! base byte (0/16/32/48), BOLD 0|1",
# Print font settings — values are "SIZE:BOLD:CAPS" where SIZE is ESC ! base byte (0/16/32/48), BOLD 0|1, CAPS 0|1
"print.font_order_number": "Font for order number header: SIZE:BOLD:CAPS",
"print.font_meta": "Font for table/waiter/time header block: SIZE:BOLD:CAPS",
@@ -53,10 +54,11 @@ DEFAULTS = {
"shifts.waiter_self_end": "true",
"business_day.force_close_allowed": "true",
"system.timezone": "Europe/Athens",
"ui.table_colours": "",
"ui.table_colours": '{"light":{"free":{"cardBg":"#dde5ef","badgeBg":"rgba(255,255,255,0.92)","nameText":"#3d5270","badgeText":"#3d5270"},"mine":{"cardBg":"#e8610a","badgeBg":"rgba(255,255,255,0.92)","nameText":"#ffffff","badgeText":"#e8610a"},"open":{"cardBg":"#FF8F60","badgeBg":"rgba(255,255,255,0.92)","nameText":"#ffffff","badgeText":"#FF8F60"},"partially_paid":{"cardBg":"#FFDC67","badgeBg":"rgba(255,255,255,0.92)","nameText":"#ffffff","badgeText":"#d4a800"},"paid":{"cardBg":"#81D264","badgeBg":"rgba(255,255,255,0.92)","nameText":"#ffffff","badgeText":"#81D264"}},"dark":{"free":{"cardBg":"#243044","badgeBg":"rgba(255,255,255,0.92)","nameText":"#94b8d4","badgeText":"#94b8d4"},"mine":{"cardBg":"#e8610a","badgeBg":"rgba(255,255,255,0.92)","nameText":"#ffffff","badgeText":"#e8610a"},"open":{"cardBg":"#FF8F60","badgeBg":"rgba(255,255,255,0.92)","nameText":"#ffffff","badgeText":"#FF8F60"},"partially_paid":{"cardBg":"#FFDC67","badgeBg":"rgba(255,255,255,0.92)","nameText":"#ffffff","badgeText":"#d4a800"},"paid":{"cardBg":"#81D264","badgeBg":"rgba(255,255,255,0.92)","nameText":"#ffffff","badgeText":"#81D264"}}}',
"dev.spoof_printing": "false",
"print.ticket_mode": "detailed",
"print.divider_style": "dash",
"print.dot_style": "dot_space:0:0",
"print.font_order_number": "48:1:0",
"print.font_meta": "0:0:0",
"print.font_item_name": "16:1:0",

View File

@@ -356,13 +356,15 @@ def get_shift_summary(
OrderItem.status == "paid",
).all()
from datetime import timezone as tz
upper_bound = shift.ended_at or datetime.now(tz.utc)
ordered_items = db.query(OrderItem).options(
joinedload(OrderItem.product),
joinedload(OrderItem.order),
).filter(
OrderItem.added_by == waiter_id,
OrderItem.added_at >= shift.started_at,
OrderItem.added_at <= shift.ended_at,
OrderItem.added_at <= upper_bound,
(OrderItem.paid_in_shift_id != shift_id) | (OrderItem.paid_in_shift_id == None),
).all()

View File

@@ -129,7 +129,7 @@ def test_order_print(printer_id: int, db: Session = Depends(get_db), user: User
printer = db.query(Printer).filter(Printer.id == printer_id).first()
if not printer:
raise HTTPException(status_code=404, detail="Printer not found")
success, error = printer_service.send_test_order_print(printer.ip_address, printer.port, db)
success, error = printer_service.send_test_order_print(printer.ip_address, printer.port, db, printer.line_width)
return {"success": success, "error": error}

View File

@@ -1,19 +1,24 @@
from fastapi import APIRouter, Depends, HTTPException, status, Body
from sqlalchemy.orm import Session
from typing import List
from datetime import datetime, timezone, timedelta
from database import get_db
from models.table import Table, TableGroup
from models.order import Order
from models.reservation import Reservation
from models.user import User, WaiterZone
from schemas.table import (
TableCreate, TableUpdate, TableFloorplanUpdate, TableOut,
TableCreate, TableUpdate, TableFloorplanUpdate, TableOut, UpcomingReservation,
TableGroupCreate, TableGroupUpdate, TableGroupOut,
TableBatchCreate, MAX_TABLE_NAME_LENGTH,
)
from routers.deps import get_current_user, require_manager
from services.sse_bus import broadcast_sync
# Tables with a pending reservation due within this many hours get the RESERVED badge
RESERVATION_LOOKAHEAD_HOURS = 4
router = APIRouter()
@@ -98,10 +103,32 @@ def list_tables(include_inactive: bool = False, db: Session = Depends(get_db), u
).all()
}
# Fetch pending reservations due within the lookahead window for the RESERVED badge
now = datetime.now(timezone.utc)
lookahead = now + timedelta(hours=RESERVATION_LOOKAHEAD_HOURS)
upcoming_reservations = (
db.query(Reservation)
.filter(
Reservation.status == "pending",
Reservation.table_id.isnot(None),
Reservation.reserved_for >= now,
Reservation.reserved_for <= lookahead,
)
.all()
)
# Map table_id → soonest upcoming reservation
reservation_by_table: dict[int, Reservation] = {}
for res in sorted(upcoming_reservations, key=lambda r: r.reserved_for):
if res.table_id not in reservation_by_table:
reservation_by_table[res.table_id] = res
result = []
for t in tables:
out = TableOut.model_validate(t)
out.has_active_order = t.id in active_table_ids
res = reservation_by_table.get(t.id)
if res:
out.upcoming_reservation = UpcomingReservation.model_validate(res)
result.append(out)
return result

View File

@@ -52,10 +52,14 @@ def create_waiter(body: UserCreate, db: Session = Depends(get_db), user: User =
full_name=body.full_name,
nickname=body.nickname,
mobile_phone=body.mobile_phone,
email=body.email,
note=body.note,
)
db.add(new_user)
db.commit()
db.refresh(new_user)
db.add(WaiterZone(waiter_id=new_user.id, group_id=None))
db.commit()
return new_user

View File

@@ -11,6 +11,7 @@ class PrinterBase(BaseModel):
port: int = 9100
is_active: bool = True
protocol: str = "escpos_tcp"
line_width: int = 48
class PrinterCreate(PrinterBase):
@@ -23,6 +24,7 @@ class PrinterUpdate(BaseModel):
port: Optional[int] = None
is_active: Optional[bool] = None
protocol: Optional[str] = None
line_width: Optional[int] = None
class PrinterOut(PrinterBase):

View File

@@ -257,6 +257,7 @@ class PreferenceSetOut(BaseModel):
class ProductBase(BaseModel):
name: str
description: Optional[str] = None
category_id: Optional[int] = None
base_price: float
is_available: bool = True
@@ -269,7 +270,7 @@ class ProductBase(BaseModel):
digital_name: Optional[str] = None
digital_description: Optional[str] = None
digital_price: Optional[float] = None
digital_discount: float = 0.0
digital_discount: Optional[float] = None
digital_image_url: Optional[str] = None
@@ -282,6 +283,7 @@ class ProductCreate(ProductBase):
class ProductUpdate(BaseModel):
name: Optional[str] = None
description: Optional[str] = None
category_id: Optional[int] = None
base_price: Optional[float] = None
is_available: Optional[bool] = None

View File

@@ -0,0 +1,93 @@
from pydantic import BaseModel, field_validator
from typing import Optional
from datetime import datetime, timezone
class ReservationCreate(BaseModel):
guest_name: str
party_size: int
phone: Optional[str] = None
email: Optional[str] = None
note: Optional[str] = None
reserved_for: datetime
table_id: Optional[int] = None
source: str = "manager_app"
online_customer_id: Optional[str] = None
@field_validator("reserved_for", mode="before")
@classmethod
def strip_timezone(cls, v):
if isinstance(v, str):
dt = datetime.fromisoformat(v.replace("Z", "+00:00"))
elif isinstance(v, datetime):
dt = v
else:
return v
# Store as naive local — strip any tz info so SQLite keeps it as-is
if dt.tzinfo is not None:
dt = dt.astimezone().replace(tzinfo=None)
return dt
class ReservationUpdate(BaseModel):
guest_name: Optional[str] = None
party_size: Optional[int] = None
phone: Optional[str] = None
email: Optional[str] = None
note: Optional[str] = None
reserved_for: Optional[datetime] = None
table_id: Optional[int] = None
status: Optional[str] = None
@field_validator("reserved_for", mode="before")
@classmethod
def strip_timezone(cls, v):
if v is None:
return v
if isinstance(v, str):
dt = datetime.fromisoformat(v.replace("Z", "+00:00"))
elif isinstance(v, datetime):
dt = v
else:
return v
if dt.tzinfo is not None:
dt = dt.astimezone().replace(tzinfo=None)
return dt
class ReservationStatusUpdate(BaseModel):
status: str # arrived | cancelled | no_show | pending
class ReservationOut(BaseModel):
id: int
guest_name: str
party_size: int
phone: Optional[str] = None
email: Optional[str] = None
note: Optional[str] = None
reserved_for: datetime
table_id: Optional[int] = None
status: str
source: str
created_by_user_id: Optional[int] = None
online_customer_id: Optional[str] = None
created_at: datetime
updated_at: datetime
# Denormalized for convenience
table_label: Optional[str] = None
created_by_name: Optional[str] = None
model_config = {"from_attributes": True}
# Compact summary embedded in TableOut for the RESERVED badge
class ReservationSummary(BaseModel):
id: int
guest_name: str
party_size: int
reserved_for: datetime
status: str
model_config = {"from_attributes": True}

View File

@@ -1,5 +1,6 @@
from pydantic import BaseModel, field_validator
from typing import Optional, List
from datetime import datetime
MAX_TABLE_NAME_LENGTH = 6
@@ -73,6 +74,16 @@ class TableFloorplanUpdate(BaseModel):
floor_y: float
class UpcomingReservation(BaseModel):
id: int
guest_name: str
party_size: int
reserved_for: datetime
status: str
model_config = {"from_attributes": True}
class TableOut(BaseModel):
id: int
number: int
@@ -83,5 +94,6 @@ class TableOut(BaseModel):
floor_y: Optional[float] = None
group: Optional[TableGroupOut] = None
has_active_order: bool = False
upcoming_reservation: Optional[UpcomingReservation] = None
model_config = {"from_attributes": True}

View File

@@ -11,6 +11,7 @@ class UserBase(BaseModel):
full_name: Optional[str] = None
nickname: Optional[str] = None
mobile_phone: Optional[str] = None
note: Optional[str] = None
avatar_url: Optional[str] = None
email: Optional[str] = None
@@ -26,6 +27,8 @@ class UserUpdate(BaseModel):
full_name: Optional[str] = None
nickname: Optional[str] = None
mobile_phone: Optional[str] = None
email: Optional[str] = None
note: Optional[str] = None
class WaiterZoneOut(BaseModel):

View File

@@ -26,6 +26,7 @@ from middleware.license_check import license_state
ORDER_POLL_INTERVAL = settings.CONNECT_SYNC_INTERVAL_SECONDS
logging.basicConfig(level=logging.INFO)
logger = logging.getLogger(__name__)
SYNC_INTERVAL_SECONDS = 5 * 60 # 5 minutes

View File

@@ -57,6 +57,7 @@ _DIVIDER_CHARS = {
_PRINT_SETTING_KEYS = [
"print.ticket_mode",
"print.divider_style",
"print.dot_style",
"print.font_order_number",
"print.font_meta",
"print.font_item_name",
@@ -73,6 +74,7 @@ _PRINT_SETTING_KEYS = [
_PRINT_SETTING_DEFAULTS = {
"print.ticket_mode": "detailed",
"print.divider_style": "dash",
"print.dot_style": "dot_space:0:0",
"print.font_order_number": "48:1:0",
"print.font_meta": "0:0:0",
"print.font_item_name": "16:1:0",
@@ -86,6 +88,16 @@ _PRINT_SETTING_DEFAULTS = {
"print.beep_pattern": "double",
}
# dot_style → (fill_unit, needs_padding)
# fill_unit is repeated to fill available space; needs_padding forces a space
# before and after the dot region so it never touches the name or multiplier.
_DOT_STYLE_UNITS = {
"dot_space": ". ",
"dot": ".",
"dash_space": "- ",
"underscore": "_",
}
# Beep patterns: (n1=on×100ms, n2=off×100ms, n3=count)
# Using ESC BEL n1 n2 n3 (0x1B 0x07 n1 n2 n3)
_BEEP_PATTERNS = {
@@ -124,26 +136,79 @@ def _load_print_settings(db: Session) -> dict:
return settings
def _divider(p: Network, style: str = "dash"):
def load_divider_style(db: Session) -> str:
"""Public helper — returns the configured divider style string."""
row = db.query(PosSettings).filter(PosSettings.key == "print.divider_style").first()
return row.value if row else _PRINT_SETTING_DEFAULTS["print.divider_style"]
def _divider(p: Network, style: str = "dash", line_width: int = LINE_WIDTH):
char = _DIVIDER_CHARS.get(style, "-")
p._raw(b'\x1b\x61\x00')
if char:
p._raw(_gr(char * LINE_WIDTH + "\n"))
p._raw(_gr(char * line_width + "\n"))
else:
p._raw(b'\n')
def _item_line(name: str, qty: int, line_width: int = LINE_WIDTH) -> str:
"""Build a dot-leader line ending with 'xN'.
line_width must reflect the effective width at the chosen font size
(double-width fonts halve the available char count to 24)."""
suffix = f"x{qty}"
available = line_width - len(name) - len(suffix)
if available < 2:
# Name alone is too long — put qty on same line with a single space
return f"{name} {suffix}"
dots = (". " * ((available // 2) + 1))[:available]
return f"{name}{dots}{suffix}"
def _item_line(name: str, qty: int, line_width: int = LINE_WIDTH, dot_style: str = "dot_space") -> str:
"""Simple single-font dot-leader string — used by receipt and synopsis printers."""
suffix = f" x{qty}"
available = line_width - len(name) - 1 - len(suffix)
if available < 1:
return f"{name} x{qty}"
unit = _DOT_STYLE_UNITS.get(dot_style, ". ")
dots = (unit * (available // len(unit) + 1))[:available]
return f"{name} {dots}{suffix}"
def _print_item_line(
p: Network,
name: str, qty: int,
sz_name: int, b_name: bool,
dot_fill: str, dot_sz: int, dot_bold: bool,
line_width: int = LINE_WIDTH,
):
"""Print one item line as three segments with independent fonts:
[name font] name· [dot font] ····· [name font] ·xN\\n
Column widths are computed in physical printer columns (each double-width
char occupies 2 columns) so mixed font sizes stay aligned."""
suffix = f" x{qty}"
# Physical column width of each character under each font
name_char_w = 2 if sz_name in (32, 48) else 1
dot_char_w = 2 if dot_sz in (32, 48) else 1
# Columns consumed by name (with trailing space) and suffix
name_cols = (len(name) + 1) * name_char_w # +1 for the mandatory gap space
suffix_cols = len(suffix) * name_char_w
available_cols = line_width - name_cols - suffix_cols
if available_cols < dot_char_w:
# No room for even one dot — fall back to plain line in name font
_apply_font(p, sz_name, b_name)
_raw_text(p, f"{name} x{qty}\n")
_reset_font(p)
return
# How many dot characters fit in the available columns
n_dots = available_cols // dot_char_w
unit = _DOT_STYLE_UNITS.get(dot_fill, ". ")
dots = (unit * (n_dots // len(unit) + 1))[:n_dots]
# Emit: [name] [space] in name font
_apply_font(p, sz_name, b_name)
_raw_text(p, name + " ")
# Emit: dots in dot font
_apply_font(p, dot_sz, dot_bold)
_raw_text(p, dots)
# Emit: [space][xN]\n back in name font
_apply_font(p, sz_name, b_name)
_raw_text(p, suffix + "\n")
_reset_font(p)
def _apply_font(p: Network, size: int, bold: bool):
@@ -228,7 +293,7 @@ def send_test_print(ip: str, port: int, name: str) -> Tuple[bool, str]:
return False, str(e)
def send_test_order_print(ip: str, port: int, db: Session) -> Tuple[bool, str]:
def send_test_order_print(ip: str, port: int, db: Session, line_width: int = LINE_WIDTH) -> Tuple[bool, str]:
"""Print a fake order using the current font/layout settings — for settings preview."""
if _is_spoof_mode(db):
logger.info("Spoof printing ON — dropping test order print")
@@ -343,7 +408,7 @@ def send_test_order_print(ip: str, port: int, db: Session) -> Tuple[bool, str]:
try:
p = _get_printer(ip, port)
_print_kitchen_ticket(p, _Order(), items, _PatchedDB())
_print_kitchen_ticket(p, _Order(), items, _PatchedDB(), line_width)
p.close()
return True, ""
except Exception as e:
@@ -415,7 +480,7 @@ def _parse_options(item: OrderItem) -> dict:
return result
def _print_kitchen_ticket(p: Network, order: Order, items: List[OrderItem], db: Session):
def _print_kitchen_ticket(p: Network, order: Order, items: List[OrderItem], db: Session, line_width: int = LINE_WIDTH):
cfg = _load_print_settings(db)
mode = cfg.get("print.ticket_mode", "detailed")
div = cfg.get("print.divider_style", "dash")
@@ -443,24 +508,28 @@ def _print_kitchen_ticket(p: Network, order: Order, items: List[OrderItem], db:
header = f"Παρ. #{order.id} | Τρ. {table_name} | {now_str} | {waiter_nick}"
_raw_text(p, (header.upper() if c_ord else header) + "\n")
_reset_font(p)
_divider(p, div)
_divider(p, div, line_width)
# ── DETAILED header ──────────────────────────────────────────────────────
else:
_print_line(p, f"Παραγγελια #{order.id}", sz_ord, b_ord, c_ord,
align=b'\x1b\x61\x01')
_divider(p, div)
_divider(p, div, line_width)
p._raw(b'\x1b\x61\x00')
_apply_font(p, sz_meta, b_meta)
_raw_text(p, ("ΤΡΑΠΕΖΙ:" if c_meta else "Τραπεζι:") + f" Τραπεζι {table_name}\n")
_raw_text(p, ("ΗΜΕΡΟΜΗΝΙΑ:" if c_meta else "Ημερομηνια:") + f" {now_str}\n")
_raw_text(p, ("ΣΕΡΒΙΤΟΡΟΣ:" if c_meta else "Σερβιτορος:") + f" {waiter_nick}\n")
_reset_font(p)
_divider(p, div)
_divider(p, div, line_width)
# ── Items ────────────────────────────────────────────────────────────────
# Double-width fonts halve the effective character width
item_line_width = LINE_WIDTH // 2 if sz_item in (32, 48) else LINE_WIDTH
# Dot-leader style: "STYLE:SIZE:BOLD"
raw_dot = cfg.get("print.dot_style", "dot_space:0:0")
dot_parts = raw_dot.split(":")
dot_fill = dot_parts[0] if dot_parts[0] in _DOT_STYLE_UNITS else "dot_space"
dot_sz = int(dot_parts[1]) if len(dot_parts) > 1 and dot_parts[1].isdigit() else 0
dot_bold = (dot_parts[2] == "1") if len(dot_parts) > 2 else False
for item in items:
product = db.query(Product).filter(Product.id == item.product_id).first()
@@ -468,9 +537,12 @@ def _print_kitchen_ticket(p: Network, order: Order, items: List[OrderItem], db:
item_name = raw_name.upper() if c_item else raw_name
p._raw(b'\x1b\x61\x00')
_apply_font(p, sz_item, b_item)
_raw_text(p, _item_line(item_name, item.quantity, item_line_width) + "\n")
_reset_font(p)
_print_item_line(
p, item_name, item.quantity,
sz_item, b_item,
dot_fill, dot_sz, dot_bold,
line_width,
)
opts = _parse_options(item)
@@ -572,7 +644,7 @@ def _print_kitchen_ticket(p: Network, order: Order, items: List[OrderItem], db:
if not compact:
p._raw(b'\n')
_divider(p, div)
_divider(p, div, line_width)
# Order-level notes
if order.notes:
@@ -581,7 +653,7 @@ def _print_kitchen_ticket(p: Network, order: Order, items: List[OrderItem], db:
_raw_text(p, f"Σημ: {note_text}\n")
_reset_font(p)
if not compact:
_divider(p, div)
_divider(p, div, line_width)
# Footer (detailed only)
if not compact:
@@ -658,7 +730,7 @@ def print_waiter_report(ip: str, port: int, report: dict, mode: str):
value = f"{od['total']:.2f}e"
times_part = f"{time_open} - {time_close}" if time_close else time_open
prefix = f"{times_part} - {table}"
gap = LINE_WIDTH - len(prefix) - len(value)
gap = LINE_WIDTH - len(prefix) - len(value) # reports always use default width
if gap < 3:
line = f"{prefix} {value}"
else:
@@ -673,61 +745,145 @@ def print_waiter_report(ip: str, port: int, report: dict, mode: str):
logger.error("print_waiter_report failed for %s:%s%s", ip, port, e)
def _dot_leader_line(left: str, right: str, lw: int) -> str:
"""Build a plain dot-leader line: 'left . . . . right' fitting in lw chars."""
gap = lw - len(left) - len(right)
if gap < 2:
return f"{left} {right}"
dots = (". " * (gap // 2 + 1))[:gap]
return f"{left}{dots}{right}"
def _printer_report_block(p, block: dict, mode: str, lw: int, div: str):
"""Print a single printer's block onto an already-open printer connection."""
_divider(p, div, lw)
p._raw(b'\x1b\x61\x00')
p._raw(b'\x1b\x21\x10')
_raw_text(p, f"Εκτυπωτης: {block['printer_name']}\n")
p._raw(b'\x1b\x21\x00')
_divider(p, div, lw)
# Stats in double-height (no bold)
p._raw(b'\x1b\x21\x10')
_raw_text(p, f"Εκτυπωσεις: {block['print_jobs']}\n")
_raw_text(p, f"Παραγγελιες: {block['orders']}\n")
_raw_text(p, f"Αντικειμενα: {block['items']}\n")
_raw_text(p, f"Αξια: {block['total']:.2f}e\n")
p._raw(b'\x1b\x21\x00')
if mode in ("orders", "detailed") and block.get("order_data"):
_divider(p, div, lw)
for od in block["order_data"]:
# Order header line: plain font, dot leader
prefix = f"#{od['id']} {od['table']}"
value = f"{od['total']:.2f}e"
header_line = _dot_leader_line(prefix, value, lw)
# No bold — plain normal font
p._raw(b'\x1b\x21\x00')
p._raw(b'\x1b\x45\x00')
_raw_text(p, header_line + "\n")
if mode == "detailed":
for item in od.get("items", []):
# " qty x Name . . . . total_price"
qty = item["quantity"]
name = item["name"]
total_val = item.get("total", 0.0)
left = f" {qty}x {name}"
right = f"{total_val:.2f}e"
_raw_text(p, _dot_leader_line(left, right, lw) + "\n")
def _printer_item_breakdown(p, blocks: list, lw: int, div: str):
"""Print the product analysis section (combined across all blocks)."""
# Merge breakdown across all printer blocks
combined: dict = {}
for block in blocks:
for entry in block.get("item_breakdown", []):
name = entry["name"]
if name not in combined:
combined[name] = {"qty": 0, "value": 0.0}
combined[name]["qty"] += entry["qty"]
combined[name]["value"] = round(combined[name]["value"] + entry["value"], 2)
if not combined:
return
_divider(p, div, lw)
p._raw(b'\x1b\x61\x01')
p._raw(b'\x1b\x21\x10')
_raw_text(p, "ΑΝΑΛΥΣΗ ΠΡΟΙΟΝΤΩΝ\n")
p._raw(b'\x1b\x21\x00')
p._raw(b'\x1b\x61\x00')
_divider(p, div, lw)
sorted_items = sorted(combined.items(), key=lambda x: x[1]["qty"], reverse=True)
for name, data in sorted_items:
line = _dot_leader_line(name, str(data["qty"]), lw)
_raw_text(p, line + "\n")
def print_printer_report(ip: str, port: int, report: dict, mode: str):
"""Print a per-printer totals report. mode='simple'|'extensive'."""
"""Print a printer history report. report contains printer_blocks list."""
if is_spoof_mode():
logger.info("Spoof printing ON — dropping printer report print")
return
try:
lw = report.get("line_width", LINE_WIDTH)
div = report.get("div_style", "dash")
p = _get_printer(ip, port)
p._raw(b'\x1b\x61\x01')
p._raw(b'\x1b\x21\x30')
_raw_text(p, "ΑΝΑΦΟΡΑ ΕΚΤΥΠΩΤΗ\n")
p._raw(b'\x1b\x21\x00')
_divider(p)
p._raw(b'\x1b\x61\x00')
p._raw(b'\x1b\x21\x10')
_raw_text(p, f"Εκτυπωτης: {report['printer_name']}\n")
_raw_text(p, f"Απο: {report['from_dt']}\n")
_raw_text(p, f"Εως: {report['to_dt']}\n")
p._raw(b'\x1b\x21\x00')
_divider(p)
p._raw(b'\x1b\x21\x10')
_raw_text(p, f"Εργασιες εκτ.: {report['print_jobs']}\n")
_raw_text(p, f"Παραγγελιες: {report['orders']}\n")
_raw_text(p, f"Αντικειμενα: {report['items']}\n")
p._raw(b'\x1b\x21\x00')
_divider(p)
p._raw(b'\x1b\x61\x01')
p._raw(b'\x1b\x21\x30')
_raw_text(p, f"ΣΥΝΟΛΟ: {report['total']:.2f}e\n")
period = report.get("period_label", "")
if report.get("period_is_workday"):
_raw_text(p, f"Εργασιμη: {period}\n")
else:
_raw_text(p, f"Περιοδος: {period}\n")
p._raw(b'\x1b\x21\x00')
if mode == "extensive" and report.get("order_data"):
_divider(p)
blocks = report.get("printer_blocks", [])
for block in blocks:
_printer_report_block(p, block, mode, lw, div)
# Grand total footer
if len(blocks) > 1:
_divider(p, div, lw)
p._raw(b'\x1b\x61\x01')
p._raw(b'\x1b\x21\x10')
_raw_text(p, "ΣΥΝΟΛΟ ΟΛΩΝ\n")
p._raw(b'\x1b\x21\x00')
p._raw(b'\x1b\x61\x00')
_raw_text(p, "ΑΝΑΛΥΤΙΚΑ\n")
_divider(p)
for od in report["order_data"]:
# Header line: "HH:MM - TABLE . . . . 9.99e"
prefix = f"{od['time']} - {od['table']}"
value = f"{od['total']:.2f}e"
gap = LINE_WIDTH - len(prefix) - len(value)
if gap < 3:
header_line = f"{prefix} {value}"
else:
dots = (". " * ((gap // 2) + 1))[:gap]
header_line = f"{prefix}{dots}{value}"
p._raw(b'\x1b\x45\x01')
_raw_text(p, header_line + "\n")
p._raw(b'\x1b\x45\x00')
# Indented items
for item in od.get("items", []):
_raw_text(p, f" {item['quantity']} x {item['name']}\n")
total_jobs = sum(b["print_jobs"] for b in blocks)
total_orders = sum(b["orders"] for b in blocks)
total_items = sum(b["items"] for b in blocks)
total_value = sum(b["total"] for b in blocks)
p._raw(b'\x1b\x21\x10')
_raw_text(p, f"Εκτυπωσεις: {total_jobs}\n")
_raw_text(p, f"Παραγγελιες: {total_orders}\n")
_raw_text(p, f"Αντικειμενα: {total_items}\n")
p._raw(b'\x1b\x21\x00')
_divider(p, div, lw)
p._raw(b'\x1b\x61\x01')
p._raw(b'\x1b\x21\x30')
_raw_text(p, f"ΣΥΝΟΛΟ: {total_value:.2f}e\n")
p._raw(b'\x1b\x21\x00')
elif len(blocks) == 1:
_divider(p, div, lw)
p._raw(b'\x1b\x61\x01')
p._raw(b'\x1b\x21\x30')
_raw_text(p, f"ΣΥΝΟΛΟ: {blocks[0]['total']:.2f}e\n")
p._raw(b'\x1b\x21\x00')
# Optional product breakdown section
if report.get("item_breakdown") and blocks:
_printer_item_breakdown(p, blocks, lw, div)
p._raw(b'\n\n\n')
p.cut()
@@ -736,7 +892,7 @@ def print_printer_report(ip: str, port: int, report: dict, mode: str):
logger.error("print_printer_report failed for %s:%s%s", ip, port, e)
def print_order_receipt(ip: str, port: int, receipt: dict):
def print_order_receipt(ip: str, port: int, receipt: dict, line_width: int = LINE_WIDTH):
"""Print a manager-triggered order receipt."""
if is_spoof_mode():
logger.info("Spoof printing ON — dropping order receipt print")
@@ -748,7 +904,7 @@ def print_order_receipt(ip: str, port: int, receipt: dict):
p._raw(b'\x1b\x21\x30')
_raw_text(p, f"ΠΑΡΑΓΓΕΛΙΑ #{receipt['order_id']}\n")
p._raw(b'\x1b\x21\x00')
_divider(p)
_divider(p, line_width=line_width)
p._raw(b'\x1b\x61\x00')
p._raw(b'\x1b\x21\x10')
@@ -758,21 +914,21 @@ def print_order_receipt(ip: str, port: int, receipt: dict):
if receipt.get("closed_at"):
_raw_text(p, f"Εκλεισε: {receipt['closed_at']}\n")
p._raw(b'\x1b\x21\x00')
_divider(p)
_divider(p, line_width=line_width)
for item in receipt.get("items", []):
p._raw(b'\x1b\x21\x10')
_raw_text(p, _item_line(item["name"], item["quantity"]) + "\n")
_raw_text(p, _item_line(item["name"], item["quantity"], line_width=line_width) + "\n")
p._raw(b'\x1b\x21\x00')
_raw_text(p, f" {item['unit_price']:.2f}e x{item['quantity']} = {item['total']:.2f}e\n")
_divider(p)
_divider(p, line_width=line_width)
if receipt.get("notes"):
p._raw(b'\x1b\x21\x10')
_raw_text(p, f"Σημ: {receipt['notes']}\n")
p._raw(b'\x1b\x21\x00')
_divider(p)
_divider(p, line_width=line_width)
p._raw(b'\x1b\x61\x01')
p._raw(b'\x1b\x21\x30')
@@ -786,7 +942,7 @@ def print_order_receipt(ip: str, port: int, receipt: dict):
logger.error("print_order_receipt failed for %s:%s%s", ip, port, e)
def print_order_synopsis(ip: str, port: int, synopsis: dict):
def print_order_synopsis(ip: str, port: int, synopsis: dict, line_width: int = LINE_WIDTH):
"""Print a waiter-triggered order synopsis (not a kitchen ticket)."""
if is_spoof_mode():
logger.info("Spoof printing ON — dropping order synopsis print")
@@ -798,7 +954,7 @@ def print_order_synopsis(ip: str, port: int, synopsis: dict):
p._raw(b'\x1b\x21\x30')
_raw_text(p, "ΣΥΝΟΨΗ ΠΑΡΑΓΓΕΛΙΑΣ\n")
p._raw(b'\x1b\x21\x00')
_divider(p)
_divider(p, line_width=line_width)
p._raw(b'\x1b\x61\x00')
p._raw(b'\x1b\x21\x10')
@@ -806,7 +962,7 @@ def print_order_synopsis(ip: str, port: int, synopsis: dict):
_raw_text(p, f"Σερβιτορος: {synopsis['waiter_name']}\n")
_raw_text(p, f"Ωρα: {synopsis['opened_at']}\n")
p._raw(b'\x1b\x21\x00')
_divider(p)
_divider(p, line_width=line_width)
paid_items = [i for i in synopsis.get("items", []) if i["status"] == "paid"]
active_items = [i for i in synopsis.get("items", []) if i["status"] == "active"]
@@ -816,18 +972,18 @@ def print_order_synopsis(ip: str, port: int, synopsis: dict):
_raw_text(p, "ΕΚΚΡΕΜΗ:\n")
p._raw(b'\x1b\x21\x00')
for item in active_items:
_raw_text(p, f" {_item_line(item['name'], item['quantity'])}\n")
_raw_text(p, f" {_item_line(item['name'], item['quantity'], line_width=line_width - 2)}\n")
_raw_text(p, f" {item['unit_price']:.2f}e x{item['quantity']} = {item['total']:.2f}e\n")
_divider(p)
_divider(p, line_width=line_width)
if paid_items:
p._raw(b'\x1b\x21\x10')
_raw_text(p, "ΠΛΗΡΩΜΕΝΑ:\n")
p._raw(b'\x1b\x21\x00')
for item in paid_items:
_raw_text(p, f" {_item_line(item['name'], item['quantity'])}\n")
_raw_text(p, f" {_item_line(item['name'], item['quantity'], line_width=line_width - 2)}\n")
_raw_text(p, f" {item['unit_price']:.2f}e x{item['quantity']} = {item['total']:.2f}e\n")
_divider(p)
_divider(p, line_width=line_width)
p._raw(b'\x1b\x61\x01')
p._raw(b'\x1b\x21\x30')
@@ -845,6 +1001,189 @@ def print_order_synopsis(ip: str, port: int, synopsis: dict):
logger.error("print_order_synopsis failed for %s:%s%s", ip, port, e)
# ── Analytical report prints (products / categories / tables) ─────────────────
def print_products_report(ip: str, port: int, report: dict):
"""Print a product sales report. mode='smart'(sold only) or 'full'(all products)."""
if is_spoof_mode():
logger.info("Spoof printing ON — dropping products report print")
return
try:
lw = report.get("line_width", LINE_WIDTH)
div = report.get("div_style", "dash")
mode = report.get("mode", "smart")
p = _get_printer(ip, port)
p._raw(b'\x1b\x61\x01')
p._raw(b'\x1b\x21\x30')
title = "ΑΝΑΦΟΡΑ ΠΡΟΙΟΝΤΩΝ" if mode == "smart" else "ΠΛΗΡΗΣ ΑΝΑΛΥΣΗ ΠΡΟΙΟΝΤΩΝ"
_raw_text(p, title + "\n")
p._raw(b'\x1b\x21\x00')
_divider(p, div, lw)
p._raw(b'\x1b\x61\x00')
p._raw(b'\x1b\x21\x10')
period = report.get("period_label", "")
if report.get("period_is_workday"):
_raw_text(p, f"Εργασιμη: {period}\n")
else:
_raw_text(p, f"Περιοδος: {period}\n")
p._raw(b'\x1b\x21\x00')
_divider(p, div, lw)
items = report.get("items", [])
# Only items with sales contribute to pct denominators
sold_items = [x for x in items if x["qty"] > 0]
total_qty = sum(x["qty"] for x in sold_items)
total_rev = sum(x["revenue"] for x in sold_items)
for item in items:
if item["qty"] == 0 and mode == "smart":
continue
left = item["name"]
right = f"{item['qty']} τεμ."
_raw_text(p, _dot_leader_line(left, right, lw) + "\n")
pct_rev = round(item["revenue"] / total_rev * 100, 1) if total_rev else 0
pct_qty = round(item["qty"] / total_qty * 100, 1) if total_qty else 0
_raw_text(p, f" {item['revenue']:.2f}e | {pct_rev}% εσ. {pct_qty}% τεμ.\n")
_divider(p, div, lw)
p._raw(b'\x1b\x61\x01')
p._raw(b'\x1b\x21\x10')
_raw_text(p, f"Συνολο τεμ.: {total_qty}\n")
p._raw(b'\x1b\x21\x00')
p._raw(b'\x1b\x21\x30')
_raw_text(p, f"ΑΞΙΑ: {total_rev:.2f}e\n")
p._raw(b'\x1b\x21\x00')
p._raw(b'\n\n\n')
p.cut()
p.close()
except Exception as e:
logger.error("print_products_report failed for %s:%s%s", ip, port, e)
def print_categories_report(ip: str, port: int, report: dict):
"""Print a category sales report."""
if is_spoof_mode():
logger.info("Spoof printing ON — dropping categories report print")
return
try:
lw = report.get("line_width", LINE_WIDTH)
div = report.get("div_style", "dash")
mode = report.get("mode", "smart")
p = _get_printer(ip, port)
p._raw(b'\x1b\x61\x01')
p._raw(b'\x1b\x21\x30')
_raw_text(p, "ΑΝΑΦΟΡΑ ΚΑΤΗΓΟΡΙΩΝ\n")
p._raw(b'\x1b\x21\x00')
_divider(p, div, lw)
p._raw(b'\x1b\x61\x00')
p._raw(b'\x1b\x21\x10')
period = report.get("period_label", "")
if report.get("period_is_workday"):
_raw_text(p, f"Εργασιμη: {period}\n")
else:
_raw_text(p, f"Περιοδος: {period}\n")
p._raw(b'\x1b\x21\x00')
_divider(p, div, lw)
categories = report.get("categories", [])
total_rev = sum(c["revenue"] for c in categories)
total_qty = sum(c["units_sold"] for c in categories)
for cat in categories:
_divider(p, div, lw)
p._raw(b'\x1b\x21\x10')
_raw_text(p, f"{cat['name']}\n")
p._raw(b'\x1b\x21\x00')
_raw_text(p, _dot_leader_line("Τεμαχια", str(cat["units_sold"]), lw) + "\n")
_raw_text(p, _dot_leader_line("Εσοδα", f"{cat['revenue']:.2f}e", lw) + "\n")
pct_rev = cat.get("pct_rev", cat.get("pct", 0))
pct_qty = cat.get("pct_qty", 0)
_raw_text(p, _dot_leader_line("% Εσοδων", f"{pct_rev}%", lw) + "\n")
_raw_text(p, _dot_leader_line("% Τεμαχιων", f"{pct_qty}%", lw) + "\n")
if mode == "full" and cat.get("products"):
for prod in cat["products"]:
left = f" {prod['name']}"
right = f"{prod['qty']} | {prod.get('pct_rev', 0)}%E {prod.get('pct_qty', 0)}%T"
_raw_text(p, _dot_leader_line(left, right, lw) + "\n")
_divider(p, div, lw)
p._raw(b'\x1b\x61\x01')
p._raw(b'\x1b\x21\x10')
_raw_text(p, f"Συνολο τεμ.: {total_qty}\n")
p._raw(b'\x1b\x21\x00')
p._raw(b'\x1b\x21\x30')
_raw_text(p, f"ΑΞΙΑ: {total_rev:.2f}e\n")
p._raw(b'\x1b\x21\x00')
p._raw(b'\n\n\n')
p.cut()
p.close()
except Exception as e:
logger.error("print_categories_report failed for %s:%s%s", ip, port, e)
def print_tables_report(ip: str, port: int, report: dict):
"""Print a table performance report."""
if is_spoof_mode():
logger.info("Spoof printing ON — dropping tables report print")
return
try:
lw = report.get("line_width", LINE_WIDTH)
div = report.get("div_style", "dash")
mode = report.get("mode", "smart")
p = _get_printer(ip, port)
p._raw(b'\x1b\x61\x01')
p._raw(b'\x1b\x21\x30')
_raw_text(p, "ΑΝΑΦΟΡΑ ΤΡΑΠΕΖΙΩΝ\n")
p._raw(b'\x1b\x21\x00')
_divider(p, div, lw)
p._raw(b'\x1b\x61\x00')
p._raw(b'\x1b\x21\x10')
period = report.get("period_label", "")
if report.get("period_is_workday"):
_raw_text(p, f"Εργασιμη: {period}\n")
else:
_raw_text(p, f"Περιοδος: {period}\n")
p._raw(b'\x1b\x21\x00')
_divider(p, div, lw)
tables = report.get("tables", [])
total_rev = sum(t["revenue"] for t in tables)
total_orders = sum(t["order_count"] for t in tables)
for t in tables:
left = t["name"]
right = f"{t['revenue']:.2f}e"
_raw_text(p, _dot_leader_line(left, right, lw) + "\n")
if mode == "full":
avg = t["revenue"] / t["order_count"] if t["order_count"] else 0
_raw_text(p, f" {t['order_count']} παρ. / μ.ο. {avg:.2f}e\n")
if t.get("avg_duration_minutes") is not None:
_raw_text(p, f" Μ. διαρκεια: {t['avg_duration_minutes']:.0f} λεπτα\n")
_divider(p, div, lw)
p._raw(b'\x1b\x61\x01')
p._raw(b'\x1b\x21\x10')
_raw_text(p, f"Παραγγελιες: {total_orders}\n")
p._raw(b'\x1b\x21\x00')
p._raw(b'\x1b\x21\x30')
_raw_text(p, f"ΑΞΙΑ: {total_rev:.2f}e\n")
p._raw(b'\x1b\x21\x00')
p._raw(b'\n\n\n')
p.cut()
p.close()
except Exception as e:
logger.error("print_tables_report failed for %s:%s%s", ip, port, e)
# ── Routing logic ────────────────────────────────────────────────────────────
def route_and_print(order_id: int, item_ids: List[int]):
@@ -918,7 +1257,7 @@ def _do_route_and_print(order_id: int, item_ids: List[int], db: Session) -> List
error_msg = None
try:
p = _get_printer(printer.ip_address, printer.port)
_print_kitchen_ticket(p, order, zone_items, db)
_print_kitchen_ticket(p, order, zone_items, db, printer.line_width)
p.close()
success = True
for item in zone_items:

View File

@@ -0,0 +1,48 @@
"""Background task: auto-mark overdue pending reservations as no_show."""
import asyncio
import logging
from datetime import datetime, timezone, timedelta
logger = logging.getLogger(__name__)
# Grace period after reserved_for before a pending reservation becomes no_show
NO_SHOW_GRACE_MINUTES = 30
# How often the checker runs
CHECK_INTERVAL_SECONDS = 300 # 5 minutes
async def _run_no_show_checker():
# Import here to avoid circular imports at module load time
from database import SessionLocal
from models.reservation import Reservation
while True:
await asyncio.sleep(CHECK_INTERVAL_SECONDS)
try:
db = SessionLocal()
try:
cutoff = datetime.now(timezone.utc) - timedelta(minutes=NO_SHOW_GRACE_MINUTES)
overdue = (
db.query(Reservation)
.filter(
Reservation.status == "pending",
Reservation.reserved_for < cutoff,
)
.all()
)
if overdue:
for r in overdue:
r.status = "no_show"
r.updated_at = datetime.now(timezone.utc)
db.commit()
logger.info("no_show_checker: marked %d reservation(s) as no_show", len(overdue))
finally:
db.close()
except Exception:
logger.exception("no_show_checker: unexpected error")
async def start_reservation_tasks() -> asyncio.Task:
task = asyncio.create_task(_run_no_show_checker())
return task

View File

@@ -0,0 +1,119 @@
# -*- coding: utf-8 -*-
"""
Wipes the POS database completely and re-applies the schema + default seed data.
Usage:
python wipe_database.py
Safe to run even if the backend is stopped. Does NOT delete the .db file —
it drops all tables and recreates them so the file path stays intact.
After running this, run demo_seed.py to populate with demo data.
"""
import sys
# ── Confirmation prompt ───────────────────────────────────────────────────────
print("=" * 60)
print(" WARNING: This will PERMANENTLY DELETE all data in the")
print(" POS database including orders, products, users, etc.")
print("=" * 60)
answer = input("\nType YES to confirm: ").strip()
if answer != "YES":
print("Aborted.")
sys.exit(0)
# ── Imports (after confirmation, so we don't waste time if user bails) ────────
from sqlalchemy import text, inspect
from database import engine, Base, SessionLocal
# Import all models so Base.metadata knows about every table
import models.user # noqa: F401
import models.table # noqa: F401
import models.printer # noqa: F401
import models.product # noqa: F401
import models.order # noqa: F401
import models.business_day # noqa: F401
import models.shift # noqa: F401
import models.settings # noqa: F401
import models.flag # noqa: F401
import models.message # noqa: F401
print("\nDropping all tables...")
# SQLite doesn't support DROP TABLE ... CASCADE, so we disable FK checks,
# drop every table we know about, then re-enable them.
with engine.connect() as conn:
conn.execute(text("PRAGMA foreign_keys = OFF"))
conn.commit()
# Reflect the actual DB to catch any tables we might have missed in models
inspector = inspect(engine)
existing_tables = inspector.get_table_names()
# Drop in reverse dependency order using reflected metadata
with engine.connect() as conn:
for table_name in existing_tables:
conn.execute(text(f"DROP TABLE IF EXISTS [{table_name}]"))
print(f" dropped: {table_name}")
conn.execute(text("PRAGMA foreign_keys = ON"))
conn.commit()
print("\nRecreating schema...")
Base.metadata.create_all(bind=engine)
# Re-run the same migration/seed SQL from main.py so default settings,
# flags, and quick message templates are present after the wipe.
print("Applying seed migrations...")
seed_migrations = [
# pos_settings defaults
"INSERT OR IGNORE INTO pos_settings (key, value, updated_at) VALUES ('shifts.waiter_self_start', 'true', CURRENT_TIMESTAMP)",
"INSERT OR IGNORE INTO pos_settings (key, value, updated_at) VALUES ('shifts.waiter_self_end', 'true', CURRENT_TIMESTAMP)",
"INSERT OR IGNORE INTO pos_settings (key, value, updated_at) VALUES ('business_day.force_close_allowed', 'true', CURRENT_TIMESTAMP)",
"INSERT OR IGNORE INTO pos_settings (key, value, updated_at) VALUES ('flags.display_mode', 'both', CURRENT_TIMESTAMP)",
"INSERT OR IGNORE INTO pos_settings (key, value, updated_at) VALUES ('print.ticket_mode', 'detailed', CURRENT_TIMESTAMP)",
"INSERT OR IGNORE INTO pos_settings (key, value, updated_at) VALUES ('print.font_order_number', '48:1:0', CURRENT_TIMESTAMP)",
"INSERT OR IGNORE INTO pos_settings (key, value, updated_at) VALUES ('print.font_meta', '0:0:0', CURRENT_TIMESTAMP)",
"INSERT OR IGNORE INTO pos_settings (key, value, updated_at) VALUES ('print.font_item_name', '16:1:0', CURRENT_TIMESTAMP)",
"INSERT OR IGNORE INTO pos_settings (key, value, updated_at) VALUES ('print.font_quick', '0:0:0', CURRENT_TIMESTAMP)",
"INSERT OR IGNORE INTO pos_settings (key, value, updated_at) VALUES ('print.font_pref', '0:0:0', CURRENT_TIMESTAMP)",
"INSERT OR IGNORE INTO pos_settings (key, value, updated_at) VALUES ('print.font_extra', '0:0:0', CURRENT_TIMESTAMP)",
"INSERT OR IGNORE INTO pos_settings (key, value, updated_at) VALUES ('print.font_ingredient', '0:0:0', CURRENT_TIMESTAMP)",
"INSERT OR IGNORE INTO pos_settings (key, value, updated_at) VALUES ('print.font_item_note', '0:0:0', CURRENT_TIMESTAMP)",
"INSERT OR IGNORE INTO pos_settings (key, value, updated_at) VALUES ('print.font_order_note', '0:1:0', CURRENT_TIMESTAMP)",
"INSERT OR IGNORE INTO pos_settings (key, value, updated_at) VALUES ('venue.name', '', CURRENT_TIMESTAMP)",
"INSERT OR IGNORE INTO pos_settings (key, value, updated_at) VALUES ('venue.type', '', CURRENT_TIMESTAMP)",
"INSERT OR IGNORE INTO pos_settings (key, value, updated_at) VALUES ('security.login_method', 'password', CURRENT_TIMESTAMP)",
"INSERT OR IGNORE INTO pos_settings (key, value, updated_at) VALUES ('security.autofill_username', 'true', CURRENT_TIMESTAMP)",
"INSERT OR IGNORE INTO pos_settings (key, value, updated_at) VALUES ('security.auto_lock', 'false', CURRENT_TIMESTAMP)",
"INSERT OR IGNORE INTO pos_settings (key, value, updated_at) VALUES ('security.auto_lock_seconds', '300', CURRENT_TIMESTAMP)",
"INSERT OR IGNORE INTO pos_settings (key, value, updated_at) VALUES ('security.auto_logout', 'false', CURRENT_TIMESTAMP)",
"INSERT OR IGNORE INTO pos_settings (key, value, updated_at) VALUES ('security.auto_logout_seconds', '1800', CURRENT_TIMESTAMP)",
# Default table flag definitions
"INSERT OR IGNORE INTO table_flag_defs (id, name, emoji, color, sort_order) VALUES (1, 'Χρειάζεται καθάρισμα', '🧹', '#ef4444', 1)",
"INSERT OR IGNORE INTO table_flag_defs (id, name, emoji, color, sort_order) VALUES (2, 'Χρειάζεται Βοήθεια', '🆘', '#f97316', 2)",
"INSERT OR IGNORE INTO table_flag_defs (id, name, emoji, color, sort_order) VALUES (3, 'Χρειάζεται Σερβιτόρο', '🔔', '#eab308', 3)",
"INSERT OR IGNORE INTO table_flag_defs (id, name, emoji, color, sort_order) VALUES (4, 'Περιμένει να πληρώσει', '💳', '#3b82f6', 4)",
"INSERT OR IGNORE INTO table_flag_defs (id, name, emoji, color, sort_order) VALUES (5, 'VIP', '', '#8b5cf6', 5)",
"INSERT OR IGNORE INTO table_flag_defs (id, name, emoji, color, sort_order) VALUES (6, 'Ευγενικός Πελάτης', '😊', '#22c55e', 6)",
"INSERT OR IGNORE INTO table_flag_defs (id, name, emoji, color, sort_order) VALUES (7, 'Αγενής Πελάτης', '😤', '#dc2626', 7)",
"INSERT OR IGNORE INTO table_flag_defs (id, name, emoji, color, sort_order) VALUES (8, 'Αλλεργίες', '⚠️', '#f59e0b', 8)",
"INSERT OR IGNORE INTO table_flag_defs (id, name, emoji, color, sort_order) VALUES (9, 'Παιδιά στο τραπέζι', '👶', '#06b6d4', 9)",
"INSERT OR IGNORE INTO table_flag_defs (id, name, emoji, color, sort_order) VALUES (10, 'Επέτειος / Γενέθλια', '🎂', '#ec4899', 10)",
# Default quick message templates
"INSERT OR IGNORE INTO quick_message_templates (id, body, sort_order) VALUES (1, 'Σε χρειάζομαι τώρα', 1)",
"INSERT OR IGNORE INTO quick_message_templates (id, body, sort_order) VALUES (2, 'Πάρε διάλειμμα', 2)",
"INSERT OR IGNORE INTO quick_message_templates (id, body, sort_order) VALUES (3, 'Ετοιμάσου για κλείσιμο', 3)",
"INSERT OR IGNORE INTO quick_message_templates (id, body, sort_order) VALUES (4, 'Ήρθε νέος πελάτης', 4)",
"INSERT OR IGNORE INTO quick_message_templates (id, body, sort_order) VALUES (5, 'Ο πελάτης περιμένει να πληρώσει', 5)",
]
for sql in seed_migrations:
try:
with engine.connect() as conn:
conn.execute(text(sql))
conn.commit()
except Exception as e:
print(f" [skip] {e}")
print("\nDone. Database is clean and ready.")
print("Run python demo_seed.py to populate with demo data.")