Backend overhaul: new models, routers, schemas for shifts, business day, flags, messages, settings
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -12,6 +12,7 @@ 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 schemas.order import OrderOut
|
||||
from schemas.table import TableOut
|
||||
from routers.deps import require_manager
|
||||
@@ -20,6 +21,12 @@ from services.printer_service import print_waiter_report, print_printer_report,
|
||||
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"),
|
||||
@@ -438,3 +445,213 @@ def print_printer_totals(
|
||||
|
||||
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()),
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user