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:
@@ -6,10 +6,12 @@ from datetime import datetime, timezone
|
||||
|
||||
from database import get_db
|
||||
from models.business_day import BusinessDay
|
||||
from models.order import Order, OrderItem, OrderAuditLog
|
||||
from models.order import Order, OrderItem, OrderAuditLog, PrintLog
|
||||
from models.shift import WaiterShift
|
||||
from models.flag import TableFlagAssignment
|
||||
from models.message import StaffMessage, StaffMessageAck
|
||||
from models.table import Table, TableGroup
|
||||
from models.printer import Printer
|
||||
from schemas.business_day import BusinessDayOut, OpenBusinessDayRequest, CloseBusinessDayRequest
|
||||
from routers.deps import get_current_user, require_manager
|
||||
from models.user import User
|
||||
@@ -189,6 +191,219 @@ def delete_business_day(
|
||||
db.commit()
|
||||
|
||||
|
||||
@router.get("/summary")
|
||||
def business_day_summary(
|
||||
day_id: Optional[int] = None,
|
||||
db: Session = Depends(get_db),
|
||||
user: User = Depends(require_manager),
|
||||
):
|
||||
"""Full summary for a business day: per-waiter, per-zone, per-printer, and checks.
|
||||
If day_id is omitted, uses the currently open business day."""
|
||||
if day_id:
|
||||
day = db.query(BusinessDay).filter(BusinessDay.id == day_id).first()
|
||||
if not day:
|
||||
raise HTTPException(status_code=404, detail="Business day not found")
|
||||
else:
|
||||
day = db.query(BusinessDay).filter(BusinessDay.status == "open").first()
|
||||
if not day:
|
||||
raise HTTPException(status_code=404, detail="No open business day")
|
||||
|
||||
orders = db.query(Order).filter(Order.business_day_id == day.id).all()
|
||||
order_ids = [o.id for o in orders]
|
||||
|
||||
all_items = db.query(OrderItem).filter(OrderItem.order_id.in_(order_ids)).all() if order_ids else []
|
||||
paid_items = [i for i in all_items if i.status == "paid"]
|
||||
active_items = [i for i in all_items if i.status == "active"]
|
||||
|
||||
shifts = db.query(WaiterShift).filter(WaiterShift.business_day_id == day.id).all()
|
||||
users_map = {u.id: u for u in db.query(User).all()}
|
||||
tables_map = {t.id: t for t in db.query(Table).all()}
|
||||
groups_map = {g.id: g for g in db.query(TableGroup).all()}
|
||||
printers_map = {p.id: p for p in db.query(Printer).all()}
|
||||
|
||||
# ── Per-waiter summary ──────────────────────────────────────────────────
|
||||
waiter_stats: dict = {}
|
||||
|
||||
def _waiter_entry(uid):
|
||||
if uid not in waiter_stats:
|
||||
u = users_map.get(uid)
|
||||
name = (u.full_name or u.nickname or u.username) if u else f"#{uid}"
|
||||
role = u.role if u else "waiter"
|
||||
waiter_stats[uid] = {
|
||||
"waiter_id": uid,
|
||||
"waiter_name": name,
|
||||
"role": role,
|
||||
"orders_opened": 0,
|
||||
"items_ordered": 0,
|
||||
"total_items_value": 0.0,
|
||||
"items_paid": 0,
|
||||
"total_collected": 0.0,
|
||||
"cash": 0.0,
|
||||
"card": 0.0,
|
||||
"transfer": 0.0,
|
||||
"treat": 0.0,
|
||||
"other": 0.0,
|
||||
"shift_started_at": None,
|
||||
"shift_ended_at": None,
|
||||
"starting_cash": None,
|
||||
}
|
||||
return waiter_stats[uid]
|
||||
|
||||
for o in orders:
|
||||
if o.status == "cancelled":
|
||||
continue
|
||||
e = _waiter_entry(o.opened_by)
|
||||
e["orders_opened"] += 1
|
||||
|
||||
for item in all_items:
|
||||
if item.status == "cancelled":
|
||||
continue
|
||||
e = _waiter_entry(item.added_by)
|
||||
e["items_ordered"] += item.quantity
|
||||
e["total_items_value"] += item.unit_price * item.quantity
|
||||
|
||||
for item in paid_items:
|
||||
if item.paid_by:
|
||||
e = _waiter_entry(item.paid_by)
|
||||
val = item.unit_price * item.quantity
|
||||
e["items_paid"] += item.quantity
|
||||
e["total_collected"] += val
|
||||
method = (item.payment_method or "cash").lower()
|
||||
if method == "cash":
|
||||
e["cash"] += val
|
||||
elif method == "card":
|
||||
e["card"] += val
|
||||
elif method == "transfer":
|
||||
e["transfer"] += val
|
||||
elif method in ("treat", "comp"):
|
||||
e["treat"] += val
|
||||
else:
|
||||
e["other"] += val
|
||||
|
||||
for shift in shifts:
|
||||
e = _waiter_entry(shift.waiter_id)
|
||||
if e["shift_started_at"] is None or (shift.started_at and _dt(shift.started_at) < e["shift_started_at"]):
|
||||
e["shift_started_at"] = _dt(shift.started_at)
|
||||
if shift.starting_cash is not None:
|
||||
e["starting_cash"] = shift.starting_cash
|
||||
if shift.ended_at:
|
||||
ended = _dt(shift.ended_at)
|
||||
if e["shift_ended_at"] is None or ended > e["shift_ended_at"]:
|
||||
e["shift_ended_at"] = ended
|
||||
|
||||
# manager gets shift = day open/close
|
||||
manager_opener = users_map.get(day.opened_by_id)
|
||||
if manager_opener:
|
||||
e = _waiter_entry(day.opened_by_id)
|
||||
if e["shift_started_at"] is None:
|
||||
e["shift_started_at"] = _dt(day.opened_at)
|
||||
if e["shift_ended_at"] is None and day.closed_at:
|
||||
e["shift_ended_at"] = _dt(day.closed_at)
|
||||
|
||||
# ── Per-zone summary ────────────────────────────────────────────────────
|
||||
zone_stats: dict = {} # group_id → stats
|
||||
|
||||
def _zone_entry(gid, gname, gcolor):
|
||||
if gid not in zone_stats:
|
||||
zone_stats[gid] = {
|
||||
"group_id": gid,
|
||||
"group_name": gname,
|
||||
"color": gcolor,
|
||||
"orders": 0,
|
||||
"items": 0,
|
||||
"revenue": 0.0,
|
||||
}
|
||||
return zone_stats[gid]
|
||||
|
||||
for o in orders:
|
||||
if o.status == "cancelled" or not o.table_id:
|
||||
continue
|
||||
table = tables_map.get(o.table_id)
|
||||
group = groups_map.get(table.group_id) if table and table.group_id else None
|
||||
gid = group.id if group else 0
|
||||
gname = group.name if group else "Χωρίς Ζώνη"
|
||||
gcolor = group.color if group else "#9ca3af"
|
||||
entry = _zone_entry(gid, gname, gcolor)
|
||||
entry["orders"] += 1
|
||||
for item in o.items:
|
||||
if item.status == "cancelled":
|
||||
continue
|
||||
entry["items"] += item.quantity
|
||||
if item.status == "paid":
|
||||
entry["revenue"] += item.unit_price * item.quantity
|
||||
|
||||
# ── Per-printer summary ─────────────────────────────────────────────────
|
||||
# Build item_id → value lookup for revenue-per-printer calculation
|
||||
items_value_map = {i.id: i.unit_price * i.quantity for i in all_items if i.status != "cancelled"}
|
||||
|
||||
printer_stats: dict = {}
|
||||
if order_ids:
|
||||
logs = db.query(PrintLog).filter(PrintLog.order_id.in_(order_ids)).all()
|
||||
for log in logs:
|
||||
pid = log.printer_id
|
||||
if pid not in printer_stats:
|
||||
pr = printers_map.get(pid)
|
||||
printer_stats[pid] = {
|
||||
"printer_id": pid,
|
||||
"printer_name": pr.name if pr else f"#{pid}",
|
||||
"ip_address": pr.ip_address if pr else "",
|
||||
"jobs": 0,
|
||||
"success": 0,
|
||||
"failed": 0,
|
||||
"items_value": 0.0,
|
||||
}
|
||||
printer_stats[pid]["jobs"] += 1
|
||||
if log.success:
|
||||
printer_stats[pid]["success"] += 1
|
||||
try:
|
||||
import json as _json
|
||||
item_ids = _json.loads(log.item_ids) if log.item_ids else []
|
||||
for iid in item_ids:
|
||||
printer_stats[pid]["items_value"] += items_value_map.get(iid, 0.0)
|
||||
except Exception:
|
||||
pass
|
||||
else:
|
||||
printer_stats[pid]["failed"] += 1
|
||||
|
||||
for pid in printer_stats:
|
||||
printer_stats[pid]["items_value"] = round(printer_stats[pid]["items_value"], 2)
|
||||
|
||||
# ── Checks ──────────────────────────────────────────────────────────────
|
||||
open_orders = [o for o in orders if o.status in ("open", "partially_paid")]
|
||||
with_unpaid_items = sum(
|
||||
1 for o in open_orders
|
||||
if any(i.status == "active" for i in o.items)
|
||||
)
|
||||
active_shift_count = sum(1 for s in shifts if s.ended_at is None)
|
||||
|
||||
total_revenue = sum(i.unit_price * i.quantity for i in paid_items)
|
||||
total_items_ordered = sum(i.quantity for i in all_items if i.status != "cancelled")
|
||||
total_orders = sum(1 for o in orders if o.status != "cancelled")
|
||||
|
||||
# Round float fields in waiter stats
|
||||
for w in waiter_stats.values():
|
||||
for key in ("total_collected", "total_items_value", "cash", "card", "transfer", "treat", "other"):
|
||||
w[key] = round(w[key], 2)
|
||||
|
||||
return {
|
||||
"day_id": day.id,
|
||||
"status": day.status,
|
||||
"opened_at": _dt(day.opened_at),
|
||||
"closed_at": _dt(day.closed_at),
|
||||
"total_revenue": round(total_revenue, 2),
|
||||
"total_orders": total_orders,
|
||||
"total_items_ordered": total_items_ordered,
|
||||
"waiters": sorted(waiter_stats.values(), key=lambda x: -x["total_collected"]),
|
||||
"zones": sorted(zone_stats.values(), key=lambda x: -x["revenue"]),
|
||||
"printers": list(printer_stats.values()),
|
||||
"checks": {
|
||||
"open_orders": len(open_orders),
|
||||
"with_unpaid_items": with_unpaid_items,
|
||||
"active_shifts": active_shift_count,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@router.get("/history")
|
||||
def business_day_history(
|
||||
db: Session = Depends(get_db),
|
||||
|
||||
@@ -67,16 +67,21 @@ def _audit(db: Session, order_id: int, event_type: str, waiter_id: int = None,
|
||||
))
|
||||
|
||||
|
||||
ACTIVE_STATUSES = ["open", "partially_paid", "paid"]
|
||||
|
||||
@router.get("/", response_model=List[OrderOut])
|
||||
def list_orders(
|
||||
order_status: Optional[str] = None,
|
||||
waiter_id: Optional[int] = None,
|
||||
all: bool = False,
|
||||
db: Session = Depends(get_db),
|
||||
user: User = Depends(require_manager),
|
||||
):
|
||||
q = db.query(Order)
|
||||
if order_status:
|
||||
q = q.filter(Order.status == order_status)
|
||||
elif not all:
|
||||
q = q.filter(Order.status.in_(ACTIVE_STATUSES))
|
||||
if waiter_id:
|
||||
q = q.join(OrderWaiter).filter(OrderWaiter.waiter_id == waiter_id)
|
||||
return q.all()
|
||||
@@ -183,9 +188,13 @@ def get_order(order_id: int, db: Session = Depends(get_db), user: User = Depends
|
||||
"is_duplicate": l.is_duplicate,
|
||||
}
|
||||
|
||||
table = db.query(Table).filter(Table.id == order.table_id).first() if order.table_id else None
|
||||
table_name = (table.label or f"T{table.number}") if table else None
|
||||
|
||||
return {
|
||||
"id": order.id,
|
||||
"table_id": order.table_id,
|
||||
"table_name": table_name,
|
||||
"opened_by": order.opened_by,
|
||||
"opened_at": order.opened_at,
|
||||
"status": order.status,
|
||||
@@ -347,12 +356,13 @@ def pay_items(order_id: int, body: PayItemsRequest, db: Session = Depends(get_db
|
||||
WaiterShift.waiter_id == user.id,
|
||||
WaiterShift.ended_at == None,
|
||||
).first()
|
||||
effective_method = body.payment_method or "cash"
|
||||
total_paid = 0.0
|
||||
for item in items:
|
||||
item.status = "paid"
|
||||
item.paid_by = user.id
|
||||
item.paid_at = now
|
||||
item.payment_method = body.payment_method
|
||||
item.payment_method = effective_method
|
||||
item.paid_in_shift_id = active_shift.id if active_shift else None
|
||||
total_paid += item.unit_price * item.quantity
|
||||
|
||||
@@ -452,6 +462,7 @@ def pay_items_offline(
|
||||
WaiterShift.ended_at == None,
|
||||
).first()
|
||||
|
||||
effective_method = body.payment_method or "cash"
|
||||
total_paid = 0.0
|
||||
paid_ids = []
|
||||
if not is_duplicate:
|
||||
@@ -459,7 +470,7 @@ def pay_items_offline(
|
||||
item.status = "paid"
|
||||
item.paid_by = user.id
|
||||
item.paid_at = paid_at
|
||||
item.payment_method = body.payment_method
|
||||
item.payment_method = effective_method
|
||||
item.paid_in_shift_id = active_shift.id if active_shift else None
|
||||
total_paid += item.unit_price * item.quantity
|
||||
paid_ids.append(item.id)
|
||||
@@ -605,7 +616,7 @@ def print_order(
|
||||
"notes": order.notes,
|
||||
}
|
||||
|
||||
background_tasks.add_task(print_order_receipt, printer.ip_address, printer.port, receipt)
|
||||
background_tasks.add_task(print_order_receipt, printer.ip_address, printer.port, receipt, printer.line_width)
|
||||
return {"status": "printing"}
|
||||
|
||||
|
||||
@@ -900,5 +911,5 @@ def print_synopsis(
|
||||
"remaining": total - paid_total,
|
||||
}
|
||||
|
||||
background_tasks.add_task(print_order_synopsis, printer.ip_address, printer.port, synopsis)
|
||||
background_tasks.add_task(print_order_synopsis, printer.ip_address, printer.port, synopsis, printer.line_width)
|
||||
return {"status": "printing"}
|
||||
|
||||
@@ -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
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
171
local_backend/routers/reservations.py
Normal file
171
local_backend/routers/reservations.py
Normal file
@@ -0,0 +1,171 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException, status, Query
|
||||
from sqlalchemy.orm import Session
|
||||
from typing import List, Optional
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from database import get_db
|
||||
from models.reservation import Reservation
|
||||
from models.table import Table
|
||||
from schemas.reservation import (
|
||||
ReservationCreate, ReservationUpdate, ReservationStatusUpdate, ReservationOut,
|
||||
)
|
||||
from routers.deps import get_current_user, require_manager
|
||||
from models.user import User
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
VALID_STATUSES = {"pending", "arrived", "cancelled", "no_show"}
|
||||
|
||||
|
||||
def _enrich(r: Reservation) -> ReservationOut:
|
||||
out = ReservationOut.model_validate(r)
|
||||
if r.table:
|
||||
out.table_label = r.table.label or f"T{r.table.number}"
|
||||
if r.created_by:
|
||||
out.created_by_name = (
|
||||
r.created_by.full_name or r.created_by.nickname or r.created_by.username
|
||||
)
|
||||
return out
|
||||
|
||||
|
||||
@router.post("/", response_model=ReservationOut, status_code=status.HTTP_201_CREATED)
|
||||
def create_reservation(
|
||||
body: ReservationCreate,
|
||||
db: Session = Depends(get_db),
|
||||
user: User = Depends(get_current_user),
|
||||
):
|
||||
if body.table_id:
|
||||
if not db.query(Table).filter(Table.id == body.table_id, Table.is_active == True).first():
|
||||
raise HTTPException(status_code=400, detail="Table not found or inactive")
|
||||
|
||||
r = Reservation(
|
||||
guest_name=body.guest_name,
|
||||
party_size=body.party_size,
|
||||
phone=body.phone,
|
||||
email=body.email,
|
||||
note=body.note,
|
||||
reserved_for=body.reserved_for,
|
||||
table_id=body.table_id,
|
||||
source=body.source,
|
||||
online_customer_id=body.online_customer_id,
|
||||
created_by_user_id=user.id,
|
||||
status="pending",
|
||||
)
|
||||
db.add(r)
|
||||
db.commit()
|
||||
db.refresh(r)
|
||||
return _enrich(r)
|
||||
|
||||
|
||||
@router.get("/", response_model=List[ReservationOut])
|
||||
def list_reservations(
|
||||
tab: Optional[str] = Query(None), # "upcoming" | "history" — if omitted returns all
|
||||
date: Optional[str] = Query(None), # ISO date string YYYY-MM-DD to filter by day
|
||||
db: Session = Depends(get_db),
|
||||
user: User = Depends(get_current_user),
|
||||
):
|
||||
q = db.query(Reservation)
|
||||
|
||||
if date:
|
||||
try:
|
||||
day = datetime.fromisoformat(date).date()
|
||||
except ValueError:
|
||||
raise HTTPException(status_code=400, detail="Invalid date format")
|
||||
from sqlalchemy import func
|
||||
q = q.filter(func.date(Reservation.reserved_for) == day)
|
||||
|
||||
if tab == "upcoming":
|
||||
# pending + no_show (actionable), ordered soonest first
|
||||
q = q.filter(Reservation.status.in_(["pending", "no_show"]))
|
||||
q = q.order_by(Reservation.reserved_for.asc())
|
||||
elif tab == "history":
|
||||
# arrived, cancelled, plus no_show/pending that are clearly in the past
|
||||
now = datetime.now(timezone.utc)
|
||||
q = q.filter(
|
||||
(Reservation.status.in_(["arrived", "cancelled"])) |
|
||||
(
|
||||
Reservation.status.in_(["no_show", "pending"]) &
|
||||
(Reservation.reserved_for < now)
|
||||
)
|
||||
)
|
||||
q = q.order_by(Reservation.reserved_for.desc())
|
||||
else:
|
||||
q = q.order_by(Reservation.reserved_for.asc())
|
||||
|
||||
reservations = q.all()
|
||||
return [_enrich(r) for r in reservations]
|
||||
|
||||
|
||||
@router.get("/{reservation_id}", response_model=ReservationOut)
|
||||
def get_reservation(
|
||||
reservation_id: int,
|
||||
db: Session = Depends(get_db),
|
||||
user: User = Depends(get_current_user),
|
||||
):
|
||||
r = db.query(Reservation).filter(Reservation.id == reservation_id).first()
|
||||
if not r:
|
||||
raise HTTPException(status_code=404, detail="Reservation not found")
|
||||
return _enrich(r)
|
||||
|
||||
|
||||
@router.patch("/{reservation_id}", response_model=ReservationOut)
|
||||
def update_reservation(
|
||||
reservation_id: int,
|
||||
body: ReservationUpdate,
|
||||
db: Session = Depends(get_db),
|
||||
user: User = Depends(get_current_user),
|
||||
):
|
||||
r = db.query(Reservation).filter(Reservation.id == reservation_id).first()
|
||||
if not r:
|
||||
raise HTTPException(status_code=404, detail="Reservation not found")
|
||||
|
||||
updates = body.model_dump(exclude_none=True)
|
||||
|
||||
if "table_id" in updates and updates["table_id"] is not None:
|
||||
if not db.query(Table).filter(Table.id == updates["table_id"], Table.is_active == True).first():
|
||||
raise HTTPException(status_code=400, detail="Table not found or inactive")
|
||||
|
||||
if "status" in updates and updates["status"] not in VALID_STATUSES:
|
||||
raise HTTPException(status_code=400, detail=f"Invalid status: {updates['status']}")
|
||||
|
||||
for field, value in updates.items():
|
||||
setattr(r, field, value)
|
||||
|
||||
r.updated_at = datetime.now(timezone.utc)
|
||||
db.commit()
|
||||
db.refresh(r)
|
||||
return _enrich(r)
|
||||
|
||||
|
||||
@router.patch("/{reservation_id}/status", response_model=ReservationOut)
|
||||
def update_reservation_status(
|
||||
reservation_id: int,
|
||||
body: ReservationStatusUpdate,
|
||||
db: Session = Depends(get_db),
|
||||
user: User = Depends(get_current_user),
|
||||
):
|
||||
if body.status not in VALID_STATUSES:
|
||||
raise HTTPException(status_code=400, detail=f"Invalid status: {body.status}")
|
||||
|
||||
r = db.query(Reservation).filter(Reservation.id == reservation_id).first()
|
||||
if not r:
|
||||
raise HTTPException(status_code=404, detail="Reservation not found")
|
||||
|
||||
r.status = body.status
|
||||
r.updated_at = datetime.now(timezone.utc)
|
||||
db.commit()
|
||||
db.refresh(r)
|
||||
return _enrich(r)
|
||||
|
||||
|
||||
@router.delete("/{reservation_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||
def delete_reservation(
|
||||
reservation_id: int,
|
||||
db: Session = Depends(get_db),
|
||||
user: User = Depends(require_manager),
|
||||
):
|
||||
r = db.query(Reservation).filter(Reservation.id == reservation_id).first()
|
||||
if not r:
|
||||
raise HTTPException(status_code=404, detail="Reservation not found")
|
||||
db.delete(r)
|
||||
db.commit()
|
||||
@@ -27,6 +27,7 @@ VALID_SETTINGS = {
|
||||
# Print layout
|
||||
"print.ticket_mode": "Kitchen ticket layout mode: 'detailed' or 'compact'",
|
||||
"print.divider_style": "Divider character used between sections: dash, equals, star, or empty",
|
||||
"print.dot_style": "Dot-leader style for item lines: STYLE:SIZE:BOLD where STYLE is dot_space|dot|dash_space|underscore, SIZE is ESC ! base byte (0/16/32/48), BOLD 0|1",
|
||||
# Print font settings — values are "SIZE:BOLD:CAPS" where SIZE is ESC ! base byte (0/16/32/48), BOLD 0|1, CAPS 0|1
|
||||
"print.font_order_number": "Font for order number header: SIZE:BOLD:CAPS",
|
||||
"print.font_meta": "Font for table/waiter/time header block: SIZE:BOLD:CAPS",
|
||||
@@ -53,10 +54,11 @@ DEFAULTS = {
|
||||
"shifts.waiter_self_end": "true",
|
||||
"business_day.force_close_allowed": "true",
|
||||
"system.timezone": "Europe/Athens",
|
||||
"ui.table_colours": "",
|
||||
"ui.table_colours": '{"light":{"free":{"cardBg":"#dde5ef","badgeBg":"rgba(255,255,255,0.92)","nameText":"#3d5270","badgeText":"#3d5270"},"mine":{"cardBg":"#e8610a","badgeBg":"rgba(255,255,255,0.92)","nameText":"#ffffff","badgeText":"#e8610a"},"open":{"cardBg":"#FF8F60","badgeBg":"rgba(255,255,255,0.92)","nameText":"#ffffff","badgeText":"#FF8F60"},"partially_paid":{"cardBg":"#FFDC67","badgeBg":"rgba(255,255,255,0.92)","nameText":"#ffffff","badgeText":"#d4a800"},"paid":{"cardBg":"#81D264","badgeBg":"rgba(255,255,255,0.92)","nameText":"#ffffff","badgeText":"#81D264"}},"dark":{"free":{"cardBg":"#243044","badgeBg":"rgba(255,255,255,0.92)","nameText":"#94b8d4","badgeText":"#94b8d4"},"mine":{"cardBg":"#e8610a","badgeBg":"rgba(255,255,255,0.92)","nameText":"#ffffff","badgeText":"#e8610a"},"open":{"cardBg":"#FF8F60","badgeBg":"rgba(255,255,255,0.92)","nameText":"#ffffff","badgeText":"#FF8F60"},"partially_paid":{"cardBg":"#FFDC67","badgeBg":"rgba(255,255,255,0.92)","nameText":"#ffffff","badgeText":"#d4a800"},"paid":{"cardBg":"#81D264","badgeBg":"rgba(255,255,255,0.92)","nameText":"#ffffff","badgeText":"#81D264"}}}',
|
||||
"dev.spoof_printing": "false",
|
||||
"print.ticket_mode": "detailed",
|
||||
"print.divider_style": "dash",
|
||||
"print.dot_style": "dot_space:0:0",
|
||||
"print.font_order_number": "48:1:0",
|
||||
"print.font_meta": "0:0:0",
|
||||
"print.font_item_name": "16:1:0",
|
||||
|
||||
@@ -356,13 +356,15 @@ def get_shift_summary(
|
||||
OrderItem.status == "paid",
|
||||
).all()
|
||||
|
||||
from datetime import timezone as tz
|
||||
upper_bound = shift.ended_at or datetime.now(tz.utc)
|
||||
ordered_items = db.query(OrderItem).options(
|
||||
joinedload(OrderItem.product),
|
||||
joinedload(OrderItem.order),
|
||||
).filter(
|
||||
OrderItem.added_by == waiter_id,
|
||||
OrderItem.added_at >= shift.started_at,
|
||||
OrderItem.added_at <= shift.ended_at,
|
||||
OrderItem.added_at <= upper_bound,
|
||||
(OrderItem.paid_in_shift_id != shift_id) | (OrderItem.paid_in_shift_id == None),
|
||||
).all()
|
||||
|
||||
|
||||
@@ -129,7 +129,7 @@ def test_order_print(printer_id: int, db: Session = Depends(get_db), user: User
|
||||
printer = db.query(Printer).filter(Printer.id == printer_id).first()
|
||||
if not printer:
|
||||
raise HTTPException(status_code=404, detail="Printer not found")
|
||||
success, error = printer_service.send_test_order_print(printer.ip_address, printer.port, db)
|
||||
success, error = printer_service.send_test_order_print(printer.ip_address, printer.port, db, printer.line_width)
|
||||
return {"success": success, "error": error}
|
||||
|
||||
|
||||
|
||||
@@ -1,19 +1,24 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException, status, Body
|
||||
from sqlalchemy.orm import Session
|
||||
from typing import List
|
||||
from datetime import datetime, timezone, timedelta
|
||||
|
||||
from database import get_db
|
||||
from models.table import Table, TableGroup
|
||||
from models.order import Order
|
||||
from models.reservation import Reservation
|
||||
from models.user import User, WaiterZone
|
||||
from schemas.table import (
|
||||
TableCreate, TableUpdate, TableFloorplanUpdate, TableOut,
|
||||
TableCreate, TableUpdate, TableFloorplanUpdate, TableOut, UpcomingReservation,
|
||||
TableGroupCreate, TableGroupUpdate, TableGroupOut,
|
||||
TableBatchCreate, MAX_TABLE_NAME_LENGTH,
|
||||
)
|
||||
from routers.deps import get_current_user, require_manager
|
||||
from services.sse_bus import broadcast_sync
|
||||
|
||||
# Tables with a pending reservation due within this many hours get the RESERVED badge
|
||||
RESERVATION_LOOKAHEAD_HOURS = 4
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@@ -98,10 +103,32 @@ def list_tables(include_inactive: bool = False, db: Session = Depends(get_db), u
|
||||
).all()
|
||||
}
|
||||
|
||||
# Fetch pending reservations due within the lookahead window for the RESERVED badge
|
||||
now = datetime.now(timezone.utc)
|
||||
lookahead = now + timedelta(hours=RESERVATION_LOOKAHEAD_HOURS)
|
||||
upcoming_reservations = (
|
||||
db.query(Reservation)
|
||||
.filter(
|
||||
Reservation.status == "pending",
|
||||
Reservation.table_id.isnot(None),
|
||||
Reservation.reserved_for >= now,
|
||||
Reservation.reserved_for <= lookahead,
|
||||
)
|
||||
.all()
|
||||
)
|
||||
# Map table_id → soonest upcoming reservation
|
||||
reservation_by_table: dict[int, Reservation] = {}
|
||||
for res in sorted(upcoming_reservations, key=lambda r: r.reserved_for):
|
||||
if res.table_id not in reservation_by_table:
|
||||
reservation_by_table[res.table_id] = res
|
||||
|
||||
result = []
|
||||
for t in tables:
|
||||
out = TableOut.model_validate(t)
|
||||
out.has_active_order = t.id in active_table_ids
|
||||
res = reservation_by_table.get(t.id)
|
||||
if res:
|
||||
out.upcoming_reservation = UpcomingReservation.model_validate(res)
|
||||
result.append(out)
|
||||
return result
|
||||
|
||||
|
||||
@@ -52,10 +52,14 @@ def create_waiter(body: UserCreate, db: Session = Depends(get_db), user: User =
|
||||
full_name=body.full_name,
|
||||
nickname=body.nickname,
|
||||
mobile_phone=body.mobile_phone,
|
||||
email=body.email,
|
||||
note=body.note,
|
||||
)
|
||||
db.add(new_user)
|
||||
db.commit()
|
||||
db.refresh(new_user)
|
||||
db.add(WaiterZone(waiter_id=new_user.id, group_id=None))
|
||||
db.commit()
|
||||
return new_user
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user