import json from fastapi import APIRouter, Depends, Query, HTTPException, BackgroundTasks 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 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, db: Session = Depends(get_db), user: User = Depends(require_manager), ): """Items sent (added) per waiter — regardless of payment status.""" 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.in_(["active", "paid"]), 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", response_model=List[OrderOut]) 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, page: int = 1, page_size: int = 50, db: Session = Depends(get_db), user: User = Depends(require_manager), ): q = db.query(Order) 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) return q.order_by(Order.opened_at.desc()).offset((page - 1) * page_size).limit(page_size).all() @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()), }