feat: reports cost tracking, staff hourly rate, product form refactor

- Backend reports: fix cost/profit calculation to handle null unit_cost
  with has_gap flag; expose total_cost in product & workday summary
- StaffTab: add hourly_rate field in payroll section (admin-only)
- ProductFormModal: major refactor of form layout and structure
- ProductsTab: minor tweaks aligned with form changes
- Report pages (Today, WorkDaySummary, CategoryPerformance,
  ProductPerformance, RevenueTrends): UI improvements and cost data

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-06-08 09:58:55 +03:00
parent 34b58b3f2e
commit 5da378a0ae
10 changed files with 673 additions and 330 deletions

View File

@@ -948,6 +948,7 @@ def product_performance(
"category_id": product.category_id if product else None,
"qty_sold": 0,
"revenue": 0.0,
"total_cost": 0.0,
"trackable_profit": 0.0,
"uncosted_revenue": 0.0,
"uncosted_items": 0,
@@ -959,7 +960,9 @@ def product_performance(
summary[pid]["revenue"] += revenue
summary[pid]["order_ids"].add(item.order_id)
if item.unit_cost is not None:
summary[pid]["trackable_profit"] += revenue - (item.unit_cost * qty)
cost = item.unit_cost * qty
summary[pid]["total_cost"] += cost
summary[pid]["trackable_profit"] += revenue - cost
else:
summary[pid]["uncosted_revenue"] += revenue
summary[pid]["uncosted_items"] += qty
@@ -974,7 +977,7 @@ def product_performance(
"product_id": pid,
"product_name": product.name if product else f"#{pid}",
"category_id": product.category_id if product else None,
"qty_sold": 0, "revenue": 0.0, "trackable_profit": 0.0,
"qty_sold": 0, "revenue": 0.0, "total_cost": 0.0, "trackable_profit": 0.0,
"uncosted_revenue": 0.0, "uncosted_items": 0, "order_ids": set(),
}
@@ -984,6 +987,7 @@ def product_performance(
wdata = waste_by_product.get(pid, {"waste_qty": 0.0, "waste_cost": 0.0})
entry["order_count"] = len(entry.pop("order_ids"))
entry["revenue"] = round(entry["revenue"], 2)
entry["total_cost"] = round(entry["total_cost"], 2)
entry["trackable_profit"] = round(entry["trackable_profit"], 2)
entry["uncosted_revenue"] = round(entry["uncosted_revenue"], 2)
entry["has_gap"] = entry["uncosted_items"] > 0
@@ -1123,10 +1127,22 @@ def business_days_list(
cancelled_orders = [o for o in orders if o.status == "cancelled"]
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
)
revenue = 0.0
total_cost = 0.0
trackable_profit = 0.0
has_gap = False
for o in closed_orders:
for i in o.items:
if i.status not in ("active", "paid"):
continue
rev = i.unit_price * i.quantity
revenue += rev
if i.unit_cost is not None:
cost = i.unit_cost * i.quantity
total_cost += cost
trackable_profit += rev - cost
else:
has_gap = True
opener = waiters_db.get(d.opened_by_id)
closer = waiters_db.get(d.closed_by_id) if d.closed_by_id else None
result.append({
@@ -1143,6 +1159,9 @@ def business_days_list(
"waiter_count": len(waiter_ids),
"shift_count": len(day_shifts),
"revenue": round(revenue, 2),
"total_cost": round(total_cost, 2),
"trackable_profit": round(trackable_profit, 2),
"has_gap": has_gap,
})
return {"business_days": result}
@@ -1164,10 +1183,22 @@ def current_business_day(
WaiterShift.business_day_id == day.id,
WaiterShift.ended_at == None,
).all()
revenue = sum(
sum(i.unit_price * i.quantity for i in o.items if i.status in ("active", "paid"))
for o in closed_orders
)
revenue = 0.0
total_cost = 0.0
trackable_profit = 0.0
has_gap = False
for o in closed_orders:
for i in o.items:
if i.status not in ("active", "paid"):
continue
rev = i.unit_price * i.quantity
revenue += rev
if i.unit_cost is not None:
cost = i.unit_cost * i.quantity
total_cost += cost
trackable_profit += rev - cost
else:
has_gap = True
item_counts: dict = {}
for o in orders:
@@ -1189,6 +1220,9 @@ def current_business_day(
"id": day.id,
"opened_at": _dt(day.opened_at),
"revenue": round(revenue, 2),
"total_cost": round(total_cost, 2),
"trackable_profit": round(trackable_profit, 2),
"has_gap": has_gap,
"orders_closed": len(closed_orders),
"orders_open": len(open_orders),
"active_waiters": len(active_shifts),
@@ -1229,14 +1263,22 @@ def revenue_trends(
key = dt.strftime("%Y-%m-%d")
if key not in buckets:
buckets[key] = {"date": key, "revenue": 0.0, "orders": 0}
rev = sum(i.unit_price * i.quantity for i in order.items if i.status in ("active", "paid"))
buckets[key]["revenue"] += rev
buckets[key] = {"date": key, "revenue": 0.0, "profit": 0.0, "has_gap": False, "orders": 0}
for i in order.items:
if i.status not in ("active", "paid"):
continue
rev = i.unit_price * i.quantity
buckets[key]["revenue"] += rev
if i.unit_cost is not None:
buckets[key]["profit"] += rev - i.unit_cost * i.quantity
else:
buckets[key]["has_gap"] = True
buckets[key]["orders"] += 1
arr = sorted(buckets.values(), key=lambda x: x["date"])
for d in arr:
d["revenue"] = round(d["revenue"], 2)
d["profit"] = round(d["profit"], 2)
if granularity == "daily":
for i, d in enumerate(arr):
@@ -1286,18 +1328,37 @@ def category_performance(
"color": cat.color if cat and hasattr(cat, "color") else None,
"units_sold": 0,
"revenue": 0.0,
"total_cost": 0.0,
"trackable_profit": 0.0,
"uncosted_revenue": 0.0,
"has_gap": False,
"product_ids": set(),
}
summary[cid]["units_sold"] += item.quantity
summary[cid]["revenue"] += item.unit_price * item.quantity
qty = item.quantity
rev = item.unit_price * qty
summary[cid]["units_sold"] += qty
summary[cid]["revenue"] += rev
summary[cid]["product_ids"].add(item.product_id)
if item.unit_cost is not None:
cost = item.unit_cost * qty
summary[cid]["total_cost"] += cost
summary[cid]["trackable_profit"] += rev - cost
else:
summary[cid]["uncosted_revenue"] += rev
summary[cid]["has_gap"] = True
total_rev = sum(v["revenue"] for v in summary.values())
total_profit = sum(v["trackable_profit"] for v in summary.values())
result = []
for entry in summary.values():
entry["product_count"] = len(entry.pop("product_ids"))
entry["revenue"] = round(entry["revenue"], 2)
entry["pct"] = round((entry["revenue"] / total_rev * 100) if total_rev else 0, 1)
entry["total_cost"] = round(entry["total_cost"], 2)
entry["trackable_profit"] = round(entry["trackable_profit"], 2)
entry["uncosted_revenue"] = round(entry["uncosted_revenue"], 2)
entry["pct_rev"] = round((entry["revenue"] / total_rev * 100) if total_rev else 0, 1)
entry["pct_profit"] = round((entry["trackable_profit"] / total_profit * 100) if total_profit > 0 else 0, 1)
entry["pct"] = entry["pct_rev"]
result.append(entry)
result.sort(key=lambda x: x["revenue"], reverse=True)