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:
@@ -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),
|
||||
|
||||
Reference in New Issue
Block a user