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

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