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

@@ -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()