feat(local_backend): reservations, print analytics, workday summary, zone-PIN management
- New reservations module: model, schema, router (CRUD + status updates + upcoming alerts) and background task for auto-expiring stale reservations - Reports: print_products, print_categories, print_tables analytics endpoints plus meta_products and business_day_summary for workday close/view flow - printer_service: configurable font sizes/weights, donut/bar chart print layout helpers, analytics print blocks per printer - tables/schemas: surfaced color, zone, and other new fields on Table, Product, User, Printer - demo_seed.py for quick dev DB population; wipe_database.py utility Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -20,7 +20,10 @@ 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
|
||||
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()
|
||||
|
||||
@@ -416,17 +419,88 @@ class PrintWaiterReportBody(BaseModel):
|
||||
|
||||
|
||||
class PrintPrinterReportBody(BaseModel):
|
||||
# printer_target_id: 0 = all printers, >0 = specific printer id
|
||||
printer_target_id: int
|
||||
printer_id: int
|
||||
mode: str # "simple" | "extensive"
|
||||
from_dt: str
|
||||
to_dt: str
|
||||
# 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,
|
||||
@@ -497,73 +571,293 @@ def print_printer_totals(
|
||||
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")
|
||||
|
||||
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})
|
||||
# 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
|
||||
|
||||
order_data = list(order_map.values())
|
||||
# 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_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"),
|
||||
"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
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -1148,6 +1442,28 @@ def meta_printers(
|
||||
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
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
Reference in New Issue
Block a user