Files
xenia-pos-local/local_backend/routers/business_day.py
bonamin 5de89a722c feat: major dashboard & waiter PWA overhaul
- Manager dashboard: replaced monolithic DashboardTab/OperationsPage with new
  DashboardPage; added OrderDetailModal, ShiftDetailModal, DeleteConfirmModal,
  PaymentMethodModal; updated Sidebar routing and App navigation
- Reports: reworked WorkDaySummary, OrderHistory, ShiftsOverview with detail modals
- Backend routers: extended orders, reports, shifts, products, business_day endpoints;
  updated cloud_sync service
- Waiter PWA: refreshed app icons, improved ConnectionLostModal UX, updated
  TableCard, SSEContext, connectionStore; added useProductCache hook; vite config tweaks

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-21 15:24:54 +03:00

223 lines
8.0 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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.delete("/{day_id}", status_code=status.HTTP_204_NO_CONTENT)
def delete_business_day(
day_id: int,
db: Session = Depends(get_db),
user: User = Depends(require_manager),
):
"""Permanently delete a business day. Only allowed when it has no shifts."""
day = db.query(BusinessDay).filter(BusinessDay.id == day_id).first()
if not day:
raise HTTPException(status_code=404, detail="Business day not found")
if day.status == "open":
raise HTTPException(status_code=400, detail="Cannot delete an open business day — close it first")
shift_count = db.query(WaiterShift).filter(WaiterShift.business_day_id == day_id).count()
if shift_count > 0:
raise HTTPException(
status_code=409,
detail=f"Δεν είναι δυνατή η διαγραφή: η εργάσιμη μέρα έχει {shift_count} βάρδια/ες. Διαγράψτε πρώτα όλες τις βάρδιες.",
)
db.query(Order).filter(Order.business_day_id == day_id).update(
{"business_day_id": None}, synchronize_session=False
)
db.delete(day)
db.commit()
@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}