feat(local_backend): reservations, print analytics, workday summary, zone-PIN management
- New reservations module: model, schema, router (CRUD + status updates + upcoming alerts) and background task for auto-expiring stale reservations - Reports: print_products, print_categories, print_tables analytics endpoints plus meta_products and business_day_summary for workday close/view flow - printer_service: configurable font sizes/weights, donut/bar chart print layout helpers, analytics print blocks per printer - tables/schemas: surfaced color, zone, and other new fields on Table, Product, User, Printer - demo_seed.py for quick dev DB population; wipe_database.py utility Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -7,4 +7,4 @@ RUN pip install --no-cache-dir -r requirements.txt
|
||||
|
||||
COPY . .
|
||||
|
||||
CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000"]
|
||||
CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000", "--log-level", "info"]
|
||||
|
||||
430
local_backend/demo_seed.py
Normal file
430
local_backend/demo_seed.py
Normal file
@@ -0,0 +1,430 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
Populates the POS database with realistic demo data for customer presentations.
|
||||
|
||||
Generates:
|
||||
- 1 manager account (username: manager / PIN: 1234 / password: password)
|
||||
- 4 demo waiters
|
||||
- 2 table groups with 12 tables total
|
||||
- A full menu: 3 categories, ~20 products
|
||||
- 45 days of backdated history (business days, shifts, orders, payments)
|
||||
|
||||
Usage:
|
||||
python demo_seed.py
|
||||
|
||||
Must be run from the local_backend directory. Designed to run on a clean DB
|
||||
(after wipe_database.py), but safe to run multiple times — checks for the
|
||||
[DEMO] marker before inserting anything.
|
||||
"""
|
||||
import json
|
||||
import random
|
||||
import sys
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
import bcrypt
|
||||
from sqlalchemy import text
|
||||
|
||||
from database import engine, Base, SessionLocal
|
||||
|
||||
import models.user
|
||||
import models.table
|
||||
import models.printer
|
||||
import models.product
|
||||
import models.order
|
||||
import models.business_day
|
||||
import models.shift
|
||||
import models.settings
|
||||
import models.flag
|
||||
import models.message
|
||||
|
||||
from models.user import User, WaiterZone
|
||||
from models.table import TableGroup, Table
|
||||
from models.product import Category, Product
|
||||
from models.order import Order, OrderItem, OrderWaiter, OrderAuditLog
|
||||
from models.business_day import BusinessDay
|
||||
from models.shift import WaiterShift
|
||||
|
||||
Base.metadata.create_all(bind=engine)
|
||||
|
||||
# ── Config ────────────────────────────────────────────────────────────────────
|
||||
|
||||
DEMO_DAYS = 120 # how many past days to generate (~4 months)
|
||||
ORDERS_PER_DAY = (18, 35) # min/max orders per business day
|
||||
|
||||
# ── Helpers ───────────────────────────────────────────────────────────────────
|
||||
|
||||
def _utc(dt: datetime) -> datetime:
|
||||
if dt.tzinfo is None:
|
||||
return dt.replace(tzinfo=timezone.utc)
|
||||
return dt
|
||||
|
||||
|
||||
def _hash_pin(pin: str) -> str:
|
||||
return bcrypt.hashpw(pin.encode(), bcrypt.gensalt()).decode()
|
||||
|
||||
|
||||
def _hash_password(pw: str) -> str:
|
||||
return bcrypt.hashpw(pw.encode(), bcrypt.gensalt()).decode()
|
||||
|
||||
|
||||
def _audit(db, order_id, event_type, waiter_id=None, item_ids=None,
|
||||
amount=None, payment_method=None, note=None, created_at=None):
|
||||
log = OrderAuditLog(
|
||||
order_id=order_id,
|
||||
event_type=event_type,
|
||||
waiter_id=waiter_id,
|
||||
item_ids=json.dumps(item_ids) if item_ids is not None else None,
|
||||
amount=amount,
|
||||
payment_method=payment_method,
|
||||
note=note,
|
||||
)
|
||||
if created_at:
|
||||
log.created_at = _utc(created_at)
|
||||
db.add(log)
|
||||
|
||||
|
||||
# ── Menu definition ───────────────────────────────────────────────────────────
|
||||
# (category_name, color, [(product_name, price), ...])
|
||||
MENU = [
|
||||
("Ορεκτικά", "#f97316", [
|
||||
("Τζατζίκι", 4.50),
|
||||
("Ταραμοσαλάτα", 4.50),
|
||||
("Χωριάτικη Σαλάτα", 7.50),
|
||||
("Κεφτεδάκια", 8.00),
|
||||
("Σαγανάκι", 6.50),
|
||||
("Χούμους", 5.00),
|
||||
]),
|
||||
("Κυρίως Πιάτα", "#ef4444", [
|
||||
("Μπριζόλα Χοιρινή", 12.00),
|
||||
("Μπριζόλα Μοσχαρίσια", 16.00),
|
||||
("Σουβλάκι Κοτόπουλο", 10.00),
|
||||
("Μουσακάς", 11.00),
|
||||
("Παστίτσιο", 10.00),
|
||||
("Σπαγγέτι Μπολονέζ", 9.50),
|
||||
("Γύρος Χοιρινός", 8.50),
|
||||
]),
|
||||
("Ποτά & Αναψυκτικά", "#3b82f6", [
|
||||
("Νερό 500ml", 1.00),
|
||||
("Αναψυκτικό", 2.50),
|
||||
("Μπύρα Φιάλη", 4.00),
|
||||
("Κρασί (ποτήρι)", 4.50),
|
||||
("Καφές Ελληνικός", 2.50),
|
||||
("Φραπέ", 3.00),
|
||||
("Χυμός Πορτοκάλι", 3.50),
|
||||
]),
|
||||
]
|
||||
|
||||
# Weighted product picker: drinks ordered ~40% of the time, mains ~35%, starters ~25%
|
||||
CATEGORY_WEIGHTS = [0.25, 0.35, 0.40]
|
||||
|
||||
WAITER_NAMES = [
|
||||
("Νίκος", "Νίκος Π."),
|
||||
("Μαρία", "Μαρία Κ."),
|
||||
("Πέτρος", "Πέτρος Α."),
|
||||
("Έλενα", "Έλενα Μ."),
|
||||
]
|
||||
|
||||
PAYMENT_METHODS = ["cash", "cash", "cash", "card", "card"] # 60% cash / 40% card
|
||||
|
||||
# ── Main ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
db = SessionLocal()
|
||||
|
||||
try:
|
||||
# ── Guard: abort if demo data already present ─────────────────────────────
|
||||
existing = db.query(User).filter(User.full_name.like("[DEMO]%")).first()
|
||||
if existing:
|
||||
print("Demo data already present (found [DEMO] user). Nothing to do.")
|
||||
print("Run python wipe_database.py first if you want a fresh seed.")
|
||||
sys.exit(0)
|
||||
|
||||
print("Creating demo data...")
|
||||
|
||||
# ── Manager account ───────────────────────────────────────────────────────
|
||||
manager = db.query(User).filter(User.username == "manager").first()
|
||||
if not manager:
|
||||
manager = User(
|
||||
username="manager",
|
||||
pin_hash=_hash_pin("1234"),
|
||||
password_hash=_hash_password("password"),
|
||||
role="manager",
|
||||
full_name="Manager",
|
||||
is_active=True,
|
||||
)
|
||||
db.add(manager)
|
||||
db.flush()
|
||||
print(" created manager (username: manager / PIN: 1234 / password: password)")
|
||||
else:
|
||||
# Ensure password_hash is set on existing manager
|
||||
if not manager.password_hash:
|
||||
manager.password_hash = _hash_password("password")
|
||||
db.flush()
|
||||
print(" manager account already exists — updated password to 'password'")
|
||||
|
||||
# ── Waiters ───────────────────────────────────────────────────────────────
|
||||
waiters = []
|
||||
for nickname, full_name in WAITER_NAMES:
|
||||
username = f"demo_{nickname.lower()}"
|
||||
w = db.query(User).filter(User.username == username).first()
|
||||
if not w:
|
||||
w = User(
|
||||
username=username,
|
||||
pin_hash=_hash_pin(str(random.randint(1000, 9999))),
|
||||
role="waiter",
|
||||
full_name=f"[DEMO] {full_name}",
|
||||
nickname=nickname,
|
||||
is_active=True,
|
||||
)
|
||||
db.add(w)
|
||||
db.flush()
|
||||
waiters.append(w)
|
||||
print(f" created {len(waiters)} demo waiters")
|
||||
|
||||
# ── Table groups & tables ─────────────────────────────────────────────────
|
||||
group_configs = [
|
||||
("Κεντρική Αίθουσα", "Κ", "#6366f1", list(range(1, 9))), # tables 1-8
|
||||
("Βεράντα", "Β", "#22c55e", list(range(1, 6))), # tables 1-5
|
||||
]
|
||||
all_tables = []
|
||||
for g_name, g_prefix, g_color, table_numbers in group_configs:
|
||||
grp = db.query(TableGroup).filter(TableGroup.name == g_name).first()
|
||||
if not grp:
|
||||
grp = TableGroup(name=g_name, prefix=g_prefix, color=g_color, sort_order=len(all_tables))
|
||||
db.add(grp)
|
||||
db.flush()
|
||||
for n in table_numbers:
|
||||
tbl = db.query(Table).filter(Table.group_id == grp.id, Table.number == n).first()
|
||||
if not tbl:
|
||||
tbl = Table(number=n, group_id=grp.id, is_active=True)
|
||||
db.add(tbl)
|
||||
db.flush()
|
||||
all_tables.append(tbl)
|
||||
print(f" created {len(all_tables)} tables across {len(group_configs)} groups")
|
||||
|
||||
# ── Waiter zone assignments (all waiters → all zones) ─────────────────────
|
||||
for w in waiters:
|
||||
existing_zone = db.query(WaiterZone).filter(
|
||||
WaiterZone.waiter_id == w.id, WaiterZone.group_id.is_(None)
|
||||
).first()
|
||||
if not existing_zone:
|
||||
db.add(WaiterZone(waiter_id=w.id, group_id=None))
|
||||
db.flush()
|
||||
|
||||
# ── Menu ──────────────────────────────────────────────────────────────────
|
||||
categories = []
|
||||
all_products = [] # flat list for random picks
|
||||
category_product_map = [] # parallel list of product sub-lists (for weighted pick)
|
||||
|
||||
for sort_idx, (cat_name, cat_color, items) in enumerate(MENU):
|
||||
cat = db.query(Category).filter(Category.name == cat_name).first()
|
||||
if not cat:
|
||||
cat = Category(name=cat_name, color=cat_color, sort_order=sort_idx)
|
||||
db.add(cat)
|
||||
db.flush()
|
||||
categories.append(cat)
|
||||
|
||||
cat_products = []
|
||||
for prod_sort, (prod_name, prod_price) in enumerate(items):
|
||||
p = db.query(Product).filter(Product.name == prod_name, Product.category_id == cat.id).first()
|
||||
if not p:
|
||||
p = Product(
|
||||
name=prod_name,
|
||||
category_id=cat.id,
|
||||
base_price=prod_price,
|
||||
is_available=True,
|
||||
lifecycle_status="active",
|
||||
sort_order=prod_sort,
|
||||
)
|
||||
db.add(p)
|
||||
db.flush()
|
||||
cat_products.append(p)
|
||||
all_products.append(p)
|
||||
category_product_map.append(cat_products)
|
||||
|
||||
print(f" created {len(categories)} categories with {len(all_products)} products")
|
||||
|
||||
db.commit()
|
||||
|
||||
# ── Historical days ───────────────────────────────────────────────────────
|
||||
today = datetime.now(timezone.utc).date()
|
||||
total_orders = 0
|
||||
total_revenue = 0.0
|
||||
|
||||
print(f"\nGenerating {DEMO_DAYS} days of history...")
|
||||
|
||||
for days_ago in range(DEMO_DAYS, 0, -1):
|
||||
day_date = today - timedelta(days=days_ago)
|
||||
|
||||
# Business day: opens at ~10:00, closes at ~23:30
|
||||
open_time = _utc(datetime(day_date.year, day_date.month, day_date.day,
|
||||
10, random.randint(0, 30)))
|
||||
close_time = _utc(datetime(day_date.year, day_date.month, day_date.day,
|
||||
23, random.randint(15, 45)))
|
||||
|
||||
bday = BusinessDay(
|
||||
status="closed",
|
||||
opened_at=open_time,
|
||||
opened_by_id=manager.id,
|
||||
closed_at=close_time,
|
||||
closed_by_id=manager.id,
|
||||
)
|
||||
db.add(bday)
|
||||
db.flush()
|
||||
|
||||
# 2-4 waiters work each day
|
||||
working_waiters = random.sample(waiters, k=random.randint(2, len(waiters)))
|
||||
STARTING_CASH_OPTIONS = [50.0, 80.0, 100.0, 110.0, 120.0, 150.0, 200.0]
|
||||
shifts = {}
|
||||
shift_totals = {} # shift_id → running revenue total, filled during order loop
|
||||
for w in working_waiters:
|
||||
shift_start = open_time + timedelta(minutes=random.randint(0, 30))
|
||||
shift_end = close_time - timedelta(minutes=random.randint(0, 20))
|
||||
shift = WaiterShift(
|
||||
waiter_id=w.id,
|
||||
business_day_id=bday.id,
|
||||
started_at=shift_start,
|
||||
ended_at=shift_end,
|
||||
starting_cash=random.choice(STARTING_CASH_OPTIONS),
|
||||
)
|
||||
db.add(shift)
|
||||
db.flush()
|
||||
shifts[w.id] = shift
|
||||
shift_totals[shift.id] = 0.0
|
||||
|
||||
# Orders — two rushes: lunch (12-15h) and dinner (19-23h)
|
||||
n_orders = random.randint(*ORDERS_PER_DAY)
|
||||
lunch_count = int(n_orders * 0.4)
|
||||
dinner_count = n_orders - lunch_count
|
||||
|
||||
order_times = []
|
||||
for _ in range(lunch_count):
|
||||
h = random.randint(12, 14)
|
||||
m = random.randint(0, 59)
|
||||
order_times.append(datetime(day_date.year, day_date.month, day_date.day, h, m))
|
||||
for _ in range(dinner_count):
|
||||
h = random.randint(19, 22)
|
||||
m = random.randint(0, 59)
|
||||
order_times.append(datetime(day_date.year, day_date.month, day_date.day, h, m))
|
||||
order_times.sort()
|
||||
|
||||
# Track which tables are "busy" at each point in time so we don't double-seat
|
||||
# Format: {table_id: release_time}
|
||||
table_busy_until: dict[int, datetime] = {}
|
||||
|
||||
for opened_at_naive in order_times:
|
||||
opened_at = _utc(opened_at_naive)
|
||||
|
||||
# Pick a free table
|
||||
free_tables = [
|
||||
t for t in all_tables
|
||||
if table_busy_until.get(t.id, datetime.min.replace(tzinfo=timezone.utc)) <= opened_at
|
||||
]
|
||||
if not free_tables:
|
||||
free_tables = all_tables # all busy → just reuse (edge case)
|
||||
table = random.choice(free_tables)
|
||||
|
||||
# Pick a waiter who is working today
|
||||
waiter = random.choice(working_waiters)
|
||||
shift = shifts[waiter.id]
|
||||
|
||||
# Order duration: 20-70 min
|
||||
duration_min = random.randint(20, 70)
|
||||
closed_at = opened_at + timedelta(minutes=duration_min)
|
||||
# Cap to close_time
|
||||
if closed_at > close_time:
|
||||
closed_at = close_time - timedelta(minutes=2)
|
||||
|
||||
table_busy_until[table.id] = closed_at
|
||||
|
||||
order = Order(
|
||||
table_id=table.id,
|
||||
opened_by=waiter.id,
|
||||
business_day_id=bday.id,
|
||||
status="closed",
|
||||
closed_by=manager.id,
|
||||
notes=None,
|
||||
source="pos",
|
||||
)
|
||||
order.opened_at = opened_at
|
||||
order.closed_at = closed_at
|
||||
db.add(order)
|
||||
db.flush()
|
||||
|
||||
db.add(OrderWaiter(
|
||||
order_id=order.id,
|
||||
waiter_id=waiter.id,
|
||||
assigned_at=opened_at,
|
||||
))
|
||||
|
||||
_audit(db, order.id, "ORDER_OPENED", waiter_id=waiter.id, created_at=opened_at)
|
||||
|
||||
# Items: 2-6 items, weighted by category
|
||||
n_items = random.randint(2, 6)
|
||||
chosen_category_lists = random.choices(category_product_map, weights=CATEGORY_WEIGHTS, k=n_items)
|
||||
items_added = []
|
||||
order_total = 0.0
|
||||
items_added_at = opened_at + timedelta(minutes=random.randint(2, 8))
|
||||
|
||||
for prod_list in chosen_category_lists:
|
||||
product = random.choice(prod_list)
|
||||
qty = random.choices([1, 2, 3], weights=[0.7, 0.2, 0.1])[0]
|
||||
payment_method = random.choice(PAYMENT_METHODS)
|
||||
paid_at = closed_at - timedelta(minutes=random.randint(1, 5))
|
||||
line_total = product.base_price * qty
|
||||
order_total += line_total
|
||||
|
||||
item = OrderItem(
|
||||
order_id=order.id,
|
||||
product_id=product.id,
|
||||
added_by=waiter.id,
|
||||
quantity=qty,
|
||||
unit_price=product.base_price,
|
||||
status="paid",
|
||||
printed=True,
|
||||
paid_by=waiter.id,
|
||||
paid_at=_utc(paid_at),
|
||||
payment_method=payment_method,
|
||||
paid_in_shift_id=shift.id,
|
||||
)
|
||||
item.added_at = _utc(items_added_at)
|
||||
db.add(item)
|
||||
db.flush()
|
||||
items_added.append(item.id)
|
||||
shift_totals[shift.id] = round(shift_totals[shift.id] + line_total, 2)
|
||||
|
||||
_audit(db, order.id, "ITEMS_ADDED",
|
||||
waiter_id=waiter.id, item_ids=items_added,
|
||||
created_at=items_added_at)
|
||||
|
||||
# Payment audit (single payment per order for simplicity)
|
||||
payment_method = random.choice(PAYMENT_METHODS)
|
||||
pay_at = closed_at - timedelta(minutes=1)
|
||||
_audit(db, order.id, "PAYMENT",
|
||||
waiter_id=waiter.id, item_ids=items_added,
|
||||
amount=round(order_total, 2), payment_method=payment_method,
|
||||
created_at=pay_at)
|
||||
|
||||
_audit(db, order.id, "ORDER_CLOSED",
|
||||
waiter_id=waiter.id, created_at=closed_at)
|
||||
|
||||
total_orders += 1
|
||||
total_revenue += order_total
|
||||
|
||||
# Write total_collected snapshot onto each ended shift
|
||||
for w in working_waiters:
|
||||
s = shifts[w.id]
|
||||
s.total_collected = shift_totals[s.id]
|
||||
|
||||
db.commit()
|
||||
print(f" {day_date} {n_orders:2d} orders {len(working_waiters)} waiters")
|
||||
|
||||
print(f"\nDone.")
|
||||
print(f" Total orders : {total_orders}")
|
||||
print(f" Total revenue : €{total_revenue:,.2f}")
|
||||
print(f"\nLogin credentials:")
|
||||
print(f" Manager — username: manager | password: password | PIN: 1234")
|
||||
for w in waiters:
|
||||
print(f" Waiter — username: {w.username}")
|
||||
|
||||
finally:
|
||||
db.close()
|
||||
@@ -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"])
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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)
|
||||
|
||||
42
local_backend/models/reservation.py
Normal file
42
local_backend/models/reservation.py
Normal file
@@ -0,0 +1,42 @@
|
||||
from sqlalchemy import Column, Integer, String, DateTime, ForeignKey, Text
|
||||
from sqlalchemy.orm import relationship
|
||||
from datetime import datetime, timezone
|
||||
from database import Base
|
||||
|
||||
|
||||
class Reservation(Base):
|
||||
__tablename__ = "reservations"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
|
||||
# Guest info
|
||||
guest_name = Column(String, nullable=False)
|
||||
party_size = Column(Integer, nullable=False)
|
||||
phone = Column(String, nullable=True)
|
||||
email = Column(String, nullable=True)
|
||||
note = Column(Text, nullable=True)
|
||||
|
||||
# When the customer wants the table
|
||||
reserved_for = Column(DateTime, nullable=False)
|
||||
|
||||
# Optional table assignment (assignable/editable later)
|
||||
table_id = Column(Integer, ForeignKey("tables.id"), nullable=True)
|
||||
|
||||
# Lifecycle: pending | arrived | cancelled | no_show
|
||||
status = Column(String, nullable=False, default="pending")
|
||||
|
||||
# Source: manager_app | waiter_app | online
|
||||
source = Column(String, nullable=False, default="manager_app")
|
||||
|
||||
# Who entered this reservation (staff user)
|
||||
created_by_user_id = Column(Integer, ForeignKey("users.id"), nullable=True)
|
||||
|
||||
# For future online reservations: cloud-side customer identifier
|
||||
online_customer_id = Column(String, nullable=True)
|
||||
|
||||
created_at = Column(DateTime, default=lambda: datetime.now(timezone.utc))
|
||||
updated_at = Column(DateTime, default=lambda: datetime.now(timezone.utc),
|
||||
onupdate=lambda: datetime.now(timezone.utc))
|
||||
|
||||
table = relationship("Table", foreign_keys=[table_id])
|
||||
created_by = relationship("User", foreign_keys=[created_by_user_id])
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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"}
|
||||
|
||||
@@ -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
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
171
local_backend/routers/reservations.py
Normal file
171
local_backend/routers/reservations.py
Normal file
@@ -0,0 +1,171 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException, status, Query
|
||||
from sqlalchemy.orm import Session
|
||||
from typing import List, Optional
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from database import get_db
|
||||
from models.reservation import Reservation
|
||||
from models.table import Table
|
||||
from schemas.reservation import (
|
||||
ReservationCreate, ReservationUpdate, ReservationStatusUpdate, ReservationOut,
|
||||
)
|
||||
from routers.deps import get_current_user, require_manager
|
||||
from models.user import User
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
VALID_STATUSES = {"pending", "arrived", "cancelled", "no_show"}
|
||||
|
||||
|
||||
def _enrich(r: Reservation) -> ReservationOut:
|
||||
out = ReservationOut.model_validate(r)
|
||||
if r.table:
|
||||
out.table_label = r.table.label or f"T{r.table.number}"
|
||||
if r.created_by:
|
||||
out.created_by_name = (
|
||||
r.created_by.full_name or r.created_by.nickname or r.created_by.username
|
||||
)
|
||||
return out
|
||||
|
||||
|
||||
@router.post("/", response_model=ReservationOut, status_code=status.HTTP_201_CREATED)
|
||||
def create_reservation(
|
||||
body: ReservationCreate,
|
||||
db: Session = Depends(get_db),
|
||||
user: User = Depends(get_current_user),
|
||||
):
|
||||
if body.table_id:
|
||||
if not db.query(Table).filter(Table.id == body.table_id, Table.is_active == True).first():
|
||||
raise HTTPException(status_code=400, detail="Table not found or inactive")
|
||||
|
||||
r = Reservation(
|
||||
guest_name=body.guest_name,
|
||||
party_size=body.party_size,
|
||||
phone=body.phone,
|
||||
email=body.email,
|
||||
note=body.note,
|
||||
reserved_for=body.reserved_for,
|
||||
table_id=body.table_id,
|
||||
source=body.source,
|
||||
online_customer_id=body.online_customer_id,
|
||||
created_by_user_id=user.id,
|
||||
status="pending",
|
||||
)
|
||||
db.add(r)
|
||||
db.commit()
|
||||
db.refresh(r)
|
||||
return _enrich(r)
|
||||
|
||||
|
||||
@router.get("/", response_model=List[ReservationOut])
|
||||
def list_reservations(
|
||||
tab: Optional[str] = Query(None), # "upcoming" | "history" — if omitted returns all
|
||||
date: Optional[str] = Query(None), # ISO date string YYYY-MM-DD to filter by day
|
||||
db: Session = Depends(get_db),
|
||||
user: User = Depends(get_current_user),
|
||||
):
|
||||
q = db.query(Reservation)
|
||||
|
||||
if date:
|
||||
try:
|
||||
day = datetime.fromisoformat(date).date()
|
||||
except ValueError:
|
||||
raise HTTPException(status_code=400, detail="Invalid date format")
|
||||
from sqlalchemy import func
|
||||
q = q.filter(func.date(Reservation.reserved_for) == day)
|
||||
|
||||
if tab == "upcoming":
|
||||
# pending + no_show (actionable), ordered soonest first
|
||||
q = q.filter(Reservation.status.in_(["pending", "no_show"]))
|
||||
q = q.order_by(Reservation.reserved_for.asc())
|
||||
elif tab == "history":
|
||||
# arrived, cancelled, plus no_show/pending that are clearly in the past
|
||||
now = datetime.now(timezone.utc)
|
||||
q = q.filter(
|
||||
(Reservation.status.in_(["arrived", "cancelled"])) |
|
||||
(
|
||||
Reservation.status.in_(["no_show", "pending"]) &
|
||||
(Reservation.reserved_for < now)
|
||||
)
|
||||
)
|
||||
q = q.order_by(Reservation.reserved_for.desc())
|
||||
else:
|
||||
q = q.order_by(Reservation.reserved_for.asc())
|
||||
|
||||
reservations = q.all()
|
||||
return [_enrich(r) for r in reservations]
|
||||
|
||||
|
||||
@router.get("/{reservation_id}", response_model=ReservationOut)
|
||||
def get_reservation(
|
||||
reservation_id: int,
|
||||
db: Session = Depends(get_db),
|
||||
user: User = Depends(get_current_user),
|
||||
):
|
||||
r = db.query(Reservation).filter(Reservation.id == reservation_id).first()
|
||||
if not r:
|
||||
raise HTTPException(status_code=404, detail="Reservation not found")
|
||||
return _enrich(r)
|
||||
|
||||
|
||||
@router.patch("/{reservation_id}", response_model=ReservationOut)
|
||||
def update_reservation(
|
||||
reservation_id: int,
|
||||
body: ReservationUpdate,
|
||||
db: Session = Depends(get_db),
|
||||
user: User = Depends(get_current_user),
|
||||
):
|
||||
r = db.query(Reservation).filter(Reservation.id == reservation_id).first()
|
||||
if not r:
|
||||
raise HTTPException(status_code=404, detail="Reservation not found")
|
||||
|
||||
updates = body.model_dump(exclude_none=True)
|
||||
|
||||
if "table_id" in updates and updates["table_id"] is not None:
|
||||
if not db.query(Table).filter(Table.id == updates["table_id"], Table.is_active == True).first():
|
||||
raise HTTPException(status_code=400, detail="Table not found or inactive")
|
||||
|
||||
if "status" in updates and updates["status"] not in VALID_STATUSES:
|
||||
raise HTTPException(status_code=400, detail=f"Invalid status: {updates['status']}")
|
||||
|
||||
for field, value in updates.items():
|
||||
setattr(r, field, value)
|
||||
|
||||
r.updated_at = datetime.now(timezone.utc)
|
||||
db.commit()
|
||||
db.refresh(r)
|
||||
return _enrich(r)
|
||||
|
||||
|
||||
@router.patch("/{reservation_id}/status", response_model=ReservationOut)
|
||||
def update_reservation_status(
|
||||
reservation_id: int,
|
||||
body: ReservationStatusUpdate,
|
||||
db: Session = Depends(get_db),
|
||||
user: User = Depends(get_current_user),
|
||||
):
|
||||
if body.status not in VALID_STATUSES:
|
||||
raise HTTPException(status_code=400, detail=f"Invalid status: {body.status}")
|
||||
|
||||
r = db.query(Reservation).filter(Reservation.id == reservation_id).first()
|
||||
if not r:
|
||||
raise HTTPException(status_code=404, detail="Reservation not found")
|
||||
|
||||
r.status = body.status
|
||||
r.updated_at = datetime.now(timezone.utc)
|
||||
db.commit()
|
||||
db.refresh(r)
|
||||
return _enrich(r)
|
||||
|
||||
|
||||
@router.delete("/{reservation_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||
def delete_reservation(
|
||||
reservation_id: int,
|
||||
db: Session = Depends(get_db),
|
||||
user: User = Depends(require_manager),
|
||||
):
|
||||
r = db.query(Reservation).filter(Reservation.id == reservation_id).first()
|
||||
if not r:
|
||||
raise HTTPException(status_code=404, detail="Reservation not found")
|
||||
db.delete(r)
|
||||
db.commit()
|
||||
@@ -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",
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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}
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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
|
||||
|
||||
93
local_backend/schemas/reservation.py
Normal file
93
local_backend/schemas/reservation.py
Normal file
@@ -0,0 +1,93 @@
|
||||
from pydantic import BaseModel, field_validator
|
||||
from typing import Optional
|
||||
from datetime import datetime, timezone
|
||||
|
||||
|
||||
class ReservationCreate(BaseModel):
|
||||
guest_name: str
|
||||
party_size: int
|
||||
phone: Optional[str] = None
|
||||
email: Optional[str] = None
|
||||
note: Optional[str] = None
|
||||
reserved_for: datetime
|
||||
table_id: Optional[int] = None
|
||||
source: str = "manager_app"
|
||||
online_customer_id: Optional[str] = None
|
||||
|
||||
@field_validator("reserved_for", mode="before")
|
||||
@classmethod
|
||||
def strip_timezone(cls, v):
|
||||
if isinstance(v, str):
|
||||
dt = datetime.fromisoformat(v.replace("Z", "+00:00"))
|
||||
elif isinstance(v, datetime):
|
||||
dt = v
|
||||
else:
|
||||
return v
|
||||
# Store as naive local — strip any tz info so SQLite keeps it as-is
|
||||
if dt.tzinfo is not None:
|
||||
dt = dt.astimezone().replace(tzinfo=None)
|
||||
return dt
|
||||
|
||||
|
||||
class ReservationUpdate(BaseModel):
|
||||
guest_name: Optional[str] = None
|
||||
party_size: Optional[int] = None
|
||||
phone: Optional[str] = None
|
||||
email: Optional[str] = None
|
||||
note: Optional[str] = None
|
||||
reserved_for: Optional[datetime] = None
|
||||
table_id: Optional[int] = None
|
||||
status: Optional[str] = None
|
||||
|
||||
@field_validator("reserved_for", mode="before")
|
||||
@classmethod
|
||||
def strip_timezone(cls, v):
|
||||
if v is None:
|
||||
return v
|
||||
if isinstance(v, str):
|
||||
dt = datetime.fromisoformat(v.replace("Z", "+00:00"))
|
||||
elif isinstance(v, datetime):
|
||||
dt = v
|
||||
else:
|
||||
return v
|
||||
if dt.tzinfo is not None:
|
||||
dt = dt.astimezone().replace(tzinfo=None)
|
||||
return dt
|
||||
|
||||
|
||||
class ReservationStatusUpdate(BaseModel):
|
||||
status: str # arrived | cancelled | no_show | pending
|
||||
|
||||
|
||||
class ReservationOut(BaseModel):
|
||||
id: int
|
||||
guest_name: str
|
||||
party_size: int
|
||||
phone: Optional[str] = None
|
||||
email: Optional[str] = None
|
||||
note: Optional[str] = None
|
||||
reserved_for: datetime
|
||||
table_id: Optional[int] = None
|
||||
status: str
|
||||
source: str
|
||||
created_by_user_id: Optional[int] = None
|
||||
online_customer_id: Optional[str] = None
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
|
||||
# Denormalized for convenience
|
||||
table_label: Optional[str] = None
|
||||
created_by_name: Optional[str] = None
|
||||
|
||||
model_config = {"from_attributes": True}
|
||||
|
||||
|
||||
# Compact summary embedded in TableOut for the RESERVED badge
|
||||
class ReservationSummary(BaseModel):
|
||||
id: int
|
||||
guest_name: str
|
||||
party_size: int
|
||||
reserved_for: datetime
|
||||
status: str
|
||||
|
||||
model_config = {"from_attributes": True}
|
||||
@@ -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}
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
48
local_backend/services/reservation_tasks.py
Normal file
48
local_backend/services/reservation_tasks.py
Normal file
@@ -0,0 +1,48 @@
|
||||
"""Background task: auto-mark overdue pending reservations as no_show."""
|
||||
import asyncio
|
||||
import logging
|
||||
from datetime import datetime, timezone, timedelta
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Grace period after reserved_for before a pending reservation becomes no_show
|
||||
NO_SHOW_GRACE_MINUTES = 30
|
||||
|
||||
# How often the checker runs
|
||||
CHECK_INTERVAL_SECONDS = 300 # 5 minutes
|
||||
|
||||
|
||||
async def _run_no_show_checker():
|
||||
# Import here to avoid circular imports at module load time
|
||||
from database import SessionLocal
|
||||
from models.reservation import Reservation
|
||||
|
||||
while True:
|
||||
await asyncio.sleep(CHECK_INTERVAL_SECONDS)
|
||||
try:
|
||||
db = SessionLocal()
|
||||
try:
|
||||
cutoff = datetime.now(timezone.utc) - timedelta(minutes=NO_SHOW_GRACE_MINUTES)
|
||||
overdue = (
|
||||
db.query(Reservation)
|
||||
.filter(
|
||||
Reservation.status == "pending",
|
||||
Reservation.reserved_for < cutoff,
|
||||
)
|
||||
.all()
|
||||
)
|
||||
if overdue:
|
||||
for r in overdue:
|
||||
r.status = "no_show"
|
||||
r.updated_at = datetime.now(timezone.utc)
|
||||
db.commit()
|
||||
logger.info("no_show_checker: marked %d reservation(s) as no_show", len(overdue))
|
||||
finally:
|
||||
db.close()
|
||||
except Exception:
|
||||
logger.exception("no_show_checker: unexpected error")
|
||||
|
||||
|
||||
async def start_reservation_tasks() -> asyncio.Task:
|
||||
task = asyncio.create_task(_run_no_show_checker())
|
||||
return task
|
||||
119
local_backend/wipe_database.py
Normal file
119
local_backend/wipe_database.py
Normal file
@@ -0,0 +1,119 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
Wipes the POS database completely and re-applies the schema + default seed data.
|
||||
|
||||
Usage:
|
||||
python wipe_database.py
|
||||
|
||||
Safe to run even if the backend is stopped. Does NOT delete the .db file —
|
||||
it drops all tables and recreates them so the file path stays intact.
|
||||
After running this, run demo_seed.py to populate with demo data.
|
||||
"""
|
||||
import sys
|
||||
|
||||
# ── Confirmation prompt ───────────────────────────────────────────────────────
|
||||
print("=" * 60)
|
||||
print(" WARNING: This will PERMANENTLY DELETE all data in the")
|
||||
print(" POS database including orders, products, users, etc.")
|
||||
print("=" * 60)
|
||||
answer = input("\nType YES to confirm: ").strip()
|
||||
if answer != "YES":
|
||||
print("Aborted.")
|
||||
sys.exit(0)
|
||||
|
||||
# ── Imports (after confirmation, so we don't waste time if user bails) ────────
|
||||
from sqlalchemy import text, inspect
|
||||
from database import engine, Base, SessionLocal
|
||||
|
||||
# Import all models so Base.metadata knows about every table
|
||||
import models.user # noqa: F401
|
||||
import models.table # noqa: F401
|
||||
import models.printer # noqa: F401
|
||||
import models.product # noqa: F401
|
||||
import models.order # noqa: F401
|
||||
import models.business_day # noqa: F401
|
||||
import models.shift # noqa: F401
|
||||
import models.settings # noqa: F401
|
||||
import models.flag # noqa: F401
|
||||
import models.message # noqa: F401
|
||||
|
||||
print("\nDropping all tables...")
|
||||
|
||||
# SQLite doesn't support DROP TABLE ... CASCADE, so we disable FK checks,
|
||||
# drop every table we know about, then re-enable them.
|
||||
with engine.connect() as conn:
|
||||
conn.execute(text("PRAGMA foreign_keys = OFF"))
|
||||
conn.commit()
|
||||
|
||||
# Reflect the actual DB to catch any tables we might have missed in models
|
||||
inspector = inspect(engine)
|
||||
existing_tables = inspector.get_table_names()
|
||||
|
||||
# Drop in reverse dependency order using reflected metadata
|
||||
with engine.connect() as conn:
|
||||
for table_name in existing_tables:
|
||||
conn.execute(text(f"DROP TABLE IF EXISTS [{table_name}]"))
|
||||
print(f" dropped: {table_name}")
|
||||
conn.execute(text("PRAGMA foreign_keys = ON"))
|
||||
conn.commit()
|
||||
|
||||
print("\nRecreating schema...")
|
||||
Base.metadata.create_all(bind=engine)
|
||||
|
||||
# Re-run the same migration/seed SQL from main.py so default settings,
|
||||
# flags, and quick message templates are present after the wipe.
|
||||
print("Applying seed migrations...")
|
||||
|
||||
seed_migrations = [
|
||||
# pos_settings defaults
|
||||
"INSERT OR IGNORE INTO pos_settings (key, value, updated_at) VALUES ('shifts.waiter_self_start', 'true', CURRENT_TIMESTAMP)",
|
||||
"INSERT OR IGNORE INTO pos_settings (key, value, updated_at) VALUES ('shifts.waiter_self_end', 'true', CURRENT_TIMESTAMP)",
|
||||
"INSERT OR IGNORE INTO pos_settings (key, value, updated_at) VALUES ('business_day.force_close_allowed', 'true', CURRENT_TIMESTAMP)",
|
||||
"INSERT OR IGNORE INTO pos_settings (key, value, updated_at) VALUES ('flags.display_mode', 'both', CURRENT_TIMESTAMP)",
|
||||
"INSERT OR IGNORE INTO pos_settings (key, value, updated_at) VALUES ('print.ticket_mode', 'detailed', CURRENT_TIMESTAMP)",
|
||||
"INSERT OR IGNORE INTO pos_settings (key, value, updated_at) VALUES ('print.font_order_number', '48:1:0', CURRENT_TIMESTAMP)",
|
||||
"INSERT OR IGNORE INTO pos_settings (key, value, updated_at) VALUES ('print.font_meta', '0:0:0', CURRENT_TIMESTAMP)",
|
||||
"INSERT OR IGNORE INTO pos_settings (key, value, updated_at) VALUES ('print.font_item_name', '16:1:0', CURRENT_TIMESTAMP)",
|
||||
"INSERT OR IGNORE INTO pos_settings (key, value, updated_at) VALUES ('print.font_quick', '0:0:0', CURRENT_TIMESTAMP)",
|
||||
"INSERT OR IGNORE INTO pos_settings (key, value, updated_at) VALUES ('print.font_pref', '0:0:0', CURRENT_TIMESTAMP)",
|
||||
"INSERT OR IGNORE INTO pos_settings (key, value, updated_at) VALUES ('print.font_extra', '0:0:0', CURRENT_TIMESTAMP)",
|
||||
"INSERT OR IGNORE INTO pos_settings (key, value, updated_at) VALUES ('print.font_ingredient', '0:0:0', CURRENT_TIMESTAMP)",
|
||||
"INSERT OR IGNORE INTO pos_settings (key, value, updated_at) VALUES ('print.font_item_note', '0:0:0', CURRENT_TIMESTAMP)",
|
||||
"INSERT OR IGNORE INTO pos_settings (key, value, updated_at) VALUES ('print.font_order_note', '0:1:0', CURRENT_TIMESTAMP)",
|
||||
"INSERT OR IGNORE INTO pos_settings (key, value, updated_at) VALUES ('venue.name', '', CURRENT_TIMESTAMP)",
|
||||
"INSERT OR IGNORE INTO pos_settings (key, value, updated_at) VALUES ('venue.type', '', CURRENT_TIMESTAMP)",
|
||||
"INSERT OR IGNORE INTO pos_settings (key, value, updated_at) VALUES ('security.login_method', 'password', CURRENT_TIMESTAMP)",
|
||||
"INSERT OR IGNORE INTO pos_settings (key, value, updated_at) VALUES ('security.autofill_username', 'true', CURRENT_TIMESTAMP)",
|
||||
"INSERT OR IGNORE INTO pos_settings (key, value, updated_at) VALUES ('security.auto_lock', 'false', CURRENT_TIMESTAMP)",
|
||||
"INSERT OR IGNORE INTO pos_settings (key, value, updated_at) VALUES ('security.auto_lock_seconds', '300', CURRENT_TIMESTAMP)",
|
||||
"INSERT OR IGNORE INTO pos_settings (key, value, updated_at) VALUES ('security.auto_logout', 'false', CURRENT_TIMESTAMP)",
|
||||
"INSERT OR IGNORE INTO pos_settings (key, value, updated_at) VALUES ('security.auto_logout_seconds', '1800', CURRENT_TIMESTAMP)",
|
||||
# Default table flag definitions
|
||||
"INSERT OR IGNORE INTO table_flag_defs (id, name, emoji, color, sort_order) VALUES (1, 'Χρειάζεται καθάρισμα', '🧹', '#ef4444', 1)",
|
||||
"INSERT OR IGNORE INTO table_flag_defs (id, name, emoji, color, sort_order) VALUES (2, 'Χρειάζεται Βοήθεια', '🆘', '#f97316', 2)",
|
||||
"INSERT OR IGNORE INTO table_flag_defs (id, name, emoji, color, sort_order) VALUES (3, 'Χρειάζεται Σερβιτόρο', '🔔', '#eab308', 3)",
|
||||
"INSERT OR IGNORE INTO table_flag_defs (id, name, emoji, color, sort_order) VALUES (4, 'Περιμένει να πληρώσει', '💳', '#3b82f6', 4)",
|
||||
"INSERT OR IGNORE INTO table_flag_defs (id, name, emoji, color, sort_order) VALUES (5, 'VIP', '⭐', '#8b5cf6', 5)",
|
||||
"INSERT OR IGNORE INTO table_flag_defs (id, name, emoji, color, sort_order) VALUES (6, 'Ευγενικός Πελάτης', '😊', '#22c55e', 6)",
|
||||
"INSERT OR IGNORE INTO table_flag_defs (id, name, emoji, color, sort_order) VALUES (7, 'Αγενής Πελάτης', '😤', '#dc2626', 7)",
|
||||
"INSERT OR IGNORE INTO table_flag_defs (id, name, emoji, color, sort_order) VALUES (8, 'Αλλεργίες', '⚠️', '#f59e0b', 8)",
|
||||
"INSERT OR IGNORE INTO table_flag_defs (id, name, emoji, color, sort_order) VALUES (9, 'Παιδιά στο τραπέζι', '👶', '#06b6d4', 9)",
|
||||
"INSERT OR IGNORE INTO table_flag_defs (id, name, emoji, color, sort_order) VALUES (10, 'Επέτειος / Γενέθλια', '🎂', '#ec4899', 10)",
|
||||
# Default quick message templates
|
||||
"INSERT OR IGNORE INTO quick_message_templates (id, body, sort_order) VALUES (1, 'Σε χρειάζομαι τώρα', 1)",
|
||||
"INSERT OR IGNORE INTO quick_message_templates (id, body, sort_order) VALUES (2, 'Πάρε διάλειμμα', 2)",
|
||||
"INSERT OR IGNORE INTO quick_message_templates (id, body, sort_order) VALUES (3, 'Ετοιμάσου για κλείσιμο', 3)",
|
||||
"INSERT OR IGNORE INTO quick_message_templates (id, body, sort_order) VALUES (4, 'Ήρθε νέος πελάτης', 4)",
|
||||
"INSERT OR IGNORE INTO quick_message_templates (id, body, sort_order) VALUES (5, 'Ο πελάτης περιμένει να πληρώσει', 5)",
|
||||
]
|
||||
|
||||
for sql in seed_migrations:
|
||||
try:
|
||||
with engine.connect() as conn:
|
||||
conn.execute(text(sql))
|
||||
conn.commit()
|
||||
except Exception as e:
|
||||
print(f" [skip] {e}")
|
||||
|
||||
print("\nDone. Database is clean and ready.")
|
||||
print("Run python demo_seed.py to populate with demo data.")
|
||||
Reference in New Issue
Block a user