Files
xenia-pos-local/local_backend/routers/reports.py
bonamin 5de89a722c feat: major dashboard & waiter PWA overhaul
- Manager dashboard: replaced monolithic DashboardTab/OperationsPage with new
  DashboardPage; added OrderDetailModal, ShiftDetailModal, DeleteConfirmModal,
  PaymentMethodModal; updated Sidebar routing and App navigation
- Reports: reworked WorkDaySummary, OrderHistory, ShiftsOverview with detail modals
- Backend routers: extended orders, reports, shifts, products, business_day endpoints;
  updated cloud_sync service
- Waiter PWA: refreshed app icons, improved ConnectionLostModal UX, updated
  TableCard, SSEContext, connectionStore; added useProductCache hook; vite config tweaks

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-21 15:24:54 +03:00

1270 lines
48 KiB
Python

import csv
import io
import json
from fastapi import APIRouter, Depends, Query, HTTPException, BackgroundTasks
from fastapi.responses import StreamingResponse
from pydantic import BaseModel
from sqlalchemy.orm import Session
from sqlalchemy import func
from datetime import date, datetime, timedelta
from typing import Optional, List
from database import get_db
from models.order import Order, OrderItem, OrderWaiter, PrintLog
from models.user import User
from models.table import Table
from models.printer import Printer
from models.shift import WaiterShift
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
router = APIRouter()
def _dt(dt):
if dt is None:
return None
return (dt.isoformat() + "Z") if dt.tzinfo is None else dt.isoformat()
@router.get("/shift")
def shift_summary(
from_dt: Optional[str] = Query(default=None, alias="from"),
to_dt: Optional[str] = Query(default=None, alias="to"),
report_date: Optional[date] = Query(default=None, alias="date"),
waiter_id: Optional[int] = None,
db: Session = Depends(get_db),
user: User = Depends(require_manager),
):
"""Payments collected per waiter — based on paid_by on order items."""
if from_dt and to_dt:
start = datetime.fromisoformat(from_dt)
end = datetime.fromisoformat(to_dt)
else:
target = report_date or date.today()
start = datetime.combine(target, datetime.min.time())
end = start + timedelta(days=1)
q = db.query(OrderItem).filter(
OrderItem.status == "paid",
OrderItem.paid_at >= start,
OrderItem.paid_at < end,
)
if waiter_id:
q = q.filter(OrderItem.paid_by == waiter_id)
items = q.all()
waiters_db = {u.id: u for u in db.query(User).all()}
tables_db = {t.id: (t.label or f"T{t.number}") for t in db.query(Table).all()}
# Build per-waiter summary keyed by waiter_id
summary: dict[int, dict] = {}
for item in items:
wid = item.paid_by
if wid not in summary:
w = waiters_db.get(wid)
wname = (w.full_name or w.username) if w else f"#{wid}"
summary[wid] = {
"waiter_id": wid,
"waiter_name": wname,
"items": 0,
"total": 0.0,
"order_data": {},
}
summary[wid]["items"] += item.quantity
val = item.unit_price * item.quantity
summary[wid]["total"] += val
oid = item.order_id
if oid not in summary[wid]["order_data"]:
order = db.query(Order).filter(Order.id == oid).first()
summary[wid]["order_data"][oid] = {
"id": oid,
"time_open": order.opened_at.strftime("%H:%M") if order else "",
"time_close": order.closed_at.strftime("%H:%M") if order and order.closed_at else "",
"table": tables_db.get(order.table_id, f"#{oid}") if order else f"#{oid}",
"total": 0.0,
"items": [],
}
summary[wid]["order_data"][oid]["total"] += val
product_name = item.product.name if item.product else f"#{item.product_id}"
summary[wid]["order_data"][oid]["items"].append(
{"name": product_name, "quantity": item.quantity}
)
result = []
for entry in summary.values():
entry["orders"] = len(entry["order_data"])
entry["order_data"] = list(entry["order_data"].values())
result.append(entry)
return {"from": start.isoformat(), "to": end.isoformat(), "waiters": result}
@router.get("/shift/orders")
def shift_orders_summary(
from_dt: Optional[str] = Query(default=None, alias="from"),
to_dt: Optional[str] = Query(default=None, alias="to"),
report_date: Optional[date] = Query(default=None, alias="date"),
waiter_id: Optional[int] = None,
business_day_id: Optional[int] = None,
db: Session = Depends(get_db),
user: User = Depends(require_manager),
):
"""Items sent (added) per waiter — regardless of payment status."""
q = db.query(OrderItem).filter(OrderItem.status.in_(["active", "paid"]))
if business_day_id:
q = q.join(Order).filter(Order.business_day_id == business_day_id)
else:
if from_dt and to_dt:
start = datetime.fromisoformat(from_dt)
end = datetime.fromisoformat(to_dt)
else:
target = report_date or date.today()
start = datetime.combine(target, datetime.min.time())
end = start + timedelta(days=1)
q = q.filter(OrderItem.added_at >= start, OrderItem.added_at < end)
if waiter_id:
q = q.filter(OrderItem.added_by == waiter_id)
items = q.all()
waiters_db = {u.id: u for u in db.query(User).all()}
tables_db = {t.id: (t.label or f"T{t.number}") for t in db.query(Table).all()}
summary: dict[int, dict] = {}
for item in items:
wid = item.added_by
if wid not in summary:
w = waiters_db.get(wid)
wname = (w.full_name or w.username) if w else f"#{wid}"
summary[wid] = {
"waiter_id": wid,
"waiter_name": wname,
"items": 0,
"total": 0.0,
"order_data": {},
}
summary[wid]["items"] += item.quantity
val = item.unit_price * item.quantity
summary[wid]["total"] += val
oid = item.order_id
if oid not in summary[wid]["order_data"]:
order = db.query(Order).filter(Order.id == oid).first()
summary[wid]["order_data"][oid] = {
"id": oid,
"time_open": order.opened_at.strftime("%H:%M") if order else "",
"time_close": order.closed_at.strftime("%H:%M") if order and order.closed_at else "",
"table": tables_db.get(order.table_id, f"#{oid}") if order else f"#{oid}",
"total": 0.0,
"items": [],
}
summary[wid]["order_data"][oid]["total"] += val
product_name = item.product.name if item.product else f"#{item.product_id}"
summary[wid]["order_data"][oid]["items"].append(
{"name": product_name, "quantity": item.quantity}
)
result = []
for entry in summary.values():
entry["orders"] = len(entry["order_data"])
entry["order_data"] = list(entry["order_data"].values())
result.append(entry)
return {"from": start.isoformat(), "to": end.isoformat(), "waiters": result}
@router.get("/orders/history")
def order_history(
from_date: Optional[str] = Query(default=None, alias="from"),
to_date: Optional[str] = Query(default=None, alias="to"),
waiter_id: Optional[int] = None,
order_status: Optional[str] = Query(default=None, alias="status"),
table_id: Optional[int] = None,
business_day_id: Optional[int] = None,
page: int = 1,
page_size: int = 50,
db: Session = Depends(get_db),
user: User = Depends(require_manager),
):
from sqlalchemy.orm import joinedload
from models.table import Table as TableModel
q = db.query(Order).options(
joinedload(Order.items).joinedload(OrderItem.product),
joinedload(Order.waiters),
joinedload(Order.audit_logs),
)
if business_day_id:
q = q.filter(Order.business_day_id == business_day_id)
elif from_date or to_date:
if from_date:
q = q.filter(Order.opened_at >= datetime.fromisoformat(from_date))
if to_date:
q = q.filter(Order.opened_at <= datetime.fromisoformat(to_date))
if waiter_id:
q = q.join(OrderWaiter).filter(OrderWaiter.waiter_id == waiter_id)
if order_status:
q = q.filter(Order.status == order_status)
if table_id:
q = q.filter(Order.table_id == table_id)
orders = q.order_by(Order.opened_at.desc()).offset((page - 1) * page_size).limit(page_size).all()
# Collect all waiter IDs and table IDs to resolve in bulk
waiter_ids: set[int] = set()
table_ids: set[int] = set()
item_waiter_ids: set[int] = set()
for o in orders:
if o.opened_by:
waiter_ids.add(o.opened_by)
if o.closed_by:
waiter_ids.add(o.closed_by)
if o.table_id:
table_ids.add(o.table_id)
for item in o.items:
if item.added_by:
item_waiter_ids.add(item.added_by)
if item.paid_by:
item_waiter_ids.add(item.paid_by)
for log in o.audit_logs:
if log.waiter_id:
waiter_ids.add(log.waiter_id)
all_waiter_ids = waiter_ids | item_waiter_ids
waiters_map: dict[int, str] = {}
if all_waiter_ids:
for w in db.query(User).filter(User.id.in_(all_waiter_ids)).all():
waiters_map[w.id] = w.full_name or w.username
tables_map: dict[int, str] = {}
if table_ids:
for t in db.query(TableModel).filter(TableModel.id.in_(table_ids)).all():
prefix = (t.group.prefix if t.group and t.group.prefix else "") if t.group else ""
tables_map[t.id] = t.label if t.label else f"{prefix}{t.number}"
def _wname(wid):
if wid is None:
return None
return waiters_map.get(wid, f"#{wid}")
def _dt_local(dt):
if dt is None:
return None
return (dt.isoformat() + "Z") if dt.tzinfo is None else dt.isoformat()
result = []
for o in orders:
items_out = []
for item in o.items:
items_out.append({
"id": item.id,
"order_id": item.order_id,
"product_id": item.product_id,
"product": {"id": item.product.id, "name": item.product.name} if item.product else None,
"added_by": item.added_by,
"added_by_name": _wname(item.added_by),
"added_at": _dt_local(item.added_at),
"quantity": item.quantity,
"unit_price": float(item.unit_price),
"status": item.status,
"paid_by": item.paid_by,
"paid_by_name": _wname(item.paid_by),
"paid_at": _dt_local(item.paid_at),
"payment_method": item.payment_method,
"paid_in_shift_id": item.paid_in_shift_id,
"notes": item.notes,
"printed": item.printed,
"selected_options": item.selected_options,
"removed_ingredients": item.removed_ingredients,
})
audit_out = []
for log in o.audit_logs:
audit_out.append({
"id": log.id,
"order_id": log.order_id,
"event_type": log.event_type,
"waiter_id": log.waiter_id,
"waiter_name": _wname(log.waiter_id),
"item_ids": log.item_ids,
"amount": log.amount,
"payment_method": log.payment_method,
"note": log.note,
"created_at": _dt_local(log.created_at),
"offline_at": log.offline_at,
"is_duplicate": log.is_duplicate,
})
result.append({
"id": o.id,
"table_id": o.table_id,
"table_name": tables_map.get(o.table_id) if o.table_id else None,
"opened_by": o.opened_by,
"opened_by_name": _wname(o.opened_by),
"opened_at": _dt_local(o.opened_at),
"closed_by": o.closed_by,
"closed_by_name": _wname(o.closed_by),
"closed_at": _dt_local(o.closed_at),
"status": o.status,
"notes": o.notes,
"business_day_id": o.business_day_id,
"items": items_out,
"waiters": [{"waiter_id": w.waiter_id} for w in o.waiters],
"audit_logs": audit_out,
})
return result
@router.get("/tables/summary")
def tables_summary(db: Session = Depends(get_db), user: User = Depends(require_manager)):
tables = db.query(Table).filter(Table.is_active == True).all()
result = []
for table in tables:
active_order = db.query(Order).filter(
Order.table_id == table.id,
Order.status.in_(["open", "partially_paid"]),
).first()
result.append({
"table": TableOut.model_validate(table),
"status": active_order.status if active_order else "free",
"order_id": active_order.id if active_order else None,
})
return result
@router.get("/printers")
def printer_totals(
from_date: Optional[str] = Query(default=None, alias="from"),
to_date: Optional[str] = Query(default=None, alias="to"),
db: Session = Depends(get_db),
user: User = Depends(require_manager),
):
"""Returns totals per printer based on print_log entries in the date range."""
q = db.query(PrintLog).filter(PrintLog.success == True)
if from_date:
q = q.filter(PrintLog.printed_at >= datetime.fromisoformat(from_date))
if to_date:
q = q.filter(PrintLog.printed_at <= datetime.fromisoformat(to_date))
logs = q.all()
printers_db = {p.id: p for p in db.query(Printer).all()}
tables_db = {t.id: (t.label or f"T{t.number}") for t in db.query(Table).all()}
# summary[pid] — aggregated totals
summary: dict[int, dict] = {}
# order_map[pid][order_id] — per-order detail with items
order_map: dict[int, dict] = {}
for log in logs:
pid = log.printer_id
if pid not in summary:
printer = printers_db.get(pid)
summary[pid] = {
"printer_id": pid,
"printer_name": printer.name if printer else f"Printer #{pid}",
"print_jobs": 0,
"orders": set(),
"items": 0,
"total": 0.0,
}
order_map[pid] = {}
summary[pid]["print_jobs"] += 1
summary[pid]["orders"].add(log.order_id)
oid = log.order_id
if oid not in order_map[pid]:
order = db.query(Order).filter(Order.id == oid).first()
order_map[pid][oid] = {
"order_id": oid,
"time": log.printed_at.strftime("%H:%M"),
"table": tables_db.get(order.table_id, f"#{oid}") if order else f"#{oid}",
"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"):
summary[pid]["items"] += item.quantity
val = item.unit_price * item.quantity
summary[pid]["total"] += val
order_map[pid][oid]["total"] += val
product_name = item.product.name if item.product else f"#{item.product_id}"
order_map[pid][oid]["items"].append({"name": product_name, "quantity": item.quantity})
result = []
for pid, entry in summary.items():
entry["orders"] = len(entry["orders"])
entry["order_data"] = list(order_map.get(pid, {}).values())
result.append(entry)
return {"printers": result}
class PrintWaiterReportBody(BaseModel):
waiter_name: str
printer_id: int
mode: str # "simple" | "extensive"
from_dt: str
to_dt: str
class PrintPrinterReportBody(BaseModel):
printer_target_id: int
printer_id: int
mode: str # "simple" | "extensive"
from_dt: str
to_dt: str
class PrintOrderBody(BaseModel):
printer_id: int
@router.post("/print/waiter")
def print_waiter(
body: PrintWaiterReportBody,
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")
from_dt = datetime.fromisoformat(body.from_dt)
to_dt = datetime.fromisoformat(body.to_dt)
# Gather orders for this waiter in time range
waiter = db.query(User).filter(User.username == body.waiter_name).first()
q = db.query(Order).filter(
Order.opened_at >= from_dt,
Order.opened_at <= to_dt,
)
if waiter:
q = q.filter(Order.opened_by == waiter.id)
else:
q = q.filter(False)
orders = q.all()
# Enrich with table names
tables = {t.id: (t.label or f"T{t.number}") for t in db.query(Table).all()}
order_data = []
for o in orders:
active_items = [i for i in o.items if i.status in ("active", "paid")]
total = sum(i.unit_price * i.quantity for i in active_items)
order_data.append({
"id": o.id,
"time_open": o.opened_at.strftime("%H:%M"),
"time_close": o.closed_at.strftime("%H:%M") if o.closed_at else "",
"table": tables.get(o.table_id, f"#{o.table_id}"),
"total": total,
"items": [
{"name": (i.product.name if i.product else f"#{i.product_id}"), "quantity": i.quantity}
for i in active_items
],
})
items_count = sum(
i.quantity for o in orders for i in o.items if i.status in ("active", "paid")
)
grand_total = sum(d["total"] for d in order_data)
report = {
"waiter_name": body.waiter_name,
"orders": len(orders),
"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"),
}
background_tasks.add_task(print_waiter_report, printer.ip_address, printer.port, report, body.mode)
return {"status": "printing"}
@router.post("/print/printer")
def print_printer_totals(
body: PrintPrinterReportBody,
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")
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})
order_data = list(order_map.values())
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"),
}
background_tasks.add_task(print_printer_report, printer.ip_address, printer.port, report, body.mode)
return {"status": "printing"}
# ---------------------------------------------------------------------------
# Shift history report
# ---------------------------------------------------------------------------
@router.get("/shifts")
def shifts_report(
waiter_id: Optional[int] = None,
business_day_id: Optional[int] = None,
from_dt: Optional[str] = Query(default=None, alias="from"),
to_dt: Optional[str] = Query(default=None, alias="to"),
active_only: bool = False,
db: Session = Depends(get_db),
user: User = Depends(require_manager),
):
from routers.shifts import compute_shift_total
q = db.query(WaiterShift)
if waiter_id:
q = q.filter(WaiterShift.waiter_id == waiter_id)
if business_day_id:
q = q.filter(WaiterShift.business_day_id == business_day_id)
if from_dt:
q = q.filter(WaiterShift.started_at >= datetime.fromisoformat(from_dt))
if to_dt:
q = q.filter(WaiterShift.started_at <= datetime.fromisoformat(to_dt))
if active_only:
q = q.filter(WaiterShift.ended_at == None)
shifts = q.order_by(WaiterShift.started_at.desc()).all()
waiters_db = {u.id: u for u in db.query(User).all()}
result = []
for shift in shifts:
w = waiters_db.get(shift.waiter_id)
wname = (w.full_name or w.username) if w else f"#{shift.waiter_id}"
total = compute_shift_total(shift.id, db) if shift.ended_at is None else (shift.total_collected or 0.0)
result.append({
"id": shift.id,
"waiter_id": shift.waiter_id,
"waiter_name": wname,
"business_day_id": shift.business_day_id,
"started_at": _dt(shift.started_at),
"ended_at": _dt(shift.ended_at),
"starting_cash": shift.starting_cash,
"total_collected": total,
"net_to_deliver": round(total + (shift.starting_cash or 0.0), 2),
"is_active": shift.ended_at is None,
"notes": shift.notes,
})
return {"shifts": result}
# ---------------------------------------------------------------------------
# Product performance analytics
# ---------------------------------------------------------------------------
@router.get("/products/performance")
def product_performance(
from_dt: Optional[str] = Query(default=None, alias="from"),
to_dt: Optional[str] = Query(default=None, alias="to"),
business_day_id: Optional[int] = None,
category_id: Optional[int] = None,
db: Session = Depends(get_db),
user: User = Depends(require_manager),
):
from models.product import Product
q = db.query(OrderItem).filter(OrderItem.status.in_(["active", "paid"]))
if from_dt:
q = q.filter(OrderItem.added_at >= datetime.fromisoformat(from_dt))
if to_dt:
q = q.filter(OrderItem.added_at <= datetime.fromisoformat(to_dt))
if business_day_id:
q = q.join(Order).filter(Order.business_day_id == business_day_id)
items = q.all()
products_db = {p.id: p for p in db.query(Product).all()}
summary: dict = {}
for item in items:
pid = item.product_id
product = products_db.get(pid)
if category_id and (not product or product.category_id != category_id):
continue
if pid not in summary:
summary[pid] = {
"product_id": pid,
"product_name": product.name if product else f"#{pid}",
"category_id": product.category_id if product else None,
"qty_sold": 0,
"revenue": 0.0,
"order_ids": set(),
}
summary[pid]["qty_sold"] += item.quantity
summary[pid]["revenue"] += item.unit_price * item.quantity
summary[pid]["order_ids"].add(item.order_id)
result = []
for entry in summary.values():
entry["order_count"] = len(entry.pop("order_ids"))
entry["revenue"] = round(entry["revenue"], 2)
result.append(entry)
result.sort(key=lambda x: x["qty_sold"], reverse=True)
return {"products": result}
# ---------------------------------------------------------------------------
# Table performance analytics
# ---------------------------------------------------------------------------
@router.get("/tables/performance")
def table_performance(
from_dt: Optional[str] = Query(default=None, alias="from"),
to_dt: Optional[str] = Query(default=None, alias="to"),
business_day_id: Optional[int] = None,
db: Session = Depends(get_db),
user: User = Depends(require_manager),
):
q = db.query(Order).filter(Order.status.in_(["closed", "paid"]))
if from_dt:
q = q.filter(Order.opened_at >= datetime.fromisoformat(from_dt))
if to_dt:
q = q.filter(Order.opened_at <= datetime.fromisoformat(to_dt))
if business_day_id:
q = q.filter(Order.business_day_id == business_day_id)
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] = {
"table_id": tid,
"table_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
)
result = []
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)
result.append(entry)
result.sort(key=lambda x: x["revenue"], reverse=True)
return {"tables": result}
# ---------------------------------------------------------------------------
# Traffic analysis (hour-of-day / day-of-week)
# ---------------------------------------------------------------------------
@router.get("/traffic")
def traffic_analysis(
from_dt: Optional[str] = Query(default=None, alias="from"),
to_dt: Optional[str] = Query(default=None, alias="to"),
business_day_id: Optional[int] = None,
db: Session = Depends(get_db),
user: User = Depends(require_manager),
):
q = db.query(Order)
if from_dt:
q = q.filter(Order.opened_at >= datetime.fromisoformat(from_dt))
if to_dt:
q = q.filter(Order.opened_at <= datetime.fromisoformat(to_dt))
if business_day_id:
q = q.filter(Order.business_day_id == business_day_id)
orders = q.all()
by_hour = {h: {"hour": h, "orders": 0, "revenue": 0.0} for h in range(24)}
day_labels = ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"]
by_weekday = {d: {"day": d, "label": day_labels[d], "orders": 0, "revenue": 0.0} for d in range(7)}
for order in orders:
revenue = sum(
i.unit_price * i.quantity for i in order.items if i.status in ("active", "paid")
)
h = order.opened_at.hour
d = order.opened_at.weekday()
by_hour[h]["orders"] += 1
by_hour[h]["revenue"] += revenue
by_weekday[d]["orders"] += 1
by_weekday[d]["revenue"] += revenue
for h in by_hour:
by_hour[h]["revenue"] = round(by_hour[h]["revenue"], 2)
for d in by_weekday:
by_weekday[d]["revenue"] = round(by_weekday[d]["revenue"], 2)
return {
"by_hour": list(by_hour.values()),
"by_weekday": list(by_weekday.values()),
}
# ---------------------------------------------------------------------------
# Business days list
# ---------------------------------------------------------------------------
@router.get("/business-days")
def business_days_list(
from_dt: Optional[str] = Query(default=None, alias="from"),
to_dt: Optional[str] = Query(default=None, alias="to"),
db: Session = Depends(get_db),
user: User = Depends(require_manager),
):
q = db.query(BusinessDay)
if from_dt:
q = q.filter(BusinessDay.opened_at >= datetime.fromisoformat(from_dt))
if to_dt:
q = q.filter(BusinessDay.opened_at <= datetime.fromisoformat(to_dt))
days = q.order_by(BusinessDay.opened_at.desc()).all()
waiters_db = {u.id: u for u in db.query(User).all()}
result = []
for d in days:
orders = db.query(Order).filter(Order.business_day_id == d.id).all()
closed_orders = [o for o in orders if o.status in ("closed", "paid")]
cancelled_orders = [o for o in orders if o.status == "cancelled"]
day_shifts = db.query(WaiterShift).filter(WaiterShift.business_day_id == d.id).all()
waiter_ids = {s.waiter_id for s in day_shifts}
revenue = sum(
sum(i.unit_price * i.quantity for i in o.items if i.status in ("active", "paid"))
for o in closed_orders
)
opener = waiters_db.get(d.opened_by_id)
closer = waiters_db.get(d.closed_by_id) if d.closed_by_id else None
result.append({
"id": d.id,
"status": d.status,
"opened_at": _dt(d.opened_at),
"closed_at": _dt(d.closed_at),
"opened_by": (opener.full_name or opener.username) if opener else None,
"closed_by": (closer.full_name or closer.username) if closer else None,
"notes": d.notes,
"order_count": len(orders),
"closed_order_count": len(closed_orders),
"cancellation_count": len(cancelled_orders),
"waiter_count": len(waiter_ids),
"shift_count": len(day_shifts),
"revenue": round(revenue, 2),
})
return {"business_days": result}
@router.get("/business-days/current")
def current_business_day(
db: Session = Depends(get_db),
user: User = Depends(require_manager),
):
day = db.query(BusinessDay).filter(BusinessDay.status == "open").order_by(BusinessDay.opened_at.desc()).first()
if not day:
return {"business_day": None}
orders = db.query(Order).filter(Order.business_day_id == day.id).all()
closed_orders = [o for o in orders if o.status in ("closed", "paid")]
open_orders = [o for o in orders if o.status in ("open", "partially_paid")]
cancelled_orders = [o for o in orders if o.status == "cancelled"]
active_shifts = db.query(WaiterShift).filter(
WaiterShift.business_day_id == day.id,
WaiterShift.ended_at == None,
).all()
revenue = sum(
sum(i.unit_price * i.quantity for i in o.items if i.status in ("active", "paid"))
for o in closed_orders
)
item_counts: dict = {}
for o in orders:
if o.status == "cancelled":
continue
for item in o.items:
if item.status in ("active", "paid"):
pid = item.product_id
item_counts[pid] = item_counts.get(pid, 0) + item.quantity
top_product_id = max(item_counts, key=item_counts.get) if item_counts else None
top_product = None
if top_product_id:
from models.product import Product
p = db.query(Product).filter(Product.id == top_product_id).first()
top_product = {"id": top_product_id, "name": p.name if p else f"#{top_product_id}", "qty": item_counts[top_product_id]}
return {
"business_day": {
"id": day.id,
"opened_at": _dt(day.opened_at),
"revenue": round(revenue, 2),
"orders_closed": len(closed_orders),
"orders_open": len(open_orders),
"active_waiters": len(active_shifts),
"cancellations": len(cancelled_orders),
"top_product": top_product,
}
}
# ---------------------------------------------------------------------------
# Revenue trends
# ---------------------------------------------------------------------------
@router.get("/revenue/trends")
def revenue_trends(
from_dt: Optional[str] = Query(default=None, alias="from"),
to_dt: Optional[str] = Query(default=None, alias="to"),
granularity: str = Query(default="daily"), # daily | weekly | monthly
db: Session = Depends(get_db),
user: User = Depends(require_manager),
):
q = db.query(Order).filter(Order.status.in_(["closed", "paid"]))
if from_dt:
q = q.filter(Order.opened_at >= datetime.fromisoformat(from_dt))
if to_dt:
q = q.filter(Order.opened_at <= datetime.fromisoformat(to_dt))
orders = q.all()
buckets: dict = {}
for order in orders:
dt = order.opened_at
if granularity == "monthly":
key = dt.strftime("%Y-%m")
elif granularity == "weekly":
monday = dt - timedelta(days=dt.weekday())
key = monday.strftime("%Y-%m-%d")
else:
key = dt.strftime("%Y-%m-%d")
if key not in buckets:
buckets[key] = {"date": key, "revenue": 0.0, "orders": 0}
rev = sum(i.unit_price * i.quantity for i in order.items if i.status in ("active", "paid"))
buckets[key]["revenue"] += rev
buckets[key]["orders"] += 1
arr = sorted(buckets.values(), key=lambda x: x["date"])
for d in arr:
d["revenue"] = round(d["revenue"], 2)
if granularity == "daily":
for i, d in enumerate(arr):
window = arr[max(0, i - 6): i + 1]
d["rolling7"] = round(sum(x["revenue"] for x in window) / len(window), 2)
return {"trends": arr, "granularity": granularity}
# ---------------------------------------------------------------------------
# Category performance
# ---------------------------------------------------------------------------
@router.get("/categories/performance")
def category_performance(
from_dt: Optional[str] = Query(default=None, alias="from"),
to_dt: Optional[str] = Query(default=None, alias="to"),
business_day_id: Optional[int] = None,
db: Session = Depends(get_db),
user: User = Depends(require_manager),
):
from models.product import Product, Category
q = db.query(OrderItem).filter(OrderItem.status.in_(["active", "paid"]))
if from_dt:
q = q.filter(OrderItem.added_at >= datetime.fromisoformat(from_dt))
if to_dt:
q = q.filter(OrderItem.added_at <= datetime.fromisoformat(to_dt))
if business_day_id:
q = q.join(Order).filter(Order.business_day_id == business_day_id)
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: dict = {}
for item in items:
product = products_db.get(item.product_id)
if not product:
continue
cid = product.category_id
if cid not in summary:
cat = categories_db.get(cid)
summary[cid] = {
"category_id": cid,
"category_name": cat.name if cat else f"#{cid}",
"color": cat.color if cat and hasattr(cat, "color") else None,
"units_sold": 0,
"revenue": 0.0,
"product_ids": set(),
}
summary[cid]["units_sold"] += item.quantity
summary[cid]["revenue"] += item.unit_price * item.quantity
summary[cid]["product_ids"].add(item.product_id)
total_rev = sum(v["revenue"] for v in summary.values())
result = []
for entry in summary.values():
entry["product_count"] = len(entry.pop("product_ids"))
entry["revenue"] = round(entry["revenue"], 2)
entry["pct"] = round((entry["revenue"] / total_rev * 100) if total_rev else 0, 1)
result.append(entry)
result.sort(key=lambda x: x["revenue"], reverse=True)
return {"categories": result}
# ---------------------------------------------------------------------------
# Cancellations log
# ---------------------------------------------------------------------------
@router.get("/cancellations")
def cancellations_log(
from_dt: Optional[str] = Query(default=None, alias="from"),
to_dt: Optional[str] = Query(default=None, alias="to"),
business_day_id: Optional[int] = None,
waiter_id: Optional[int] = None,
db: Session = Depends(get_db),
user: User = Depends(require_manager),
):
q = db.query(OrderItem).filter(OrderItem.status == "cancelled")
if from_dt:
q = q.filter(OrderItem.added_at >= datetime.fromisoformat(from_dt))
if to_dt:
q = q.filter(OrderItem.added_at <= datetime.fromisoformat(to_dt))
if business_day_id:
q = q.join(Order).filter(Order.business_day_id == business_day_id)
if waiter_id:
q = q.filter(OrderItem.cancelled_by == waiter_id)
items = q.order_by(OrderItem.added_at.desc()).all()
waiters_db = {u.id: u for u in db.query(User).all()}
tables_db = {t.id: (t.label or f"T{t.number}") for t in db.query(Table).all()}
result = []
for item in items:
order = db.query(Order).filter(Order.id == item.order_id).first()
table_name = tables_db.get(order.table_id, f"#{order.table_id}") if order else ""
waiter = waiters_db.get(item.added_by)
waiter_name = (waiter.full_name or waiter.username) if waiter else f"#{item.added_by}"
cancelled_by_user = waiters_db.get(item.cancelled_by) if item.cancelled_by else None
cancelled_by_name = (cancelled_by_user.full_name or cancelled_by_user.username) if cancelled_by_user else None
product_name = item.product.name if item.product else f"#{item.product_id}"
cancelled_at = getattr(item, "cancelled_at", None)
result.append({
"id": item.id,
"order_id": item.order_id,
"table": table_name,
"waiter_name": waiter_name,
"product_name": product_name,
"quantity": item.quantity,
"unit_price": item.unit_price,
"value": round(item.unit_price * item.quantity, 2),
"cancelled_by": cancelled_by_name,
"cancel_reason": getattr(item, "cancel_reason", None),
"cancelled_at": _dt(cancelled_at) if cancelled_at else _dt(item.added_at),
})
total_value = sum(r["value"] for r in result)
return {"cancellations": result, "total_value": round(total_value, 2)}
# ---------------------------------------------------------------------------
# Printer history (detailed log)
# ---------------------------------------------------------------------------
@router.get("/printers/history")
def printer_history(
from_dt: Optional[str] = Query(default=None, alias="from"),
to_dt: Optional[str] = Query(default=None, alias="to"),
business_day_id: Optional[int] = None,
printer_id: Optional[int] = None,
db: Session = Depends(get_db),
user: User = Depends(require_manager),
):
q = db.query(PrintLog)
if from_dt:
q = q.filter(PrintLog.printed_at >= datetime.fromisoformat(from_dt))
if to_dt:
q = q.filter(PrintLog.printed_at <= datetime.fromisoformat(to_dt))
if printer_id:
q = q.filter(PrintLog.printer_id == printer_id)
if business_day_id:
q = q.join(Order).filter(Order.business_day_id == business_day_id)
logs = q.order_by(PrintLog.printed_at.desc()).all()
printers_db = {p.id: p for p in db.query(Printer).all()}
tables_db = {t.id: (t.label or f"T{t.number}") for t in db.query(Table).all()}
result = []
for log in logs:
order = db.query(Order).filter(Order.id == log.order_id).first()
printer = printers_db.get(log.printer_id)
try:
item_ids = json.loads(log.item_ids)
except Exception:
item_ids = []
items = []
for iid in item_ids:
oi = db.query(OrderItem).filter(OrderItem.id == iid).first()
if oi:
pname = oi.product.name if oi.product else f"#{oi.product_id}"
items.append({"name": pname, "quantity": oi.quantity})
result.append({
"id": log.id,
"printed_at": _dt(log.printed_at),
"printer_name": printer.name if printer else f"#{log.printer_id}",
"order_id": log.order_id,
"table": tables_db.get(order.table_id, "") if order else "",
"items": items,
"success": log.success,
"error_message": log.error_message,
})
total = len(result)
failed = sum(1 for r in result if not r["success"])
return {"logs": result, "total": total, "failed": failed}
# ---------------------------------------------------------------------------
# Waiters list (for filter dropdowns)
# ---------------------------------------------------------------------------
@router.get("/meta/waiters")
def meta_waiters(
db: Session = Depends(get_db),
user: User = Depends(require_manager),
):
waiters = db.query(User).filter(User.role == "waiter", User.is_active == True).all()
return {"waiters": [{"id": w.id, "name": w.full_name or w.username} for w in waiters]}
@router.get("/meta/tables")
def meta_tables(
db: Session = Depends(get_db),
user: User = Depends(require_manager),
):
tables = db.query(Table).filter(Table.is_active == True).all()
return {"tables": [{"id": t.id, "name": t.label or f"T{t.number}", "group": t.group.name if t.group else None} for t in tables]}
@router.get("/meta/printers")
def meta_printers(
db: Session = Depends(get_db),
user: User = Depends(require_manager),
):
printers = db.query(Printer).filter(Printer.is_active == True).all()
return {"printers": [{"id": p.id, "name": p.name} for p in printers]}
# ---------------------------------------------------------------------------
# CSV export endpoints
# ---------------------------------------------------------------------------
def _csv_response(rows: list, filename: str) -> StreamingResponse:
if not rows:
rows = [{}]
output = io.StringIO()
writer = csv.DictWriter(output, fieldnames=list(rows[0].keys()))
writer.writeheader()
writer.writerows(rows)
output.seek(0)
return StreamingResponse(
iter([output.getvalue()]),
media_type="text/csv",
headers={"Content-Disposition": f'attachment; filename="{filename}"'},
)
@router.get("/shifts/export")
def shifts_export(
waiter_id: Optional[int] = None,
business_day_id: Optional[int] = None,
from_dt: Optional[str] = Query(default=None, alias="from"),
to_dt: Optional[str] = Query(default=None, alias="to"),
db: Session = Depends(get_db),
user: User = Depends(require_manager),
):
data = shifts_report(waiter_id=waiter_id, business_day_id=business_day_id, from_dt=from_dt, to_dt=to_dt, active_only=False, db=db, user=user)
rows = []
for s in data["shifts"]:
rows.append({
"id": s["id"],
"waiter": s["waiter_name"],
"started_at": s["started_at"],
"ended_at": s["ended_at"] or "",
"starting_cash": s["starting_cash"],
"total_collected": s["total_collected"],
"net_to_deliver": s["net_to_deliver"],
"status": "active" if s["is_active"] else "ended",
})
date_str = (from_dt or "")[:10]
return _csv_response(rows, f"shifts-{date_str}.csv")
@router.get("/orders/export")
def orders_export(
from_date: Optional[str] = Query(default=None, alias="from"),
to_date: Optional[str] = Query(default=None, alias="to"),
waiter_id: Optional[int] = None,
order_status: Optional[str] = Query(default=None, alias="status"),
table_id: Optional[int] = None,
db: Session = Depends(get_db),
user: User = Depends(require_manager),
):
orders = order_history(from_date=from_date, to_date=to_date, waiter_id=waiter_id, order_status=order_status, table_id=table_id, page=1, page_size=10000, db=db, user=user)
tables_db = {t.id: (t.label or f"T{t.number}") for t in db.query(Table).all()}
waiters_db = {u.id: u for u in db.query(User).all()}
rows = []
for o in orders:
waiter = waiters_db.get(o.opened_by)
rows.append({
"id": o.id,
"table": tables_db.get(o.table_id, ""),
"waiter": (waiter.full_name or waiter.username) if waiter else "",
"opened_at": _dt(o.opened_at),
"closed_at": _dt(o.closed_at) if o.closed_at else "",
"status": o.status,
"total": round(sum(i.unit_price * i.quantity for i in o.items if i.status in ("active", "paid")), 2),
})
date_str = (from_date or "")[:10]
return _csv_response(rows, f"orders-{date_str}.csv")
@router.get("/products/export")
def products_export(
from_dt: Optional[str] = Query(default=None, alias="from"),
to_dt: Optional[str] = Query(default=None, alias="to"),
business_day_id: Optional[int] = None,
category_id: Optional[int] = None,
db: Session = Depends(get_db),
user: User = Depends(require_manager),
):
data = product_performance(from_dt=from_dt, to_dt=to_dt, business_day_id=business_day_id, category_id=category_id, db=db, user=user)
rows = [{
"product_id": p["product_id"],
"product_name": p["product_name"],
"category_id": p.get("category_id", ""),
"qty_sold": p["qty_sold"],
"revenue": p["revenue"],
"order_count": p["order_count"],
} for p in data["products"]]
date_str = (from_dt or "")[:10]
return _csv_response(rows, f"products-{date_str}.csv")
@router.get("/printers/export")
def printers_export(
from_dt: Optional[str] = Query(default=None, alias="from"),
to_dt: Optional[str] = Query(default=None, alias="to"),
printer_id: Optional[int] = None,
business_day_id: Optional[int] = None,
db: Session = Depends(get_db),
user: User = Depends(require_manager),
):
data = printer_history(from_dt=from_dt, to_dt=to_dt, business_day_id=business_day_id, printer_id=printer_id, db=db, user=user)
rows = [{
"id": r["id"],
"printed_at": r["printed_at"],
"printer": r["printer_name"],
"order_id": r["order_id"],
"table": r["table"],
"success": r["success"],
"error_message": r.get("error_message") or "",
} for r in data["logs"]]
date_str = (from_dt or "")[:10]
return _csv_response(rows, f"printer-history-{date_str}.csv")
@router.get("/cancellations/export")
def cancellations_export(
from_dt: Optional[str] = Query(default=None, alias="from"),
to_dt: Optional[str] = Query(default=None, alias="to"),
business_day_id: Optional[int] = None,
waiter_id: Optional[int] = None,
db: Session = Depends(get_db),
user: User = Depends(require_manager),
):
data = cancellations_log(from_dt=from_dt, to_dt=to_dt, business_day_id=business_day_id, waiter_id=waiter_id, db=db, user=user)
rows = [{
"id": c["id"],
"order_id": c["order_id"],
"table": c["table"],
"waiter": c["waiter_name"],
"product": c["product_name"],
"quantity": c["quantity"],
"value": c["value"],
"cancelled_by": c.get("cancelled_by") or "",
"reason": c.get("cancel_reason") or "",
"cancelled_at": c["cancelled_at"],
} for c in data["cancellations"]]
date_str = (from_dt or "")[:10]
return _csv_response(rows, f"cancellations-{date_str}.csv")