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>
This commit is contained in:
@@ -162,6 +162,33 @@ def close_business_day(
|
||||
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),
|
||||
|
||||
@@ -120,14 +120,83 @@ def list_active_orders(db: Session = Depends(get_db), user: User = Depends(get_c
|
||||
]
|
||||
|
||||
|
||||
@router.get("/{order_id}", response_model=OrderOut)
|
||||
@router.get("/{order_id}")
|
||||
def get_order(order_id: int, db: Session = Depends(get_db), user: User = Depends(get_current_user)):
|
||||
order = db.query(Order).filter(Order.id == order_id).first()
|
||||
if not order:
|
||||
raise HTTPException(status_code=404, detail="Order not found")
|
||||
if not _can_access_order(order, user, db):
|
||||
raise HTTPException(status_code=403, detail="Access denied")
|
||||
return order
|
||||
|
||||
# Resolve all user IDs referenced by this order in one query
|
||||
user_ids = set()
|
||||
user_ids.add(order.opened_by)
|
||||
if order.closed_by:
|
||||
user_ids.add(order.closed_by)
|
||||
for item in order.items:
|
||||
user_ids.add(item.added_by)
|
||||
if item.paid_by:
|
||||
user_ids.add(item.paid_by)
|
||||
for log in order.audit_logs:
|
||||
if log.waiter_id:
|
||||
user_ids.add(log.waiter_id)
|
||||
|
||||
users = db.query(User).filter(User.id.in_(user_ids)).all()
|
||||
name_map = {u.id: u.nickname or u.full_name or u.username for u in users}
|
||||
|
||||
def fmt_item(i):
|
||||
return {
|
||||
"id": i.id,
|
||||
"order_id": i.order_id,
|
||||
"product_id": i.product_id,
|
||||
"product": {"id": i.product.id, "name": i.product.name} if i.product else None,
|
||||
"added_by": i.added_by,
|
||||
"added_by_name": name_map.get(i.added_by),
|
||||
"quantity": i.quantity,
|
||||
"unit_price": i.unit_price,
|
||||
"selected_options": i.selected_options,
|
||||
"removed_ingredients": i.removed_ingredients,
|
||||
"notes": i.notes,
|
||||
"status": i.status,
|
||||
"added_at": i.added_at,
|
||||
"printed": i.printed,
|
||||
"paid_by": i.paid_by,
|
||||
"paid_by_name": name_map.get(i.paid_by) if i.paid_by else None,
|
||||
"paid_at": i.paid_at,
|
||||
"payment_method": i.payment_method,
|
||||
"paid_in_shift_id": i.paid_in_shift_id,
|
||||
}
|
||||
|
||||
def fmt_log(l):
|
||||
return {
|
||||
"id": l.id,
|
||||
"order_id": l.order_id,
|
||||
"event_type": l.event_type,
|
||||
"waiter_id": l.waiter_id,
|
||||
"waiter_name": name_map.get(l.waiter_id) if l.waiter_id else None,
|
||||
"item_ids": l.item_ids,
|
||||
"amount": l.amount,
|
||||
"payment_method": l.payment_method,
|
||||
"note": l.note,
|
||||
"created_at": l.created_at,
|
||||
"offline_at": l.offline_at,
|
||||
"is_duplicate": l.is_duplicate,
|
||||
}
|
||||
|
||||
return {
|
||||
"id": order.id,
|
||||
"table_id": order.table_id,
|
||||
"opened_by": order.opened_by,
|
||||
"opened_at": order.opened_at,
|
||||
"status": order.status,
|
||||
"closed_at": order.closed_at,
|
||||
"closed_by": order.closed_by,
|
||||
"notes": order.notes,
|
||||
"business_day_id": order.business_day_id,
|
||||
"items": [fmt_item(i) for i in order.items],
|
||||
"waiters": [{"waiter_id": w.waiter_id} for w in order.waiters],
|
||||
"audit_logs": [fmt_log(l) for l in order.audit_logs],
|
||||
}
|
||||
|
||||
|
||||
@router.post("/", response_model=OrderOut, status_code=status.HTTP_201_CREATED)
|
||||
@@ -310,10 +379,26 @@ def close_order(order_id: int, db: Session = Depends(get_db), user: User = Depen
|
||||
raise HTTPException(status_code=403, detail="Access denied")
|
||||
if order.status not in ("paid", "open", "partially_paid"):
|
||||
raise HTTPException(status_code=400, detail="Cannot close order in current status")
|
||||
|
||||
now = datetime.now(timezone.utc)
|
||||
|
||||
# Mark all still-active items as 'closed' — unpaid, closed by manager
|
||||
active_items = db.query(OrderItem).filter(
|
||||
OrderItem.order_id == order_id,
|
||||
OrderItem.status == "active",
|
||||
).all()
|
||||
closed_item_ids = []
|
||||
for item in active_items:
|
||||
item.status = "closed"
|
||||
closed_item_ids.append(item.id)
|
||||
|
||||
order.status = "closed"
|
||||
order.closed_at = datetime.now(timezone.utc)
|
||||
order.closed_at = now
|
||||
order.closed_by = user.id
|
||||
_audit(db, order_id, "ORDER_CLOSED", waiter_id=user.id)
|
||||
|
||||
note = f"Κλείσιμο από manager — {len(closed_item_ids)} απλήρωτα αντικείμενα" if closed_item_ids else None
|
||||
_audit(db, order_id, "ORDER_CLOSED", waiter_id=user.id,
|
||||
item_ids=closed_item_ids if closed_item_ids else None, note=note)
|
||||
db.commit()
|
||||
broadcast_sync("order_closed", {"order_id": order_id, "table_id": order.table_id})
|
||||
return {"status": "closed"}
|
||||
@@ -419,10 +504,26 @@ def cancel_order(order_id: int, db: Session = Depends(get_db), user: User = Depe
|
||||
order = db.query(Order).filter(Order.id == order_id).first()
|
||||
if not order:
|
||||
raise HTTPException(status_code=404, detail="Order not found")
|
||||
|
||||
now = datetime.now(timezone.utc)
|
||||
|
||||
# Cancel all still-active items
|
||||
active_items = db.query(OrderItem).filter(
|
||||
OrderItem.order_id == order_id,
|
||||
OrderItem.status == "active",
|
||||
).all()
|
||||
cancelled_item_ids = []
|
||||
for item in active_items:
|
||||
item.status = "cancelled"
|
||||
cancelled_item_ids.append(item.id)
|
||||
|
||||
order.status = "cancelled"
|
||||
order.closed_at = datetime.now(timezone.utc)
|
||||
order.closed_at = now
|
||||
order.closed_by = user.id
|
||||
_audit(db, order_id, "ORDER_CANCELLED", waiter_id=user.id)
|
||||
|
||||
note = f"Ακύρωση από manager — {len(cancelled_item_ids)} αντικείμενα ακυρώθηκαν" if cancelled_item_ids else None
|
||||
_audit(db, order_id, "ORDER_CANCELLED", waiter_id=user.id,
|
||||
item_ids=cancelled_item_ids if cancelled_item_ids else None, note=note)
|
||||
db.commit()
|
||||
broadcast_sync("order_closed", {"order_id": order_id, "table_id": order.table_id})
|
||||
|
||||
|
||||
@@ -17,9 +17,14 @@ from schemas.product import (
|
||||
CategoryReparentRequest,
|
||||
)
|
||||
from routers.deps import get_current_user, require_manager
|
||||
from services.sse_bus import broadcast_sync
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
def _broadcast_products_changed():
|
||||
broadcast_sync("products_changed", {})
|
||||
|
||||
IMAGE_DIR = "/app/data/product_images"
|
||||
|
||||
|
||||
@@ -118,6 +123,7 @@ def create_category(body: CategoryCreate, db: Session = Depends(get_db), user: U
|
||||
db.add(cat)
|
||||
db.commit()
|
||||
db.refresh(cat)
|
||||
_broadcast_products_changed()
|
||||
return cat
|
||||
|
||||
|
||||
@@ -128,6 +134,7 @@ def reorder_categories(items: List[CategoryReorderItem], db: Session = Depends(g
|
||||
if cat:
|
||||
cat.sort_order = item.sort_order
|
||||
db.commit()
|
||||
_broadcast_products_changed()
|
||||
|
||||
|
||||
@router.put("/categories/reorder-subcategories", status_code=status.HTTP_204_NO_CONTENT)
|
||||
@@ -138,6 +145,7 @@ def reorder_subcategories(items: List[SubcategoryReorderItem], db: Session = Dep
|
||||
if cat:
|
||||
cat.sort_order = item.sort_order
|
||||
db.commit()
|
||||
_broadcast_products_changed()
|
||||
|
||||
|
||||
@router.put("/categories/reorder-general", status_code=status.HTTP_204_NO_CONTENT)
|
||||
@@ -148,6 +156,7 @@ def reorder_general(items: List[ParentGeneralReorderItem], db: Session = Depends
|
||||
if cat:
|
||||
cat.general_sort_order = item.general_sort_order
|
||||
db.commit()
|
||||
_broadcast_products_changed()
|
||||
|
||||
|
||||
@router.put("/categories/{category_id}/reparent", response_model=CategoryOut)
|
||||
@@ -176,6 +185,7 @@ def reparent_category(category_id: int, body: CategoryReparentRequest, db: Sessi
|
||||
cat.sort_order = sibling_count
|
||||
db.commit()
|
||||
db.refresh(cat)
|
||||
_broadcast_products_changed()
|
||||
return cat
|
||||
|
||||
|
||||
@@ -188,6 +198,7 @@ def update_category(category_id: int, body: CategoryUpdate, db: Session = Depend
|
||||
setattr(cat, field, value)
|
||||
db.commit()
|
||||
db.refresh(cat)
|
||||
_broadcast_products_changed()
|
||||
return cat
|
||||
|
||||
|
||||
@@ -198,6 +209,7 @@ def delete_category(category_id: int, db: Session = Depends(get_db), user: User
|
||||
raise HTTPException(status_code=404, detail="Category not found")
|
||||
db.delete(cat)
|
||||
db.commit()
|
||||
_broadcast_products_changed()
|
||||
|
||||
|
||||
# ── Products ──────────────────────────────────────────────────────────────────
|
||||
@@ -218,6 +230,7 @@ def reorder_products(items: List[ProductReorderItem], db: Session = Depends(get_
|
||||
if product:
|
||||
product.sort_order = item.sort_order
|
||||
db.commit()
|
||||
_broadcast_products_changed()
|
||||
|
||||
|
||||
@router.post("/", response_model=ProductOut, status_code=status.HTTP_201_CREATED)
|
||||
@@ -255,6 +268,7 @@ def create_product(body: ProductCreate, db: Session = Depends(get_db), user: Use
|
||||
_replace_preference_sets(db, product, body.preference_sets)
|
||||
db.commit()
|
||||
db.refresh(product)
|
||||
_broadcast_products_changed()
|
||||
return product
|
||||
|
||||
|
||||
@@ -275,6 +289,7 @@ def update_product(product_id: int, body: ProductUpdate, db: Session = Depends(g
|
||||
_replace_preference_sets(db, product, body.preference_sets)
|
||||
db.commit()
|
||||
db.refresh(product)
|
||||
_broadcast_products_changed()
|
||||
return product
|
||||
|
||||
|
||||
@@ -305,6 +320,7 @@ async def upload_product_image(product_id: int, file: UploadFile = File(...), db
|
||||
product.image_url = f"/static/product_images/{filename}"
|
||||
db.commit()
|
||||
db.refresh(product)
|
||||
_broadcast_products_changed()
|
||||
return product
|
||||
|
||||
|
||||
@@ -329,3 +345,4 @@ def delete_product(product_id: int, hard: bool = False, db: Session = Depends(ge
|
||||
else:
|
||||
db.delete(product)
|
||||
db.commit()
|
||||
_broadcast_products_changed()
|
||||
|
||||
@@ -178,7 +178,7 @@ def shift_orders_summary(
|
||||
return {"from": start.isoformat(), "to": end.isoformat(), "waiters": result}
|
||||
|
||||
|
||||
@router.get("/orders/history", response_model=List[OrderOut])
|
||||
@router.get("/orders/history")
|
||||
def order_history(
|
||||
from_date: Optional[str] = Query(default=None, alias="from"),
|
||||
to_date: Optional[str] = Query(default=None, alias="to"),
|
||||
@@ -191,7 +191,14 @@ def order_history(
|
||||
db: Session = Depends(get_db),
|
||||
user: User = Depends(require_manager),
|
||||
):
|
||||
q = db.query(Order)
|
||||
from sqlalchemy.orm import joinedload
|
||||
from models.table import Table as TableModel
|
||||
|
||||
q = db.query(Order).options(
|
||||
joinedload(Order.items).joinedload(OrderItem.product),
|
||||
joinedload(Order.waiters),
|
||||
joinedload(Order.audit_logs),
|
||||
)
|
||||
if business_day_id:
|
||||
q = q.filter(Order.business_day_id == business_day_id)
|
||||
elif from_date or to_date:
|
||||
@@ -205,7 +212,109 @@ def order_history(
|
||||
q = q.filter(Order.status == order_status)
|
||||
if table_id:
|
||||
q = q.filter(Order.table_id == table_id)
|
||||
return q.order_by(Order.opened_at.desc()).offset((page - 1) * page_size).limit(page_size).all()
|
||||
orders = q.order_by(Order.opened_at.desc()).offset((page - 1) * page_size).limit(page_size).all()
|
||||
|
||||
# Collect all waiter IDs and table IDs to resolve in bulk
|
||||
waiter_ids: set[int] = set()
|
||||
table_ids: set[int] = set()
|
||||
item_waiter_ids: set[int] = set()
|
||||
for o in orders:
|
||||
if o.opened_by:
|
||||
waiter_ids.add(o.opened_by)
|
||||
if o.closed_by:
|
||||
waiter_ids.add(o.closed_by)
|
||||
if o.table_id:
|
||||
table_ids.add(o.table_id)
|
||||
for item in o.items:
|
||||
if item.added_by:
|
||||
item_waiter_ids.add(item.added_by)
|
||||
if item.paid_by:
|
||||
item_waiter_ids.add(item.paid_by)
|
||||
for log in o.audit_logs:
|
||||
if log.waiter_id:
|
||||
waiter_ids.add(log.waiter_id)
|
||||
|
||||
all_waiter_ids = waiter_ids | item_waiter_ids
|
||||
waiters_map: dict[int, str] = {}
|
||||
if all_waiter_ids:
|
||||
for w in db.query(User).filter(User.id.in_(all_waiter_ids)).all():
|
||||
waiters_map[w.id] = w.full_name or w.username
|
||||
|
||||
tables_map: dict[int, str] = {}
|
||||
if table_ids:
|
||||
for t in db.query(TableModel).filter(TableModel.id.in_(table_ids)).all():
|
||||
prefix = (t.group.prefix if t.group and t.group.prefix else "") if t.group else ""
|
||||
tables_map[t.id] = t.label if t.label else f"{prefix}{t.number}"
|
||||
|
||||
def _wname(wid):
|
||||
if wid is None:
|
||||
return None
|
||||
return waiters_map.get(wid, f"#{wid}")
|
||||
|
||||
def _dt_local(dt):
|
||||
if dt is None:
|
||||
return None
|
||||
return (dt.isoformat() + "Z") if dt.tzinfo is None else dt.isoformat()
|
||||
|
||||
result = []
|
||||
for o in orders:
|
||||
items_out = []
|
||||
for item in o.items:
|
||||
items_out.append({
|
||||
"id": item.id,
|
||||
"order_id": item.order_id,
|
||||
"product_id": item.product_id,
|
||||
"product": {"id": item.product.id, "name": item.product.name} if item.product else None,
|
||||
"added_by": item.added_by,
|
||||
"added_by_name": _wname(item.added_by),
|
||||
"added_at": _dt_local(item.added_at),
|
||||
"quantity": item.quantity,
|
||||
"unit_price": float(item.unit_price),
|
||||
"status": item.status,
|
||||
"paid_by": item.paid_by,
|
||||
"paid_by_name": _wname(item.paid_by),
|
||||
"paid_at": _dt_local(item.paid_at),
|
||||
"payment_method": item.payment_method,
|
||||
"paid_in_shift_id": item.paid_in_shift_id,
|
||||
"notes": item.notes,
|
||||
"printed": item.printed,
|
||||
"selected_options": item.selected_options,
|
||||
"removed_ingredients": item.removed_ingredients,
|
||||
})
|
||||
audit_out = []
|
||||
for log in o.audit_logs:
|
||||
audit_out.append({
|
||||
"id": log.id,
|
||||
"order_id": log.order_id,
|
||||
"event_type": log.event_type,
|
||||
"waiter_id": log.waiter_id,
|
||||
"waiter_name": _wname(log.waiter_id),
|
||||
"item_ids": log.item_ids,
|
||||
"amount": log.amount,
|
||||
"payment_method": log.payment_method,
|
||||
"note": log.note,
|
||||
"created_at": _dt_local(log.created_at),
|
||||
"offline_at": log.offline_at,
|
||||
"is_duplicate": log.is_duplicate,
|
||||
})
|
||||
result.append({
|
||||
"id": o.id,
|
||||
"table_id": o.table_id,
|
||||
"table_name": tables_map.get(o.table_id) if o.table_id else None,
|
||||
"opened_by": o.opened_by,
|
||||
"opened_by_name": _wname(o.opened_by),
|
||||
"opened_at": _dt_local(o.opened_at),
|
||||
"closed_by": o.closed_by,
|
||||
"closed_by_name": _wname(o.closed_by),
|
||||
"closed_at": _dt_local(o.closed_at),
|
||||
"status": o.status,
|
||||
"notes": o.notes,
|
||||
"business_day_id": o.business_day_id,
|
||||
"items": items_out,
|
||||
"waiters": [{"waiter_id": w.waiter_id} for w in o.waiters],
|
||||
"audit_logs": audit_out,
|
||||
})
|
||||
return result
|
||||
|
||||
|
||||
@router.get("/tables/summary")
|
||||
@@ -689,9 +798,8 @@ def business_days_list(
|
||||
orders = db.query(Order).filter(Order.business_day_id == d.id).all()
|
||||
closed_orders = [o for o in orders if o.status in ("closed", "paid")]
|
||||
cancelled_orders = [o for o in orders if o.status == "cancelled"]
|
||||
waiter_ids = set()
|
||||
for s in (db.query(WaiterShift).filter(WaiterShift.business_day_id == d.id).all()):
|
||||
waiter_ids.add(s.waiter_id)
|
||||
day_shifts = db.query(WaiterShift).filter(WaiterShift.business_day_id == d.id).all()
|
||||
waiter_ids = {s.waiter_id for s in day_shifts}
|
||||
revenue = sum(
|
||||
sum(i.unit_price * i.quantity for i in o.items if i.status in ("active", "paid"))
|
||||
for o in closed_orders
|
||||
@@ -710,6 +818,7 @@ def business_days_list(
|
||||
"closed_order_count": len(closed_orders),
|
||||
"cancellation_count": len(cancelled_orders),
|
||||
"waiter_count": len(waiter_ids),
|
||||
"shift_count": len(day_shifts),
|
||||
"revenue": round(revenue, 2),
|
||||
})
|
||||
return {"business_days": result}
|
||||
|
||||
@@ -289,22 +289,66 @@ def get_shift(
|
||||
return _enrich_shift(shift, db)
|
||||
|
||||
|
||||
@router.delete("/{shift_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||
def delete_shift(
|
||||
shift_id: int,
|
||||
db: Session = Depends(get_db),
|
||||
user: User = Depends(require_manager),
|
||||
):
|
||||
"""Permanently delete a shift, its breaks, and all orders opened during that shift."""
|
||||
from models.order import Order, OrderItem, OrderAuditLog, OrderWaiter
|
||||
import json as _json
|
||||
|
||||
shift = db.query(WaiterShift).filter(WaiterShift.id == shift_id).first()
|
||||
if not shift:
|
||||
raise HTTPException(status_code=404, detail="Shift not found")
|
||||
if shift.ended_at is None:
|
||||
raise HTTPException(status_code=400, detail="Cannot delete an active shift — end it first")
|
||||
|
||||
# Find all orders opened during this shift's time window by this waiter
|
||||
orders = db.query(Order).filter(
|
||||
Order.opened_by == shift.waiter_id,
|
||||
Order.opened_at >= shift.started_at,
|
||||
Order.opened_at <= shift.ended_at,
|
||||
).all()
|
||||
|
||||
for order in orders:
|
||||
db.query(OrderAuditLog).filter(OrderAuditLog.order_id == order.id).delete(synchronize_session=False)
|
||||
db.query(OrderWaiter).filter(OrderWaiter.order_id == order.id).delete(synchronize_session=False)
|
||||
db.query(OrderItem).filter(OrderItem.order_id == order.id).delete(synchronize_session=False)
|
||||
db.delete(order)
|
||||
|
||||
db.delete(shift)
|
||||
db.commit()
|
||||
|
||||
|
||||
@router.get("/{shift_id}/summary")
|
||||
def get_shift_summary(
|
||||
shift_id: int,
|
||||
db: Session = Depends(get_db),
|
||||
user: User = Depends(require_manager),
|
||||
):
|
||||
"""Full shift summary: enriched shift data + paid items grouped by order."""
|
||||
"""Full shift summary with complete per-item waiter attribution.
|
||||
|
||||
Returns all items that either:
|
||||
- were paid in this shift (paid_in_shift_id == shift_id), OR
|
||||
- were added by this waiter (added_by == waiter_id) and are active/unpaid or paid in another shift.
|
||||
|
||||
Each item includes added_by_name, paid_by_name, timestamps, and order context.
|
||||
Orders include opener_name and closer_name.
|
||||
"""
|
||||
from models.order import Order
|
||||
from models.table import Table
|
||||
from sqlalchemy.orm import joinedload
|
||||
|
||||
shift = db.query(WaiterShift).filter(WaiterShift.id == shift_id).first()
|
||||
if not shift:
|
||||
raise HTTPException(status_code=404, detail="Shift not found")
|
||||
|
||||
from models.table import Table
|
||||
items = db.query(OrderItem).options(
|
||||
waiter_id = shift.waiter_id
|
||||
|
||||
# Collect all relevant items: paid in this shift OR added by this waiter
|
||||
paid_items = db.query(OrderItem).options(
|
||||
joinedload(OrderItem.product),
|
||||
joinedload(OrderItem.order),
|
||||
).filter(
|
||||
@@ -312,48 +356,115 @@ def get_shift_summary(
|
||||
OrderItem.status == "paid",
|
||||
).all()
|
||||
|
||||
# Build table_id -> display name map for all referenced tables
|
||||
table_ids = {item.order.table_id for item in items if item.order and item.order.table_id}
|
||||
ordered_items = db.query(OrderItem).options(
|
||||
joinedload(OrderItem.product),
|
||||
joinedload(OrderItem.order),
|
||||
).filter(
|
||||
OrderItem.added_by == waiter_id,
|
||||
OrderItem.added_at >= shift.started_at,
|
||||
OrderItem.added_at <= shift.ended_at,
|
||||
(OrderItem.paid_in_shift_id != shift_id) | (OrderItem.paid_in_shift_id == None),
|
||||
).all()
|
||||
|
||||
# Deduplicate: union by item id, paid_items takes precedence
|
||||
items_by_id: dict[int, OrderItem] = {}
|
||||
for item in ordered_items:
|
||||
items_by_id[item.id] = item
|
||||
for item in paid_items:
|
||||
items_by_id[item.id] = item
|
||||
all_items = list(items_by_id.values())
|
||||
|
||||
# Build lookup maps
|
||||
all_waiter_ids = set()
|
||||
for item in all_items:
|
||||
all_waiter_ids.add(item.added_by)
|
||||
if item.paid_by:
|
||||
all_waiter_ids.add(item.paid_by)
|
||||
all_order_ids = {item.order_id for item in all_items}
|
||||
|
||||
# Load orders with opener/closer
|
||||
orders_db: dict[int, Order] = {}
|
||||
if all_order_ids:
|
||||
for o in db.query(Order).filter(Order.id.in_(all_order_ids)).all():
|
||||
orders_db[o.id] = o
|
||||
if o.opened_by:
|
||||
all_waiter_ids.add(o.opened_by)
|
||||
if o.closed_by:
|
||||
all_waiter_ids.add(o.closed_by)
|
||||
|
||||
# Load table names
|
||||
table_ids = {o.table_id for o in orders_db.values() if o.table_id}
|
||||
tables_map: dict[int, str] = {}
|
||||
if table_ids:
|
||||
tbl_rows = db.query(Table).filter(Table.id.in_(table_ids)).all()
|
||||
for t in tbl_rows:
|
||||
for t in db.query(Table).filter(Table.id.in_(table_ids)).all():
|
||||
prefix = (t.group.prefix if t.group and t.group.prefix else "") if t.group else ""
|
||||
tables_map[t.id] = t.label if t.label else f"{prefix}{t.number}"
|
||||
|
||||
orders_seen = {}
|
||||
for item in items:
|
||||
# Load waiter names
|
||||
waiters_map: dict[int, str] = {}
|
||||
if all_waiter_ids:
|
||||
for w in db.query(User).filter(User.id.in_(all_waiter_ids)).all():
|
||||
waiters_map[w.id] = w.full_name or w.username
|
||||
|
||||
def _wname(wid):
|
||||
if wid is None:
|
||||
return None
|
||||
return waiters_map.get(wid, f"#{wid}")
|
||||
|
||||
# Build orders dict
|
||||
orders_out: dict[int, dict] = {}
|
||||
for oid, o in orders_db.items():
|
||||
tid = o.table_id
|
||||
orders_out[oid] = {
|
||||
"order_id": oid,
|
||||
"table_id": tid,
|
||||
"table_name": tables_map.get(tid) if tid else None,
|
||||
"opened_at": _dt(o.opened_at),
|
||||
"closed_at": _dt(o.closed_at),
|
||||
"opener_name": _wname(o.opened_by),
|
||||
"closer_name": _wname(o.closed_by),
|
||||
"status": o.status,
|
||||
"items": [],
|
||||
}
|
||||
|
||||
# Attach items to their orders
|
||||
for item in all_items:
|
||||
oid = item.order_id
|
||||
if oid not in orders_seen:
|
||||
o = item.order
|
||||
tid = o.table_id if o else None
|
||||
orders_seen[oid] = {
|
||||
"order_id": oid,
|
||||
"table_id": tid,
|
||||
"table_name": tables_map.get(tid) if tid else None,
|
||||
"opened_at": _dt(o.opened_at) if o else None,
|
||||
"items": [],
|
||||
}
|
||||
orders_seen[oid]["items"].append({
|
||||
if oid not in orders_out:
|
||||
continue
|
||||
orders_out[oid]["items"].append({
|
||||
"id": item.id,
|
||||
"product_name": item.product.name if item.product else f"#{item.product_id}",
|
||||
"quantity": item.quantity,
|
||||
"unit_price": float(item.unit_price),
|
||||
"subtotal": round(float(item.unit_price) * item.quantity, 2),
|
||||
"status": item.status,
|
||||
"added_by_id": item.added_by,
|
||||
"added_by_name": _wname(item.added_by),
|
||||
"added_at": _dt(item.added_at),
|
||||
"paid_by_id": item.paid_by,
|
||||
"paid_by_name": _wname(item.paid_by),
|
||||
"paid_at": _dt(item.paid_at),
|
||||
"paid_in_shift_id": item.paid_in_shift_id,
|
||||
"payment_method": item.payment_method,
|
||||
})
|
||||
|
||||
# Compute hours worked
|
||||
# Remove orders with no items (shouldn't happen but safety net)
|
||||
populated_orders = [o for o in orders_out.values() if o["items"]]
|
||||
|
||||
# Compute duration
|
||||
started = shift.started_at
|
||||
ended = shift.ended_at
|
||||
duration_minutes = None
|
||||
if started and ended:
|
||||
duration_minutes = int((ended - started).total_seconds() / 60)
|
||||
elif started:
|
||||
from datetime import datetime, timezone as tz
|
||||
duration_minutes = int((datetime.now(tz.utc) - started.replace(tzinfo=tz.utc) if started.tzinfo is None else datetime.now(tz.utc) - started).total_seconds() / 60)
|
||||
from datetime import timezone as tz
|
||||
now = datetime.now(tz.utc)
|
||||
s = started if started.tzinfo else started.replace(tzinfo=tz.utc)
|
||||
duration_minutes = int((now - s).total_seconds() / 60)
|
||||
|
||||
enriched = _enrich_shift(shift, db)
|
||||
enriched["orders"] = list(orders_seen.values())
|
||||
enriched["orders"] = populated_orders
|
||||
enriched["duration_minutes"] = duration_minutes
|
||||
return enriched
|
||||
|
||||
@@ -76,7 +76,12 @@ def _compute_expiry_fields(expires_at_str: str | None) -> dict:
|
||||
|
||||
|
||||
def _get_local_ip() -> str | None:
|
||||
"""Best-effort detection of the machine's LAN IP address."""
|
||||
"""Best-effort detection of the host machine's LAN IP address.
|
||||
When running inside Docker the socket trick returns the container/bridge IP,
|
||||
so we honour HOST_IP if it is explicitly provided via the environment."""
|
||||
import os
|
||||
if host_ip := os.environ.get("HOST_IP", "").strip():
|
||||
return host_ip
|
||||
try:
|
||||
with socket.socket(socket.AF_INET, socket.SOCK_DGRAM) as s:
|
||||
s.connect(("8.8.8.8", 80))
|
||||
|
||||
Reference in New Issue
Block a user