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, load_divider_style, print_products_report, print_categories_report, print_tables_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"), 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: 0 = all printers, >0 = specific printer id printer_target_id: int printer_id: int # 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, 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), ): # 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") tables = {t.id: (t.label or f"T{t.number}") for t in db.query(Table).all()} # 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 # 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_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 # --------------------------------------------------------------------------- @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 _enrich_shift 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() return {"shifts": [_enrich_shift(s, db) for s in shifts]} # --------------------------------------------------------------------------- # 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 from models.waste import WasteLog 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()} # Phase 2H: load waste for the same period wq = db.query(WasteLog) if business_day_id: wq = wq.filter(WasteLog.business_day_id == business_day_id) elif from_dt and to_dt: wq = wq.filter( WasteLog.logged_at >= datetime.fromisoformat(from_dt), WasteLog.logged_at <= datetime.fromisoformat(to_dt), ) waste_entries = wq.all() waste_by_product: dict = {} for w in waste_entries: pid = w.product_id if pid not in waste_by_product: waste_by_product[pid] = {"waste_qty": 0.0, "waste_cost": 0.0} waste_by_product[pid]["waste_qty"] += w.quantity waste_by_product[pid]["waste_cost"] += w.total_cost or 0.0 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, "trackable_profit": 0.0, "uncosted_revenue": 0.0, "uncosted_items": 0, "order_ids": set(), } qty = item.quantity revenue = item.unit_price * qty summary[pid]["qty_sold"] += qty summary[pid]["revenue"] += revenue summary[pid]["order_ids"].add(item.order_id) if item.unit_cost is not None: summary[pid]["trackable_profit"] += revenue - (item.unit_cost * qty) else: summary[pid]["uncosted_revenue"] += revenue summary[pid]["uncosted_items"] += qty # Also include products that only have waste (not sold) in the period for pid, wdata in waste_by_product.items(): if pid not in summary: product = products_db.get(pid) if category_id and (not product or product.category_id != category_id): continue 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, "trackable_profit": 0.0, "uncosted_revenue": 0.0, "uncosted_items": 0, "order_ids": set(), } result = [] for entry in summary.values(): pid = entry["product_id"] wdata = waste_by_product.get(pid, {"waste_qty": 0.0, "waste_cost": 0.0}) entry["order_count"] = len(entry.pop("order_ids")) entry["revenue"] = round(entry["revenue"], 2) entry["trackable_profit"] = round(entry["trackable_profit"], 2) entry["uncosted_revenue"] = round(entry["uncosted_revenue"], 2) entry["has_gap"] = entry["uncosted_items"] > 0 entry["waste_qty"] = round(wdata["waste_qty"], 3) entry["waste_cost"] = round(wdata["waste_cost"], 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()} # Cache orders to avoid repeated queries order_cache = {} def _get_order(oid): if oid not in order_cache: order_cache[oid] = db.query(Order).filter(Order.id == oid).first() return order_cache[oid] result = [] seen_order_totals = {} # order_id → total, so we don't double-count reprints for log in logs: order = _get_order(log.order_id) 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}) # Compute full order total (all active+paid items, not just printed slice) order_total = None if order: order_total = sum( i.unit_price * i.quantity for i in order.items if i.status in ("active", "paid") ) if log.success and log.order_id not in seen_order_totals: seen_order_totals[log.order_id] = order_total 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, "order_total": round(order_total, 2) if order_total is not None else None, }) total = len(result) failed = sum(1 for r in result if not r["success"]) # Sum unique order totals (avoid inflating on reprints of the same order) total_amount = round(sum(seen_order_totals.values()), 2) if seen_order_totals else None return {"logs": result, "total": total, "failed": failed, "total_amount": total_amount} # --------------------------------------------------------------------------- # 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]} @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 # --------------------------------------------------------------------------- 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 "", "duration_hours": s.get("duration_hours", ""), "hourly_rate": s.get("hourly_rate_snapshot", ""), "shift_pay": s.get("shift_pay", ""), "counted_cash_end": s.get("counted_cash_end", ""), "cash_discrepancy": s.get("cash_discrepancy", ""), "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")