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:
2026-06-07 20:25:22 +03:00
parent aed71a18d8
commit 79bd3b0f41
25 changed files with 2017 additions and 146 deletions

View File

@@ -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
# ---------------------------------------------------------------------------