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:
195
local_backend/routers/business_day.py
Normal file
195
local_backend/routers/business_day.py
Normal file
@@ -0,0 +1,195 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from sqlalchemy import func
|
||||
from sqlalchemy.orm import Session
|
||||
from typing import Optional
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from database import get_db
|
||||
from models.business_day import BusinessDay
|
||||
from models.order import Order, OrderItem, OrderAuditLog
|
||||
from models.shift import WaiterShift
|
||||
from models.flag import TableFlagAssignment
|
||||
from models.message import StaffMessage, StaffMessageAck
|
||||
from schemas.business_day import BusinessDayOut, OpenBusinessDayRequest, CloseBusinessDayRequest
|
||||
from routers.deps import get_current_user, require_manager
|
||||
from models.user import User
|
||||
from middleware.license_check import license_state
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
def _dt(dt):
|
||||
if dt is None:
|
||||
return None
|
||||
return (dt.isoformat() + "Z") if dt.tzinfo is None else dt.isoformat()
|
||||
|
||||
|
||||
@router.get("/current", response_model=Optional[BusinessDayOut])
|
||||
def get_current_business_day(
|
||||
db: Session = Depends(get_db),
|
||||
user: User = Depends(get_current_user),
|
||||
):
|
||||
return db.query(BusinessDay).filter(BusinessDay.status == "open").first()
|
||||
|
||||
|
||||
@router.post("/open", response_model=BusinessDayOut, status_code=status.HTTP_201_CREATED)
|
||||
def open_business_day(
|
||||
body: OpenBusinessDayRequest,
|
||||
db: Session = Depends(get_db),
|
||||
user: User = Depends(require_manager),
|
||||
):
|
||||
existing = db.query(BusinessDay).filter(BusinessDay.status == "open").first()
|
||||
if existing:
|
||||
raise HTTPException(status_code=400, detail="A business day is already open")
|
||||
|
||||
# Gate: admin lock (already enforced or pending)
|
||||
if license_state.get("locked") or license_state.get("lock_pending"):
|
||||
raise HTTPException(
|
||||
status_code=423,
|
||||
detail={
|
||||
"code": "SYSTEM_LOCKED",
|
||||
"message": "Το σύστημα έχει κλειδωθεί από διαχειριστή. Επικοινωνήστε με την υποστήριξη.",
|
||||
},
|
||||
)
|
||||
|
||||
# Gate: license expired and expiry grace period also over
|
||||
if not license_state.get("licensed", True):
|
||||
expires_at = license_state.get("expires_at", "")
|
||||
raise HTTPException(
|
||||
status_code=402,
|
||||
detail={
|
||||
"code": "LICENSE_EXPIRED",
|
||||
"message": "Η άδεια χρήσης έχει λήξει. Ανανεώστε την άδεια ή επικοινωνήστε με την υποστήριξη.",
|
||||
"expires_at": expires_at,
|
||||
},
|
||||
)
|
||||
|
||||
day = BusinessDay(opened_by_id=user.id, notes=body.notes)
|
||||
db.add(day)
|
||||
db.commit()
|
||||
db.refresh(day)
|
||||
return day
|
||||
|
||||
|
||||
@router.post("/close", response_model=BusinessDayOut)
|
||||
def close_business_day(
|
||||
body: CloseBusinessDayRequest,
|
||||
db: Session = Depends(get_db),
|
||||
user: User = Depends(require_manager),
|
||||
):
|
||||
day = db.query(BusinessDay).filter(BusinessDay.status == "open").first()
|
||||
if not day:
|
||||
raise HTTPException(status_code=404, detail="No open business day")
|
||||
|
||||
open_orders = db.query(Order).filter(
|
||||
Order.business_day_id == day.id,
|
||||
Order.status.in_(["open", "partially_paid"]),
|
||||
).all()
|
||||
|
||||
if open_orders and not body.force:
|
||||
# Count orders that have at least one active (unpaid) item — covers both
|
||||
# "open" (fully unpaid) and "partially_paid" (partially unpaid) orders.
|
||||
with_pending = sum(
|
||||
1 for o in open_orders
|
||||
if db.query(OrderItem).filter(
|
||||
OrderItem.order_id == o.id,
|
||||
OrderItem.status == "active",
|
||||
).first() is not None
|
||||
)
|
||||
raise HTTPException(
|
||||
status_code=409,
|
||||
detail={
|
||||
"message": f"{len(open_orders)} table(s) still open, {with_pending} with unpaid items.",
|
||||
"open_orders": len(open_orders),
|
||||
"partially_paid": with_pending,
|
||||
},
|
||||
)
|
||||
|
||||
now = datetime.now(timezone.utc)
|
||||
|
||||
# Close all non-terminal orders for this business day (open, partially_paid, paid)
|
||||
all_unclosed = db.query(Order).filter(
|
||||
Order.business_day_id == day.id,
|
||||
Order.status.in_(["open", "partially_paid", "paid"]),
|
||||
).all()
|
||||
for order in all_unclosed:
|
||||
was_unpaid = order.status in ("open", "partially_paid")
|
||||
order.status = "closed"
|
||||
order.closed_at = now
|
||||
order.closed_by = user.id
|
||||
if was_unpaid:
|
||||
db.add(OrderAuditLog(
|
||||
order_id=order.id,
|
||||
event_type="ORDER_CLOSED",
|
||||
waiter_id=user.id,
|
||||
note="Force-closed at end of business day",
|
||||
))
|
||||
|
||||
active_shifts = db.query(WaiterShift).filter(
|
||||
WaiterShift.business_day_id == day.id,
|
||||
WaiterShift.ended_at == None,
|
||||
).all()
|
||||
for shift in active_shifts:
|
||||
items = db.query(OrderItem).filter(
|
||||
OrderItem.paid_in_shift_id == shift.id,
|
||||
OrderItem.status == "paid",
|
||||
).all()
|
||||
shift.total_collected = sum(i.unit_price * i.quantity for i in items)
|
||||
shift.ended_at = now
|
||||
|
||||
# Clear all table flags and staff messages — fresh slate for the next day
|
||||
db.query(TableFlagAssignment).delete(synchronize_session=False)
|
||||
db.query(StaffMessageAck).delete(synchronize_session=False)
|
||||
db.query(StaffMessage).delete(synchronize_session=False)
|
||||
|
||||
day.status = "closed"
|
||||
day.closed_at = now
|
||||
day.closed_by_id = user.id
|
||||
if body.notes:
|
||||
day.notes = body.notes
|
||||
|
||||
db.commit()
|
||||
db.refresh(day)
|
||||
|
||||
# Deferred lock: if cloud requested a lock while the workday was open,
|
||||
# enforce it now that the day has closed.
|
||||
if license_state.get("lock_pending"):
|
||||
license_state["lock_pending"] = False
|
||||
license_state["locked"] = True
|
||||
from services.cloud_sync import _persist_state
|
||||
_persist_state()
|
||||
|
||||
return day
|
||||
|
||||
|
||||
@router.get("/history")
|
||||
def business_day_history(
|
||||
db: Session = Depends(get_db),
|
||||
user: User = Depends(require_manager),
|
||||
):
|
||||
days = db.query(BusinessDay).order_by(BusinessDay.opened_at.desc()).all()
|
||||
result = []
|
||||
for day in days:
|
||||
order_count = db.query(Order).filter(Order.business_day_id == day.id).count()
|
||||
revenue = (
|
||||
db.query(func.sum(OrderItem.unit_price * OrderItem.quantity))
|
||||
.join(Order)
|
||||
.filter(Order.business_day_id == day.id, OrderItem.status == "paid")
|
||||
.scalar() or 0.0
|
||||
)
|
||||
w_opener = day.opener
|
||||
w_closer = day.closer
|
||||
result.append({
|
||||
"id": day.id,
|
||||
"status": day.status,
|
||||
"opened_at": _dt(day.opened_at),
|
||||
"closed_at": _dt(day.closed_at),
|
||||
"opened_by_id": day.opened_by_id,
|
||||
"opened_by_name": (w_opener.full_name or w_opener.username) if w_opener else None,
|
||||
"closed_by_id": day.closed_by_id,
|
||||
"closed_by_name": (w_closer.full_name or w_closer.username) if w_closer else None,
|
||||
"notes": day.notes,
|
||||
"order_count": order_count,
|
||||
"revenue": round(revenue, 2),
|
||||
})
|
||||
return {"business_days": result}
|
||||
Reference in New Issue
Block a user