feat(local_backend): reservations, print analytics, workday summary, zone-PIN management

- New reservations module: model, schema, router (CRUD + status updates + upcoming alerts)
  and background task for auto-expiring stale reservations
- Reports: print_products, print_categories, print_tables analytics endpoints
  plus meta_products and business_day_summary for workday close/view flow
- printer_service: configurable font sizes/weights, donut/bar chart print layout helpers,
  analytics print blocks per printer
- tables/schemas: surfaced color, zone, and other new fields on Table, Product, User, Printer
- demo_seed.py for quick dev DB population; wipe_database.py utility

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-06-07 20:25:22 +03:00
parent aed71a18d8
commit 79bd3b0f41
25 changed files with 2017 additions and 146 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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