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, PrintLog from models.shift import WaiterShift from models.flag import TableFlagAssignment from models.message import StaffMessage, StaffMessageAck from models.table import Table, TableGroup from models.printer import Printer 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("/summary") def business_day_summary( day_id: Optional[int] = None, db: Session = Depends(get_db), user: User = Depends(require_manager), ): """Full summary for a business day: per-waiter, per-zone, per-printer, and checks. If day_id is omitted, uses the currently open business day.""" if day_id: day = db.query(BusinessDay).filter(BusinessDay.id == day_id).first() if not day: raise HTTPException(status_code=404, detail="Business day not found") else: day = db.query(BusinessDay).filter(BusinessDay.status == "open").first() if not day: raise HTTPException(status_code=404, detail="No open business day") orders = db.query(Order).filter(Order.business_day_id == day.id).all() order_ids = [o.id for o in orders] all_items = db.query(OrderItem).filter(OrderItem.order_id.in_(order_ids)).all() if order_ids else [] paid_items = [i for i in all_items if i.status == "paid"] active_items = [i for i in all_items if i.status == "active"] shifts = db.query(WaiterShift).filter(WaiterShift.business_day_id == day.id).all() users_map = {u.id: u for u in db.query(User).all()} tables_map = {t.id: t for t in db.query(Table).all()} groups_map = {g.id: g for g in db.query(TableGroup).all()} printers_map = {p.id: p for p in db.query(Printer).all()} # ── Per-waiter summary ────────────────────────────────────────────────── waiter_stats: dict = {} def _waiter_entry(uid): if uid not in waiter_stats: u = users_map.get(uid) name = (u.full_name or u.nickname or u.username) if u else f"#{uid}" role = u.role if u else "waiter" waiter_stats[uid] = { "waiter_id": uid, "waiter_name": name, "role": role, "orders_opened": 0, "items_ordered": 0, "total_items_value": 0.0, "items_paid": 0, "total_collected": 0.0, "cash": 0.0, "card": 0.0, "transfer": 0.0, "treat": 0.0, "other": 0.0, "shift_started_at": None, "shift_ended_at": None, "starting_cash": None, # Phase 2B payroll "hourly_rate_snapshot": None, "duration_hours": None, "shift_pay": None, # distinct tables served (not order count) "tables_served": 0, } return waiter_stats[uid] # track unique tables per waiter (opened_by) waiter_tables: dict = {} # uid → set of table_ids for o in orders: if o.status == "cancelled": continue e = _waiter_entry(o.opened_by) e["orders_opened"] += 1 if o.table_id: waiter_tables.setdefault(o.opened_by, set()).add(o.table_id) for item in all_items: if item.status == "cancelled": continue e = _waiter_entry(item.added_by) e["items_ordered"] += item.quantity e["total_items_value"] += item.unit_price * item.quantity for item in paid_items: if item.paid_by: e = _waiter_entry(item.paid_by) val = item.unit_price * item.quantity e["items_paid"] += item.quantity e["total_collected"] += val method = (item.payment_method or "cash").lower() if method == "cash": e["cash"] += val elif method == "card": e["card"] += val elif method == "transfer": e["transfer"] += val elif method in ("treat", "comp"): e["treat"] += val else: e["other"] += val for shift in shifts: from routers.shifts import compute_shift_pay e = _waiter_entry(shift.waiter_id) if e["shift_started_at"] is None or (shift.started_at and _dt(shift.started_at) < e["shift_started_at"]): e["shift_started_at"] = _dt(shift.started_at) if shift.starting_cash is not None: e["starting_cash"] = shift.starting_cash if shift.ended_at: ended = _dt(shift.ended_at) if e["shift_ended_at"] is None or ended > e["shift_ended_at"]: e["shift_ended_at"] = ended # payroll — take the most recent non-null snapshot if shift.hourly_rate_snapshot is not None: e["hourly_rate_snapshot"] = shift.hourly_rate_snapshot pay_data = compute_shift_pay(shift) e["duration_hours"] = round((e["duration_hours"] or 0) + pay_data["duration_hours"], 2) if pay_data["shift_pay"] is not None: e["shift_pay"] = round((e["shift_pay"] or 0) + pay_data["shift_pay"], 2) # write tables_served counts for uid, table_set in waiter_tables.items(): if uid in waiter_stats: waiter_stats[uid]["tables_served"] = len(table_set) # manager gets shift = day open/close manager_opener = users_map.get(day.opened_by_id) if manager_opener: e = _waiter_entry(day.opened_by_id) if e["shift_started_at"] is None: e["shift_started_at"] = _dt(day.opened_at) if e["shift_ended_at"] is None and day.closed_at: e["shift_ended_at"] = _dt(day.closed_at) # ── Per-zone summary ──────────────────────────────────────────────────── zone_stats: dict = {} # group_id → stats def _zone_entry(gid, gname, gcolor): if gid not in zone_stats: zone_stats[gid] = { "group_id": gid, "group_name": gname, "color": gcolor, "orders": 0, "items": 0, "revenue": 0.0, } return zone_stats[gid] for o in orders: if o.status == "cancelled" or not o.table_id: continue table = tables_map.get(o.table_id) group = groups_map.get(table.group_id) if table and table.group_id else None gid = group.id if group else 0 gname = group.name if group else "Χωρίς Ζώνη" gcolor = group.color if group else "#9ca3af" entry = _zone_entry(gid, gname, gcolor) entry["orders"] += 1 for item in o.items: if item.status == "cancelled": continue entry["items"] += item.quantity if item.status == "paid": entry["revenue"] += item.unit_price * item.quantity # ── Per-printer summary ───────────────────────────────────────────────── # Build item_id → value lookup for revenue-per-printer calculation items_value_map = {i.id: i.unit_price * i.quantity for i in all_items if i.status != "cancelled"} printer_stats: dict = {} if order_ids: logs = db.query(PrintLog).filter(PrintLog.order_id.in_(order_ids)).all() for log in logs: pid = log.printer_id if pid not in printer_stats: pr = printers_map.get(pid) printer_stats[pid] = { "printer_id": pid, "printer_name": pr.name if pr else f"#{pid}", "ip_address": pr.ip_address if pr else "", "jobs": 0, "success": 0, "failed": 0, "items_value": 0.0, } printer_stats[pid]["jobs"] += 1 if log.success: printer_stats[pid]["success"] += 1 try: import json as _json item_ids = _json.loads(log.item_ids) if log.item_ids else [] for iid in item_ids: printer_stats[pid]["items_value"] += items_value_map.get(iid, 0.0) except Exception: pass else: printer_stats[pid]["failed"] += 1 for pid in printer_stats: printer_stats[pid]["items_value"] = round(printer_stats[pid]["items_value"], 2) # ── Checks ────────────────────────────────────────────────────────────── open_orders = [o for o in orders if o.status in ("open", "partially_paid")] with_unpaid_items = sum( 1 for o in open_orders if any(i.status == "active" for i in o.items) ) active_shift_count = sum(1 for s in shifts if s.ended_at is None) total_revenue = sum(i.unit_price * i.quantity for i in paid_items) total_items_ordered = sum(i.quantity for i in all_items if i.status != "cancelled") total_orders = sum(1 for o in orders if o.status != "cancelled") # Round float fields in waiter stats for w in waiter_stats.values(): for key in ("total_collected", "total_items_value", "cash", "card", "transfer", "treat", "other"): w[key] = round(w[key], 2) return { "day_id": day.id, "status": day.status, "opened_at": _dt(day.opened_at), "closed_at": _dt(day.closed_at), "total_revenue": round(total_revenue, 2), "total_orders": total_orders, "total_items_ordered": total_items_ordered, "waiters": sorted(waiter_stats.values(), key=lambda x: -x["total_collected"]), "zones": sorted(zone_stats.values(), key=lambda x: -x["revenue"]), "printers": list(printer_stats.values()), "checks": { "open_orders": len(open_orders), "with_unpaid_items": with_unpaid_items, "active_shifts": active_shift_count, }, } @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}