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 . .
|
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.settings # noqa: F401
|
||||||
import models.flag # noqa: F401 — registers TableFlagDef, TableFlagAssignment
|
import models.flag # noqa: F401 — registers TableFlagDef, TableFlagAssignment
|
||||||
import models.message # noqa: F401 — registers StaffMessage, StaffMessageAck, QuickMessageTemplate
|
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 auth, tables, products, orders, waiters, reports, system, setup as setup_router
|
||||||
from routers import business_day as business_day_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 sse as sse_router
|
||||||
from routers import data_transfer as data_transfer_router
|
from routers import data_transfer as data_transfer_router
|
||||||
from routers import connect_orders as connect_orders_router
|
from routers import connect_orders as connect_orders_router
|
||||||
|
from routers import reservations as reservations_router
|
||||||
|
|
||||||
|
|
||||||
def _run_migrations():
|
def _run_migrations():
|
||||||
@@ -237,6 +239,29 @@ def _run_migrations():
|
|||||||
"ALTER TABLE products ADD COLUMN digital_price REAL",
|
"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_discount REAL NOT NULL DEFAULT 0.0",
|
||||||
"ALTER TABLE products ADD COLUMN digital_image_url VARCHAR",
|
"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:
|
for sql in migrations:
|
||||||
try:
|
try:
|
||||||
@@ -251,12 +276,15 @@ def _run_migrations():
|
|||||||
async def lifespan(app: FastAPI):
|
async def lifespan(app: FastAPI):
|
||||||
import asyncio
|
import asyncio
|
||||||
from services.sse_bus import init_loop
|
from services.sse_bus import init_loop
|
||||||
|
from services.reservation_tasks import start_reservation_tasks
|
||||||
init_loop(asyncio.get_running_loop())
|
init_loop(asyncio.get_running_loop())
|
||||||
Base.metadata.create_all(bind=engine)
|
Base.metadata.create_all(bind=engine)
|
||||||
_run_migrations()
|
_run_migrations()
|
||||||
sync_task = await start_cloud_sync()
|
sync_task = await start_cloud_sync()
|
||||||
|
reservation_task = await start_reservation_tasks()
|
||||||
yield
|
yield
|
||||||
sync_task.cancel()
|
sync_task.cancel()
|
||||||
|
reservation_task.cancel()
|
||||||
|
|
||||||
|
|
||||||
app = FastAPI(title="POS Local Backend", lifespan=lifespan)
|
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(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(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(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)
|
port = Column(Integer, default=9100, nullable=False)
|
||||||
is_active = Column(Boolean, default=True, nullable=False)
|
is_active = Column(Boolean, default=True, nullable=False)
|
||||||
protocol = Column(String, default="escpos_tcp", 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")
|
products = relationship("Product", back_populates="printer_zone")
|
||||||
print_logs = relationship("PrintLog", back_populates="printer")
|
print_logs = relationship("PrintLog", back_populates="printer")
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ class Product(Base):
|
|||||||
|
|
||||||
id = Column(Integer, primary_key=True, index=True)
|
id = Column(Integer, primary_key=True, index=True)
|
||||||
name = Column(String, nullable=False)
|
name = Column(String, nullable=False)
|
||||||
|
description = Column(Text, nullable=True)
|
||||||
category_id = Column(Integer, ForeignKey("categories.id"), nullable=True)
|
category_id = Column(Integer, ForeignKey("categories.id"), nullable=True)
|
||||||
base_price = Column(Float, nullable=False)
|
base_price = Column(Float, nullable=False)
|
||||||
is_available = Column(Boolean, default=True, 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)
|
full_name = Column(String, nullable=True)
|
||||||
nickname = Column(String, nullable=True)
|
nickname = Column(String, nullable=True)
|
||||||
mobile_phone = Column(String, nullable=True)
|
mobile_phone = Column(String, nullable=True)
|
||||||
|
note = Column(String, nullable=True)
|
||||||
avatar_url = Column(String, nullable=True)
|
avatar_url = Column(String, nullable=True)
|
||||||
created_at = Column(DateTime(timezone=True), default=_utcnow)
|
created_at = Column(DateTime(timezone=True), default=_utcnow)
|
||||||
|
|
||||||
|
|||||||
@@ -6,10 +6,12 @@ from datetime import datetime, timezone
|
|||||||
|
|
||||||
from database import get_db
|
from database import get_db
|
||||||
from models.business_day import BusinessDay
|
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.shift import WaiterShift
|
||||||
from models.flag import TableFlagAssignment
|
from models.flag import TableFlagAssignment
|
||||||
from models.message import StaffMessage, StaffMessageAck
|
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 schemas.business_day import BusinessDayOut, OpenBusinessDayRequest, CloseBusinessDayRequest
|
||||||
from routers.deps import get_current_user, require_manager
|
from routers.deps import get_current_user, require_manager
|
||||||
from models.user import User
|
from models.user import User
|
||||||
@@ -189,6 +191,219 @@ def delete_business_day(
|
|||||||
db.commit()
|
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")
|
@router.get("/history")
|
||||||
def business_day_history(
|
def business_day_history(
|
||||||
db: Session = Depends(get_db),
|
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])
|
@router.get("/", response_model=List[OrderOut])
|
||||||
def list_orders(
|
def list_orders(
|
||||||
order_status: Optional[str] = None,
|
order_status: Optional[str] = None,
|
||||||
waiter_id: Optional[int] = None,
|
waiter_id: Optional[int] = None,
|
||||||
|
all: bool = False,
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
user: User = Depends(require_manager),
|
user: User = Depends(require_manager),
|
||||||
):
|
):
|
||||||
q = db.query(Order)
|
q = db.query(Order)
|
||||||
if order_status:
|
if order_status:
|
||||||
q = q.filter(Order.status == order_status)
|
q = q.filter(Order.status == order_status)
|
||||||
|
elif not all:
|
||||||
|
q = q.filter(Order.status.in_(ACTIVE_STATUSES))
|
||||||
if waiter_id:
|
if waiter_id:
|
||||||
q = q.join(OrderWaiter).filter(OrderWaiter.waiter_id == waiter_id)
|
q = q.join(OrderWaiter).filter(OrderWaiter.waiter_id == waiter_id)
|
||||||
return q.all()
|
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,
|
"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 {
|
return {
|
||||||
"id": order.id,
|
"id": order.id,
|
||||||
"table_id": order.table_id,
|
"table_id": order.table_id,
|
||||||
|
"table_name": table_name,
|
||||||
"opened_by": order.opened_by,
|
"opened_by": order.opened_by,
|
||||||
"opened_at": order.opened_at,
|
"opened_at": order.opened_at,
|
||||||
"status": order.status,
|
"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.waiter_id == user.id,
|
||||||
WaiterShift.ended_at == None,
|
WaiterShift.ended_at == None,
|
||||||
).first()
|
).first()
|
||||||
|
effective_method = body.payment_method or "cash"
|
||||||
total_paid = 0.0
|
total_paid = 0.0
|
||||||
for item in items:
|
for item in items:
|
||||||
item.status = "paid"
|
item.status = "paid"
|
||||||
item.paid_by = user.id
|
item.paid_by = user.id
|
||||||
item.paid_at = now
|
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
|
item.paid_in_shift_id = active_shift.id if active_shift else None
|
||||||
total_paid += item.unit_price * item.quantity
|
total_paid += item.unit_price * item.quantity
|
||||||
|
|
||||||
@@ -452,6 +462,7 @@ def pay_items_offline(
|
|||||||
WaiterShift.ended_at == None,
|
WaiterShift.ended_at == None,
|
||||||
).first()
|
).first()
|
||||||
|
|
||||||
|
effective_method = body.payment_method or "cash"
|
||||||
total_paid = 0.0
|
total_paid = 0.0
|
||||||
paid_ids = []
|
paid_ids = []
|
||||||
if not is_duplicate:
|
if not is_duplicate:
|
||||||
@@ -459,7 +470,7 @@ def pay_items_offline(
|
|||||||
item.status = "paid"
|
item.status = "paid"
|
||||||
item.paid_by = user.id
|
item.paid_by = user.id
|
||||||
item.paid_at = paid_at
|
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
|
item.paid_in_shift_id = active_shift.id if active_shift else None
|
||||||
total_paid += item.unit_price * item.quantity
|
total_paid += item.unit_price * item.quantity
|
||||||
paid_ids.append(item.id)
|
paid_ids.append(item.id)
|
||||||
@@ -605,7 +616,7 @@ def print_order(
|
|||||||
"notes": order.notes,
|
"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"}
|
return {"status": "printing"}
|
||||||
|
|
||||||
|
|
||||||
@@ -900,5 +911,5 @@ def print_synopsis(
|
|||||||
"remaining": total - paid_total,
|
"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"}
|
return {"status": "printing"}
|
||||||
|
|||||||
@@ -20,7 +20,10 @@ from models.business_day import BusinessDay
|
|||||||
from schemas.order import OrderOut
|
from schemas.order import OrderOut
|
||||||
from schemas.table import TableOut
|
from schemas.table import TableOut
|
||||||
from routers.deps import require_manager
|
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()
|
router = APIRouter()
|
||||||
|
|
||||||
@@ -416,17 +419,88 @@ class PrintWaiterReportBody(BaseModel):
|
|||||||
|
|
||||||
|
|
||||||
class PrintPrinterReportBody(BaseModel):
|
class PrintPrinterReportBody(BaseModel):
|
||||||
|
# printer_target_id: 0 = all printers, >0 = specific printer id
|
||||||
printer_target_id: int
|
printer_target_id: int
|
||||||
printer_id: int
|
printer_id: int
|
||||||
mode: str # "simple" | "extensive"
|
# mode: "quick" | "orders" | "detailed"
|
||||||
from_dt: str
|
mode: str
|
||||||
to_dt: str
|
from_dt: Optional[str] = None
|
||||||
|
to_dt: Optional[str] = None
|
||||||
|
business_day_id: Optional[int] = None
|
||||||
|
item_breakdown: bool = False
|
||||||
|
|
||||||
|
|
||||||
class PrintOrderBody(BaseModel):
|
class PrintOrderBody(BaseModel):
|
||||||
printer_id: int
|
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")
|
@router.post("/print/waiter")
|
||||||
def print_waiter(
|
def print_waiter(
|
||||||
body: PrintWaiterReportBody,
|
body: PrintWaiterReportBody,
|
||||||
@@ -497,73 +571,293 @@ def print_printer_totals(
|
|||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
user: User = Depends(require_manager),
|
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()
|
printer = db.query(Printer).filter(Printer.id == body.printer_id, Printer.is_active == True).first()
|
||||||
if not printer:
|
if not printer:
|
||||||
raise HTTPException(status_code=404, detail="Printer not found or inactive")
|
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()}
|
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
|
# Build time filter for logs
|
||||||
order_map: dict = {}
|
def _base_log_q():
|
||||||
items_count = 0
|
q = db.query(PrintLog).filter(PrintLog.success == True)
|
||||||
grand_total = 0.0
|
if body.business_day_id:
|
||||||
for log in logs:
|
q = q.join(Order).filter(Order.business_day_id == body.business_day_id)
|
||||||
oid = log.order_id
|
elif body.from_dt and body.to_dt:
|
||||||
if oid not in order_map:
|
from_dt = datetime.fromisoformat(body.from_dt)
|
||||||
order = db.query(Order).filter(Order.id == oid).first()
|
to_dt = datetime.fromisoformat(body.to_dt)
|
||||||
if order:
|
q = q.filter(PrintLog.printed_at >= from_dt, PrintLog.printed_at <= to_dt)
|
||||||
order_map[oid] = {
|
return q
|
||||||
"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})
|
|
||||||
|
|
||||||
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 = {
|
report = {
|
||||||
"printer_name": target_name,
|
"printer_blocks": printer_blocks,
|
||||||
"print_jobs": len(logs),
|
"period_label": period_label,
|
||||||
"orders": len(order_map),
|
"period_is_workday": period_is_workday,
|
||||||
"items": items_count,
|
"mode": body.mode,
|
||||||
"total": grand_total,
|
"item_breakdown": body.item_breakdown,
|
||||||
"order_data": order_data if body.mode == "extensive" else [],
|
"line_width": printer.line_width,
|
||||||
"from_dt": from_dt.strftime("%d/%m/%Y %H:%M"),
|
"div_style": load_divider_style(db),
|
||||||
"to_dt": to_dt.strftime("%d/%m/%Y %H:%M"),
|
|
||||||
}
|
}
|
||||||
|
|
||||||
background_tasks.add_task(print_printer_report, printer.ip_address, printer.port, report, body.mode)
|
background_tasks.add_task(print_printer_report, printer.ip_address, printer.port, report, body.mode)
|
||||||
return {"status": "printing"}
|
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
|
# Shift history report
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@@ -1148,6 +1442,28 @@ def meta_printers(
|
|||||||
return {"printers": [{"id": p.id, "name": p.name} for p in 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
|
# 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 layout
|
||||||
"print.ticket_mode": "Kitchen ticket layout mode: 'detailed' or 'compact'",
|
"print.ticket_mode": "Kitchen ticket layout mode: 'detailed' or 'compact'",
|
||||||
"print.divider_style": "Divider character used between sections: dash, equals, star, or empty",
|
"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 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_order_number": "Font for order number header: SIZE:BOLD:CAPS",
|
||||||
"print.font_meta": "Font for table/waiter/time header block: 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",
|
"shifts.waiter_self_end": "true",
|
||||||
"business_day.force_close_allowed": "true",
|
"business_day.force_close_allowed": "true",
|
||||||
"system.timezone": "Europe/Athens",
|
"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",
|
"dev.spoof_printing": "false",
|
||||||
"print.ticket_mode": "detailed",
|
"print.ticket_mode": "detailed",
|
||||||
"print.divider_style": "dash",
|
"print.divider_style": "dash",
|
||||||
|
"print.dot_style": "dot_space:0:0",
|
||||||
"print.font_order_number": "48:1:0",
|
"print.font_order_number": "48:1:0",
|
||||||
"print.font_meta": "0:0:0",
|
"print.font_meta": "0:0:0",
|
||||||
"print.font_item_name": "16:1:0",
|
"print.font_item_name": "16:1:0",
|
||||||
|
|||||||
@@ -356,13 +356,15 @@ def get_shift_summary(
|
|||||||
OrderItem.status == "paid",
|
OrderItem.status == "paid",
|
||||||
).all()
|
).all()
|
||||||
|
|
||||||
|
from datetime import timezone as tz
|
||||||
|
upper_bound = shift.ended_at or datetime.now(tz.utc)
|
||||||
ordered_items = db.query(OrderItem).options(
|
ordered_items = db.query(OrderItem).options(
|
||||||
joinedload(OrderItem.product),
|
joinedload(OrderItem.product),
|
||||||
joinedload(OrderItem.order),
|
joinedload(OrderItem.order),
|
||||||
).filter(
|
).filter(
|
||||||
OrderItem.added_by == waiter_id,
|
OrderItem.added_by == waiter_id,
|
||||||
OrderItem.added_at >= shift.started_at,
|
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),
|
(OrderItem.paid_in_shift_id != shift_id) | (OrderItem.paid_in_shift_id == None),
|
||||||
).all()
|
).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()
|
printer = db.query(Printer).filter(Printer.id == printer_id).first()
|
||||||
if not printer:
|
if not printer:
|
||||||
raise HTTPException(status_code=404, detail="Printer not found")
|
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}
|
return {"success": success, "error": error}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,19 +1,24 @@
|
|||||||
from fastapi import APIRouter, Depends, HTTPException, status, Body
|
from fastapi import APIRouter, Depends, HTTPException, status, Body
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
from typing import List
|
from typing import List
|
||||||
|
from datetime import datetime, timezone, timedelta
|
||||||
|
|
||||||
from database import get_db
|
from database import get_db
|
||||||
from models.table import Table, TableGroup
|
from models.table import Table, TableGroup
|
||||||
from models.order import Order
|
from models.order import Order
|
||||||
|
from models.reservation import Reservation
|
||||||
from models.user import User, WaiterZone
|
from models.user import User, WaiterZone
|
||||||
from schemas.table import (
|
from schemas.table import (
|
||||||
TableCreate, TableUpdate, TableFloorplanUpdate, TableOut,
|
TableCreate, TableUpdate, TableFloorplanUpdate, TableOut, UpcomingReservation,
|
||||||
TableGroupCreate, TableGroupUpdate, TableGroupOut,
|
TableGroupCreate, TableGroupUpdate, TableGroupOut,
|
||||||
TableBatchCreate, MAX_TABLE_NAME_LENGTH,
|
TableBatchCreate, MAX_TABLE_NAME_LENGTH,
|
||||||
)
|
)
|
||||||
from routers.deps import get_current_user, require_manager
|
from routers.deps import get_current_user, require_manager
|
||||||
from services.sse_bus import broadcast_sync
|
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()
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
@@ -98,10 +103,32 @@ def list_tables(include_inactive: bool = False, db: Session = Depends(get_db), u
|
|||||||
).all()
|
).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 = []
|
result = []
|
||||||
for t in tables:
|
for t in tables:
|
||||||
out = TableOut.model_validate(t)
|
out = TableOut.model_validate(t)
|
||||||
out.has_active_order = t.id in active_table_ids
|
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)
|
result.append(out)
|
||||||
return result
|
return result
|
||||||
|
|
||||||
|
|||||||
@@ -52,10 +52,14 @@ def create_waiter(body: UserCreate, db: Session = Depends(get_db), user: User =
|
|||||||
full_name=body.full_name,
|
full_name=body.full_name,
|
||||||
nickname=body.nickname,
|
nickname=body.nickname,
|
||||||
mobile_phone=body.mobile_phone,
|
mobile_phone=body.mobile_phone,
|
||||||
|
email=body.email,
|
||||||
|
note=body.note,
|
||||||
)
|
)
|
||||||
db.add(new_user)
|
db.add(new_user)
|
||||||
db.commit()
|
db.commit()
|
||||||
db.refresh(new_user)
|
db.refresh(new_user)
|
||||||
|
db.add(WaiterZone(waiter_id=new_user.id, group_id=None))
|
||||||
|
db.commit()
|
||||||
return new_user
|
return new_user
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ class PrinterBase(BaseModel):
|
|||||||
port: int = 9100
|
port: int = 9100
|
||||||
is_active: bool = True
|
is_active: bool = True
|
||||||
protocol: str = "escpos_tcp"
|
protocol: str = "escpos_tcp"
|
||||||
|
line_width: int = 48
|
||||||
|
|
||||||
|
|
||||||
class PrinterCreate(PrinterBase):
|
class PrinterCreate(PrinterBase):
|
||||||
@@ -23,6 +24,7 @@ class PrinterUpdate(BaseModel):
|
|||||||
port: Optional[int] = None
|
port: Optional[int] = None
|
||||||
is_active: Optional[bool] = None
|
is_active: Optional[bool] = None
|
||||||
protocol: Optional[str] = None
|
protocol: Optional[str] = None
|
||||||
|
line_width: Optional[int] = None
|
||||||
|
|
||||||
|
|
||||||
class PrinterOut(PrinterBase):
|
class PrinterOut(PrinterBase):
|
||||||
|
|||||||
@@ -257,6 +257,7 @@ class PreferenceSetOut(BaseModel):
|
|||||||
|
|
||||||
class ProductBase(BaseModel):
|
class ProductBase(BaseModel):
|
||||||
name: str
|
name: str
|
||||||
|
description: Optional[str] = None
|
||||||
category_id: Optional[int] = None
|
category_id: Optional[int] = None
|
||||||
base_price: float
|
base_price: float
|
||||||
is_available: bool = True
|
is_available: bool = True
|
||||||
@@ -269,7 +270,7 @@ class ProductBase(BaseModel):
|
|||||||
digital_name: Optional[str] = None
|
digital_name: Optional[str] = None
|
||||||
digital_description: Optional[str] = None
|
digital_description: Optional[str] = None
|
||||||
digital_price: Optional[float] = None
|
digital_price: Optional[float] = None
|
||||||
digital_discount: float = 0.0
|
digital_discount: Optional[float] = None
|
||||||
digital_image_url: Optional[str] = None
|
digital_image_url: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
@@ -282,6 +283,7 @@ class ProductCreate(ProductBase):
|
|||||||
|
|
||||||
class ProductUpdate(BaseModel):
|
class ProductUpdate(BaseModel):
|
||||||
name: Optional[str] = None
|
name: Optional[str] = None
|
||||||
|
description: Optional[str] = None
|
||||||
category_id: Optional[int] = None
|
category_id: Optional[int] = None
|
||||||
base_price: Optional[float] = None
|
base_price: Optional[float] = None
|
||||||
is_available: Optional[bool] = 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 pydantic import BaseModel, field_validator
|
||||||
from typing import Optional, List
|
from typing import Optional, List
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
MAX_TABLE_NAME_LENGTH = 6
|
MAX_TABLE_NAME_LENGTH = 6
|
||||||
|
|
||||||
@@ -73,6 +74,16 @@ class TableFloorplanUpdate(BaseModel):
|
|||||||
floor_y: float
|
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):
|
class TableOut(BaseModel):
|
||||||
id: int
|
id: int
|
||||||
number: int
|
number: int
|
||||||
@@ -83,5 +94,6 @@ class TableOut(BaseModel):
|
|||||||
floor_y: Optional[float] = None
|
floor_y: Optional[float] = None
|
||||||
group: Optional[TableGroupOut] = None
|
group: Optional[TableGroupOut] = None
|
||||||
has_active_order: bool = False
|
has_active_order: bool = False
|
||||||
|
upcoming_reservation: Optional[UpcomingReservation] = None
|
||||||
|
|
||||||
model_config = {"from_attributes": True}
|
model_config = {"from_attributes": True}
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ class UserBase(BaseModel):
|
|||||||
full_name: Optional[str] = None
|
full_name: Optional[str] = None
|
||||||
nickname: Optional[str] = None
|
nickname: Optional[str] = None
|
||||||
mobile_phone: Optional[str] = None
|
mobile_phone: Optional[str] = None
|
||||||
|
note: Optional[str] = None
|
||||||
avatar_url: Optional[str] = None
|
avatar_url: Optional[str] = None
|
||||||
email: Optional[str] = None
|
email: Optional[str] = None
|
||||||
|
|
||||||
@@ -26,6 +27,8 @@ class UserUpdate(BaseModel):
|
|||||||
full_name: Optional[str] = None
|
full_name: Optional[str] = None
|
||||||
nickname: Optional[str] = None
|
nickname: Optional[str] = None
|
||||||
mobile_phone: Optional[str] = None
|
mobile_phone: Optional[str] = None
|
||||||
|
email: Optional[str] = None
|
||||||
|
note: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
class WaiterZoneOut(BaseModel):
|
class WaiterZoneOut(BaseModel):
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ from middleware.license_check import license_state
|
|||||||
|
|
||||||
ORDER_POLL_INTERVAL = settings.CONNECT_SYNC_INTERVAL_SECONDS
|
ORDER_POLL_INTERVAL = settings.CONNECT_SYNC_INTERVAL_SECONDS
|
||||||
|
|
||||||
|
logging.basicConfig(level=logging.INFO)
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
SYNC_INTERVAL_SECONDS = 5 * 60 # 5 minutes
|
SYNC_INTERVAL_SECONDS = 5 * 60 # 5 minutes
|
||||||
|
|||||||
@@ -57,6 +57,7 @@ _DIVIDER_CHARS = {
|
|||||||
_PRINT_SETTING_KEYS = [
|
_PRINT_SETTING_KEYS = [
|
||||||
"print.ticket_mode",
|
"print.ticket_mode",
|
||||||
"print.divider_style",
|
"print.divider_style",
|
||||||
|
"print.dot_style",
|
||||||
"print.font_order_number",
|
"print.font_order_number",
|
||||||
"print.font_meta",
|
"print.font_meta",
|
||||||
"print.font_item_name",
|
"print.font_item_name",
|
||||||
@@ -73,6 +74,7 @@ _PRINT_SETTING_KEYS = [
|
|||||||
_PRINT_SETTING_DEFAULTS = {
|
_PRINT_SETTING_DEFAULTS = {
|
||||||
"print.ticket_mode": "detailed",
|
"print.ticket_mode": "detailed",
|
||||||
"print.divider_style": "dash",
|
"print.divider_style": "dash",
|
||||||
|
"print.dot_style": "dot_space:0:0",
|
||||||
"print.font_order_number": "48:1:0",
|
"print.font_order_number": "48:1:0",
|
||||||
"print.font_meta": "0:0:0",
|
"print.font_meta": "0:0:0",
|
||||||
"print.font_item_name": "16:1:0",
|
"print.font_item_name": "16:1:0",
|
||||||
@@ -86,6 +88,16 @@ _PRINT_SETTING_DEFAULTS = {
|
|||||||
"print.beep_pattern": "double",
|
"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)
|
# Beep patterns: (n1=on×100ms, n2=off×100ms, n3=count)
|
||||||
# Using ESC BEL n1 n2 n3 (0x1B 0x07 n1 n2 n3)
|
# Using ESC BEL n1 n2 n3 (0x1B 0x07 n1 n2 n3)
|
||||||
_BEEP_PATTERNS = {
|
_BEEP_PATTERNS = {
|
||||||
@@ -124,28 +136,81 @@ def _load_print_settings(db: Session) -> dict:
|
|||||||
return settings
|
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, "-")
|
char = _DIVIDER_CHARS.get(style, "-")
|
||||||
p._raw(b'\x1b\x61\x00')
|
p._raw(b'\x1b\x61\x00')
|
||||||
if char:
|
if char:
|
||||||
p._raw(_gr(char * LINE_WIDTH + "\n"))
|
p._raw(_gr(char * line_width + "\n"))
|
||||||
else:
|
else:
|
||||||
p._raw(b'\n')
|
p._raw(b'\n')
|
||||||
|
|
||||||
|
|
||||||
def _item_line(name: str, qty: int, line_width: int = LINE_WIDTH) -> str:
|
def _item_line(name: str, qty: int, line_width: int = LINE_WIDTH, dot_style: str = "dot_space") -> str:
|
||||||
"""Build a dot-leader line ending with 'xN'.
|
"""Simple single-font dot-leader string — used by receipt and synopsis printers."""
|
||||||
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}"
|
suffix = f" x{qty}"
|
||||||
available = line_width - len(name) - len(suffix)
|
available = line_width - len(name) - 1 - len(suffix)
|
||||||
if available < 2:
|
if available < 1:
|
||||||
# Name alone is too long — put qty on same line with a single space
|
return f"{name} x{qty}"
|
||||||
return f"{name} {suffix}"
|
unit = _DOT_STYLE_UNITS.get(dot_style, ". ")
|
||||||
dots = (". " * ((available // 2) + 1))[:available]
|
dots = (unit * (available // len(unit) + 1))[:available]
|
||||||
return f"{name} {dots}{suffix}"
|
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):
|
def _apply_font(p: Network, size: int, bold: bool):
|
||||||
p._raw(bytes([0x1b, 0x21, size]))
|
p._raw(bytes([0x1b, 0x21, size]))
|
||||||
p._raw(b'\x1b\x45\x01' if bold else b'\x1b\x45\x00')
|
p._raw(b'\x1b\x45\x01' if bold else b'\x1b\x45\x00')
|
||||||
@@ -228,7 +293,7 @@ def send_test_print(ip: str, port: int, name: str) -> Tuple[bool, str]:
|
|||||||
return False, str(e)
|
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."""
|
"""Print a fake order using the current font/layout settings — for settings preview."""
|
||||||
if _is_spoof_mode(db):
|
if _is_spoof_mode(db):
|
||||||
logger.info("Spoof printing ON — dropping test order print")
|
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:
|
try:
|
||||||
p = _get_printer(ip, port)
|
p = _get_printer(ip, port)
|
||||||
_print_kitchen_ticket(p, _Order(), items, _PatchedDB())
|
_print_kitchen_ticket(p, _Order(), items, _PatchedDB(), line_width)
|
||||||
p.close()
|
p.close()
|
||||||
return True, ""
|
return True, ""
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
@@ -415,7 +480,7 @@ def _parse_options(item: OrderItem) -> dict:
|
|||||||
return result
|
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)
|
cfg = _load_print_settings(db)
|
||||||
mode = cfg.get("print.ticket_mode", "detailed")
|
mode = cfg.get("print.ticket_mode", "detailed")
|
||||||
div = cfg.get("print.divider_style", "dash")
|
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}"
|
header = f"Παρ. #{order.id} | Τρ. {table_name} | {now_str} | {waiter_nick}"
|
||||||
_raw_text(p, (header.upper() if c_ord else header) + "\n")
|
_raw_text(p, (header.upper() if c_ord else header) + "\n")
|
||||||
_reset_font(p)
|
_reset_font(p)
|
||||||
_divider(p, div)
|
_divider(p, div, line_width)
|
||||||
|
|
||||||
# ── DETAILED header ──────────────────────────────────────────────────────
|
# ── DETAILED header ──────────────────────────────────────────────────────
|
||||||
else:
|
else:
|
||||||
_print_line(p, f"Παραγγελια #{order.id}", sz_ord, b_ord, c_ord,
|
_print_line(p, f"Παραγγελια #{order.id}", sz_ord, b_ord, c_ord,
|
||||||
align=b'\x1b\x61\x01')
|
align=b'\x1b\x61\x01')
|
||||||
_divider(p, div)
|
_divider(p, div, line_width)
|
||||||
p._raw(b'\x1b\x61\x00')
|
p._raw(b'\x1b\x61\x00')
|
||||||
_apply_font(p, sz_meta, b_meta)
|
_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" Τραπεζι {table_name}\n")
|
||||||
_raw_text(p, ("ΗΜΕΡΟΜΗΝΙΑ:" if c_meta else "Ημερομηνια:") + f" {now_str}\n")
|
_raw_text(p, ("ΗΜΕΡΟΜΗΝΙΑ:" if c_meta else "Ημερομηνια:") + f" {now_str}\n")
|
||||||
_raw_text(p, ("ΣΕΡΒΙΤΟΡΟΣ:" if c_meta else "Σερβιτορος:") + f" {waiter_nick}\n")
|
_raw_text(p, ("ΣΕΡΒΙΤΟΡΟΣ:" if c_meta else "Σερβιτορος:") + f" {waiter_nick}\n")
|
||||||
_reset_font(p)
|
_reset_font(p)
|
||||||
_divider(p, div)
|
_divider(p, div, line_width)
|
||||||
|
|
||||||
# ── Items ────────────────────────────────────────────────────────────────
|
# ── Items ────────────────────────────────────────────────────────────────
|
||||||
# Double-width fonts halve the effective character width
|
# Dot-leader style: "STYLE:SIZE:BOLD"
|
||||||
item_line_width = LINE_WIDTH // 2 if sz_item in (32, 48) else LINE_WIDTH
|
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:
|
for item in items:
|
||||||
product = db.query(Product).filter(Product.id == item.product_id).first()
|
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
|
item_name = raw_name.upper() if c_item else raw_name
|
||||||
|
|
||||||
p._raw(b'\x1b\x61\x00')
|
p._raw(b'\x1b\x61\x00')
|
||||||
_apply_font(p, sz_item, b_item)
|
_print_item_line(
|
||||||
_raw_text(p, _item_line(item_name, item.quantity, item_line_width) + "\n")
|
p, item_name, item.quantity,
|
||||||
_reset_font(p)
|
sz_item, b_item,
|
||||||
|
dot_fill, dot_sz, dot_bold,
|
||||||
|
line_width,
|
||||||
|
)
|
||||||
|
|
||||||
opts = _parse_options(item)
|
opts = _parse_options(item)
|
||||||
|
|
||||||
@@ -572,7 +644,7 @@ def _print_kitchen_ticket(p: Network, order: Order, items: List[OrderItem], db:
|
|||||||
if not compact:
|
if not compact:
|
||||||
p._raw(b'\n')
|
p._raw(b'\n')
|
||||||
|
|
||||||
_divider(p, div)
|
_divider(p, div, line_width)
|
||||||
|
|
||||||
# Order-level notes
|
# Order-level notes
|
||||||
if order.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")
|
_raw_text(p, f"Σημ: {note_text}\n")
|
||||||
_reset_font(p)
|
_reset_font(p)
|
||||||
if not compact:
|
if not compact:
|
||||||
_divider(p, div)
|
_divider(p, div, line_width)
|
||||||
|
|
||||||
# Footer (detailed only)
|
# Footer (detailed only)
|
||||||
if not compact:
|
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"
|
value = f"{od['total']:.2f}e"
|
||||||
times_part = f"{time_open} - {time_close}" if time_close else time_open
|
times_part = f"{time_open} - {time_close}" if time_close else time_open
|
||||||
prefix = f"{times_part} - {table}"
|
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:
|
if gap < 3:
|
||||||
line = f"{prefix} {value}"
|
line = f"{prefix} {value}"
|
||||||
else:
|
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)
|
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):
|
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():
|
if is_spoof_mode():
|
||||||
logger.info("Spoof printing ON — dropping printer report print")
|
logger.info("Spoof printing ON — dropping printer report print")
|
||||||
return
|
return
|
||||||
try:
|
try:
|
||||||
|
lw = report.get("line_width", LINE_WIDTH)
|
||||||
|
div = report.get("div_style", "dash")
|
||||||
|
|
||||||
p = _get_printer(ip, port)
|
p = _get_printer(ip, port)
|
||||||
|
|
||||||
p._raw(b'\x1b\x61\x01')
|
p._raw(b'\x1b\x61\x01')
|
||||||
p._raw(b'\x1b\x21\x30')
|
p._raw(b'\x1b\x21\x30')
|
||||||
_raw_text(p, "ΑΝΑΦΟΡΑ ΕΚΤΥΠΩΤΗ\n")
|
_raw_text(p, "ΑΝΑΦΟΡΑ ΕΚΤΥΠΩΤΗ\n")
|
||||||
p._raw(b'\x1b\x21\x00')
|
p._raw(b'\x1b\x21\x00')
|
||||||
_divider(p)
|
|
||||||
|
|
||||||
p._raw(b'\x1b\x61\x00')
|
p._raw(b'\x1b\x61\x00')
|
||||||
p._raw(b'\x1b\x21\x10')
|
p._raw(b'\x1b\x21\x10')
|
||||||
_raw_text(p, f"Εκτυπωτης: {report['printer_name']}\n")
|
period = report.get("period_label", "")
|
||||||
_raw_text(p, f"Απο: {report['from_dt']}\n")
|
if report.get("period_is_workday"):
|
||||||
_raw_text(p, f"Εως: {report['to_dt']}\n")
|
_raw_text(p, f"Εργασιμη: {period}\n")
|
||||||
|
else:
|
||||||
|
_raw_text(p, f"Περιοδος: {period}\n")
|
||||||
p._raw(b'\x1b\x21\x00')
|
p._raw(b'\x1b\x21\x00')
|
||||||
_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')
|
p._raw(b'\x1b\x21\x10')
|
||||||
_raw_text(p, f"Εργασιες εκτ.: {report['print_jobs']}\n")
|
_raw_text(p, "ΣΥΝΟΛΟ ΟΛΩΝ\n")
|
||||||
_raw_text(p, f"Παραγγελιες: {report['orders']}\n")
|
|
||||||
_raw_text(p, f"Αντικειμενα: {report['items']}\n")
|
|
||||||
p._raw(b'\x1b\x21\x00')
|
p._raw(b'\x1b\x21\x00')
|
||||||
_divider(p)
|
p._raw(b'\x1b\x61\x00')
|
||||||
|
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\x61\x01')
|
||||||
p._raw(b'\x1b\x21\x30')
|
p._raw(b'\x1b\x21\x30')
|
||||||
_raw_text(p, f"ΣΥΝΟΛΟ: {report['total']:.2f}e\n")
|
_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')
|
p._raw(b'\x1b\x21\x00')
|
||||||
|
|
||||||
if mode == "extensive" and report.get("order_data"):
|
# Optional product breakdown section
|
||||||
_divider(p)
|
if report.get("item_breakdown") and blocks:
|
||||||
p._raw(b'\x1b\x61\x00')
|
_printer_item_breakdown(p, blocks, lw, div)
|
||||||
_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")
|
|
||||||
|
|
||||||
p._raw(b'\n\n\n')
|
p._raw(b'\n\n\n')
|
||||||
p.cut()
|
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)
|
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."""
|
"""Print a manager-triggered order receipt."""
|
||||||
if is_spoof_mode():
|
if is_spoof_mode():
|
||||||
logger.info("Spoof printing ON — dropping order receipt print")
|
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')
|
p._raw(b'\x1b\x21\x30')
|
||||||
_raw_text(p, f"ΠΑΡΑΓΓΕΛΙΑ #{receipt['order_id']}\n")
|
_raw_text(p, f"ΠΑΡΑΓΓΕΛΙΑ #{receipt['order_id']}\n")
|
||||||
p._raw(b'\x1b\x21\x00')
|
p._raw(b'\x1b\x21\x00')
|
||||||
_divider(p)
|
_divider(p, line_width=line_width)
|
||||||
|
|
||||||
p._raw(b'\x1b\x61\x00')
|
p._raw(b'\x1b\x61\x00')
|
||||||
p._raw(b'\x1b\x21\x10')
|
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"):
|
if receipt.get("closed_at"):
|
||||||
_raw_text(p, f"Εκλεισε: {receipt['closed_at']}\n")
|
_raw_text(p, f"Εκλεισε: {receipt['closed_at']}\n")
|
||||||
p._raw(b'\x1b\x21\x00')
|
p._raw(b'\x1b\x21\x00')
|
||||||
_divider(p)
|
_divider(p, line_width=line_width)
|
||||||
|
|
||||||
for item in receipt.get("items", []):
|
for item in receipt.get("items", []):
|
||||||
p._raw(b'\x1b\x21\x10')
|
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')
|
p._raw(b'\x1b\x21\x00')
|
||||||
_raw_text(p, f" {item['unit_price']:.2f}e x{item['quantity']} = {item['total']:.2f}e\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 receipt.get("notes"):
|
if receipt.get("notes"):
|
||||||
p._raw(b'\x1b\x21\x10')
|
p._raw(b'\x1b\x21\x10')
|
||||||
_raw_text(p, f"Σημ: {receipt['notes']}\n")
|
_raw_text(p, f"Σημ: {receipt['notes']}\n")
|
||||||
p._raw(b'\x1b\x21\x00')
|
p._raw(b'\x1b\x21\x00')
|
||||||
_divider(p)
|
_divider(p, line_width=line_width)
|
||||||
|
|
||||||
p._raw(b'\x1b\x61\x01')
|
p._raw(b'\x1b\x61\x01')
|
||||||
p._raw(b'\x1b\x21\x30')
|
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)
|
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)."""
|
"""Print a waiter-triggered order synopsis (not a kitchen ticket)."""
|
||||||
if is_spoof_mode():
|
if is_spoof_mode():
|
||||||
logger.info("Spoof printing ON — dropping order synopsis print")
|
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')
|
p._raw(b'\x1b\x21\x30')
|
||||||
_raw_text(p, "ΣΥΝΟΨΗ ΠΑΡΑΓΓΕΛΙΑΣ\n")
|
_raw_text(p, "ΣΥΝΟΨΗ ΠΑΡΑΓΓΕΛΙΑΣ\n")
|
||||||
p._raw(b'\x1b\x21\x00')
|
p._raw(b'\x1b\x21\x00')
|
||||||
_divider(p)
|
_divider(p, line_width=line_width)
|
||||||
|
|
||||||
p._raw(b'\x1b\x61\x00')
|
p._raw(b'\x1b\x61\x00')
|
||||||
p._raw(b'\x1b\x21\x10')
|
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['waiter_name']}\n")
|
||||||
_raw_text(p, f"Ωρα: {synopsis['opened_at']}\n")
|
_raw_text(p, f"Ωρα: {synopsis['opened_at']}\n")
|
||||||
p._raw(b'\x1b\x21\x00')
|
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"]
|
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"]
|
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")
|
_raw_text(p, "ΕΚΚΡΕΜΗ:\n")
|
||||||
p._raw(b'\x1b\x21\x00')
|
p._raw(b'\x1b\x21\x00')
|
||||||
for item in active_items:
|
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")
|
_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:
|
if paid_items:
|
||||||
p._raw(b'\x1b\x21\x10')
|
p._raw(b'\x1b\x21\x10')
|
||||||
_raw_text(p, "ΠΛΗΡΩΜΕΝΑ:\n")
|
_raw_text(p, "ΠΛΗΡΩΜΕΝΑ:\n")
|
||||||
p._raw(b'\x1b\x21\x00')
|
p._raw(b'\x1b\x21\x00')
|
||||||
for item in paid_items:
|
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")
|
_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\x61\x01')
|
||||||
p._raw(b'\x1b\x21\x30')
|
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)
|
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 ────────────────────────────────────────────────────────────
|
# ── Routing logic ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
def route_and_print(order_id: int, item_ids: List[int]):
|
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
|
error_msg = None
|
||||||
try:
|
try:
|
||||||
p = _get_printer(printer.ip_address, printer.port)
|
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()
|
p.close()
|
||||||
success = True
|
success = True
|
||||||
for item in zone_items:
|
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