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:
2026-05-20 14:04:38 +03:00
commit 8ba8c95ecd
209 changed files with 48017 additions and 0 deletions

View File

View 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

View 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}

View 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."}

View 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

View 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": []})

View 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]

View 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"}

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

File diff suppressed because it is too large Load Diff

View 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}

View 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)

View 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

View 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",
},
)

View 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"}

View 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

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