From 79bd3b0f418e3458516122e359dcf89988b88bf0 Mon Sep 17 00:00:00 2001 From: bonamin Date: Sun, 7 Jun 2026 20:25:22 +0300 Subject: [PATCH] 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 --- local_backend/Dockerfile | 2 +- local_backend/demo_seed.py | 430 +++++++++++++++++ local_backend/main.py | 29 ++ local_backend/models/printer.py | 1 + local_backend/models/product.py | 1 + local_backend/models/reservation.py | 42 ++ local_backend/models/user.py | 1 + local_backend/routers/business_day.py | 217 ++++++++- local_backend/routers/orders.py | 19 +- local_backend/routers/reports.py | 428 ++++++++++++++--- local_backend/routers/reservations.py | 171 +++++++ local_backend/routers/settings.py | 4 +- local_backend/routers/shifts.py | 4 +- local_backend/routers/system.py | 2 +- local_backend/routers/tables.py | 29 +- local_backend/routers/waiters.py | 4 + local_backend/schemas/printer.py | 2 + local_backend/schemas/product.py | 4 +- local_backend/schemas/reservation.py | 93 ++++ local_backend/schemas/table.py | 12 + local_backend/schemas/user.py | 3 + local_backend/services/cloud_sync.py | 1 + local_backend/services/printer_service.py | 497 ++++++++++++++++---- local_backend/services/reservation_tasks.py | 48 ++ local_backend/wipe_database.py | 119 +++++ 25 files changed, 2017 insertions(+), 146 deletions(-) create mode 100644 local_backend/demo_seed.py create mode 100644 local_backend/models/reservation.py create mode 100644 local_backend/routers/reservations.py create mode 100644 local_backend/schemas/reservation.py create mode 100644 local_backend/services/reservation_tasks.py create mode 100644 local_backend/wipe_database.py diff --git a/local_backend/Dockerfile b/local_backend/Dockerfile index 617e0d7..5fcd71a 100644 --- a/local_backend/Dockerfile +++ b/local_backend/Dockerfile @@ -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"] diff --git a/local_backend/demo_seed.py b/local_backend/demo_seed.py new file mode 100644 index 0000000..4f6d57e --- /dev/null +++ b/local_backend/demo_seed.py @@ -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() diff --git a/local_backend/main.py b/local_backend/main.py index 68b9600..33644d5 100644 --- a/local_backend/main.py +++ b/local_backend/main.py @@ -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"]) diff --git a/local_backend/models/printer.py b/local_backend/models/printer.py index 8f8de7d..6fcb3dc 100644 --- a/local_backend/models/printer.py +++ b/local_backend/models/printer.py @@ -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") diff --git a/local_backend/models/product.py b/local_backend/models/product.py index 54edf3e..d113382 100644 --- a/local_backend/models/product.py +++ b/local_backend/models/product.py @@ -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) diff --git a/local_backend/models/reservation.py b/local_backend/models/reservation.py new file mode 100644 index 0000000..4940ee5 --- /dev/null +++ b/local_backend/models/reservation.py @@ -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]) diff --git a/local_backend/models/user.py b/local_backend/models/user.py index 1cefe90..7a26107 100644 --- a/local_backend/models/user.py +++ b/local_backend/models/user.py @@ -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) diff --git a/local_backend/routers/business_day.py b/local_backend/routers/business_day.py index ffa2a87..1335a50 100644 --- a/local_backend/routers/business_day.py +++ b/local_backend/routers/business_day.py @@ -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), diff --git a/local_backend/routers/orders.py b/local_backend/routers/orders.py index 36a931e..c17dd13 100644 --- a/local_backend/routers/orders.py +++ b/local_backend/routers/orders.py @@ -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"} diff --git a/local_backend/routers/reports.py b/local_backend/routers/reports.py index 7f43b4b..3040a94 100644 --- a/local_backend/routers/reports.py +++ b/local_backend/routers/reports.py @@ -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 # --------------------------------------------------------------------------- diff --git a/local_backend/routers/reservations.py b/local_backend/routers/reservations.py new file mode 100644 index 0000000..2136afb --- /dev/null +++ b/local_backend/routers/reservations.py @@ -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() diff --git a/local_backend/routers/settings.py b/local_backend/routers/settings.py index d11de9a..a9781ae 100644 --- a/local_backend/routers/settings.py +++ b/local_backend/routers/settings.py @@ -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", diff --git a/local_backend/routers/shifts.py b/local_backend/routers/shifts.py index 57a375d..ae03985 100644 --- a/local_backend/routers/shifts.py +++ b/local_backend/routers/shifts.py @@ -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() diff --git a/local_backend/routers/system.py b/local_backend/routers/system.py index 516ffff..c73d72e 100644 --- a/local_backend/routers/system.py +++ b/local_backend/routers/system.py @@ -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} diff --git a/local_backend/routers/tables.py b/local_backend/routers/tables.py index 6f51e80..52d82ad 100644 --- a/local_backend/routers/tables.py +++ b/local_backend/routers/tables.py @@ -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 diff --git a/local_backend/routers/waiters.py b/local_backend/routers/waiters.py index b291f00..3761a1f 100644 --- a/local_backend/routers/waiters.py +++ b/local_backend/routers/waiters.py @@ -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 diff --git a/local_backend/schemas/printer.py b/local_backend/schemas/printer.py index 9faf8f1..b7f2135 100644 --- a/local_backend/schemas/printer.py +++ b/local_backend/schemas/printer.py @@ -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): diff --git a/local_backend/schemas/product.py b/local_backend/schemas/product.py index 24b1a6b..6021960 100644 --- a/local_backend/schemas/product.py +++ b/local_backend/schemas/product.py @@ -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 diff --git a/local_backend/schemas/reservation.py b/local_backend/schemas/reservation.py new file mode 100644 index 0000000..813f3a4 --- /dev/null +++ b/local_backend/schemas/reservation.py @@ -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} diff --git a/local_backend/schemas/table.py b/local_backend/schemas/table.py index 21a059f..487565a 100644 --- a/local_backend/schemas/table.py +++ b/local_backend/schemas/table.py @@ -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} diff --git a/local_backend/schemas/user.py b/local_backend/schemas/user.py index 9330115..6a9bdd4 100644 --- a/local_backend/schemas/user.py +++ b/local_backend/schemas/user.py @@ -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): diff --git a/local_backend/services/cloud_sync.py b/local_backend/services/cloud_sync.py index 9c25367..1b898bc 100644 --- a/local_backend/services/cloud_sync.py +++ b/local_backend/services/cloud_sync.py @@ -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 diff --git a/local_backend/services/printer_service.py b/local_backend/services/printer_service.py index 2aa8341..dee2962 100644 --- a/local_backend/services/printer_service.py +++ b/local_backend/services/printer_service.py @@ -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: diff --git a/local_backend/services/reservation_tasks.py b/local_backend/services/reservation_tasks.py new file mode 100644 index 0000000..bce1bb2 --- /dev/null +++ b/local_backend/services/reservation_tasks.py @@ -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 diff --git a/local_backend/wipe_database.py b/local_backend/wipe_database.py new file mode 100644 index 0000000..34ab130 --- /dev/null +++ b/local_backend/wipe_database.py @@ -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.")