- 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>
223 lines
8.0 KiB
Python
223 lines
8.0 KiB
Python
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}
|