Files
xenia-pos-local/local_backend/routers/business_day.py
bonamin 184bee5942 feat: Phase 2K — business day close summary (expanded)
Backend — /api/business-day/summary now returns full financial summary:
- COGS: trackable cost (sum of unit_cost snapshots on paid items), uncosted item count
  and revenue, gross_profit, cogs_has_gap flag
- Labor: total_labor_cost (sum of shift_pay for the day), labor_untracked_shifts count
- Waste: waste_item_count + waste_estimated_cost from waste_log for this business day
- Expenses: expenses_total, expenses_paid_today, expenses_due for expenses logged today
- Tabbed revenue: revenue from items with status='tabbed' (deferred, not yet collected)
- Net estimate: revenue - COGS - labor - waste - expenses_paid; net_has_unknowns flag
  when any component has gaps (uncosted items or untracked shifts)
- compute_shift_pay import hoisted out of loop (bug fix)

Frontend — WorkdaySummaryModal OverviewTab fully expanded:
- New FinancialRow helper for consistent two-column P&L rows with accent/warn/indent
- REVENUE section: total sales, tabbed deduction, net collected
- COGS section: trackable cost, uncosted items warning, gross profit with gap indicator
- LABOR section: tracked labor cost, untracked shifts warning
- WASTE section: item count + estimated cost (hidden when zero)
- EXPENSES section: total, paid today, still due (hidden when zero)
- NET ESTIMATE: prominent bottom line, amber warning when unknowns exist
- Payment breakdown bar (existing)
- Cash reconciliation summary when store cash was counted

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-08 03:40:12 +03:00

553 lines
22 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, 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
# Phase 2E: store-level cash reconciliation
if body.store_closing_cash is not None:
day.store_closing_cash = body.store_closing_cash
# expected = store_opening_cash + sum of all waiter collected amounts this day
all_shifts = db.query(WaiterShift).filter(WaiterShift.business_day_id == day.id).all()
total_collected = sum(s.total_collected or 0.0 for s in all_shifts)
expected_closing = (day.store_opening_cash or 0.0) + total_collected
day.store_cash_discrepancy = round(body.store_closing_cash - expected_closing, 2)
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
from routers.shifts import compute_shift_pay
for shift in shifts:
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)
# Phase 2E: store-level cash totals for summary view
total_waiter_collected = sum(s.total_collected or 0.0 for s in shifts)
store_expected_closing = (day.store_opening_cash or 0.0) + total_waiter_collected
# ── Phase 2K: full financial summary ──────────────<E29480><E29480>─────────────────────
# COGS from Phase 2A cost snapshots on paid items
from models.waste import WasteLog
from models.expenses import Expense
from models.tabs import Tab, TabEntry
cogs_trackable = 0.0
cogs_uncosted_revenue = 0.0
cogs_uncosted_count = 0
for item in paid_items:
rev = item.unit_price * item.quantity
if item.unit_cost is not None:
cogs_trackable += item.unit_cost * item.quantity
else:
cogs_uncosted_revenue += rev
cogs_uncosted_count += 1
gross_profit = round(total_revenue - cogs_trackable, 2)
cogs_has_gap = cogs_uncosted_count > 0
# LABOR from shifts
total_labor_cost = 0.0
labor_untracked_shifts = 0
for shift in shifts:
pay_data = compute_shift_pay(shift)
if pay_data["shift_pay"] is not None:
total_labor_cost += pay_data["shift_pay"]
else:
labor_untracked_shifts += 1
# WASTE from Phase 2H
waste_entries = db.query(WasteLog).filter(WasteLog.business_day_id == day.id).all()
waste_item_count = len(waste_entries)
waste_estimated_cost = round(sum(w.total_cost for w in waste_entries if w.total_cost is not None), 2)
# EXPENSES logged today
expenses_today = db.query(Expense).filter(Expense.business_day_id == day.id).all()
expenses_total = round(sum(e.total_amount for e in expenses_today), 2)
expenses_paid_today = round(sum(e.paid_amount for e in expenses_today), 2)
expenses_due = round(expenses_total - expenses_paid_today, 2)
# TABBED REVENUE (items moved to tabs — not yet collected)
tabbed_items = [i for i in all_items if i.status == "tabbed"]
tabbed_revenue = round(sum(i.unit_price * i.quantity for i in tabbed_items), 2)
# NET estimate (revenue - COGS - labor - waste - expenses paid)
net_estimate = round(
total_revenue
- cogs_trackable
- total_labor_cost
- waste_estimated_cost
- expenses_paid_today,
2
)
net_has_unknowns = cogs_has_gap or labor_untracked_shifts > 0
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,
},
# Phase 2E — cash reconciliation
"store_opening_cash": day.store_opening_cash,
"store_closing_cash": day.store_closing_cash,
"store_cash_discrepancy": day.store_cash_discrepancy,
"store_expected_closing": round(store_expected_closing, 2),
"total_waiter_collected": round(total_waiter_collected, 2),
# Phase 2K — full financial summary
"cogs_trackable": round(cogs_trackable, 2),
"cogs_uncosted_revenue": round(cogs_uncosted_revenue, 2),
"cogs_uncosted_count": cogs_uncosted_count,
"cogs_has_gap": cogs_has_gap,
"gross_profit": gross_profit,
"total_labor_cost": round(total_labor_cost, 2),
"labor_untracked_shifts": labor_untracked_shifts,
"waste_item_count": waste_item_count,
"waste_estimated_cost": waste_estimated_cost,
"expenses_total": expenses_total,
"expenses_paid_today": expenses_paid_today,
"expenses_due": expenses_due,
"tabbed_revenue": tabbed_revenue,
"net_estimate": net_estimate,
"net_has_unknowns": net_has_unknowns,
}
@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}