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>
553 lines
22 KiB
Python
553 lines
22 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, 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}
|