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:
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user