feat: initial commit — local services (backend + manager dashboard + waiter PWA)
Includes all work to date: - local_backend: FastAPI backend with products, orders, tables, shifts, cloud sync - manager_dashboard: React manager UI with product/category management, reports, settings - waiter_pwa: React PWA for waiter devices - Category reparent endpoint and UI - Waiter domain: local_ip sent on heartbeat, waiter_domain persisted from cloud response - QR code modal in AppInfoTab for waiter domain - Product form: number input spinners removed, category pre-selected on new product - Category row: count badge moved to far right Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
0
local_backend/routers/__init__.py
Normal file
0
local_backend/routers/__init__.py
Normal file
174
local_backend/routers/auth.py
Normal file
174
local_backend/routers/auth.py
Normal file
@@ -0,0 +1,174 @@
|
||||
import bcrypt
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from database import get_db
|
||||
from models.user import User
|
||||
from schemas.auth import LoginRequest, TokenResponse, UpdateMeRequest
|
||||
from pydantic import BaseModel as _PydanticBase
|
||||
|
||||
class LoginByIdRequest(_PydanticBase):
|
||||
waiter_id: int
|
||||
pin: str
|
||||
from schemas.user import UserOut
|
||||
from routers.deps import get_current_user, make_token, decode_token, blacklist_token
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
class NoAuthLoginRequest(_PydanticBase):
|
||||
username: str
|
||||
|
||||
|
||||
@router.post("/login-no-auth", response_model=TokenResponse)
|
||||
def login_no_auth(body: NoAuthLoginRequest, db: Session = Depends(get_db)):
|
||||
"""Login with no credentials — only works when security.login_method = 'none'."""
|
||||
from models.settings import PosSettings
|
||||
setting = db.query(PosSettings).filter(PosSettings.key == "security.login_method").first()
|
||||
if not setting or setting.value != "none":
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="No-auth login is not enabled.")
|
||||
user = db.query(User).filter(User.username == body.username, User.is_active == True).first()
|
||||
if not user or user.role not in ("manager", "sysadmin"):
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid credentials")
|
||||
token = make_token(user)
|
||||
return TokenResponse(access_token=token, user=UserOut.model_validate(user))
|
||||
|
||||
|
||||
@router.post("/login", response_model=TokenResponse)
|
||||
def login(body: LoginRequest, db: Session = Depends(get_db)):
|
||||
user = db.query(User).filter(User.username == body.username, User.is_active == True).first()
|
||||
if not user:
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid credentials")
|
||||
|
||||
authenticated = False
|
||||
if body.password and user.password_hash:
|
||||
authenticated = bcrypt.checkpw(body.password.encode(), user.password_hash.encode())
|
||||
elif body.pin and user.pin_hash:
|
||||
authenticated = bcrypt.checkpw(body.pin.encode(), user.pin_hash.encode())
|
||||
|
||||
if not authenticated:
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid credentials")
|
||||
|
||||
token = make_token(user)
|
||||
return TokenResponse(access_token=token, user=UserOut.model_validate(user))
|
||||
|
||||
|
||||
@router.post("/login-by-id", response_model=TokenResponse)
|
||||
def login_by_id(body: LoginByIdRequest, db: Session = Depends(get_db)):
|
||||
"""Login using waiter id + PIN (used by the waiter-picker login screen)."""
|
||||
user = db.query(User).filter(User.id == body.waiter_id, User.is_active == True).first()
|
||||
if not user or not bcrypt.checkpw(body.pin.encode(), user.pin_hash.encode()):
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Λανθασμένο PIN")
|
||||
token = make_token(user)
|
||||
return TokenResponse(access_token=token, user=UserOut.model_validate(user))
|
||||
|
||||
|
||||
@router.post("/refresh", response_model=TokenResponse)
|
||||
def refresh(token: str, db: Session = Depends(get_db)):
|
||||
payload = decode_token(token)
|
||||
user = db.query(User).filter(User.id == int(payload["sub"]), User.is_active == True).first()
|
||||
if not user:
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="User not found")
|
||||
blacklist_token(token)
|
||||
new_token = make_token(user)
|
||||
return TokenResponse(access_token=new_token, user=UserOut.model_validate(user))
|
||||
|
||||
|
||||
@router.post("/logout")
|
||||
def logout(token: str):
|
||||
blacklist_token(token)
|
||||
return {"status": "logged out"}
|
||||
|
||||
|
||||
@router.get("/me", response_model=UserOut)
|
||||
def me(user: User = Depends(get_current_user)):
|
||||
return user
|
||||
|
||||
|
||||
# ─── Public manager list (login screen — no auth required) ───────────────────
|
||||
|
||||
class PublicManagerOut(_PydanticBase):
|
||||
id: int
|
||||
username: str
|
||||
full_name: str | None
|
||||
model_config = {"from_attributes": True}
|
||||
|
||||
|
||||
@router.get("/managers", response_model=list[PublicManagerOut])
|
||||
def public_manager_list(db: Session = Depends(get_db)):
|
||||
"""Public endpoint — returns active manager/sysadmin accounts for login screen."""
|
||||
managers = db.query(User).filter(
|
||||
User.role.in_(["manager", "sysadmin"]),
|
||||
User.is_active == True,
|
||||
).all()
|
||||
return [PublicManagerOut(id=m.id, username=m.username, full_name=m.full_name) for m in managers]
|
||||
|
||||
|
||||
# ─── Public waiter list (login screen — no auth required) ────────────────────
|
||||
|
||||
from pydantic import BaseModel as _BaseModel
|
||||
|
||||
class PublicWaiterOut(_BaseModel):
|
||||
id: int
|
||||
full_name: str | None
|
||||
nickname: str | None
|
||||
avatar_url: str | None
|
||||
on_shift: bool
|
||||
model_config = {"from_attributes": True}
|
||||
|
||||
|
||||
@router.get("/waiters", response_model=list[PublicWaiterOut])
|
||||
def public_waiter_list(db: Session = Depends(get_db)):
|
||||
"""Public endpoint — returns active waiters with on-shift flag. No auth required."""
|
||||
from models.shift import WaiterShift
|
||||
waiters = db.query(User).filter(User.role == "waiter", User.is_active == True).all()
|
||||
on_shift_ids = {
|
||||
row.waiter_id
|
||||
for row in db.query(WaiterShift).filter(WaiterShift.ended_at == None).all()
|
||||
}
|
||||
return [
|
||||
PublicWaiterOut(
|
||||
id=w.id,
|
||||
full_name=w.full_name,
|
||||
nickname=w.nickname,
|
||||
avatar_url=w.avatar_url,
|
||||
on_shift=w.id in on_shift_ids,
|
||||
)
|
||||
for w in waiters
|
||||
]
|
||||
|
||||
|
||||
@router.patch("/me", response_model=UserOut)
|
||||
def update_me(body: UpdateMeRequest, db: Session = Depends(get_db), user: User = Depends(get_current_user)):
|
||||
# Password change — requires current_password verification
|
||||
if body.new_password is not None:
|
||||
if not body.current_password:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="current_password is required to set a new password")
|
||||
if not user.password_hash:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Account has no password set")
|
||||
if not bcrypt.checkpw(body.current_password.encode(), user.password_hash.encode()):
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Current password is incorrect")
|
||||
if len(body.new_password) < 6:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="New password must be at least 6 characters")
|
||||
user.password_hash = bcrypt.hashpw(body.new_password.encode(), bcrypt.gensalt()).decode()
|
||||
|
||||
# PIN change — no current PIN required (already authenticated)
|
||||
if body.new_pin is not None:
|
||||
if len(body.new_pin) < 4:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="PIN must be at least 4 digits")
|
||||
user.pin_hash = bcrypt.hashpw(body.new_pin.encode(), bcrypt.gensalt()).decode()
|
||||
|
||||
# Username change — check uniqueness
|
||||
if body.username is not None and body.username != user.username:
|
||||
conflict = db.query(User).filter(User.username == body.username, User.id != user.id).first()
|
||||
if conflict:
|
||||
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="Username already taken")
|
||||
user.username = body.username
|
||||
|
||||
# Display name
|
||||
if body.full_name is not None:
|
||||
user.full_name = body.full_name
|
||||
|
||||
db.commit()
|
||||
db.refresh(user)
|
||||
return user
|
||||
195
local_backend/routers/business_day.py
Normal file
195
local_backend/routers/business_day.py
Normal file
@@ -0,0 +1,195 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from sqlalchemy import func
|
||||
from sqlalchemy.orm import Session
|
||||
from typing import Optional
|
||||
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.shift import WaiterShift
|
||||
from models.flag import TableFlagAssignment
|
||||
from models.message import StaffMessage, StaffMessageAck
|
||||
from schemas.business_day import BusinessDayOut, OpenBusinessDayRequest, CloseBusinessDayRequest
|
||||
from routers.deps import get_current_user, require_manager
|
||||
from models.user import User
|
||||
from middleware.license_check import license_state
|
||||
|
||||
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("/current", response_model=Optional[BusinessDayOut])
|
||||
def get_current_business_day(
|
||||
db: Session = Depends(get_db),
|
||||
user: User = Depends(get_current_user),
|
||||
):
|
||||
return db.query(BusinessDay).filter(BusinessDay.status == "open").first()
|
||||
|
||||
|
||||
@router.post("/open", response_model=BusinessDayOut, status_code=status.HTTP_201_CREATED)
|
||||
def open_business_day(
|
||||
body: OpenBusinessDayRequest,
|
||||
db: Session = Depends(get_db),
|
||||
user: User = Depends(require_manager),
|
||||
):
|
||||
existing = db.query(BusinessDay).filter(BusinessDay.status == "open").first()
|
||||
if existing:
|
||||
raise HTTPException(status_code=400, detail="A business day is already open")
|
||||
|
||||
# Gate: admin lock (already enforced or pending)
|
||||
if license_state.get("locked") or license_state.get("lock_pending"):
|
||||
raise HTTPException(
|
||||
status_code=423,
|
||||
detail={
|
||||
"code": "SYSTEM_LOCKED",
|
||||
"message": "Το σύστημα έχει κλειδωθεί από διαχειριστή. Επικοινωνήστε με την υποστήριξη.",
|
||||
},
|
||||
)
|
||||
|
||||
# Gate: license expired and expiry grace period also over
|
||||
if not license_state.get("licensed", True):
|
||||
expires_at = license_state.get("expires_at", "")
|
||||
raise HTTPException(
|
||||
status_code=402,
|
||||
detail={
|
||||
"code": "LICENSE_EXPIRED",
|
||||
"message": "Η άδεια χρήσης έχει λήξει. Ανανεώστε την άδεια ή επικοινωνήστε με την υποστήριξη.",
|
||||
"expires_at": expires_at,
|
||||
},
|
||||
)
|
||||
|
||||
day = BusinessDay(opened_by_id=user.id, notes=body.notes)
|
||||
db.add(day)
|
||||
db.commit()
|
||||
db.refresh(day)
|
||||
return day
|
||||
|
||||
|
||||
@router.post("/close", response_model=BusinessDayOut)
|
||||
def close_business_day(
|
||||
body: CloseBusinessDayRequest,
|
||||
db: Session = Depends(get_db),
|
||||
user: User = Depends(require_manager),
|
||||
):
|
||||
day = db.query(BusinessDay).filter(BusinessDay.status == "open").first()
|
||||
if not day:
|
||||
raise HTTPException(status_code=404, detail="No open business day")
|
||||
|
||||
open_orders = db.query(Order).filter(
|
||||
Order.business_day_id == day.id,
|
||||
Order.status.in_(["open", "partially_paid"]),
|
||||
).all()
|
||||
|
||||
if open_orders and not body.force:
|
||||
# Count orders that have at least one active (unpaid) item — covers both
|
||||
# "open" (fully unpaid) and "partially_paid" (partially unpaid) orders.
|
||||
with_pending = sum(
|
||||
1 for o in open_orders
|
||||
if db.query(OrderItem).filter(
|
||||
OrderItem.order_id == o.id,
|
||||
OrderItem.status == "active",
|
||||
).first() is not None
|
||||
)
|
||||
raise HTTPException(
|
||||
status_code=409,
|
||||
detail={
|
||||
"message": f"{len(open_orders)} table(s) still open, {with_pending} with unpaid items.",
|
||||
"open_orders": len(open_orders),
|
||||
"partially_paid": with_pending,
|
||||
},
|
||||
)
|
||||
|
||||
now = datetime.now(timezone.utc)
|
||||
|
||||
# Close all non-terminal orders for this business day (open, partially_paid, paid)
|
||||
all_unclosed = db.query(Order).filter(
|
||||
Order.business_day_id == day.id,
|
||||
Order.status.in_(["open", "partially_paid", "paid"]),
|
||||
).all()
|
||||
for order in all_unclosed:
|
||||
was_unpaid = order.status in ("open", "partially_paid")
|
||||
order.status = "closed"
|
||||
order.closed_at = now
|
||||
order.closed_by = user.id
|
||||
if was_unpaid:
|
||||
db.add(OrderAuditLog(
|
||||
order_id=order.id,
|
||||
event_type="ORDER_CLOSED",
|
||||
waiter_id=user.id,
|
||||
note="Force-closed at end of business day",
|
||||
))
|
||||
|
||||
active_shifts = db.query(WaiterShift).filter(
|
||||
WaiterShift.business_day_id == day.id,
|
||||
WaiterShift.ended_at == None,
|
||||
).all()
|
||||
for shift in active_shifts:
|
||||
items = db.query(OrderItem).filter(
|
||||
OrderItem.paid_in_shift_id == shift.id,
|
||||
OrderItem.status == "paid",
|
||||
).all()
|
||||
shift.total_collected = sum(i.unit_price * i.quantity for i in items)
|
||||
shift.ended_at = now
|
||||
|
||||
# Clear all table flags and staff messages — fresh slate for the next day
|
||||
db.query(TableFlagAssignment).delete(synchronize_session=False)
|
||||
db.query(StaffMessageAck).delete(synchronize_session=False)
|
||||
db.query(StaffMessage).delete(synchronize_session=False)
|
||||
|
||||
day.status = "closed"
|
||||
day.closed_at = now
|
||||
day.closed_by_id = user.id
|
||||
if body.notes:
|
||||
day.notes = body.notes
|
||||
|
||||
db.commit()
|
||||
db.refresh(day)
|
||||
|
||||
# Deferred lock: if cloud requested a lock while the workday was open,
|
||||
# enforce it now that the day has closed.
|
||||
if license_state.get("lock_pending"):
|
||||
license_state["lock_pending"] = False
|
||||
license_state["locked"] = True
|
||||
from services.cloud_sync import _persist_state
|
||||
_persist_state()
|
||||
|
||||
return day
|
||||
|
||||
|
||||
@router.get("/history")
|
||||
def business_day_history(
|
||||
db: Session = Depends(get_db),
|
||||
user: User = Depends(require_manager),
|
||||
):
|
||||
days = db.query(BusinessDay).order_by(BusinessDay.opened_at.desc()).all()
|
||||
result = []
|
||||
for day in days:
|
||||
order_count = db.query(Order).filter(Order.business_day_id == day.id).count()
|
||||
revenue = (
|
||||
db.query(func.sum(OrderItem.unit_price * OrderItem.quantity))
|
||||
.join(Order)
|
||||
.filter(Order.business_day_id == day.id, OrderItem.status == "paid")
|
||||
.scalar() or 0.0
|
||||
)
|
||||
w_opener = day.opener
|
||||
w_closer = day.closer
|
||||
result.append({
|
||||
"id": day.id,
|
||||
"status": day.status,
|
||||
"opened_at": _dt(day.opened_at),
|
||||
"closed_at": _dt(day.closed_at),
|
||||
"opened_by_id": day.opened_by_id,
|
||||
"opened_by_name": (w_opener.full_name or w_opener.username) if w_opener else None,
|
||||
"closed_by_id": day.closed_by_id,
|
||||
"closed_by_name": (w_closer.full_name or w_closer.username) if w_closer else None,
|
||||
"notes": day.notes,
|
||||
"order_count": order_count,
|
||||
"revenue": round(revenue, 2),
|
||||
})
|
||||
return {"business_days": result}
|
||||
342
local_backend/routers/data_transfer.py
Normal file
342
local_backend/routers/data_transfer.py
Normal file
@@ -0,0 +1,342 @@
|
||||
import json
|
||||
from datetime import datetime, timezone
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from fastapi.responses import JSONResponse
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from database import get_db
|
||||
from models.product import (
|
||||
Category, Product, ProductOption, ProductQuickOption,
|
||||
ProductIngredient, ProductPreferenceSet, ProductPreferenceChoice,
|
||||
)
|
||||
from models.table import Table, TableGroup
|
||||
from models.user import User
|
||||
from routers.deps import require_manager
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
EXPORT_VERSION = 1
|
||||
|
||||
|
||||
def _serialize_product(p: Product) -> dict:
|
||||
quick_options = [
|
||||
{"name": qo.name, "price": qo.price, "allow_multiple": qo.allow_multiple,
|
||||
"sort_order": qo.sort_order, "is_favorite": qo.is_favorite,
|
||||
"favorite_sort_order": qo.favorite_sort_order, "is_compact": qo.is_compact}
|
||||
for qo in p.quick_options
|
||||
]
|
||||
options = []
|
||||
for opt in p.options:
|
||||
sub = json.loads(opt.sub_choices) if opt.sub_choices else []
|
||||
options.append({"name": opt.name, "extra_cost": opt.extra_cost,
|
||||
"allow_multiple": opt.allow_multiple, "sub_choices": sub,
|
||||
"is_favorite": opt.is_favorite, "favorite_sort_order": opt.favorite_sort_order})
|
||||
ingredients = [
|
||||
{"name": ing.name, "extra_cost": ing.extra_cost,
|
||||
"is_favorite": ing.is_favorite, "favorite_sort_order": ing.favorite_sort_order}
|
||||
for ing in p.ingredients
|
||||
]
|
||||
preference_sets = []
|
||||
for ps in p.preference_sets:
|
||||
shared = json.loads(ps.shared_subset) if ps.shared_subset else None
|
||||
default_index = None
|
||||
choices = []
|
||||
for i, ch in enumerate(ps.choices):
|
||||
if ch.id == ps.default_choice_id:
|
||||
default_index = i
|
||||
sub = json.loads(ch.sub_choices) if ch.sub_choices else []
|
||||
choices.append({"name": ch.name, "extra_cost": ch.extra_cost,
|
||||
"sub_choices": sub, "disables_subset": ch.disables_subset})
|
||||
preference_sets.append({
|
||||
"name": ps.name, "choices": choices,
|
||||
"default_choice_index": default_index, "shared_subset": shared,
|
||||
"is_favorite": ps.is_favorite, "favorite_sort_order": ps.favorite_sort_order,
|
||||
})
|
||||
return {
|
||||
"name": p.name, "base_price": p.base_price, "is_available": p.is_available,
|
||||
"lifecycle_status": p.lifecycle_status, "sort_order": p.sort_order,
|
||||
"printer_zone_id": None, # always stripped on export
|
||||
"quick_options": quick_options, "options": options,
|
||||
"ingredients": ingredients, "preference_sets": preference_sets,
|
||||
}
|
||||
|
||||
|
||||
def _serialize_category(cat: Category) -> dict:
|
||||
products = [_serialize_product(p) for p in cat.products if p.lifecycle_status != "archived"]
|
||||
return {
|
||||
"name": cat.name, "color": cat.color, "sort_order": cat.sort_order,
|
||||
"parent_name": cat.parent.name if cat.parent else None,
|
||||
"general_sort_order": cat.general_sort_order, "auto_expanded": cat.auto_expanded,
|
||||
"products": products,
|
||||
}
|
||||
|
||||
|
||||
@router.get("/export/catalog")
|
||||
def export_catalog(db: Session = Depends(get_db), user: User = Depends(require_manager)):
|
||||
categories = db.query(Category).order_by(Category.sort_order).all()
|
||||
orphan_products = (
|
||||
db.query(Product)
|
||||
.filter(Product.category_id == None, Product.lifecycle_status != "archived")
|
||||
.order_by(Product.sort_order)
|
||||
.all()
|
||||
)
|
||||
data = {
|
||||
"categories": [_serialize_category(c) for c in categories],
|
||||
"uncategorized_products": [_serialize_product(p) for p in orphan_products],
|
||||
}
|
||||
payload = {
|
||||
"xenia_export_version": EXPORT_VERSION,
|
||||
"bundle": "catalog",
|
||||
"exported_at": datetime.now(timezone.utc).isoformat(),
|
||||
"data": data,
|
||||
}
|
||||
today = datetime.now(timezone.utc).strftime("%Y-%m-%d")
|
||||
return JSONResponse(
|
||||
content=payload,
|
||||
headers={"Content-Disposition": f'attachment; filename="xenia-catalog-{today}.json"'},
|
||||
)
|
||||
|
||||
|
||||
@router.get("/export/tables")
|
||||
def export_tables(db: Session = Depends(get_db), user: User = Depends(require_manager)):
|
||||
groups = db.query(TableGroup).order_by(TableGroup.sort_order).all()
|
||||
table_groups = []
|
||||
for g in groups:
|
||||
tables = [
|
||||
{"number": t.number, "label": t.label, "is_active": t.is_active,
|
||||
"floor_x": t.floor_x, "floor_y": t.floor_y}
|
||||
for t in sorted(g.tables, key=lambda t: t.number)
|
||||
]
|
||||
table_groups.append({
|
||||
"name": g.name, "prefix": g.prefix, "sort_order": g.sort_order,
|
||||
"color": g.color, "tables": tables,
|
||||
})
|
||||
ungrouped = (
|
||||
db.query(Table)
|
||||
.filter(Table.group_id == None)
|
||||
.order_by(Table.number)
|
||||
.all()
|
||||
)
|
||||
ungrouped_tables = [
|
||||
{"number": t.number, "label": t.label, "is_active": t.is_active,
|
||||
"floor_x": t.floor_x, "floor_y": t.floor_y}
|
||||
for t in ungrouped
|
||||
]
|
||||
payload = {
|
||||
"xenia_export_version": EXPORT_VERSION,
|
||||
"bundle": "tables",
|
||||
"exported_at": datetime.now(timezone.utc).isoformat(),
|
||||
"data": {"table_groups": table_groups, "ungrouped_tables": ungrouped_tables},
|
||||
}
|
||||
today = datetime.now(timezone.utc).strftime("%Y-%m-%d")
|
||||
return JSONResponse(
|
||||
content=payload,
|
||||
headers={"Content-Disposition": f'attachment; filename="xenia-tables-{today}.json"'},
|
||||
)
|
||||
|
||||
|
||||
@router.post("/import/catalog")
|
||||
def import_catalog(payload: dict, db: Session = Depends(get_db), user: User = Depends(require_manager)):
|
||||
if payload.get("bundle") != "catalog":
|
||||
raise HTTPException(status_code=400, detail="Wrong bundle type. Expected 'catalog'.")
|
||||
if payload.get("xenia_export_version", 0) != EXPORT_VERSION:
|
||||
raise HTTPException(status_code=400, detail="Unsupported export version.")
|
||||
|
||||
data = payload.get("data", {})
|
||||
categories_data = data.get("categories", [])
|
||||
|
||||
def _upsert_category(cat_data: dict, parent_id=None) -> Category:
|
||||
existing = db.query(Category).filter(
|
||||
Category.name == cat_data["name"],
|
||||
Category.parent_id == parent_id,
|
||||
).first()
|
||||
if existing:
|
||||
existing.color = cat_data.get("color")
|
||||
existing.sort_order = cat_data.get("sort_order", 0)
|
||||
existing.general_sort_order = cat_data.get("general_sort_order", 0)
|
||||
existing.auto_expanded = cat_data.get("auto_expanded", False)
|
||||
db.flush()
|
||||
return existing
|
||||
else:
|
||||
cat = Category(
|
||||
name=cat_data["name"],
|
||||
color=cat_data.get("color"),
|
||||
sort_order=cat_data.get("sort_order", 0),
|
||||
parent_id=parent_id,
|
||||
general_sort_order=cat_data.get("general_sort_order", 0),
|
||||
auto_expanded=cat_data.get("auto_expanded", False),
|
||||
)
|
||||
db.add(cat)
|
||||
db.flush()
|
||||
return cat
|
||||
|
||||
def _upsert_product(prod_data: dict, category_id=None):
|
||||
existing = db.query(Product).filter(Product.name == prod_data["name"]).first()
|
||||
if existing:
|
||||
existing.base_price = prod_data["base_price"]
|
||||
existing.is_available = prod_data.get("is_available", True)
|
||||
existing.lifecycle_status = prod_data.get("lifecycle_status", "active")
|
||||
existing.sort_order = prod_data.get("sort_order", 0)
|
||||
existing.category_id = category_id
|
||||
existing.printer_zone_id = None
|
||||
db.flush()
|
||||
product = existing
|
||||
else:
|
||||
product = Product(
|
||||
name=prod_data["name"],
|
||||
base_price=prod_data["base_price"],
|
||||
is_available=prod_data.get("is_available", True),
|
||||
lifecycle_status=prod_data.get("lifecycle_status", "active"),
|
||||
sort_order=prod_data.get("sort_order", 0),
|
||||
category_id=category_id,
|
||||
printer_zone_id=None,
|
||||
)
|
||||
db.add(product)
|
||||
db.flush()
|
||||
|
||||
# Replace sub-items (safe: sub-items have no direct order history references)
|
||||
for qo in list(product.quick_options):
|
||||
db.delete(qo)
|
||||
for opt in list(product.options):
|
||||
db.delete(opt)
|
||||
for ing in list(product.ingredients):
|
||||
db.delete(ing)
|
||||
for ps in list(product.preference_sets):
|
||||
db.delete(ps)
|
||||
db.flush()
|
||||
|
||||
for qo in prod_data.get("quick_options", []):
|
||||
db.add(ProductQuickOption(product_id=product.id, **qo))
|
||||
|
||||
for opt in prod_data.get("options", []):
|
||||
sub_json = json.dumps(opt.get("sub_choices", []))
|
||||
db.add(ProductOption(
|
||||
product_id=product.id, name=opt["name"], extra_cost=opt.get("extra_cost", 0.0),
|
||||
allow_multiple=opt.get("allow_multiple", False), sub_choices=sub_json,
|
||||
is_favorite=opt.get("is_favorite", False),
|
||||
favorite_sort_order=opt.get("favorite_sort_order", 0),
|
||||
))
|
||||
|
||||
for ing in prod_data.get("ingredients", []):
|
||||
db.add(ProductIngredient(product_id=product.id, **ing))
|
||||
|
||||
for ps_data in prod_data.get("preference_sets", []):
|
||||
shared_json = json.dumps(ps_data["shared_subset"]) if ps_data.get("shared_subset") else None
|
||||
ps = ProductPreferenceSet(
|
||||
product_id=product.id, name=ps_data["name"],
|
||||
shared_subset=shared_json,
|
||||
is_favorite=ps_data.get("is_favorite", False),
|
||||
favorite_sort_order=ps_data.get("favorite_sort_order", 0),
|
||||
)
|
||||
db.add(ps)
|
||||
db.flush()
|
||||
created_choices = []
|
||||
for ch in ps_data.get("choices", []):
|
||||
sub_json = json.dumps(ch.get("sub_choices", []))
|
||||
choice = ProductPreferenceChoice(
|
||||
set_id=ps.id, name=ch["name"], extra_cost=ch.get("extra_cost", 0.0),
|
||||
sub_choices=sub_json, disables_subset=ch.get("disables_subset", False),
|
||||
)
|
||||
db.add(choice)
|
||||
db.flush()
|
||||
created_choices.append(choice)
|
||||
idx = ps_data.get("default_choice_index")
|
||||
if idx is not None and 0 <= idx < len(created_choices):
|
||||
ps.default_choice_id = created_choices[idx].id
|
||||
|
||||
# First pass: top-level categories (parent_name is None)
|
||||
for cat_data in categories_data:
|
||||
if cat_data.get("parent_name") is None:
|
||||
cat = _upsert_category(cat_data, parent_id=None)
|
||||
for prod_data in cat_data.get("products", []):
|
||||
_upsert_product(prod_data, category_id=cat.id)
|
||||
|
||||
# Second pass: sub-categories (parent must already exist from first pass)
|
||||
for cat_data in categories_data:
|
||||
if cat_data.get("parent_name") is not None:
|
||||
parent = db.query(Category).filter(
|
||||
Category.name == cat_data["parent_name"],
|
||||
Category.parent_id == None,
|
||||
).first()
|
||||
parent_id = parent.id if parent else None
|
||||
cat = _upsert_category(cat_data, parent_id=parent_id)
|
||||
for prod_data in cat_data.get("products", []):
|
||||
_upsert_product(prod_data, category_id=cat.id)
|
||||
|
||||
# Uncategorized products (no category)
|
||||
for prod_data in data.get("uncategorized_products", []):
|
||||
_upsert_product(prod_data, category_id=None)
|
||||
|
||||
db.commit()
|
||||
return {"ok": True, "message": "Catalog imported successfully."}
|
||||
|
||||
|
||||
@router.post("/import/tables")
|
||||
def import_tables(payload: dict, db: Session = Depends(get_db), user: User = Depends(require_manager)):
|
||||
if payload.get("bundle") != "tables":
|
||||
raise HTTPException(status_code=400, detail="Wrong bundle type. Expected 'tables'.")
|
||||
if payload.get("xenia_export_version", 0) != EXPORT_VERSION:
|
||||
raise HTTPException(status_code=400, detail="Unsupported export version.")
|
||||
|
||||
data = payload.get("data", {})
|
||||
for group_data in data.get("table_groups", []):
|
||||
existing_group = db.query(TableGroup).filter(TableGroup.name == group_data["name"]).first()
|
||||
if existing_group:
|
||||
existing_group.prefix = group_data.get("prefix")
|
||||
existing_group.sort_order = group_data.get("sort_order", 0)
|
||||
existing_group.color = group_data.get("color")
|
||||
db.flush()
|
||||
group = existing_group
|
||||
else:
|
||||
group = TableGroup(
|
||||
name=group_data["name"],
|
||||
prefix=group_data.get("prefix"),
|
||||
sort_order=group_data.get("sort_order", 0),
|
||||
color=group_data.get("color"),
|
||||
)
|
||||
db.add(group)
|
||||
db.flush()
|
||||
|
||||
for table_data in group_data.get("tables", []):
|
||||
existing_table = db.query(Table).filter(
|
||||
Table.number == table_data["number"],
|
||||
Table.group_id == group.id,
|
||||
).first()
|
||||
if existing_table:
|
||||
existing_table.label = table_data.get("label")
|
||||
existing_table.is_active = table_data.get("is_active", True)
|
||||
existing_table.floor_x = table_data.get("floor_x")
|
||||
existing_table.floor_y = table_data.get("floor_y")
|
||||
else:
|
||||
db.add(Table(
|
||||
number=table_data["number"],
|
||||
label=table_data.get("label"),
|
||||
group_id=group.id,
|
||||
is_active=table_data.get("is_active", True),
|
||||
floor_x=table_data.get("floor_x"),
|
||||
floor_y=table_data.get("floor_y"),
|
||||
))
|
||||
|
||||
# Ungrouped tables (no zone)
|
||||
for table_data in data.get("ungrouped_tables", []):
|
||||
existing_table = db.query(Table).filter(
|
||||
Table.number == table_data["number"],
|
||||
Table.group_id == None,
|
||||
).first()
|
||||
if existing_table:
|
||||
existing_table.label = table_data.get("label")
|
||||
existing_table.is_active = table_data.get("is_active", True)
|
||||
existing_table.floor_x = table_data.get("floor_x")
|
||||
existing_table.floor_y = table_data.get("floor_y")
|
||||
else:
|
||||
db.add(Table(
|
||||
number=table_data["number"],
|
||||
label=table_data.get("label"),
|
||||
group_id=None,
|
||||
is_active=table_data.get("is_active", True),
|
||||
floor_x=table_data.get("floor_x"),
|
||||
floor_y=table_data.get("floor_y"),
|
||||
))
|
||||
|
||||
db.commit()
|
||||
return {"ok": True, "message": "Tables imported successfully."}
|
||||
64
local_backend/routers/deps.py
Normal file
64
local_backend/routers/deps.py
Normal file
@@ -0,0 +1,64 @@
|
||||
import jwt
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from fastapi import Depends, HTTPException, status
|
||||
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from database import get_db
|
||||
from config import settings
|
||||
from models.user import User
|
||||
|
||||
bearer = HTTPBearer()
|
||||
|
||||
# In-memory token blacklist (cleared on restart — acceptable for local use)
|
||||
_blacklisted_tokens: set[str] = set()
|
||||
|
||||
TOKEN_EXPIRY_HOURS = 8
|
||||
|
||||
|
||||
def make_token(user: User) -> str:
|
||||
payload = {
|
||||
"sub": str(user.id),
|
||||
"username": user.username,
|
||||
"role": user.role,
|
||||
"exp": datetime.now(timezone.utc) + timedelta(hours=TOKEN_EXPIRY_HOURS),
|
||||
}
|
||||
return jwt.encode(payload, settings.SECRET_KEY, algorithm="HS256")
|
||||
|
||||
|
||||
def decode_token(token: str) -> dict:
|
||||
if token in _blacklisted_tokens:
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Token revoked")
|
||||
try:
|
||||
return jwt.decode(token, settings.SECRET_KEY, algorithms=["HS256"])
|
||||
except jwt.ExpiredSignatureError:
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Token expired")
|
||||
except jwt.InvalidTokenError:
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid token")
|
||||
|
||||
|
||||
def blacklist_token(token: str):
|
||||
_blacklisted_tokens.add(token)
|
||||
|
||||
|
||||
def get_current_user(
|
||||
credentials: HTTPAuthorizationCredentials = Depends(bearer),
|
||||
db: Session = Depends(get_db),
|
||||
) -> User:
|
||||
payload = decode_token(credentials.credentials)
|
||||
user = db.query(User).filter(User.id == int(payload["sub"]), User.is_active == True).first()
|
||||
if not user:
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="User not found")
|
||||
return user
|
||||
|
||||
|
||||
def require_manager(user: User = Depends(get_current_user)) -> User:
|
||||
if user.role not in ("manager", "sysadmin"):
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Manager access required")
|
||||
return user
|
||||
|
||||
|
||||
def require_sysadmin(user: User = Depends(get_current_user)) -> User:
|
||||
if user.role != "sysadmin":
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Sysadmin access required")
|
||||
return user
|
||||
165
local_backend/routers/flags.py
Normal file
165
local_backend/routers/flags.py
Normal file
@@ -0,0 +1,165 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from sqlalchemy.orm import Session
|
||||
from typing import List
|
||||
|
||||
from database import get_db
|
||||
from models.flag import TableFlagDef, TableFlagAssignment
|
||||
from schemas.flag import FlagDefCreate, FlagDefUpdate, FlagDefOut, FlagAssignmentOut, SetTableFlagsRequest
|
||||
from routers.deps import get_current_user, require_manager
|
||||
from models.user import User
|
||||
from services.sse_bus import broadcast_sync
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
# ─── Flag definitions (manager only) ─────────────────────────────────────────
|
||||
|
||||
@router.get("/defs", response_model=List[FlagDefOut])
|
||||
def list_flag_defs(
|
||||
include_inactive: bool = False,
|
||||
db: Session = Depends(get_db),
|
||||
user: User = Depends(get_current_user),
|
||||
):
|
||||
q = db.query(TableFlagDef)
|
||||
if not include_inactive:
|
||||
q = q.filter(TableFlagDef.is_active == True)
|
||||
return q.order_by(TableFlagDef.sort_order, TableFlagDef.id).all()
|
||||
|
||||
|
||||
@router.post("/defs", response_model=FlagDefOut, status_code=status.HTTP_201_CREATED)
|
||||
def create_flag_def(
|
||||
body: FlagDefCreate,
|
||||
db: Session = Depends(get_db),
|
||||
user: User = Depends(require_manager),
|
||||
):
|
||||
flag = TableFlagDef(**body.model_dump())
|
||||
db.add(flag)
|
||||
db.commit()
|
||||
db.refresh(flag)
|
||||
return flag
|
||||
|
||||
|
||||
@router.put("/defs/{flag_id}", response_model=FlagDefOut)
|
||||
def update_flag_def(
|
||||
flag_id: int,
|
||||
body: FlagDefUpdate,
|
||||
db: Session = Depends(get_db),
|
||||
user: User = Depends(require_manager),
|
||||
):
|
||||
flag = db.query(TableFlagDef).filter(TableFlagDef.id == flag_id).first()
|
||||
if not flag:
|
||||
raise HTTPException(status_code=404, detail="Flag not found")
|
||||
for k, v in body.model_dump(exclude_unset=True).items():
|
||||
setattr(flag, k, v)
|
||||
db.commit()
|
||||
db.refresh(flag)
|
||||
return flag
|
||||
|
||||
|
||||
@router.patch("/defs/{flag_id}/toggle-active", response_model=FlagDefOut)
|
||||
def toggle_flag_active(
|
||||
flag_id: int,
|
||||
db: Session = Depends(get_db),
|
||||
user: User = Depends(require_manager),
|
||||
):
|
||||
flag = db.query(TableFlagDef).filter(TableFlagDef.id == flag_id).first()
|
||||
if not flag:
|
||||
raise HTTPException(status_code=404, detail="Flag not found")
|
||||
flag.is_active = not flag.is_active
|
||||
db.commit()
|
||||
db.refresh(flag)
|
||||
return flag
|
||||
|
||||
|
||||
@router.delete("/defs/{flag_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||
def delete_flag_def(
|
||||
flag_id: int,
|
||||
db: Session = Depends(get_db),
|
||||
user: User = Depends(require_manager),
|
||||
):
|
||||
flag = db.query(TableFlagDef).filter(TableFlagDef.id == flag_id).first()
|
||||
if not flag:
|
||||
raise HTTPException(status_code=404, detail="Flag not found")
|
||||
in_use = db.query(TableFlagAssignment).filter(TableFlagAssignment.flag_id == flag_id).count()
|
||||
if in_use:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_409_CONFLICT,
|
||||
detail=f"Η σήμανση χρησιμοποιείται σε {in_use} τραπέζι{'α' if in_use != 1 else ''}. Αφαιρέστε την πρώτα.",
|
||||
)
|
||||
db.delete(flag)
|
||||
db.commit()
|
||||
|
||||
|
||||
# ─── All assignments (bulk endpoint for manager views) ───────────────────────
|
||||
|
||||
@router.get("/assignments", response_model=List[FlagAssignmentOut])
|
||||
def get_all_assignments(
|
||||
db: Session = Depends(get_db),
|
||||
user: User = Depends(get_current_user),
|
||||
):
|
||||
"""All active flag assignments across all tables (for manager dashboard bulk load)."""
|
||||
return db.query(TableFlagAssignment).all()
|
||||
|
||||
|
||||
# ─── Table flag assignments ───────────────────────────────────────────────────
|
||||
|
||||
@router.get("/table/{table_id}", response_model=List[FlagAssignmentOut])
|
||||
def get_table_flags(
|
||||
table_id: int,
|
||||
db: Session = Depends(get_db),
|
||||
user: User = Depends(get_current_user),
|
||||
):
|
||||
return db.query(TableFlagAssignment).filter(
|
||||
TableFlagAssignment.table_id == table_id
|
||||
).all()
|
||||
|
||||
|
||||
@router.put("/table/{table_id}", response_model=List[FlagAssignmentOut])
|
||||
def set_table_flags(
|
||||
table_id: int,
|
||||
body: SetTableFlagsRequest,
|
||||
db: Session = Depends(get_db),
|
||||
user: User = Depends(get_current_user),
|
||||
):
|
||||
"""Replace all flags on a table with the given set of flag_ids."""
|
||||
# Validate all flag_ids exist and are active
|
||||
if body.flag_ids:
|
||||
valid = db.query(TableFlagDef).filter(
|
||||
TableFlagDef.id.in_(body.flag_ids),
|
||||
TableFlagDef.is_active == True,
|
||||
).count()
|
||||
if valid != len(body.flag_ids):
|
||||
raise HTTPException(status_code=400, detail="One or more flag IDs are invalid")
|
||||
|
||||
# Delete existing assignments for this table
|
||||
db.query(TableFlagAssignment).filter(
|
||||
TableFlagAssignment.table_id == table_id
|
||||
).delete(synchronize_session=False)
|
||||
|
||||
# Insert new assignments
|
||||
for flag_id in body.flag_ids:
|
||||
db.add(TableFlagAssignment(
|
||||
table_id=table_id,
|
||||
flag_id=flag_id,
|
||||
assigned_by=user.id,
|
||||
))
|
||||
|
||||
db.commit()
|
||||
result = db.query(TableFlagAssignment).filter(
|
||||
TableFlagAssignment.table_id == table_id
|
||||
).all()
|
||||
broadcast_sync("table_flags_changed", {"table_id": table_id, "flag_ids": body.flag_ids})
|
||||
return result
|
||||
|
||||
|
||||
@router.delete("/table/{table_id}/all", status_code=status.HTTP_204_NO_CONTENT)
|
||||
def clear_table_flags(
|
||||
table_id: int,
|
||||
db: Session = Depends(get_db),
|
||||
user: User = Depends(get_current_user),
|
||||
):
|
||||
db.query(TableFlagAssignment).filter(
|
||||
TableFlagAssignment.table_id == table_id
|
||||
).delete(synchronize_session=False)
|
||||
db.commit()
|
||||
broadcast_sync("table_flags_changed", {"table_id": table_id, "flag_ids": []})
|
||||
215
local_backend/routers/messages.py
Normal file
215
local_backend/routers/messages.py
Normal file
@@ -0,0 +1,215 @@
|
||||
import json
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from sqlalchemy.orm import Session, joinedload
|
||||
from typing import List
|
||||
|
||||
from database import get_db
|
||||
from models.message import StaffMessage, StaffMessageAck, QuickMessageTemplate
|
||||
from models.user import User
|
||||
from schemas.message import (
|
||||
SendMessageRequest, StaffMessageOut,
|
||||
QuickTemplateCreate, QuickTemplateUpdate, QuickTemplateOut,
|
||||
)
|
||||
from routers.deps import get_current_user, require_manager
|
||||
from services.sse_bus import broadcast_sync
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
def _load_msg(db: Session, msg_id: int) -> StaffMessage:
|
||||
"""Reload a message with sender and acks eagerly loaded."""
|
||||
return db.query(StaffMessage).options(
|
||||
joinedload(StaffMessage.sender),
|
||||
joinedload(StaffMessage.acks),
|
||||
).filter(StaffMessage.id == msg_id).one()
|
||||
|
||||
|
||||
def _message_out(msg: StaffMessage) -> StaffMessageOut:
|
||||
sender_name = None
|
||||
try:
|
||||
sender_name = msg.sender.username if msg.sender else None
|
||||
except Exception:
|
||||
pass
|
||||
return StaffMessageOut(
|
||||
id=msg.id,
|
||||
sender_id=msg.sender_id,
|
||||
sender_name=sender_name,
|
||||
body=msg.body,
|
||||
target_waiter_ids=msg.target_waiter_ids,
|
||||
table_ids=msg.table_ids,
|
||||
created_at=msg.created_at,
|
||||
acked_by=[ack.waiter_id for ack in msg.acks],
|
||||
)
|
||||
|
||||
|
||||
# ─── Quick templates ──────────────────────────────────────────────────────────
|
||||
|
||||
@router.get("/templates", response_model=List[QuickTemplateOut])
|
||||
def list_templates(
|
||||
db: Session = Depends(get_db),
|
||||
user: User = Depends(get_current_user),
|
||||
):
|
||||
return db.query(QuickMessageTemplate).filter(
|
||||
QuickMessageTemplate.is_active == True
|
||||
).order_by(QuickMessageTemplate.sort_order, QuickMessageTemplate.id).all()
|
||||
|
||||
|
||||
@router.post("/templates", response_model=QuickTemplateOut, status_code=status.HTTP_201_CREATED)
|
||||
def create_template(
|
||||
body: QuickTemplateCreate,
|
||||
db: Session = Depends(get_db),
|
||||
user: User = Depends(require_manager),
|
||||
):
|
||||
t = QuickMessageTemplate(**body.model_dump())
|
||||
db.add(t)
|
||||
db.commit()
|
||||
db.refresh(t)
|
||||
return t
|
||||
|
||||
|
||||
@router.put("/templates/{template_id}", response_model=QuickTemplateOut)
|
||||
def update_template(
|
||||
template_id: int,
|
||||
body: QuickTemplateUpdate,
|
||||
db: Session = Depends(get_db),
|
||||
user: User = Depends(require_manager),
|
||||
):
|
||||
t = db.query(QuickMessageTemplate).filter(QuickMessageTemplate.id == template_id).first()
|
||||
if not t:
|
||||
raise HTTPException(status_code=404, detail="Template not found")
|
||||
for k, v in body.model_dump(exclude_unset=True).items():
|
||||
setattr(t, k, v)
|
||||
db.commit()
|
||||
db.refresh(t)
|
||||
return t
|
||||
|
||||
|
||||
@router.delete("/templates/{template_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||
def delete_template(
|
||||
template_id: int,
|
||||
db: Session = Depends(get_db),
|
||||
user: User = Depends(require_manager),
|
||||
):
|
||||
t = db.query(QuickMessageTemplate).filter(QuickMessageTemplate.id == template_id).first()
|
||||
if not t:
|
||||
raise HTTPException(status_code=404, detail="Template not found")
|
||||
t.is_active = False
|
||||
db.commit()
|
||||
|
||||
|
||||
# ─── Staff messages ───────────────────────────────────────────────────────────
|
||||
|
||||
@router.post("/send", response_model=StaffMessageOut, status_code=status.HTTP_201_CREATED)
|
||||
def send_message(
|
||||
body: SendMessageRequest,
|
||||
db: Session = Depends(get_db),
|
||||
user: User = Depends(require_manager),
|
||||
):
|
||||
msg = StaffMessage(
|
||||
sender_id=user.id,
|
||||
body=body.body,
|
||||
target_waiter_ids=json.dumps(body.target_waiter_ids),
|
||||
table_ids=json.dumps(body.table_ids or []),
|
||||
)
|
||||
db.add(msg)
|
||||
db.commit()
|
||||
msg = _load_msg(db, msg.id)
|
||||
out = _message_out(msg)
|
||||
# Broadcast to targeted users (empty list = all connected users)
|
||||
target_ids = body.target_waiter_ids if body.target_waiter_ids else None
|
||||
broadcast_sync(
|
||||
"message_sent",
|
||||
{
|
||||
"id": out.id,
|
||||
"sender_id": out.sender_id,
|
||||
"sender_name": out.sender_name,
|
||||
"body": out.body,
|
||||
"table_ids": out.table_ids,
|
||||
"created_at": out.created_at.isoformat() if out.created_at else None,
|
||||
},
|
||||
user_ids=target_ids,
|
||||
)
|
||||
return out
|
||||
|
||||
|
||||
@router.get("/unread", response_model=List[StaffMessageOut])
|
||||
def get_unread_messages(
|
||||
db: Session = Depends(get_db),
|
||||
user: User = Depends(get_current_user),
|
||||
):
|
||||
"""
|
||||
Returns messages targeting this waiter that they haven't acked yet.
|
||||
A message targets a waiter if their ID is in target_waiter_ids,
|
||||
OR if target_waiter_ids is empty (broadcast to all).
|
||||
"""
|
||||
all_msgs = db.query(StaffMessage).options(
|
||||
joinedload(StaffMessage.sender),
|
||||
joinedload(StaffMessage.acks),
|
||||
).order_by(StaffMessage.created_at.desc()).limit(200).all()
|
||||
acked_ids = {
|
||||
ack.message_id
|
||||
for ack in db.query(StaffMessageAck).filter(StaffMessageAck.waiter_id == user.id).all()
|
||||
}
|
||||
|
||||
result = []
|
||||
for msg in all_msgs:
|
||||
if msg.id in acked_ids:
|
||||
continue
|
||||
targets = json.loads(msg.target_waiter_ids or "[]")
|
||||
# Empty list = broadcast to all
|
||||
if not targets or user.id in targets:
|
||||
result.append(_message_out(msg))
|
||||
|
||||
return result
|
||||
|
||||
|
||||
@router.get("/recent", response_model=List[StaffMessageOut])
|
||||
def get_recent_messages(
|
||||
limit: int = 10,
|
||||
db: Session = Depends(get_db),
|
||||
user: User = Depends(get_current_user),
|
||||
):
|
||||
"""Last N messages targeting this user (for notification history drawer)."""
|
||||
all_msgs = db.query(StaffMessage).options(
|
||||
joinedload(StaffMessage.sender),
|
||||
joinedload(StaffMessage.acks),
|
||||
).order_by(StaffMessage.created_at.desc()).limit(200).all()
|
||||
result = []
|
||||
for msg in all_msgs:
|
||||
targets = json.loads(msg.target_waiter_ids or "[]")
|
||||
if not targets or user.id in targets:
|
||||
result.append(_message_out(msg))
|
||||
if len(result) >= limit:
|
||||
break
|
||||
return result
|
||||
|
||||
|
||||
@router.post("/{message_id}/ack", status_code=status.HTTP_204_NO_CONTENT)
|
||||
def ack_message(
|
||||
message_id: int,
|
||||
db: Session = Depends(get_db),
|
||||
user: User = Depends(get_current_user),
|
||||
):
|
||||
msg = db.query(StaffMessage).filter(StaffMessage.id == message_id).first()
|
||||
if not msg:
|
||||
raise HTTPException(status_code=404, detail="Message not found")
|
||||
existing = db.query(StaffMessageAck).filter(
|
||||
StaffMessageAck.message_id == message_id,
|
||||
StaffMessageAck.waiter_id == user.id,
|
||||
).first()
|
||||
if not existing:
|
||||
db.add(StaffMessageAck(message_id=message_id, waiter_id=user.id))
|
||||
db.commit()
|
||||
|
||||
|
||||
@router.get("/all", response_model=List[StaffMessageOut])
|
||||
def list_all_messages(
|
||||
limit: int = 50,
|
||||
db: Session = Depends(get_db),
|
||||
user: User = Depends(require_manager),
|
||||
):
|
||||
msgs = db.query(StaffMessage).options(
|
||||
joinedload(StaffMessage.sender),
|
||||
joinedload(StaffMessage.acks),
|
||||
).order_by(StaffMessage.created_at.desc()).limit(limit).all()
|
||||
return [_message_out(m) for m in msgs]
|
||||
803
local_backend/routers/orders.py
Normal file
803
local_backend/routers/orders.py
Normal file
@@ -0,0 +1,803 @@
|
||||
import json
|
||||
from datetime import datetime, timezone
|
||||
from fastapi import APIRouter, Depends, HTTPException, status, BackgroundTasks
|
||||
from sqlalchemy.orm import Session
|
||||
from typing import List, Optional
|
||||
|
||||
from database import get_db
|
||||
from models.order import Order, OrderItem, OrderWaiter, OrderAuditLog
|
||||
from models.user import User, WaiterZone
|
||||
from models.table import Table
|
||||
from models.product import Product
|
||||
from schemas.order import OrderCreate, OrderOut, OrderItemOut, AddItemsRequest, AddItemsResponse, PayItemsRequest, OfflinePaymentRequest, AssignWaiterRequest, OrderWaiterOut
|
||||
from pydantic import BaseModel
|
||||
|
||||
class PrintOrderRequest(BaseModel):
|
||||
printer_id: int
|
||||
|
||||
class TransferOrderRequest(BaseModel):
|
||||
target_table_id: int
|
||||
|
||||
class MergeOrderRequest(BaseModel):
|
||||
target_order_id: int
|
||||
|
||||
class SplitItemRequest(BaseModel):
|
||||
quantity: int # how many to split off into a new item row
|
||||
|
||||
class PrintSynopsisRequest(BaseModel):
|
||||
printer_id: int
|
||||
|
||||
class MoveItemsRequest(BaseModel):
|
||||
item_ids: List[int]
|
||||
target_order_id: int
|
||||
|
||||
from routers.deps import get_current_user, require_manager
|
||||
from services.printer_service import route_and_print, route_and_print_sync, print_order_receipt, print_order_synopsis
|
||||
from services.sse_bus import broadcast_sync
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
def _can_access_order(order: Order, user: User, db: Session) -> bool:
|
||||
"""Zone-based access: any waiter whose zone covers the order's table group may act on it."""
|
||||
if user.role in ("manager", "sysadmin"):
|
||||
return True
|
||||
zones = db.query(WaiterZone).filter(WaiterZone.waiter_id == user.id).all()
|
||||
if not zones:
|
||||
return False
|
||||
if any(z.group_id is None for z in zones):
|
||||
return True
|
||||
table = db.query(Table).filter(Table.id == order.table_id).first()
|
||||
if not table:
|
||||
return False
|
||||
allowed_group_ids = {z.group_id for z in zones}
|
||||
return table.group_id in allowed_group_ids
|
||||
|
||||
|
||||
def _audit(db: Session, order_id: int, event_type: str, waiter_id: int = None,
|
||||
item_ids: list = None, amount: float = None, payment_method: str = None, note: str = None):
|
||||
db.add(OrderAuditLog(
|
||||
order_id=order_id,
|
||||
event_type=event_type,
|
||||
waiter_id=waiter_id,
|
||||
item_ids=json.dumps(item_ids) if item_ids is not None else None,
|
||||
amount=amount,
|
||||
payment_method=payment_method,
|
||||
note=note,
|
||||
))
|
||||
|
||||
|
||||
@router.get("/", response_model=List[OrderOut])
|
||||
def list_orders(
|
||||
order_status: Optional[str] = None,
|
||||
waiter_id: Optional[int] = None,
|
||||
db: Session = Depends(get_db),
|
||||
user: User = Depends(require_manager),
|
||||
):
|
||||
q = db.query(Order)
|
||||
if order_status:
|
||||
q = q.filter(Order.status == order_status)
|
||||
if waiter_id:
|
||||
q = q.join(OrderWaiter).filter(OrderWaiter.waiter_id == waiter_id)
|
||||
return q.all()
|
||||
|
||||
|
||||
@router.get("/my", response_model=List[OrderOut])
|
||||
def my_orders(db: Session = Depends(get_db), user: User = Depends(get_current_user)):
|
||||
direct = db.query(Order).join(OrderWaiter).filter(
|
||||
OrderWaiter.waiter_id == user.id,
|
||||
Order.status.in_(["open", "partially_paid"]),
|
||||
).all()
|
||||
# Also orders where user is opener but not explicitly assigned
|
||||
also_opened = db.query(Order).filter(
|
||||
Order.opened_by == user.id,
|
||||
Order.status.in_(["open", "partially_paid"]),
|
||||
).all()
|
||||
seen = {o.id for o in direct}
|
||||
return direct + [o for o in also_opened if o.id not in seen]
|
||||
|
||||
|
||||
class ActiveOrderSlim(BaseModel):
|
||||
id: int
|
||||
table_id: int
|
||||
status: str
|
||||
waiter_ids: List[int]
|
||||
model_config = {"from_attributes": True}
|
||||
|
||||
|
||||
@router.get("/active", response_model=List[ActiveOrderSlim])
|
||||
def list_active_orders(db: Session = Depends(get_db), user: User = Depends(get_current_user)):
|
||||
"""All currently open/partially-paid/paid orders (lightweight). Accessible to all staff."""
|
||||
orders = db.query(Order).filter(Order.status.in_(["open", "partially_paid", "paid"])).all()
|
||||
return [
|
||||
ActiveOrderSlim(
|
||||
id=o.id,
|
||||
table_id=o.table_id,
|
||||
status=o.status,
|
||||
waiter_ids=[w.waiter_id for w in o.waiters],
|
||||
)
|
||||
for o in orders
|
||||
]
|
||||
|
||||
|
||||
@router.get("/{order_id}", response_model=OrderOut)
|
||||
def get_order(order_id: int, db: Session = Depends(get_db), user: User = Depends(get_current_user)):
|
||||
order = db.query(Order).filter(Order.id == order_id).first()
|
||||
if not order:
|
||||
raise HTTPException(status_code=404, detail="Order not found")
|
||||
if not _can_access_order(order, user, db):
|
||||
raise HTTPException(status_code=403, detail="Access denied")
|
||||
return order
|
||||
|
||||
|
||||
@router.post("/", response_model=OrderOut, status_code=status.HTTP_201_CREATED)
|
||||
def open_order(body: OrderCreate, db: Session = Depends(get_db), user: User = Depends(get_current_user)):
|
||||
from models.business_day import BusinessDay
|
||||
from models.shift import WaiterShift
|
||||
|
||||
active_day = db.query(BusinessDay).filter(BusinessDay.status == "open").first()
|
||||
if not active_day:
|
||||
raise HTTPException(status_code=403, detail="Restaurant is not open — manager must open the business day first")
|
||||
|
||||
if user.role == "waiter":
|
||||
active_shift = db.query(WaiterShift).filter(
|
||||
WaiterShift.waiter_id == user.id,
|
||||
WaiterShift.ended_at == None,
|
||||
).first()
|
||||
if not active_shift:
|
||||
raise HTTPException(status_code=403, detail="You do not have an active shift")
|
||||
|
||||
existing = db.query(Order).filter(
|
||||
Order.table_id == body.table_id,
|
||||
Order.status.in_(["open", "partially_paid", "paid"]),
|
||||
).first()
|
||||
if existing:
|
||||
raise HTTPException(status_code=400, detail="Table already has an open order")
|
||||
order = Order(table_id=body.table_id, opened_by=user.id, business_day_id=active_day.id)
|
||||
db.add(order)
|
||||
db.flush()
|
||||
db.add(OrderWaiter(order_id=order.id, waiter_id=user.id))
|
||||
_audit(db, order.id, "ORDER_OPENED", waiter_id=user.id)
|
||||
db.commit()
|
||||
db.refresh(order)
|
||||
broadcast_sync("order_updated", {"order_id": order.id, "table_id": order.table_id, "status": order.status, "action": "opened"})
|
||||
return order
|
||||
|
||||
|
||||
@router.post("/{order_id}/items", response_model=AddItemsResponse)
|
||||
def add_items(
|
||||
order_id: int,
|
||||
body: AddItemsRequest,
|
||||
db: Session = Depends(get_db),
|
||||
user: User = Depends(get_current_user),
|
||||
):
|
||||
order = db.query(Order).filter(Order.id == order_id).first()
|
||||
if not order:
|
||||
raise HTTPException(status_code=404, detail="Order not found")
|
||||
if not _can_access_order(order, user, db):
|
||||
raise HTTPException(status_code=403, detail="Access denied")
|
||||
if order.status not in ("open", "partially_paid", "paid"):
|
||||
raise HTTPException(status_code=400, detail="Order is not open")
|
||||
|
||||
# Adding items to a fully-paid order reopens it — partially_paid since prior items were paid
|
||||
if order.status == "paid":
|
||||
order.status = "partially_paid"
|
||||
|
||||
new_item_ids = []
|
||||
for item_in in body.items:
|
||||
product = db.query(Product).filter(Product.id == item_in.product_id).first()
|
||||
if not product or not product.is_available:
|
||||
raise HTTPException(status_code=400, detail=f"Product {item_in.product_id} not available")
|
||||
extra_cost = sum(
|
||||
(o.price_delta or o.extra_cost or 0.0)
|
||||
for o in (item_in.selected_options or [])
|
||||
)
|
||||
item = OrderItem(
|
||||
order_id=order_id,
|
||||
product_id=item_in.product_id,
|
||||
added_by=user.id,
|
||||
quantity=item_in.quantity,
|
||||
unit_price=product.base_price + extra_cost,
|
||||
selected_options=json.dumps([o.model_dump() for o in item_in.selected_options]) if item_in.selected_options else None,
|
||||
removed_ingredients=json.dumps(item_in.removed_ingredients) if item_in.removed_ingredients else None,
|
||||
notes=item_in.notes,
|
||||
)
|
||||
db.add(item)
|
||||
db.flush()
|
||||
new_item_ids.append(item.id)
|
||||
|
||||
_audit(db, order_id, "ITEMS_ADDED", waiter_id=user.id, item_ids=new_item_ids)
|
||||
db.commit()
|
||||
db.refresh(order)
|
||||
|
||||
print_results = route_and_print_sync(order_id, new_item_ids, db)
|
||||
broadcast_sync("order_updated", {"order_id": order.id, "table_id": order.table_id, "status": order.status, "action": "items_added", "item_ids": new_item_ids})
|
||||
return {"order": order, "print_results": print_results}
|
||||
|
||||
|
||||
@router.post("/{order_id}/retry-print", response_model=AddItemsResponse)
|
||||
def retry_print(
|
||||
order_id: int,
|
||||
db: Session = Depends(get_db),
|
||||
user: User = Depends(get_current_user),
|
||||
):
|
||||
order = db.query(Order).filter(Order.id == order_id).first()
|
||||
if not order:
|
||||
raise HTTPException(status_code=404, detail="Order not found")
|
||||
if not _can_access_order(order, user, db):
|
||||
raise HTTPException(status_code=403, detail="Access denied")
|
||||
|
||||
unprinted_ids = [item.id for item in order.items if not item.printed and item.status == "active"]
|
||||
if not unprinted_ids:
|
||||
return {"order": order, "print_results": []}
|
||||
|
||||
print_results = route_and_print_sync(order_id, unprinted_ids, db)
|
||||
db.refresh(order)
|
||||
return {"order": order, "print_results": print_results}
|
||||
|
||||
|
||||
@router.put("/{order_id}/items/{item_id}", response_model=OrderItemOut)
|
||||
def edit_item(order_id: int, item_id: int, notes: Optional[str] = None, db: Session = Depends(get_db), user: User = Depends(require_manager)):
|
||||
item = db.query(OrderItem).filter(OrderItem.id == item_id, OrderItem.order_id == order_id).first()
|
||||
if not item:
|
||||
raise HTTPException(status_code=404, detail="Item not found")
|
||||
if notes is not None:
|
||||
item.notes = notes
|
||||
db.commit()
|
||||
db.refresh(item)
|
||||
return item
|
||||
|
||||
|
||||
@router.delete("/{order_id}/items/{item_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||
def cancel_item(order_id: int, item_id: int, db: Session = Depends(get_db), user: User = Depends(require_manager)):
|
||||
item = db.query(OrderItem).filter(OrderItem.id == item_id, OrderItem.order_id == order_id).first()
|
||||
if not item:
|
||||
raise HTTPException(status_code=404, detail="Item not found")
|
||||
item.status = "cancelled"
|
||||
_audit(db, order_id, "ITEM_CANCELLED", waiter_id=user.id, item_ids=[item_id])
|
||||
db.commit()
|
||||
|
||||
|
||||
@router.post("/{order_id}/pay")
|
||||
def pay_items(order_id: int, body: PayItemsRequest, db: Session = Depends(get_db), user: User = Depends(get_current_user)):
|
||||
order = db.query(Order).filter(Order.id == order_id).first()
|
||||
if not order:
|
||||
raise HTTPException(status_code=404, detail="Order not found")
|
||||
if not _can_access_order(order, user, db):
|
||||
raise HTTPException(status_code=403, detail="Access denied")
|
||||
|
||||
from models.shift import WaiterShift
|
||||
|
||||
items = db.query(OrderItem).filter(
|
||||
OrderItem.id.in_(body.item_ids),
|
||||
OrderItem.order_id == order_id,
|
||||
OrderItem.status == "active",
|
||||
).all()
|
||||
now = datetime.now(timezone.utc)
|
||||
active_shift = db.query(WaiterShift).filter(
|
||||
WaiterShift.waiter_id == user.id,
|
||||
WaiterShift.ended_at == None,
|
||||
).first()
|
||||
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.paid_in_shift_id = active_shift.id if active_shift else None
|
||||
total_paid += item.unit_price * item.quantity
|
||||
|
||||
db.flush() # write item status changes before counting, since autoflush=False
|
||||
active_remaining = db.query(OrderItem).filter(
|
||||
OrderItem.order_id == order_id, OrderItem.status == "active"
|
||||
).count()
|
||||
order.status = "paid" if active_remaining == 0 else "partially_paid"
|
||||
|
||||
paid_ids = [i.id for i in items]
|
||||
_audit(db, order_id, "PAYMENT", waiter_id=user.id, item_ids=paid_ids,
|
||||
amount=total_paid, payment_method=body.payment_method)
|
||||
db.commit()
|
||||
broadcast_sync("order_paid", {"order_id": order_id, "table_id": order.table_id, "status": order.status, "paid_item_ids": paid_ids, "amount": total_paid, "payment_method": body.payment_method})
|
||||
return {"status": order.status, "paid_item_ids": paid_ids}
|
||||
|
||||
|
||||
@router.post("/{order_id}/close")
|
||||
def close_order(order_id: int, db: Session = Depends(get_db), user: User = Depends(get_current_user)):
|
||||
order = db.query(Order).filter(Order.id == order_id).first()
|
||||
if not order:
|
||||
raise HTTPException(status_code=404, detail="Order not found")
|
||||
if not _can_access_order(order, user, db):
|
||||
raise HTTPException(status_code=403, detail="Access denied")
|
||||
if order.status not in ("paid", "open", "partially_paid"):
|
||||
raise HTTPException(status_code=400, detail="Cannot close order in current status")
|
||||
order.status = "closed"
|
||||
order.closed_at = datetime.now(timezone.utc)
|
||||
order.closed_by = user.id
|
||||
_audit(db, order_id, "ORDER_CLOSED", waiter_id=user.id)
|
||||
db.commit()
|
||||
broadcast_sync("order_closed", {"order_id": order_id, "table_id": order.table_id})
|
||||
return {"status": "closed"}
|
||||
|
||||
|
||||
@router.post("/{order_id}/pay-offline")
|
||||
def pay_items_offline(
|
||||
order_id: int,
|
||||
body: OfflinePaymentRequest,
|
||||
db: Session = Depends(get_db),
|
||||
user: User = Depends(get_current_user),
|
||||
):
|
||||
"""
|
||||
Sync an emergency payment that was taken while the server was offline.
|
||||
The UUID prevents double-processing. If a payment with the same UUID already
|
||||
exists on this order, the duplicate is logged in red (is_duplicate=1) rather
|
||||
than silently dropped — so managers can reconcile.
|
||||
"""
|
||||
order = db.query(Order).filter(Order.id == order_id).first()
|
||||
if not order:
|
||||
raise HTTPException(status_code=404, detail="Order not found")
|
||||
if not _can_access_order(order, user, db):
|
||||
raise HTTPException(status_code=403, detail="Access denied")
|
||||
|
||||
# Check for duplicate UUID on this order
|
||||
existing_uuid = db.query(OrderAuditLog).filter(
|
||||
OrderAuditLog.order_id == order_id,
|
||||
OrderAuditLog.offline_uuid == body.uuid,
|
||||
).first()
|
||||
is_duplicate = existing_uuid is not None
|
||||
|
||||
from models.shift import WaiterShift
|
||||
items = db.query(OrderItem).filter(
|
||||
OrderItem.id.in_(body.item_ids),
|
||||
OrderItem.order_id == order_id,
|
||||
OrderItem.status == "active",
|
||||
).all()
|
||||
|
||||
# Reject empty payments — client had no offline snapshot for this table
|
||||
if not items and not is_duplicate:
|
||||
raise HTTPException(status_code=400, detail="No active items found — payment rejected")
|
||||
|
||||
# Use the client-recorded offline timestamp as paid_at so audit reflects real payment time
|
||||
try:
|
||||
paid_at = datetime.fromisoformat(body.offline_at.replace("Z", "+00:00")) if body.offline_at else datetime.now(timezone.utc)
|
||||
except (ValueError, AttributeError):
|
||||
paid_at = datetime.now(timezone.utc)
|
||||
|
||||
active_shift = db.query(WaiterShift).filter(
|
||||
WaiterShift.waiter_id == user.id,
|
||||
WaiterShift.ended_at == None,
|
||||
).first()
|
||||
|
||||
total_paid = 0.0
|
||||
paid_ids = []
|
||||
if not is_duplicate:
|
||||
for item in items:
|
||||
item.status = "paid"
|
||||
item.paid_by = user.id
|
||||
item.paid_at = paid_at
|
||||
item.payment_method = body.payment_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)
|
||||
|
||||
db.flush()
|
||||
active_remaining = db.query(OrderItem).filter(
|
||||
OrderItem.order_id == order_id, OrderItem.status == "active"
|
||||
).count()
|
||||
order.status = "paid" if active_remaining == 0 else "partially_paid"
|
||||
else:
|
||||
# Duplicate — compute total for audit record without changing item state
|
||||
total_paid = sum(i.unit_price * i.quantity for i in items)
|
||||
paid_ids = [i.id for i in items]
|
||||
|
||||
# Always write audit log — duplicate flag makes it visible in red in manager dashboard
|
||||
db.add(OrderAuditLog(
|
||||
order_id=order_id,
|
||||
event_type="PAYMENT_OFFLINE",
|
||||
waiter_id=user.id,
|
||||
item_ids=json.dumps(paid_ids),
|
||||
amount=total_paid,
|
||||
payment_method=body.payment_method,
|
||||
note=f"Emergency offline payment (uuid={body.uuid}){' — DUPLICATE' if is_duplicate else ''}",
|
||||
offline_uuid=body.uuid,
|
||||
offline_at=body.offline_at,
|
||||
is_duplicate=1 if is_duplicate else 0,
|
||||
))
|
||||
db.commit()
|
||||
|
||||
if not is_duplicate:
|
||||
broadcast_sync("order_paid", {"order_id": order_id, "table_id": order.table_id, "status": order.status, "paid_item_ids": paid_ids, "amount": total_paid, "payment_method": body.payment_method})
|
||||
|
||||
return {
|
||||
"status": order.status if not is_duplicate else "duplicate",
|
||||
"paid_item_ids": paid_ids,
|
||||
"is_duplicate": is_duplicate,
|
||||
}
|
||||
|
||||
|
||||
@router.delete("/{order_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||
def cancel_order(order_id: int, db: Session = Depends(get_db), user: User = Depends(require_manager)):
|
||||
order = db.query(Order).filter(Order.id == order_id).first()
|
||||
if not order:
|
||||
raise HTTPException(status_code=404, detail="Order not found")
|
||||
order.status = "cancelled"
|
||||
order.closed_at = datetime.now(timezone.utc)
|
||||
order.closed_by = user.id
|
||||
_audit(db, order_id, "ORDER_CANCELLED", waiter_id=user.id)
|
||||
db.commit()
|
||||
broadcast_sync("order_closed", {"order_id": order_id, "table_id": order.table_id})
|
||||
|
||||
|
||||
@router.put("/{order_id}/assign-waiter")
|
||||
def assign_waiter(order_id: int, body: AssignWaiterRequest, db: Session = Depends(get_db), user: User = Depends(get_current_user)):
|
||||
order = db.query(Order).filter(Order.id == order_id).first()
|
||||
if not order:
|
||||
raise HTTPException(status_code=404, detail="Order not found")
|
||||
existing = db.query(OrderWaiter).filter(
|
||||
OrderWaiter.order_id == order_id, OrderWaiter.waiter_id == body.waiter_id
|
||||
).first()
|
||||
if existing:
|
||||
raise HTTPException(status_code=400, detail="Waiter already assigned")
|
||||
db.add(OrderWaiter(order_id=order_id, waiter_id=body.waiter_id))
|
||||
db.commit()
|
||||
return {"status": "assigned"}
|
||||
|
||||
|
||||
@router.delete("/{order_id}/waiters/{waiter_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||
def remove_waiter(order_id: int, waiter_id: int, db: Session = Depends(get_db), user: User = Depends(require_manager)):
|
||||
assignment = db.query(OrderWaiter).filter(
|
||||
OrderWaiter.order_id == order_id, OrderWaiter.waiter_id == waiter_id
|
||||
).first()
|
||||
if not assignment:
|
||||
raise HTTPException(status_code=404, detail="Assignment not found")
|
||||
db.delete(assignment)
|
||||
db.commit()
|
||||
|
||||
|
||||
@router.post("/{order_id}/print")
|
||||
def print_order(
|
||||
order_id: int,
|
||||
body: PrintOrderRequest,
|
||||
background_tasks: BackgroundTasks,
|
||||
db: Session = Depends(get_db),
|
||||
user: User = Depends(require_manager),
|
||||
):
|
||||
from models.printer import Printer
|
||||
|
||||
order = db.query(Order).filter(Order.id == order_id).first()
|
||||
if not order:
|
||||
raise HTTPException(status_code=404, detail="Order not found")
|
||||
|
||||
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")
|
||||
|
||||
table = db.query(Table).filter(Table.id == order.table_id).first()
|
||||
table_name = (table.label or f"T{table.number}") if table else f"#{order.table_id}"
|
||||
|
||||
opener = db.query(User).filter(User.id == order.opened_by).first()
|
||||
waiter_name = opener.username if opener else f"#{order.opened_by}"
|
||||
|
||||
items_data = []
|
||||
for item in order.items:
|
||||
if item.status == "cancelled":
|
||||
continue
|
||||
product_name = item.product.name if item.product else f"#{item.product_id}"
|
||||
items_data.append({
|
||||
"name": product_name,
|
||||
"quantity": item.quantity,
|
||||
"unit_price": item.unit_price,
|
||||
"total": item.unit_price * item.quantity,
|
||||
"status": item.status,
|
||||
})
|
||||
|
||||
grand_total = sum(i["total"] for i in items_data)
|
||||
|
||||
receipt = {
|
||||
"order_id": order.id,
|
||||
"table_name": table_name,
|
||||
"waiter_name": waiter_name,
|
||||
"opened_at": order.opened_at.strftime("%d/%m/%Y %H:%M"),
|
||||
"closed_at": order.closed_at.strftime("%d/%m/%Y %H:%M") if order.closed_at else None,
|
||||
"status": order.status,
|
||||
"items": items_data,
|
||||
"total": grand_total,
|
||||
"notes": order.notes,
|
||||
}
|
||||
|
||||
background_tasks.add_task(print_order_receipt, printer.ip_address, printer.port, receipt)
|
||||
return {"status": "printing"}
|
||||
|
||||
|
||||
# ─── Transfer order to a different table ─────────────────────────────────────
|
||||
|
||||
@router.post("/{order_id}/transfer")
|
||||
def transfer_order(
|
||||
order_id: int,
|
||||
body: TransferOrderRequest,
|
||||
db: Session = Depends(get_db),
|
||||
user: User = Depends(get_current_user),
|
||||
):
|
||||
order = db.query(Order).filter(Order.id == order_id).first()
|
||||
if not order:
|
||||
raise HTTPException(status_code=404, detail="Order not found")
|
||||
if not _can_access_order(order, user, db):
|
||||
raise HTTPException(status_code=403, detail="Access denied")
|
||||
if order.status not in ("open", "partially_paid", "paid"):
|
||||
raise HTTPException(status_code=400, detail="Order is not active")
|
||||
|
||||
target_table = db.query(Table).filter(Table.id == body.target_table_id, Table.is_active == True).first()
|
||||
if not target_table:
|
||||
raise HTTPException(status_code=404, detail="Target table not found")
|
||||
if body.target_table_id == order.table_id:
|
||||
raise HTTPException(status_code=400, detail="Table is already assigned to this order")
|
||||
|
||||
conflict = db.query(Order).filter(
|
||||
Order.table_id == body.target_table_id,
|
||||
Order.status.in_(["open", "partially_paid", "paid"]),
|
||||
).first()
|
||||
if conflict:
|
||||
raise HTTPException(status_code=400, detail="Target table already has an active order")
|
||||
|
||||
old_table_id = order.table_id
|
||||
order.table_id = body.target_table_id
|
||||
_audit(db, order_id, "TABLE_TRANSFER", waiter_id=user.id,
|
||||
note=f"Transferred from table {old_table_id} to table {body.target_table_id}")
|
||||
db.commit()
|
||||
db.refresh(order)
|
||||
broadcast_sync("order_updated", {"order_id": order.id, "table_id": order.table_id, "old_table_id": old_table_id, "status": order.status, "action": "transferred"})
|
||||
return order
|
||||
|
||||
|
||||
# ─── Merge another order into this one ───────────────────────────────────────
|
||||
|
||||
@router.post("/{order_id}/merge")
|
||||
def merge_order(
|
||||
order_id: int,
|
||||
body: MergeOrderRequest,
|
||||
db: Session = Depends(get_db),
|
||||
user: User = Depends(get_current_user),
|
||||
):
|
||||
"""
|
||||
Merge source order (order_id) INTO target order (body.target_order_id).
|
||||
All items (paid + active) from the source are reassigned to the target.
|
||||
Source waiters are added to the target if not already there.
|
||||
Source order is cancelled with audit note.
|
||||
"""
|
||||
source = db.query(Order).filter(Order.id == order_id).first()
|
||||
if not source:
|
||||
raise HTTPException(status_code=404, detail="Source order not found")
|
||||
if not _can_access_order(source, user, db):
|
||||
raise HTTPException(status_code=403, detail="Access denied")
|
||||
if source.status not in ("open", "partially_paid", "paid"):
|
||||
raise HTTPException(status_code=400, detail="Source order is not active")
|
||||
|
||||
target = db.query(Order).filter(Order.id == body.target_order_id).first()
|
||||
if not target:
|
||||
raise HTTPException(status_code=404, detail="Target order not found")
|
||||
if not _can_access_order(target, user, db):
|
||||
raise HTTPException(status_code=403, detail="Access denied to target order")
|
||||
if target.status not in ("open", "partially_paid", "paid"):
|
||||
raise HTTPException(status_code=400, detail="Target order is not active")
|
||||
if source.id == target.id:
|
||||
raise HTTPException(status_code=400, detail="Cannot merge an order with itself")
|
||||
|
||||
# Move all items to target order
|
||||
moved_item_ids = []
|
||||
for item in source.items:
|
||||
item.order_id = target.id
|
||||
moved_item_ids.append(item.id)
|
||||
|
||||
# Copy source waiters to target (no duplicates)
|
||||
existing_waiter_ids = {w.waiter_id for w in target.waiters}
|
||||
for ow in source.waiters:
|
||||
if ow.waiter_id not in existing_waiter_ids:
|
||||
db.add(OrderWaiter(order_id=target.id, waiter_id=ow.waiter_id))
|
||||
|
||||
# Recompute target status after flush
|
||||
db.flush()
|
||||
active_remaining = db.query(OrderItem).filter(
|
||||
OrderItem.order_id == target.id, OrderItem.status == "active"
|
||||
).count()
|
||||
paid_exists = db.query(OrderItem).filter(
|
||||
OrderItem.order_id == target.id, OrderItem.status == "paid"
|
||||
).count()
|
||||
if active_remaining > 0:
|
||||
target.status = "partially_paid" if paid_exists > 0 else "open"
|
||||
else:
|
||||
target.status = "paid"
|
||||
|
||||
# Cancel source order
|
||||
source.status = "cancelled"
|
||||
source.closed_at = datetime.now(timezone.utc)
|
||||
source.closed_by = user.id
|
||||
|
||||
_audit(db, source.id, "ORDER_CANCELLED", waiter_id=user.id,
|
||||
note=f"Merged into order #{target.id} (table {target.table_id})")
|
||||
_audit(db, target.id, "ITEMS_ADDED", waiter_id=user.id, item_ids=moved_item_ids,
|
||||
note=f"Items merged from order #{source.id} (table {source.table_id})")
|
||||
|
||||
db.commit()
|
||||
db.refresh(target)
|
||||
broadcast_sync("order_updated", {"order_id": target.id, "table_id": target.table_id, "status": target.status, "action": "merged"})
|
||||
broadcast_sync("order_closed", {"order_id": source.id, "table_id": source.table_id})
|
||||
return target
|
||||
|
||||
|
||||
# ─── Split a stacked item into two rows ──────────────────────────────────────
|
||||
|
||||
@router.post("/{order_id}/items/{item_id}/split", response_model=List[OrderItemOut])
|
||||
def split_item(
|
||||
order_id: int,
|
||||
item_id: int,
|
||||
body: SplitItemRequest,
|
||||
db: Session = Depends(get_db),
|
||||
user: User = Depends(get_current_user),
|
||||
):
|
||||
"""
|
||||
Split qty units off item_id into a new item row.
|
||||
Both rows share all properties (product, price, options, notes).
|
||||
Only active items can be split.
|
||||
"""
|
||||
item = db.query(OrderItem).filter(
|
||||
OrderItem.id == item_id, OrderItem.order_id == order_id
|
||||
).first()
|
||||
if not item:
|
||||
raise HTTPException(status_code=404, detail="Item not found")
|
||||
if item.status != "active":
|
||||
raise HTTPException(status_code=400, detail="Only active items can be split")
|
||||
if body.quantity <= 0 or body.quantity >= item.quantity:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"Split quantity must be between 1 and {item.quantity - 1}"
|
||||
)
|
||||
|
||||
order = db.query(Order).filter(Order.id == order_id).first()
|
||||
if not _can_access_order(order, user, db):
|
||||
raise HTTPException(status_code=403, detail="Access denied")
|
||||
|
||||
# Reduce original item
|
||||
item.quantity -= body.quantity
|
||||
|
||||
# Create split-off item
|
||||
new_item = OrderItem(
|
||||
order_id=order_id,
|
||||
product_id=item.product_id,
|
||||
added_by=item.added_by,
|
||||
quantity=body.quantity,
|
||||
unit_price=item.unit_price,
|
||||
selected_options=item.selected_options,
|
||||
removed_ingredients=item.removed_ingredients,
|
||||
notes=item.notes,
|
||||
status="active",
|
||||
printed=item.printed,
|
||||
)
|
||||
db.add(new_item)
|
||||
db.commit()
|
||||
db.refresh(item)
|
||||
db.refresh(new_item)
|
||||
return [item, new_item]
|
||||
|
||||
|
||||
# ─── Move selected items to another order ────────────────────────────────────
|
||||
|
||||
@router.post("/{order_id}/move-items")
|
||||
def move_items(
|
||||
order_id: int,
|
||||
body: MoveItemsRequest,
|
||||
db: Session = Depends(get_db),
|
||||
user: User = Depends(get_current_user),
|
||||
):
|
||||
"""Move specific active items from this order to another open order."""
|
||||
source = db.query(Order).filter(Order.id == order_id).first()
|
||||
if not source:
|
||||
raise HTTPException(status_code=404, detail="Source order not found")
|
||||
if not _can_access_order(source, user, db):
|
||||
raise HTTPException(status_code=403, detail="Access denied")
|
||||
if source.status not in ("open", "partially_paid"):
|
||||
raise HTTPException(status_code=400, detail="Source order is not active")
|
||||
|
||||
target = db.query(Order).filter(Order.id == body.target_order_id).first()
|
||||
if not target:
|
||||
raise HTTPException(status_code=404, detail="Target order not found")
|
||||
if not _can_access_order(target, user, db):
|
||||
raise HTTPException(status_code=403, detail="Access denied to target order")
|
||||
if target.status not in ("open", "partially_paid"):
|
||||
raise HTTPException(status_code=400, detail="Target order is not active")
|
||||
if source.id == target.id:
|
||||
raise HTTPException(status_code=400, detail="Source and target orders are the same")
|
||||
|
||||
items = db.query(OrderItem).filter(
|
||||
OrderItem.id.in_(body.item_ids),
|
||||
OrderItem.order_id == order_id,
|
||||
OrderItem.status == "active",
|
||||
).all()
|
||||
if not items:
|
||||
raise HTTPException(status_code=400, detail="No active items found to move")
|
||||
|
||||
moved_ids = []
|
||||
for item in items:
|
||||
item.order_id = target.id
|
||||
moved_ids.append(item.id)
|
||||
|
||||
# Recompute source status
|
||||
db.flush()
|
||||
src_active = db.query(OrderItem).filter(OrderItem.order_id == source.id, OrderItem.status == "active").count()
|
||||
src_paid = db.query(OrderItem).filter(OrderItem.order_id == source.id, OrderItem.status == "paid").count()
|
||||
if src_active == 0 and src_paid == 0:
|
||||
source.status = "open"
|
||||
elif src_active == 0:
|
||||
source.status = "paid"
|
||||
else:
|
||||
source.status = "partially_paid" if src_paid > 0 else "open"
|
||||
|
||||
# Recompute target status
|
||||
tgt_active = db.query(OrderItem).filter(OrderItem.order_id == target.id, OrderItem.status == "active").count()
|
||||
tgt_paid = db.query(OrderItem).filter(OrderItem.order_id == target.id, OrderItem.status == "paid").count()
|
||||
target.status = "partially_paid" if (tgt_active > 0 and tgt_paid > 0) else ("paid" if tgt_active == 0 else "open")
|
||||
|
||||
_audit(db, source.id, "ITEMS_MOVED_OUT", waiter_id=user.id, item_ids=moved_ids,
|
||||
note=f"Moved to order #{target.id} (table {target.table_id})")
|
||||
_audit(db, target.id, "ITEMS_MOVED_IN", waiter_id=user.id, item_ids=moved_ids,
|
||||
note=f"Moved from order #{source.id} (table {source.table_id})")
|
||||
|
||||
db.commit()
|
||||
db.refresh(source)
|
||||
return {"moved_item_ids": moved_ids, "source_status": source.status, "target_status": target.status}
|
||||
|
||||
|
||||
# ─── Print order synopsis ─────────────────────────────────────────────────────
|
||||
|
||||
@router.post("/{order_id}/print-synopsis")
|
||||
def print_synopsis(
|
||||
order_id: int,
|
||||
body: PrintSynopsisRequest,
|
||||
background_tasks: BackgroundTasks,
|
||||
db: Session = Depends(get_db),
|
||||
user: User = Depends(get_current_user),
|
||||
):
|
||||
from models.printer import Printer
|
||||
|
||||
order = db.query(Order).filter(Order.id == order_id).first()
|
||||
if not order:
|
||||
raise HTTPException(status_code=404, detail="Order not found")
|
||||
if not _can_access_order(order, user, db):
|
||||
raise HTTPException(status_code=403, detail="Access denied")
|
||||
|
||||
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")
|
||||
|
||||
table = db.query(Table).filter(Table.id == order.table_id).first()
|
||||
table_name = (table.label or f"T{table.number}") if table else f"#{order.table_id}"
|
||||
opener = db.query(User).filter(User.id == order.opened_by).first()
|
||||
waiter_name = (opener.nickname or opener.username) if opener else f"#{order.opened_by}"
|
||||
|
||||
items_data = []
|
||||
for item in order.items:
|
||||
if item.status == "cancelled":
|
||||
continue
|
||||
product_name = item.product.name if item.product else f"#{item.product_id}"
|
||||
items_data.append({
|
||||
"name": product_name,
|
||||
"quantity": item.quantity,
|
||||
"unit_price": item.unit_price,
|
||||
"total": item.unit_price * item.quantity,
|
||||
"status": item.status,
|
||||
})
|
||||
|
||||
total = sum(i["total"] for i in items_data)
|
||||
paid_total = sum(i["total"] for i in items_data if i["status"] == "paid")
|
||||
|
||||
synopsis = {
|
||||
"order_id": order.id,
|
||||
"table_name": table_name,
|
||||
"waiter_name": waiter_name,
|
||||
"opened_at": order.opened_at.strftime("%d/%m/%Y %H:%M"),
|
||||
"items": items_data,
|
||||
"total": total,
|
||||
"paid_total": paid_total,
|
||||
"remaining": total - paid_total,
|
||||
}
|
||||
|
||||
background_tasks.add_task(print_order_synopsis, printer.ip_address, printer.port, synopsis)
|
||||
return {"status": "printing"}
|
||||
331
local_backend/routers/products.py
Normal file
331
local_backend/routers/products.py
Normal file
@@ -0,0 +1,331 @@
|
||||
import os
|
||||
import uuid
|
||||
import json
|
||||
from fastapi import APIRouter, Depends, HTTPException, status, UploadFile, File
|
||||
from sqlalchemy.orm import Session
|
||||
from typing import List
|
||||
|
||||
from database import get_db
|
||||
from models.product import Product, Category, ProductOption, ProductQuickOption, ProductIngredient, ProductPreferenceSet, ProductPreferenceChoice
|
||||
from models.order import OrderItem
|
||||
from models.user import User
|
||||
from schemas.product import (
|
||||
ProductCreate, ProductUpdate, ProductOut, ProductReorderItem,
|
||||
CategoryCreate, CategoryUpdate, CategoryOut, CategoryReorderItem,
|
||||
SubcategoryReorderItem, ParentGeneralReorderItem,
|
||||
PreferenceSetCreate, ProductQuickOptionCreate,
|
||||
CategoryReparentRequest,
|
||||
)
|
||||
from routers.deps import get_current_user, require_manager
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
IMAGE_DIR = "/app/data/product_images"
|
||||
|
||||
|
||||
def _replace_quick_options(db, product, quick_options):
|
||||
for qo in product.quick_options:
|
||||
db.delete(qo)
|
||||
db.flush()
|
||||
for i, qo in enumerate(quick_options):
|
||||
db.add(ProductQuickOption(
|
||||
product_id=product.id,
|
||||
name=qo.name,
|
||||
price=qo.price,
|
||||
allow_multiple=qo.allow_multiple,
|
||||
sort_order=qo.sort_order if qo.sort_order else i,
|
||||
is_favorite=qo.is_favorite,
|
||||
favorite_sort_order=qo.favorite_sort_order,
|
||||
is_compact=qo.is_compact,
|
||||
))
|
||||
|
||||
|
||||
def _replace_options(db, product, options):
|
||||
for opt in product.options:
|
||||
db.delete(opt)
|
||||
db.flush()
|
||||
for opt in options:
|
||||
sub_json = json.dumps([s.model_dump() for s in opt.sub_choices]) if opt.sub_choices else None
|
||||
db.add(ProductOption(
|
||||
product_id=product.id,
|
||||
name=opt.name,
|
||||
extra_cost=opt.extra_cost,
|
||||
allow_multiple=opt.allow_multiple,
|
||||
sub_choices=sub_json,
|
||||
is_favorite=opt.is_favorite,
|
||||
favorite_sort_order=opt.favorite_sort_order,
|
||||
))
|
||||
|
||||
|
||||
def _replace_ingredients(db, product, ingredients):
|
||||
for ing in product.ingredients:
|
||||
db.delete(ing)
|
||||
db.flush()
|
||||
for ing in ingredients:
|
||||
db.add(ProductIngredient(product_id=product.id, **ing.model_dump()))
|
||||
|
||||
|
||||
def _replace_preference_sets(db, product, sets: List[PreferenceSetCreate]):
|
||||
for ps in product.preference_sets:
|
||||
db.delete(ps)
|
||||
db.flush()
|
||||
for ps in sets:
|
||||
shared_json = json.dumps(ps.shared_subset.model_dump()) if ps.shared_subset else None
|
||||
new_set = ProductPreferenceSet(
|
||||
product_id=product.id,
|
||||
name=ps.name,
|
||||
shared_subset=shared_json,
|
||||
is_favorite=ps.is_favorite,
|
||||
favorite_sort_order=ps.favorite_sort_order,
|
||||
)
|
||||
db.add(new_set)
|
||||
db.flush()
|
||||
created_choices = []
|
||||
for ch in ps.choices:
|
||||
sub_json = json.dumps([s.model_dump() for s in ch.sub_choices]) if ch.sub_choices else None
|
||||
choice = ProductPreferenceChoice(
|
||||
set_id=new_set.id,
|
||||
name=ch.name,
|
||||
extra_cost=ch.extra_cost,
|
||||
sub_choices=sub_json,
|
||||
disables_subset=ch.disables_subset,
|
||||
)
|
||||
db.add(choice)
|
||||
db.flush()
|
||||
created_choices.append(choice)
|
||||
if ps.default_choice_index is not None and 0 <= ps.default_choice_index < len(created_choices):
|
||||
new_set.default_choice_id = created_choices[ps.default_choice_index].id
|
||||
|
||||
|
||||
# ── Categories ────────────────────────────────────────────────────────────────
|
||||
|
||||
@router.get("/categories", response_model=List[CategoryOut])
|
||||
def list_categories(db: Session = Depends(get_db), user: User = Depends(get_current_user)):
|
||||
return db.query(Category).order_by(Category.sort_order).all()
|
||||
|
||||
|
||||
@router.post("/categories", response_model=CategoryOut, status_code=status.HTTP_201_CREATED)
|
||||
def create_category(body: CategoryCreate, db: Session = Depends(get_db), user: User = Depends(require_manager)):
|
||||
# sort_order is among siblings (same parent_id level)
|
||||
sibling_count = db.query(Category).filter(Category.parent_id == body.parent_id).count()
|
||||
cat = Category(
|
||||
name=body.name,
|
||||
color=body.color,
|
||||
sort_order=sibling_count,
|
||||
parent_id=body.parent_id,
|
||||
general_sort_order=body.general_sort_order,
|
||||
)
|
||||
db.add(cat)
|
||||
db.commit()
|
||||
db.refresh(cat)
|
||||
return cat
|
||||
|
||||
|
||||
@router.put("/categories/reorder", status_code=status.HTTP_204_NO_CONTENT)
|
||||
def reorder_categories(items: List[CategoryReorderItem], db: Session = Depends(get_db), user: User = Depends(require_manager)):
|
||||
for item in items:
|
||||
cat = db.query(Category).filter(Category.id == item.id).first()
|
||||
if cat:
|
||||
cat.sort_order = item.sort_order
|
||||
db.commit()
|
||||
|
||||
|
||||
@router.put("/categories/reorder-subcategories", status_code=status.HTTP_204_NO_CONTENT)
|
||||
def reorder_subcategories(items: List[SubcategoryReorderItem], db: Session = Depends(get_db), user: User = Depends(require_manager)):
|
||||
"""Reorder sub-categories within their parent (sort_order among siblings)."""
|
||||
for item in items:
|
||||
cat = db.query(Category).filter(Category.id == item.id).first()
|
||||
if cat:
|
||||
cat.sort_order = item.sort_order
|
||||
db.commit()
|
||||
|
||||
|
||||
@router.put("/categories/reorder-general", status_code=status.HTTP_204_NO_CONTENT)
|
||||
def reorder_general(items: List[ParentGeneralReorderItem], db: Session = Depends(get_db), user: User = Depends(require_manager)):
|
||||
"""Update general_sort_order on parent categories (position of the General group)."""
|
||||
for item in items:
|
||||
cat = db.query(Category).filter(Category.id == item.id).first()
|
||||
if cat:
|
||||
cat.general_sort_order = item.general_sort_order
|
||||
db.commit()
|
||||
|
||||
|
||||
@router.put("/categories/{category_id}/reparent", response_model=CategoryOut)
|
||||
def reparent_category(category_id: int, body: CategoryReparentRequest, db: Session = Depends(get_db), user: User = Depends(require_manager)):
|
||||
"""Move a category to a new parent (or promote to top-level if parent_id is null).
|
||||
All products assigned to this category follow it automatically (no product updates needed).
|
||||
"""
|
||||
cat = db.query(Category).filter(Category.id == category_id).first()
|
||||
if not cat:
|
||||
raise HTTPException(status_code=404, detail="Category not found")
|
||||
if body.parent_id is not None:
|
||||
new_parent = db.query(Category).filter(Category.id == body.parent_id).first()
|
||||
if not new_parent:
|
||||
raise HTTPException(status_code=404, detail="Target parent category not found")
|
||||
if new_parent.parent_id is not None:
|
||||
raise HTTPException(status_code=400, detail="Cannot nest more than two levels deep")
|
||||
if body.parent_id == category_id:
|
||||
raise HTTPException(status_code=400, detail="A category cannot be its own parent")
|
||||
# If cat currently has children and is being made a sub, block it
|
||||
has_children = db.query(Category).filter(Category.parent_id == category_id).count() > 0
|
||||
if has_children and body.parent_id is not None:
|
||||
raise HTTPException(status_code=400, detail="Cannot nest a category that has subcategories")
|
||||
# Assign new sort_order at the end of the destination level
|
||||
sibling_count = db.query(Category).filter(Category.parent_id == body.parent_id).count()
|
||||
cat.parent_id = body.parent_id
|
||||
cat.sort_order = sibling_count
|
||||
db.commit()
|
||||
db.refresh(cat)
|
||||
return cat
|
||||
|
||||
|
||||
@router.put("/categories/{category_id}", response_model=CategoryOut)
|
||||
def update_category(category_id: int, body: CategoryUpdate, db: Session = Depends(get_db), user: User = Depends(require_manager)):
|
||||
cat = db.query(Category).filter(Category.id == category_id).first()
|
||||
if not cat:
|
||||
raise HTTPException(status_code=404, detail="Category not found")
|
||||
for field, value in body.model_dump(exclude_none=True).items():
|
||||
setattr(cat, field, value)
|
||||
db.commit()
|
||||
db.refresh(cat)
|
||||
return cat
|
||||
|
||||
|
||||
@router.delete("/categories/{category_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||
def delete_category(category_id: int, db: Session = Depends(get_db), user: User = Depends(require_manager)):
|
||||
cat = db.query(Category).filter(Category.id == category_id).first()
|
||||
if not cat:
|
||||
raise HTTPException(status_code=404, detail="Category not found")
|
||||
db.delete(cat)
|
||||
db.commit()
|
||||
|
||||
|
||||
# ── Products ──────────────────────────────────────────────────────────────────
|
||||
|
||||
@router.get("/", response_model=List[ProductOut])
|
||||
def list_products(all: bool = False, db: Session = Depends(get_db), user: User = Depends(get_current_user)):
|
||||
q = db.query(Product)
|
||||
if not all or user.role not in ("manager", "sysadmin"):
|
||||
# Waiters only see active, available products
|
||||
q = q.filter(Product.is_available == True, Product.lifecycle_status == "active")
|
||||
return q.order_by(Product.sort_order, Product.id).all()
|
||||
|
||||
|
||||
@router.put("/reorder", status_code=status.HTTP_204_NO_CONTENT)
|
||||
def reorder_products(items: List[ProductReorderItem], db: Session = Depends(get_db), user: User = Depends(require_manager)):
|
||||
for item in items:
|
||||
product = db.query(Product).filter(Product.id == item.id).first()
|
||||
if product:
|
||||
product.sort_order = item.sort_order
|
||||
db.commit()
|
||||
|
||||
|
||||
@router.post("/", response_model=ProductOut, status_code=status.HTTP_201_CREATED)
|
||||
def create_product(body: ProductCreate, db: Session = Depends(get_db), user: User = Depends(require_manager)):
|
||||
data = body.model_dump(exclude={"quick_options", "options", "ingredients", "preference_sets"})
|
||||
if data.get("sort_order") == 0:
|
||||
data["sort_order"] = db.query(Product).count()
|
||||
product = Product(**data)
|
||||
db.add(product)
|
||||
db.flush()
|
||||
for i, qo in enumerate(body.quick_options):
|
||||
db.add(ProductQuickOption(
|
||||
product_id=product.id,
|
||||
name=qo.name,
|
||||
price=qo.price,
|
||||
allow_multiple=qo.allow_multiple,
|
||||
sort_order=qo.sort_order if qo.sort_order else i,
|
||||
is_favorite=qo.is_favorite,
|
||||
favorite_sort_order=qo.favorite_sort_order,
|
||||
is_compact=qo.is_compact,
|
||||
))
|
||||
for opt in body.options:
|
||||
sub_json = json.dumps([s.model_dump() for s in opt.sub_choices]) if opt.sub_choices else None
|
||||
db.add(ProductOption(
|
||||
product_id=product.id,
|
||||
name=opt.name,
|
||||
extra_cost=opt.extra_cost,
|
||||
allow_multiple=opt.allow_multiple,
|
||||
sub_choices=sub_json,
|
||||
is_favorite=opt.is_favorite,
|
||||
favorite_sort_order=opt.favorite_sort_order,
|
||||
))
|
||||
for ing in body.ingredients:
|
||||
db.add(ProductIngredient(product_id=product.id, **ing.model_dump()))
|
||||
_replace_preference_sets(db, product, body.preference_sets)
|
||||
db.commit()
|
||||
db.refresh(product)
|
||||
return product
|
||||
|
||||
|
||||
@router.put("/{product_id}", response_model=ProductOut)
|
||||
def update_product(product_id: int, body: ProductUpdate, db: Session = Depends(get_db), user: User = Depends(require_manager)):
|
||||
product = db.query(Product).filter(Product.id == product_id).first()
|
||||
if not product:
|
||||
raise HTTPException(status_code=404, detail="Product not found")
|
||||
for field, value in body.model_dump(exclude_none=True, exclude={"quick_options", "options", "ingredients", "preference_sets"}).items():
|
||||
setattr(product, field, value)
|
||||
if body.quick_options is not None:
|
||||
_replace_quick_options(db, product, body.quick_options)
|
||||
if body.options is not None:
|
||||
_replace_options(db, product, body.options)
|
||||
if body.ingredients is not None:
|
||||
_replace_ingredients(db, product, body.ingredients)
|
||||
if body.preference_sets is not None:
|
||||
_replace_preference_sets(db, product, body.preference_sets)
|
||||
db.commit()
|
||||
db.refresh(product)
|
||||
return product
|
||||
|
||||
|
||||
@router.post("/{product_id}/image", response_model=ProductOut)
|
||||
async def upload_product_image(product_id: int, file: UploadFile = File(...), db: Session = Depends(get_db), user: User = Depends(require_manager)):
|
||||
product = db.query(Product).filter(Product.id == product_id).first()
|
||||
if not product:
|
||||
raise HTTPException(status_code=404, detail="Product not found")
|
||||
|
||||
if not file.content_type.startswith("image/"):
|
||||
raise HTTPException(status_code=400, detail="File must be an image")
|
||||
|
||||
os.makedirs(IMAGE_DIR, exist_ok=True)
|
||||
|
||||
if product.image_url:
|
||||
old_path = os.path.join(IMAGE_DIR, os.path.basename(product.image_url))
|
||||
if os.path.exists(old_path):
|
||||
os.remove(old_path)
|
||||
|
||||
ext = file.filename.rsplit(".", 1)[-1].lower() if "." in file.filename else "jpg"
|
||||
filename = f"{product_id}_{uuid.uuid4().hex[:8]}.{ext}"
|
||||
filepath = os.path.join(IMAGE_DIR, filename)
|
||||
|
||||
contents = await file.read()
|
||||
with open(filepath, "wb") as f:
|
||||
f.write(contents)
|
||||
|
||||
product.image_url = f"/static/product_images/{filename}"
|
||||
db.commit()
|
||||
db.refresh(product)
|
||||
return product
|
||||
|
||||
|
||||
@router.delete("/{product_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||
def delete_product(product_id: int, hard: bool = False, db: Session = Depends(get_db), user: User = Depends(require_manager)):
|
||||
product = db.query(Product).filter(Product.id == product_id).first()
|
||||
if not product:
|
||||
raise HTTPException(status_code=404, detail="Product not found")
|
||||
if hard:
|
||||
has_orders = db.query(OrderItem).filter(OrderItem.product_id == product_id).first()
|
||||
if has_orders:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="Cannot permanently delete a product that appears in past orders. Archive it instead."
|
||||
)
|
||||
db.delete(product)
|
||||
else:
|
||||
# If product has order history, archive it; otherwise hard delete
|
||||
has_orders = db.query(OrderItem).filter(OrderItem.product_id == product_id).first()
|
||||
if has_orders:
|
||||
product.lifecycle_status = "archived"
|
||||
else:
|
||||
db.delete(product)
|
||||
db.commit()
|
||||
1160
local_backend/routers/reports.py
Normal file
1160
local_backend/routers/reports.py
Normal file
File diff suppressed because it is too large
Load Diff
105
local_backend/routers/settings.py
Normal file
105
local_backend/routers/settings.py
Normal file
@@ -0,0 +1,105 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from sqlalchemy.orm import Session
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from database import get_db
|
||||
from models.settings import PosSettings
|
||||
from schemas.settings import UpdateSettingRequest
|
||||
from routers.deps import get_current_user, require_manager
|
||||
from models.user import User
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
VALID_SETTINGS = {
|
||||
# Security / auth
|
||||
"security.login_method": "How managers authenticate on first login: 'password' | 'pin' | 'none'",
|
||||
"security.autofill_username": "Auto-fill username when only one manager exists: 'true' | 'false'",
|
||||
"security.auto_lock": "Lock screen after inactivity: 'true' | 'false'",
|
||||
"security.auto_lock_seconds": "Seconds of inactivity before locking (0 = disabled)",
|
||||
"security.auto_logout": "Log out after inactivity: 'true' | 'false'",
|
||||
"security.auto_logout_seconds":"Seconds of inactivity before logging out (0 = disabled)",
|
||||
"shifts.waiter_self_start": "Allow waiters to start their own shifts without manager action",
|
||||
"shifts.waiter_self_end": "Allow waiters to end their own shifts without manager action",
|
||||
"business_day.force_close_allowed": "Allow force-closing business day with open tables",
|
||||
"system.timezone": "IANA timezone name used by the backend container (e.g. Europe/Athens). Requires container restart to take effect.",
|
||||
"ui.table_colours": "JSON blob of table card colour scheme (light + dark modes) for the Waiter PWA.",
|
||||
"dev.spoof_printing": "When enabled, all print jobs are silently dropped. Devices behave as if printing succeeded.",
|
||||
# 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 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",
|
||||
"print.font_item_name": "Font for item name lines: SIZE:BOLD:CAPS",
|
||||
"print.font_quick": "Font for quick option lines (* marker): SIZE:BOLD:CAPS",
|
||||
"print.font_pref": "Font for preference choice lines (> marker): SIZE:BOLD:CAPS",
|
||||
"print.font_extra": "Font for extra/option lines (+ marker): SIZE:BOLD:CAPS",
|
||||
"print.font_ingredient": "Font for removed ingredient lines (- marker): SIZE:BOLD:CAPS",
|
||||
"print.font_item_note": "Font for per-item note lines: SIZE:BOLD:CAPS",
|
||||
"print.font_order_note": "Font for order-level notes: SIZE:BOLD:CAPS",
|
||||
}
|
||||
|
||||
DEFAULTS = {
|
||||
"security.login_method": "password",
|
||||
"security.autofill_username": "true",
|
||||
"security.auto_lock": "false",
|
||||
"security.auto_lock_seconds": "300",
|
||||
"security.auto_logout": "false",
|
||||
"security.auto_logout_seconds": "1800",
|
||||
"shifts.waiter_self_start": "true",
|
||||
"shifts.waiter_self_end": "true",
|
||||
"business_day.force_close_allowed": "true",
|
||||
"system.timezone": "Europe/Athens",
|
||||
"ui.table_colours": "",
|
||||
"dev.spoof_printing": "false",
|
||||
"print.ticket_mode": "detailed",
|
||||
"print.divider_style": "dash",
|
||||
"print.font_order_number": "48:1:0",
|
||||
"print.font_meta": "0:0:0",
|
||||
"print.font_item_name": "16:1:0",
|
||||
"print.font_quick": "0:0:0",
|
||||
"print.font_pref": "0:0:0",
|
||||
"print.font_extra": "0:0:0",
|
||||
"print.font_ingredient": "0:0:0",
|
||||
"print.font_item_note": "0:0:0",
|
||||
"print.font_order_note": "0:1:0",
|
||||
}
|
||||
|
||||
|
||||
@router.get("/")
|
||||
def get_all_settings(
|
||||
db: Session = Depends(get_db),
|
||||
user: User = Depends(get_current_user),
|
||||
):
|
||||
stored = {s.key: s.value for s in db.query(PosSettings).all()}
|
||||
result = {}
|
||||
for key, description in VALID_SETTINGS.items():
|
||||
result[key] = {
|
||||
"value": stored.get(key, DEFAULTS.get(key, "true")),
|
||||
"description": description,
|
||||
}
|
||||
return result
|
||||
|
||||
|
||||
@router.put("/{key}")
|
||||
def update_setting(
|
||||
key: str,
|
||||
body: UpdateSettingRequest,
|
||||
db: Session = Depends(get_db),
|
||||
user: User = Depends(require_manager),
|
||||
):
|
||||
if key not in VALID_SETTINGS:
|
||||
raise HTTPException(status_code=400, detail=f"Unknown setting key: {key}")
|
||||
|
||||
setting = db.query(PosSettings).filter(PosSettings.key == key).first()
|
||||
if setting:
|
||||
setting.value = body.value
|
||||
setting.updated_at = datetime.now(timezone.utc)
|
||||
setting.updated_by_id = user.id
|
||||
else:
|
||||
setting = PosSettings(key=key, value=body.value, updated_by_id=user.id)
|
||||
db.add(setting)
|
||||
|
||||
db.commit()
|
||||
db.refresh(setting)
|
||||
return {"key": setting.key, "value": setting.value}
|
||||
116
local_backend/routers/setup.py
Normal file
116
local_backend/routers/setup.py
Normal file
@@ -0,0 +1,116 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from sqlalchemy.orm import Session
|
||||
from pydantic import BaseModel, EmailStr
|
||||
from typing import Optional
|
||||
import bcrypt
|
||||
|
||||
from database import get_db
|
||||
from models.user import User
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
class SetupStatusResponse(BaseModel):
|
||||
needs_setup: bool
|
||||
|
||||
|
||||
class SetupInitRequest(BaseModel):
|
||||
username: str
|
||||
password: str
|
||||
email: Optional[str] = None
|
||||
full_name: Optional[str] = None
|
||||
venue_type: Optional[str] = None
|
||||
venue_name: Optional[str] = None
|
||||
pin: Optional[str] = None
|
||||
|
||||
|
||||
class SetupInitResponse(BaseModel):
|
||||
ok: bool
|
||||
|
||||
|
||||
class SecurityConfigResponse(BaseModel):
|
||||
login_method: str
|
||||
autofill_username: bool
|
||||
|
||||
|
||||
@router.get("/security-config", response_model=SecurityConfigResponse)
|
||||
def security_config(db: Session = Depends(get_db)):
|
||||
"""Public endpoint — returns only the security settings needed by the login page."""
|
||||
from models.settings import PosSettings
|
||||
rows = {r.key: r.value for r in db.query(PosSettings).filter(
|
||||
PosSettings.key.in_(["security.login_method", "security.autofill_username"])
|
||||
).all()}
|
||||
return SecurityConfigResponse(
|
||||
login_method=rows.get("security.login_method", "password"),
|
||||
autofill_username=rows.get("security.autofill_username", "true") == "true",
|
||||
)
|
||||
|
||||
|
||||
@router.get("/status", response_model=SetupStatusResponse)
|
||||
def setup_status(db: Session = Depends(get_db)):
|
||||
has_manager = db.query(User).filter(
|
||||
User.role.in_(["manager", "sysadmin"]),
|
||||
User.is_active == True,
|
||||
).first()
|
||||
return SetupStatusResponse(needs_setup=has_manager is None)
|
||||
|
||||
|
||||
@router.post("/init", response_model=SetupInitResponse)
|
||||
def setup_init(body: SetupInitRequest, db: Session = Depends(get_db)):
|
||||
has_manager = db.query(User).filter(
|
||||
User.role.in_(["manager", "sysadmin"]),
|
||||
User.is_active == True,
|
||||
).first()
|
||||
if has_manager:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_409_CONFLICT,
|
||||
detail="Setup already completed — a manager account already exists.",
|
||||
)
|
||||
|
||||
if not body.username.strip():
|
||||
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail="Username is required.")
|
||||
if len(body.password) < 6:
|
||||
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail="Password must be at least 6 characters.")
|
||||
|
||||
existing = db.query(User).filter(User.username == body.username.strip()).first()
|
||||
if existing:
|
||||
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="Username already taken.")
|
||||
|
||||
password_hash = bcrypt.hashpw(body.password.encode(), bcrypt.gensalt()).decode()
|
||||
|
||||
raw_pin = body.pin if (body.pin and body.pin.isdigit() and 4 <= len(body.pin) <= 6) else "0000"
|
||||
pin_hash = bcrypt.hashpw(raw_pin.encode(), bcrypt.gensalt()).decode()
|
||||
|
||||
user = User(
|
||||
username=body.username.strip(),
|
||||
pin_hash=pin_hash,
|
||||
password_hash=password_hash,
|
||||
email=body.email,
|
||||
full_name=body.full_name,
|
||||
role="manager",
|
||||
is_active=True,
|
||||
)
|
||||
db.add(user)
|
||||
|
||||
# Persist venue settings if provided
|
||||
if body.venue_name or body.venue_type:
|
||||
from models.settings import PosSettings
|
||||
from datetime import datetime, timezone
|
||||
now = datetime.now(timezone.utc)
|
||||
if body.venue_name:
|
||||
setting = db.query(PosSettings).filter(PosSettings.key == "venue.name").first()
|
||||
if setting:
|
||||
setting.value = body.venue_name
|
||||
setting.updated_at = now
|
||||
else:
|
||||
db.add(PosSettings(key="venue.name", value=body.venue_name, updated_at=now))
|
||||
if body.venue_type:
|
||||
setting = db.query(PosSettings).filter(PosSettings.key == "venue.type").first()
|
||||
if setting:
|
||||
setting.value = body.venue_type
|
||||
setting.updated_at = now
|
||||
else:
|
||||
db.add(PosSettings(key="venue.type", value=body.venue_type, updated_at=now))
|
||||
|
||||
db.commit()
|
||||
return SetupInitResponse(ok=True)
|
||||
359
local_backend/routers/shifts.py
Normal file
359
local_backend/routers/shifts.py
Normal file
@@ -0,0 +1,359 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from sqlalchemy.orm import Session
|
||||
from typing import Optional
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from database import get_db
|
||||
from models.shift import WaiterShift, ShiftBreak
|
||||
from models.business_day import BusinessDay
|
||||
from models.order import OrderItem
|
||||
from models.settings import PosSettings
|
||||
from models.user import User
|
||||
from schemas.shift import StartShiftRequest, EndShiftRequest
|
||||
from routers.deps import get_current_user, require_manager
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
def _dt(dt):
|
||||
"""Serialize a naive-UTC datetime to ISO string with Z so JS parses it as UTC."""
|
||||
if dt is None:
|
||||
return None
|
||||
return (dt.isoformat() + "Z") if dt.tzinfo is None else dt.isoformat()
|
||||
|
||||
|
||||
def _get_setting(db: Session, key: str, default: str = "true") -> str:
|
||||
s = db.query(PosSettings).filter(PosSettings.key == key).first()
|
||||
return s.value if s else default
|
||||
|
||||
|
||||
def compute_shift_total(shift_id: int, db: Session) -> float:
|
||||
items = db.query(OrderItem).filter(
|
||||
OrderItem.paid_in_shift_id == shift_id,
|
||||
OrderItem.status == "paid",
|
||||
).all()
|
||||
return round(sum(i.unit_price * i.quantity for i in items), 2)
|
||||
|
||||
|
||||
def _enrich_shift(shift: WaiterShift, db: Session) -> dict:
|
||||
w = shift.waiter
|
||||
wname = (w.full_name or w.username) if w else f"#{shift.waiter_id}"
|
||||
total = compute_shift_total(shift.id, db) if shift.ended_at is None else (shift.total_collected or 0.0)
|
||||
return {
|
||||
"id": shift.id,
|
||||
"waiter_id": shift.waiter_id,
|
||||
"waiter_name": wname,
|
||||
"business_day_id": shift.business_day_id,
|
||||
"started_at": _dt(shift.started_at),
|
||||
"ended_at": _dt(shift.ended_at),
|
||||
"starting_cash": shift.starting_cash,
|
||||
"total_collected": total,
|
||||
"net_to_deliver": round(total + (shift.starting_cash or 0.0), 2),
|
||||
"is_active": shift.ended_at is None,
|
||||
"notes": shift.notes,
|
||||
"breaks": [
|
||||
{"id": b.id, "shift_id": b.shift_id, "started_at": _dt(b.started_at), "ended_at": _dt(b.ended_at)}
|
||||
for b in shift.breaks
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
@router.get("/my")
|
||||
def my_shift(db: Session = Depends(get_db), user: User = Depends(get_current_user)):
|
||||
shift = db.query(WaiterShift).filter(
|
||||
WaiterShift.waiter_id == user.id,
|
||||
WaiterShift.ended_at == None,
|
||||
).first()
|
||||
return _enrich_shift(shift, db) if shift else None
|
||||
|
||||
|
||||
@router.post("/start", status_code=status.HTTP_201_CREATED)
|
||||
def start_shift(
|
||||
body: StartShiftRequest,
|
||||
db: Session = Depends(get_db),
|
||||
user: User = Depends(get_current_user),
|
||||
):
|
||||
target_id = body.waiter_id
|
||||
|
||||
if target_id and target_id != user.id:
|
||||
if user.role not in ("manager", "sysadmin"):
|
||||
raise HTTPException(status_code=403, detail="Only managers can start shifts for other waiters")
|
||||
target = db.query(User).filter(User.id == target_id, User.is_active == True).first()
|
||||
if not target:
|
||||
raise HTTPException(status_code=404, detail="Waiter not found")
|
||||
else:
|
||||
target_id = user.id
|
||||
if user.role == "waiter" and _get_setting(db, "shifts.waiter_self_start") != "true":
|
||||
raise HTTPException(status_code=403, detail="Shift start requires manager confirmation")
|
||||
|
||||
active_day = db.query(BusinessDay).filter(BusinessDay.status == "open").first()
|
||||
if not active_day:
|
||||
raise HTTPException(status_code=400, detail="No open business day — manager must open the restaurant first")
|
||||
|
||||
existing = db.query(WaiterShift).filter(
|
||||
WaiterShift.waiter_id == target_id,
|
||||
WaiterShift.ended_at == None,
|
||||
).first()
|
||||
if existing:
|
||||
raise HTTPException(status_code=400, detail="Waiter already has an active shift")
|
||||
|
||||
shift = WaiterShift(
|
||||
waiter_id=target_id,
|
||||
business_day_id=active_day.id,
|
||||
starting_cash=body.starting_cash,
|
||||
)
|
||||
db.add(shift)
|
||||
db.commit()
|
||||
db.refresh(shift)
|
||||
return _enrich_shift(shift, db)
|
||||
|
||||
|
||||
@router.post("/end")
|
||||
def end_shift(
|
||||
body: EndShiftRequest,
|
||||
db: Session = Depends(get_db),
|
||||
user: User = Depends(get_current_user),
|
||||
):
|
||||
if user.role == "waiter" and _get_setting(db, "shifts.waiter_self_end") != "true":
|
||||
raise HTTPException(status_code=403, detail="Shift end requires manager confirmation")
|
||||
|
||||
shift = db.query(WaiterShift).filter(
|
||||
WaiterShift.waiter_id == user.id,
|
||||
WaiterShift.ended_at == None,
|
||||
).first()
|
||||
if not shift:
|
||||
raise HTTPException(status_code=404, detail="No active shift found")
|
||||
|
||||
now = datetime.now(timezone.utc)
|
||||
shift.total_collected = compute_shift_total(shift.id, db)
|
||||
shift.ended_at = now
|
||||
if body.notes:
|
||||
shift.notes = body.notes
|
||||
|
||||
open_break = db.query(ShiftBreak).filter(
|
||||
ShiftBreak.shift_id == shift.id, ShiftBreak.ended_at == None
|
||||
).first()
|
||||
if open_break:
|
||||
open_break.ended_at = now
|
||||
|
||||
db.commit()
|
||||
db.refresh(shift)
|
||||
return _enrich_shift(shift, db)
|
||||
|
||||
|
||||
@router.post("/manager/start", status_code=status.HTTP_201_CREATED)
|
||||
def manager_start_shift(
|
||||
body: StartShiftRequest,
|
||||
db: Session = Depends(get_db),
|
||||
user: User = Depends(require_manager),
|
||||
):
|
||||
if not body.waiter_id:
|
||||
raise HTTPException(status_code=400, detail="waiter_id is required")
|
||||
|
||||
target = db.query(User).filter(User.id == body.waiter_id, User.is_active == True).first()
|
||||
if not target:
|
||||
raise HTTPException(status_code=404, detail="Waiter not found")
|
||||
|
||||
active_day = db.query(BusinessDay).filter(BusinessDay.status == "open").first()
|
||||
if not active_day:
|
||||
raise HTTPException(status_code=400, detail="No open business day")
|
||||
|
||||
existing = db.query(WaiterShift).filter(
|
||||
WaiterShift.waiter_id == body.waiter_id,
|
||||
WaiterShift.ended_at == None,
|
||||
).first()
|
||||
if existing:
|
||||
raise HTTPException(status_code=400, detail="Waiter already has an active shift")
|
||||
|
||||
shift = WaiterShift(
|
||||
waiter_id=body.waiter_id,
|
||||
business_day_id=active_day.id,
|
||||
starting_cash=body.starting_cash,
|
||||
)
|
||||
db.add(shift)
|
||||
db.commit()
|
||||
db.refresh(shift)
|
||||
return _enrich_shift(shift, db)
|
||||
|
||||
|
||||
@router.post("/manager/end/{shift_id}")
|
||||
def manager_end_shift(
|
||||
shift_id: int,
|
||||
body: EndShiftRequest,
|
||||
db: Session = Depends(get_db),
|
||||
user: User = Depends(require_manager),
|
||||
):
|
||||
shift = db.query(WaiterShift).filter(
|
||||
WaiterShift.id == shift_id,
|
||||
WaiterShift.ended_at == None,
|
||||
).first()
|
||||
if not shift:
|
||||
raise HTTPException(status_code=404, detail="Active shift not found")
|
||||
|
||||
now = datetime.now(timezone.utc)
|
||||
shift.total_collected = compute_shift_total(shift.id, db)
|
||||
shift.ended_at = now
|
||||
if body.notes:
|
||||
shift.notes = body.notes
|
||||
|
||||
open_break = db.query(ShiftBreak).filter(
|
||||
ShiftBreak.shift_id == shift.id, ShiftBreak.ended_at == None
|
||||
).first()
|
||||
if open_break:
|
||||
open_break.ended_at = now
|
||||
|
||||
db.commit()
|
||||
db.refresh(shift)
|
||||
return _enrich_shift(shift, db)
|
||||
|
||||
|
||||
@router.post("/{shift_id}/break/start")
|
||||
def start_break(
|
||||
shift_id: int,
|
||||
db: Session = Depends(get_db),
|
||||
user: User = Depends(get_current_user),
|
||||
):
|
||||
shift = db.query(WaiterShift).filter(WaiterShift.id == shift_id).first()
|
||||
if not shift:
|
||||
raise HTTPException(status_code=404, detail="Shift not found")
|
||||
if shift.waiter_id != user.id and user.role not in ("manager", "sysadmin"):
|
||||
raise HTTPException(status_code=403, detail="Access denied")
|
||||
if shift.ended_at:
|
||||
raise HTTPException(status_code=400, detail="Shift already ended")
|
||||
|
||||
open_break = db.query(ShiftBreak).filter(
|
||||
ShiftBreak.shift_id == shift_id, ShiftBreak.ended_at == None
|
||||
).first()
|
||||
if open_break:
|
||||
raise HTTPException(status_code=400, detail="Break already in progress")
|
||||
|
||||
b = ShiftBreak(shift_id=shift_id)
|
||||
db.add(b)
|
||||
db.commit()
|
||||
db.refresh(b)
|
||||
return {"id": b.id, "shift_id": b.shift_id, "started_at": _dt(b.started_at), "ended_at": _dt(b.ended_at)}
|
||||
|
||||
|
||||
@router.post("/{shift_id}/break/end")
|
||||
def end_break(
|
||||
shift_id: int,
|
||||
db: Session = Depends(get_db),
|
||||
user: User = Depends(get_current_user),
|
||||
):
|
||||
shift = db.query(WaiterShift).filter(WaiterShift.id == shift_id).first()
|
||||
if not shift:
|
||||
raise HTTPException(status_code=404, detail="Shift not found")
|
||||
if shift.waiter_id != user.id and user.role not in ("manager", "sysadmin"):
|
||||
raise HTTPException(status_code=403, detail="Access denied")
|
||||
|
||||
open_break = db.query(ShiftBreak).filter(
|
||||
ShiftBreak.shift_id == shift_id, ShiftBreak.ended_at == None
|
||||
).first()
|
||||
if not open_break:
|
||||
raise HTTPException(status_code=404, detail="No active break found")
|
||||
|
||||
open_break.ended_at = datetime.now(timezone.utc)
|
||||
db.commit()
|
||||
db.refresh(open_break)
|
||||
return {"id": open_break.id, "shift_id": open_break.shift_id, "started_at": _dt(open_break.started_at), "ended_at": _dt(open_break.ended_at)}
|
||||
|
||||
|
||||
@router.get("/")
|
||||
def list_shifts(
|
||||
waiter_id: Optional[int] = None,
|
||||
business_day_id: Optional[int] = None,
|
||||
active_only: bool = False,
|
||||
db: Session = Depends(get_db),
|
||||
user: User = Depends(require_manager),
|
||||
):
|
||||
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 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]}
|
||||
|
||||
|
||||
@router.get("/{shift_id}")
|
||||
def get_shift(
|
||||
shift_id: int,
|
||||
db: Session = Depends(get_db),
|
||||
user: User = Depends(require_manager),
|
||||
):
|
||||
shift = db.query(WaiterShift).filter(WaiterShift.id == shift_id).first()
|
||||
if not shift:
|
||||
raise HTTPException(status_code=404, detail="Shift not found")
|
||||
return _enrich_shift(shift, db)
|
||||
|
||||
|
||||
@router.get("/{shift_id}/summary")
|
||||
def get_shift_summary(
|
||||
shift_id: int,
|
||||
db: Session = Depends(get_db),
|
||||
user: User = Depends(require_manager),
|
||||
):
|
||||
"""Full shift summary: enriched shift data + paid items grouped by order."""
|
||||
from models.order import Order
|
||||
from sqlalchemy.orm import joinedload
|
||||
|
||||
shift = db.query(WaiterShift).filter(WaiterShift.id == shift_id).first()
|
||||
if not shift:
|
||||
raise HTTPException(status_code=404, detail="Shift not found")
|
||||
|
||||
from models.table import Table
|
||||
items = db.query(OrderItem).options(
|
||||
joinedload(OrderItem.product),
|
||||
joinedload(OrderItem.order),
|
||||
).filter(
|
||||
OrderItem.paid_in_shift_id == shift_id,
|
||||
OrderItem.status == "paid",
|
||||
).all()
|
||||
|
||||
# Build table_id -> display name map for all referenced tables
|
||||
table_ids = {item.order.table_id for item in items if item.order and item.order.table_id}
|
||||
tables_map: dict[int, str] = {}
|
||||
if table_ids:
|
||||
tbl_rows = db.query(Table).filter(Table.id.in_(table_ids)).all()
|
||||
for t in tbl_rows:
|
||||
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}"
|
||||
|
||||
orders_seen = {}
|
||||
for item in items:
|
||||
oid = item.order_id
|
||||
if oid not in orders_seen:
|
||||
o = item.order
|
||||
tid = o.table_id if o else None
|
||||
orders_seen[oid] = {
|
||||
"order_id": oid,
|
||||
"table_id": tid,
|
||||
"table_name": tables_map.get(tid) if tid else None,
|
||||
"opened_at": _dt(o.opened_at) if o else None,
|
||||
"items": [],
|
||||
}
|
||||
orders_seen[oid]["items"].append({
|
||||
"id": item.id,
|
||||
"product_name": item.product.name if item.product else f"#{item.product_id}",
|
||||
"quantity": item.quantity,
|
||||
"unit_price": float(item.unit_price),
|
||||
"subtotal": round(float(item.unit_price) * item.quantity, 2),
|
||||
"paid_at": _dt(item.paid_at),
|
||||
})
|
||||
|
||||
# Compute hours worked
|
||||
started = shift.started_at
|
||||
ended = shift.ended_at
|
||||
duration_minutes = None
|
||||
if started and ended:
|
||||
duration_minutes = int((ended - started).total_seconds() / 60)
|
||||
elif started:
|
||||
from datetime import datetime, timezone as tz
|
||||
duration_minutes = int((datetime.now(tz.utc) - started.replace(tzinfo=tz.utc) if started.tzinfo is None else datetime.now(tz.utc) - started).total_seconds() / 60)
|
||||
|
||||
enriched = _enrich_shift(shift, db)
|
||||
enriched["orders"] = list(orders_seen.values())
|
||||
enriched["duration_minutes"] = duration_minutes
|
||||
return enriched
|
||||
60
local_backend/routers/sse.py
Normal file
60
local_backend/routers/sse.py
Normal file
@@ -0,0 +1,60 @@
|
||||
"""
|
||||
SSE stream endpoint — one long-lived GET per connected phone.
|
||||
|
||||
Authentication: token passed as query param ?token=<jwt>
|
||||
(EventSource API in browsers cannot set custom headers, so query param is the standard pattern.)
|
||||
|
||||
The client receives a stream of JSON lines:
|
||||
data: {"type": "...", "data": {...}}\n\n
|
||||
|
||||
A keepalive comment (": ping") is sent every 25 seconds to prevent proxy timeouts.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
from fastapi import APIRouter, Query
|
||||
from fastapi.responses import StreamingResponse
|
||||
|
||||
from routers.deps import decode_token
|
||||
from services.sse_bus import subscribe, unsubscribe
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
KEEPALIVE_INTERVAL = 25 # seconds
|
||||
|
||||
|
||||
async def _event_stream(user_id: int):
|
||||
q = await subscribe(user_id)
|
||||
try:
|
||||
while True:
|
||||
try:
|
||||
payload = await asyncio.wait_for(q.get(), timeout=KEEPALIVE_INTERVAL)
|
||||
yield f"data: {payload}\n\n"
|
||||
except asyncio.TimeoutError:
|
||||
# keepalive — prevents nginx/proxies from closing idle connections
|
||||
yield ": ping\n\n"
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
finally:
|
||||
await unsubscribe(user_id, q)
|
||||
|
||||
|
||||
@router.get("/stream")
|
||||
async def sse_stream(token: str = Query(...)):
|
||||
"""
|
||||
Open an SSE stream for the authenticated user.
|
||||
The phone connects once on login and stays connected.
|
||||
On reconnect (after network drop) it does a full GET first, then reconnects here.
|
||||
"""
|
||||
# decode_token raises HTTPException on invalid/expired — no manual check needed
|
||||
payload = decode_token(token)
|
||||
user_id: int = int(payload["sub"])
|
||||
|
||||
return StreamingResponse(
|
||||
_event_stream(user_id),
|
||||
media_type="text/event-stream",
|
||||
headers={
|
||||
"Cache-Control": "no-cache",
|
||||
"X-Accel-Buffering": "no", # disable nginx buffering
|
||||
"Connection": "keep-alive",
|
||||
},
|
||||
)
|
||||
179
local_backend/routers/system.py
Normal file
179
local_backend/routers/system.py
Normal file
@@ -0,0 +1,179 @@
|
||||
import time
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from sqlalchemy.orm import Session
|
||||
from typing import List
|
||||
|
||||
from database import get_db
|
||||
from models.printer import Printer
|
||||
from schemas.printer import PrinterCreate, PrinterUpdate, PrinterOut
|
||||
from routers.deps import get_current_user, require_manager, require_sysadmin
|
||||
from models.user import User
|
||||
from models.product import Category, Product
|
||||
from models.table import Table, TableGroup
|
||||
from services import printer_service
|
||||
from services.cloud_sync import _sync_once
|
||||
from middleware.license_check import license_state
|
||||
from config import settings
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
_start_time = time.time()
|
||||
|
||||
|
||||
@router.get("/health")
|
||||
def health():
|
||||
return {"status": "ok", "version": settings.VERSION}
|
||||
|
||||
|
||||
@router.get("/status")
|
||||
def system_status(db: Session = Depends(get_db), user: User = Depends(get_current_user)):
|
||||
from datetime import datetime, timezone
|
||||
printers = db.query(Printer).filter(Printer.is_active == True).all()
|
||||
printer_statuses = []
|
||||
for p in printers:
|
||||
reachable = printer_service.check_printer(p.ip_address, p.port)
|
||||
printer_statuses.append({"id": p.id, "name": p.name, "reachable": reachable})
|
||||
|
||||
licensed = license_state.get("licensed", True)
|
||||
locked = license_state.get("locked", False)
|
||||
lock_pending = license_state.get("lock_pending", False)
|
||||
expires_at = license_state.get("expires_at")
|
||||
days_until_expiry = license_state.get("days_until_expiry")
|
||||
grace_expires_at = license_state.get("grace_expires_at")
|
||||
|
||||
# Determine lock_reason for the frontend banner logic
|
||||
# "admin" — locked by sysadmin (immediately or deferred)
|
||||
# "expired" — license grace period over, site is blocked
|
||||
# None — all good
|
||||
lock_reason = None
|
||||
if locked or lock_pending:
|
||||
lock_reason = "admin"
|
||||
elif not licensed:
|
||||
lock_reason = "expired"
|
||||
|
||||
# Grace days remaining (only meaningful while in expiry grace period)
|
||||
grace_days_remaining = None
|
||||
if grace_expires_at:
|
||||
try:
|
||||
grace_dt = datetime.fromisoformat(grace_expires_at)
|
||||
if grace_dt.tzinfo is None:
|
||||
grace_dt = grace_dt.replace(tzinfo=timezone.utc)
|
||||
grace_days_remaining = max(0, (grace_dt - datetime.now(timezone.utc)).days)
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
return {
|
||||
"uptime_seconds": int(time.time() - _start_time),
|
||||
"version": settings.VERSION,
|
||||
"latest_version": license_state.get("latest_version"),
|
||||
"licensed": licensed,
|
||||
"locked": locked,
|
||||
"lock_pending": lock_pending,
|
||||
"lock_reason": lock_reason,
|
||||
"expires_at": expires_at,
|
||||
"days_until_expiry": days_until_expiry,
|
||||
"grace_expires_at": grace_expires_at,
|
||||
"grace_days_remaining": grace_days_remaining,
|
||||
"sync_failed": license_state.get("sync_failed", False),
|
||||
"last_sync": license_state.get("last_sync"),
|
||||
"waiter_domain": license_state.get("waiter_domain"),
|
||||
"printers": printer_statuses,
|
||||
}
|
||||
|
||||
|
||||
@router.post("/sync-license")
|
||||
async def sync_license_now(user: User = Depends(require_manager)):
|
||||
"""Trigger an immediate cloud heartbeat and return the fresh license state."""
|
||||
await _sync_once()
|
||||
return {
|
||||
"licensed": license_state.get("licensed", True),
|
||||
"locked": license_state.get("locked", False),
|
||||
"lock_pending": license_state.get("lock_pending", False),
|
||||
"lock_reason": (
|
||||
"admin" if (license_state.get("locked") or license_state.get("lock_pending"))
|
||||
else "expired" if not license_state.get("licensed", True)
|
||||
else None
|
||||
),
|
||||
"expires_at": license_state.get("expires_at"),
|
||||
"days_until_expiry": license_state.get("days_until_expiry"),
|
||||
"sync_failed": license_state.get("sync_failed", False),
|
||||
"last_sync": license_state.get("last_sync"),
|
||||
}
|
||||
|
||||
|
||||
@router.get("/printers", response_model=List[PrinterOut])
|
||||
def list_printers(db: Session = Depends(get_db), user: User = Depends(require_manager)):
|
||||
return db.query(Printer).all()
|
||||
|
||||
|
||||
@router.post("/printers", response_model=PrinterOut)
|
||||
def create_printer(body: PrinterCreate, db: Session = Depends(get_db), user: User = Depends(require_manager)):
|
||||
printer = Printer(**body.model_dump())
|
||||
db.add(printer)
|
||||
db.commit()
|
||||
db.refresh(printer)
|
||||
return printer
|
||||
|
||||
|
||||
@router.post("/printers/test")
|
||||
def test_printer(printer_id: int, db: Session = Depends(get_db), user: User = Depends(require_manager)):
|
||||
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_print(printer.ip_address, printer.port, printer.name)
|
||||
return {"success": success, "error": error}
|
||||
|
||||
|
||||
@router.post("/printers/test-order")
|
||||
def test_order_print(printer_id: int, db: Session = Depends(get_db), user: User = Depends(require_manager)):
|
||||
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)
|
||||
return {"success": success, "error": error}
|
||||
|
||||
|
||||
@router.put("/printers/{printer_id}", response_model=PrinterOut)
|
||||
def update_printer(printer_id: int, body: PrinterUpdate, db: Session = Depends(get_db), user: User = Depends(require_manager)):
|
||||
printer = db.query(Printer).filter(Printer.id == printer_id).first()
|
||||
if not printer:
|
||||
raise HTTPException(status_code=404, detail="Printer not found")
|
||||
for field, value in body.model_dump(exclude_none=True).items():
|
||||
setattr(printer, field, value)
|
||||
db.commit()
|
||||
db.refresh(printer)
|
||||
return printer
|
||||
|
||||
|
||||
@router.delete("/printers/{printer_id}")
|
||||
def delete_printer(printer_id: int, db: Session = Depends(get_db), user: User = Depends(require_manager)):
|
||||
printer = db.query(Printer).filter(Printer.id == printer_id).first()
|
||||
if not printer:
|
||||
raise HTTPException(status_code=404, detail="Printer not found")
|
||||
db.delete(printer)
|
||||
db.commit()
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
@router.get("/stats")
|
||||
def system_stats(db: Session = Depends(get_db), user: User = Depends(get_current_user)):
|
||||
return {
|
||||
"categories": db.query(Category).count(),
|
||||
"products": db.query(Product).filter(Product.lifecycle_status == "active").count(),
|
||||
"tables": db.query(Table).filter(Table.is_active == True).count(),
|
||||
"table_groups": db.query(TableGroup).count(),
|
||||
"managers": db.query(User).filter(User.role == "manager", User.is_active == True).count(),
|
||||
"waiters": db.query(User).filter(User.role == "waiter", User.is_active == True).count(),
|
||||
}
|
||||
|
||||
|
||||
@router.post("/lock")
|
||||
def lock_system(token: str, user: User = Depends(require_sysadmin)):
|
||||
license_state["locked"] = True
|
||||
return {"status": "locked"}
|
||||
|
||||
|
||||
@router.post("/unlock")
|
||||
def unlock_system(token: str, user: User = Depends(require_sysadmin)):
|
||||
license_state["locked"] = False
|
||||
return {"status": "unlocked"}
|
||||
227
local_backend/routers/tables.py
Normal file
227
local_backend/routers/tables.py
Normal file
@@ -0,0 +1,227 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from sqlalchemy.orm import Session
|
||||
from typing import List
|
||||
|
||||
from database import get_db
|
||||
from models.table import Table, TableGroup
|
||||
from models.order import Order
|
||||
from models.user import User, WaiterZone
|
||||
from schemas.table import (
|
||||
TableCreate, TableUpdate, TableFloorplanUpdate, TableOut,
|
||||
TableGroupCreate, TableGroupUpdate, TableGroupOut,
|
||||
TableBatchCreate, MAX_TABLE_NAME_LENGTH,
|
||||
)
|
||||
from routers.deps import get_current_user, require_manager
|
||||
from services.sse_bus import broadcast_sync
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
# ── Table Groups ──────────────────────────────────────────────────────────────
|
||||
|
||||
@router.get("/groups", response_model=List[TableGroupOut])
|
||||
def list_groups(db: Session = Depends(get_db), user: User = Depends(get_current_user)):
|
||||
return db.query(TableGroup).order_by(TableGroup.sort_order).all()
|
||||
|
||||
|
||||
@router.post("/groups", response_model=TableGroupOut, status_code=status.HTTP_201_CREATED)
|
||||
def create_group(body: TableGroupCreate, db: Session = Depends(get_db), user: User = Depends(require_manager)):
|
||||
if db.query(TableGroup).filter(TableGroup.name == body.name).first():
|
||||
raise HTTPException(status_code=400, detail="Group name already exists")
|
||||
sort_order = db.query(TableGroup).count()
|
||||
group = TableGroup(name=body.name, prefix=body.prefix, color=body.color, sort_order=sort_order)
|
||||
db.add(group)
|
||||
db.commit()
|
||||
db.refresh(group)
|
||||
return group
|
||||
|
||||
|
||||
@router.put("/groups/{group_id}", response_model=TableGroupOut)
|
||||
def update_group(group_id: int, body: TableGroupUpdate, db: Session = Depends(get_db), user: User = Depends(require_manager)):
|
||||
group = db.query(TableGroup).filter(TableGroup.id == group_id).first()
|
||||
if not group:
|
||||
raise HTTPException(status_code=404, detail="Group not found")
|
||||
for field, value in body.model_dump(exclude_none=True).items():
|
||||
setattr(group, field, value)
|
||||
db.commit()
|
||||
db.refresh(group)
|
||||
return group
|
||||
|
||||
|
||||
@router.delete("/groups/{group_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||
def delete_group(group_id: int, db: Session = Depends(get_db), user: User = Depends(require_manager)):
|
||||
group = db.query(TableGroup).filter(TableGroup.id == group_id).first()
|
||||
if not group:
|
||||
raise HTTPException(status_code=404, detail="Group not found")
|
||||
db.query(Table).filter(Table.group_id == group_id).update({"group_id": None})
|
||||
db.delete(group)
|
||||
db.commit()
|
||||
|
||||
|
||||
# ── Tables ────────────────────────────────────────────────────────────────────
|
||||
|
||||
def _next_global_number(db: Session) -> int:
|
||||
last = db.query(Table).order_by(Table.number.desc()).first()
|
||||
return (last.number + 1) if last else 1
|
||||
|
||||
|
||||
@router.get("/", response_model=List[TableOut])
|
||||
def list_tables(include_inactive: bool = False, db: Session = Depends(get_db), user: User = Depends(get_current_user)):
|
||||
q = db.query(Table)
|
||||
if not include_inactive:
|
||||
q = q.filter(Table.is_active == True)
|
||||
|
||||
# Zone-based filtering for waiters
|
||||
if user.role not in ("manager", "sysadmin"):
|
||||
zones = db.query(WaiterZone).filter(WaiterZone.waiter_id == user.id).all()
|
||||
# No zone rows → sees nothing
|
||||
if not zones:
|
||||
return []
|
||||
# Any row with group_id=None → sees all tables (all-zones sentinel)
|
||||
has_all_zones = any(z.group_id is None for z in zones)
|
||||
if not has_all_zones:
|
||||
allowed_group_ids = [z.group_id for z in zones]
|
||||
q = q.filter(Table.group_id.in_(allowed_group_ids))
|
||||
|
||||
tables = q.order_by(Table.group_id, Table.number).all()
|
||||
|
||||
active_table_ids = {
|
||||
row[0] for row in db.query(Order.table_id).filter(
|
||||
Order.status.in_(["open", "partially_paid", "paid"])
|
||||
).all()
|
||||
}
|
||||
|
||||
result = []
|
||||
for t in tables:
|
||||
out = TableOut.model_validate(t)
|
||||
out.has_active_order = t.id in active_table_ids
|
||||
result.append(out)
|
||||
return result
|
||||
|
||||
|
||||
@router.post("/", response_model=TableOut, status_code=status.HTTP_201_CREATED)
|
||||
def create_table(body: TableCreate, db: Session = Depends(get_db), user: User = Depends(require_manager)):
|
||||
number = _next_global_number(db)
|
||||
table = Table(number=number, label=body.label, group_id=body.group_id, is_active=True)
|
||||
db.add(table)
|
||||
db.commit()
|
||||
db.refresh(table)
|
||||
broadcast_sync("table_list_changed", {"action": "created", "table_id": table.id})
|
||||
return table
|
||||
|
||||
|
||||
@router.post("/batch", response_model=List[TableOut], status_code=status.HTTP_201_CREATED)
|
||||
def batch_create_tables(body: TableBatchCreate, db: Session = Depends(get_db), user: User = Depends(require_manager)):
|
||||
if body.count < 1 or body.count > 200:
|
||||
raise HTTPException(status_code=400, detail="Count must be between 1 and 200")
|
||||
|
||||
# Group-local label numbering: find highest suffix already used in this group
|
||||
existing_in_group = (
|
||||
db.query(Table)
|
||||
.filter(Table.group_id == body.group_id)
|
||||
.all()
|
||||
) if body.group_id else []
|
||||
|
||||
# Extract trailing integers from existing labels that start with this prefix
|
||||
used = []
|
||||
for t in existing_in_group:
|
||||
if t.label and t.label.startswith(body.name_prefix):
|
||||
suffix = t.label[len(body.name_prefix):]
|
||||
if suffix.isdigit():
|
||||
used.append(int(suffix))
|
||||
start_label_n = (max(used) + 1) if used else 1
|
||||
|
||||
# Guard: worst-case label is prefix + highest number that will be generated
|
||||
last_n = start_label_n + body.count - 1
|
||||
worst_case = f"{body.name_prefix}{last_n}"
|
||||
if len(worst_case) > MAX_TABLE_NAME_LENGTH:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"Table name '{worst_case}' would exceed {MAX_TABLE_NAME_LENGTH} characters. Shorten the prefix or reduce the count.",
|
||||
)
|
||||
|
||||
created = []
|
||||
for i in range(body.count):
|
||||
label_n = start_label_n + i
|
||||
global_number = _next_global_number(db)
|
||||
table = Table(
|
||||
number=global_number,
|
||||
label=f"{body.name_prefix}{label_n}",
|
||||
group_id=body.group_id,
|
||||
is_active=True,
|
||||
)
|
||||
db.add(table)
|
||||
db.flush()
|
||||
created.append(table)
|
||||
db.commit()
|
||||
for t in created:
|
||||
db.refresh(t)
|
||||
return created
|
||||
|
||||
|
||||
@router.put("/{table_id}", response_model=TableOut)
|
||||
def update_table(table_id: int, body: TableUpdate, db: Session = Depends(get_db), user: User = Depends(require_manager)):
|
||||
table = db.query(Table).filter(Table.id == table_id).first()
|
||||
if not table:
|
||||
raise HTTPException(status_code=404, detail="Table not found")
|
||||
for field, value in body.model_dump(exclude_none=True).items():
|
||||
setattr(table, field, value)
|
||||
db.commit()
|
||||
db.refresh(table)
|
||||
return table
|
||||
|
||||
|
||||
@router.delete("/{table_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||
def delete_table(table_id: int, hard: bool = False, db: Session = Depends(get_db), user: User = Depends(require_manager)):
|
||||
table = db.query(Table).filter(Table.id == table_id).first()
|
||||
if not table:
|
||||
raise HTTPException(status_code=404, detail="Table not found")
|
||||
active_order = db.query(Order).filter(
|
||||
Order.table_id == table_id,
|
||||
Order.status.in_(["open", "partially_paid", "paid"])
|
||||
).first()
|
||||
if active_order:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="Cannot delete or deactivate a table with an active order"
|
||||
)
|
||||
if hard:
|
||||
# Delete all past (non-active) orders for this table so FK constraint doesn't block deletion.
|
||||
# Active orders are already blocked above. Items/waiters/print_logs cascade via ORM.
|
||||
past_orders = db.query(Order).filter(Order.table_id == table_id).all()
|
||||
for order in past_orders:
|
||||
db.delete(order)
|
||||
db.flush()
|
||||
db.delete(table)
|
||||
else:
|
||||
table.is_active = False
|
||||
db.commit()
|
||||
|
||||
|
||||
@router.get("/{table_id}/status")
|
||||
def table_status(table_id: int, db: Session = Depends(get_db), user: User = Depends(get_current_user)):
|
||||
table = db.query(Table).filter(Table.id == table_id).first()
|
||||
if not table:
|
||||
raise HTTPException(status_code=404, detail="Table not found")
|
||||
active_order = (
|
||||
db.query(Order)
|
||||
.filter(Order.table_id == table_id, Order.status.in_(["open", "partially_paid", "paid"]))
|
||||
.first()
|
||||
)
|
||||
return {
|
||||
"table": TableOut.model_validate(table),
|
||||
"active_order_id": active_order.id if active_order else None,
|
||||
"order_status": active_order.status if active_order else None,
|
||||
}
|
||||
|
||||
|
||||
@router.put("/{table_id}/floorplan", response_model=TableOut)
|
||||
def update_floorplan(table_id: int, body: TableFloorplanUpdate, db: Session = Depends(get_db), user: User = Depends(require_manager)):
|
||||
table = db.query(Table).filter(Table.id == table_id).first()
|
||||
if not table:
|
||||
raise HTTPException(status_code=404, detail="Table not found")
|
||||
table.floor_x = body.floor_x
|
||||
table.floor_y = body.floor_y
|
||||
db.commit()
|
||||
db.refresh(table)
|
||||
return table
|
||||
186
local_backend/routers/waiters.py
Normal file
186
local_backend/routers/waiters.py
Normal file
@@ -0,0 +1,186 @@
|
||||
import os
|
||||
import uuid
|
||||
import bcrypt
|
||||
from fastapi import APIRouter, Depends, HTTPException, status, UploadFile, File
|
||||
from sqlalchemy.orm import Session
|
||||
from typing import List
|
||||
|
||||
from database import get_db
|
||||
from models.user import User, AssistantAssignment, WaiterZone
|
||||
from models.shift import WaiterShift
|
||||
from schemas.user import UserCreate, UserUpdate, UserOut, AssistantAssignmentOut, SetZonesRequest
|
||||
from routers.deps import require_manager, get_current_user
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
AVATAR_DIR = "/app/data/avatars"
|
||||
|
||||
|
||||
# ── Helpers ───────────────────────────────────────────────────────────────────
|
||||
|
||||
def _waiter_or_404(waiter_id: int, db: Session) -> User:
|
||||
w = db.query(User).filter(User.id == waiter_id).first()
|
||||
if not w:
|
||||
raise HTTPException(status_code=404, detail="Waiter not found")
|
||||
return w
|
||||
|
||||
|
||||
# ── CRUD ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
@router.get("/on-shift", response_model=List[UserOut])
|
||||
def list_waiters_on_shift(db: Session = Depends(get_db), user: User = Depends(get_current_user)):
|
||||
"""Waiters with an active (not-ended) shift. Accessible to all staff."""
|
||||
waiter_ids = db.query(WaiterShift.waiter_id).filter(WaiterShift.ended_at == None).subquery()
|
||||
return db.query(User).filter(User.id.in_(waiter_ids), User.role == "waiter", User.is_active == True).all()
|
||||
|
||||
|
||||
@router.get("/", response_model=List[UserOut])
|
||||
def list_waiters(db: Session = Depends(get_db), user: User = Depends(require_manager)):
|
||||
return db.query(User).filter(User.role == "waiter").all()
|
||||
|
||||
|
||||
@router.post("/", response_model=UserOut, status_code=status.HTTP_201_CREATED)
|
||||
def create_waiter(body: UserCreate, db: Session = Depends(get_db), user: User = Depends(require_manager)):
|
||||
if db.query(User).filter(User.username == body.username).first():
|
||||
raise HTTPException(status_code=400, detail="Username already exists")
|
||||
pin_hash = bcrypt.hashpw(body.pin.encode(), bcrypt.gensalt()).decode()
|
||||
new_user = User(
|
||||
username=body.username,
|
||||
pin_hash=pin_hash,
|
||||
role=body.role,
|
||||
is_active=body.is_active,
|
||||
full_name=body.full_name,
|
||||
nickname=body.nickname,
|
||||
mobile_phone=body.mobile_phone,
|
||||
)
|
||||
db.add(new_user)
|
||||
db.commit()
|
||||
db.refresh(new_user)
|
||||
return new_user
|
||||
|
||||
|
||||
@router.put("/{waiter_id}", response_model=UserOut)
|
||||
def update_waiter(waiter_id: int, body: UserUpdate, db: Session = Depends(get_db), user: User = Depends(require_manager)):
|
||||
waiter = _waiter_or_404(waiter_id, db)
|
||||
for field, value in body.model_dump(exclude_none=True).items():
|
||||
setattr(waiter, field, value)
|
||||
db.commit()
|
||||
db.refresh(waiter)
|
||||
return waiter
|
||||
|
||||
|
||||
@router.put("/{waiter_id}/reset-pin")
|
||||
def reset_pin(waiter_id: int, pin: str, db: Session = Depends(get_db), user: User = Depends(require_manager)):
|
||||
waiter = _waiter_or_404(waiter_id, db)
|
||||
waiter.pin_hash = bcrypt.hashpw(pin.encode(), bcrypt.gensalt()).decode()
|
||||
db.commit()
|
||||
return {"status": "pin reset"}
|
||||
|
||||
|
||||
@router.put("/{waiter_id}/block")
|
||||
def toggle_block(waiter_id: int, db: Session = Depends(get_db), user: User = Depends(require_manager)):
|
||||
waiter = _waiter_or_404(waiter_id, db)
|
||||
waiter.is_active = not waiter.is_active
|
||||
db.commit()
|
||||
return {"is_active": waiter.is_active}
|
||||
|
||||
|
||||
@router.delete("/{waiter_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||
def delete_waiter(waiter_id: int, db: Session = Depends(get_db), user: User = Depends(require_manager)):
|
||||
waiter = _waiter_or_404(waiter_id, db)
|
||||
db.delete(waiter)
|
||||
db.commit()
|
||||
|
||||
|
||||
# ── Avatar upload / delete ───────────────────────────────────────────────────
|
||||
|
||||
@router.post("/{waiter_id}/avatar", response_model=UserOut)
|
||||
async def upload_avatar(waiter_id: int, file: UploadFile = File(...), db: Session = Depends(get_db), user: User = Depends(require_manager)):
|
||||
waiter = _waiter_or_404(waiter_id, db)
|
||||
if not file.content_type.startswith("image/"):
|
||||
raise HTTPException(status_code=400, detail="File must be an image")
|
||||
|
||||
# Delete old avatar file if present
|
||||
if waiter.avatar_url:
|
||||
old_path = os.path.join(AVATAR_DIR, os.path.basename(waiter.avatar_url))
|
||||
if os.path.exists(old_path):
|
||||
os.remove(old_path)
|
||||
|
||||
ext = os.path.splitext(file.filename or "")[1] or ".jpg"
|
||||
filename = f"waiter_{waiter_id}_{uuid.uuid4().hex[:8]}{ext}"
|
||||
dest = os.path.join(AVATAR_DIR, filename)
|
||||
os.makedirs(AVATAR_DIR, exist_ok=True)
|
||||
content = await file.read()
|
||||
with open(dest, "wb") as f:
|
||||
f.write(content)
|
||||
|
||||
waiter.avatar_url = f"/static/avatars/{filename}"
|
||||
db.commit()
|
||||
db.refresh(waiter)
|
||||
return waiter
|
||||
|
||||
|
||||
@router.delete("/{waiter_id}/avatar", response_model=UserOut)
|
||||
def delete_avatar(waiter_id: int, db: Session = Depends(get_db), user: User = Depends(require_manager)):
|
||||
waiter = _waiter_or_404(waiter_id, db)
|
||||
if waiter.avatar_url:
|
||||
old_path = os.path.join(AVATAR_DIR, os.path.basename(waiter.avatar_url))
|
||||
if os.path.exists(old_path):
|
||||
os.remove(old_path)
|
||||
waiter.avatar_url = None
|
||||
db.commit()
|
||||
db.refresh(waiter)
|
||||
return waiter
|
||||
|
||||
|
||||
# ── Zone assignments ──────────────────────────────────────────────────────────
|
||||
|
||||
@router.put("/{waiter_id}/zones")
|
||||
def set_zones(waiter_id: int, body: SetZonesRequest, db: Session = Depends(get_db), user: User = Depends(require_manager)):
|
||||
"""Replace all zone assignments for a waiter atomically.
|
||||
|
||||
- all_zones=True → single NULL group_id row (sees everything)
|
||||
- group_ids=[1,2] → rows for groups 1 and 2 only
|
||||
- group_ids=[] → no rows at all (sees nothing)
|
||||
"""
|
||||
_waiter_or_404(waiter_id, db)
|
||||
# Wipe existing assignments
|
||||
db.query(WaiterZone).filter(WaiterZone.waiter_id == waiter_id).delete()
|
||||
|
||||
if body.all_zones:
|
||||
db.add(WaiterZone(waiter_id=waiter_id, group_id=None))
|
||||
elif body.group_ids:
|
||||
for gid in body.group_ids:
|
||||
db.add(WaiterZone(waiter_id=waiter_id, group_id=gid))
|
||||
|
||||
db.commit()
|
||||
zones = db.query(WaiterZone).filter(WaiterZone.waiter_id == waiter_id).all()
|
||||
return {"waiter_id": waiter_id, "zones": [{"id": z.id, "group_id": z.group_id} for z in zones]}
|
||||
|
||||
|
||||
# ── Assistant assignments (kept for backwards compat) ─────────────────────────
|
||||
|
||||
@router.post("/{waiter_id}/assign-assistant", response_model=AssistantAssignmentOut)
|
||||
def assign_assistant(waiter_id: int, assistant_id: int, db: Session = Depends(get_db), user: User = Depends(require_manager)):
|
||||
existing = db.query(AssistantAssignment).filter(
|
||||
AssistantAssignment.primary_waiter_id == waiter_id,
|
||||
AssistantAssignment.assistant_waiter_id == assistant_id,
|
||||
).first()
|
||||
if existing:
|
||||
raise HTTPException(status_code=400, detail="Assignment already exists")
|
||||
assignment = AssistantAssignment(primary_waiter_id=waiter_id, assistant_waiter_id=assistant_id)
|
||||
db.add(assignment)
|
||||
db.commit()
|
||||
db.refresh(assignment)
|
||||
return assignment
|
||||
|
||||
|
||||
@router.delete("/{waiter_id}/assistant", status_code=status.HTTP_204_NO_CONTENT)
|
||||
def remove_assistant(waiter_id: int, db: Session = Depends(get_db), user: User = Depends(require_manager)):
|
||||
assignment = db.query(AssistantAssignment).filter(
|
||||
AssistantAssignment.primary_waiter_id == waiter_id
|
||||
).first()
|
||||
if not assignment:
|
||||
raise HTTPException(status_code=404, detail="Assignment not found")
|
||||
db.delete(assignment)
|
||||
db.commit()
|
||||
Reference in New Issue
Block a user