feat: Phase 2H — void/waste log

Backend:
- New model: WasteLog (waste_log table) — product, quantity (float), reason, reason_notes,
  unit_cost_snapshot (copied at log time from product's effective cost), total_cost (stored),
  logged_by, business_day_id
- Cost snapshot: same logic as Phase 2A — breakdown sum first, fallback to cost_simple, null if neither
- New router /api/waste/:
  - GET / — list entries (?business_day_id= or ?from=&to=), ordered by logged_at desc
  - POST / — log waste; snapshots cost; attaches to open business day automatically
  - DELETE /{id} — manager only; only allowed within the same open business day
  - GET /summary — totals by reason + by product for a period
- product_performance report: waste_qty + waste_cost added per product from the same period;
  products with only waste (no sales) also included
- Migration: CREATE TABLE IF NOT EXISTS waste_log

Frontend:
- WastePage (/waste): fast log form (product picker, quantity, reason chips, notes);
  today's entries list below (scoped to active business day); cost shown per entry;
  delete button for today's entries; history section behind toggle with date-range picker
- ProductPerformance report: Απόβλητα column showing waste_qty + cost in amber
- Sidebar: Trash2 icon for Αποβλήτα (after Καρτέλες)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-06-08 03:19:33 +03:00
parent 2ff48730f7
commit 9cfbde0e3e
9 changed files with 607 additions and 1 deletions

View File

@@ -904,6 +904,7 @@ def product_performance(
user: User = Depends(require_manager),
):
from models.product import Product
from models.waste import WasteLog
q = db.query(OrderItem).filter(OrderItem.status.in_(["active", "paid"]))
if from_dt:
@@ -916,6 +917,24 @@ def product_performance(
items = q.all()
products_db = {p.id: p for p in db.query(Product).all()}
# Phase 2H: load waste for the same period
wq = db.query(WasteLog)
if business_day_id:
wq = wq.filter(WasteLog.business_day_id == business_day_id)
elif from_dt and to_dt:
wq = wq.filter(
WasteLog.logged_at >= datetime.fromisoformat(from_dt),
WasteLog.logged_at <= datetime.fromisoformat(to_dt),
)
waste_entries = wq.all()
waste_by_product: dict = {}
for w in waste_entries:
pid = w.product_id
if pid not in waste_by_product:
waste_by_product[pid] = {"waste_qty": 0.0, "waste_cost": 0.0}
waste_by_product[pid]["waste_qty"] += w.quantity
waste_by_product[pid]["waste_cost"] += w.total_cost or 0.0
summary: dict = {}
for item in items:
pid = item.product_id
@@ -945,13 +964,31 @@ def product_performance(
summary[pid]["uncosted_revenue"] += revenue
summary[pid]["uncosted_items"] += qty
# Also include products that only have waste (not sold) in the period
for pid, wdata in waste_by_product.items():
if pid not in summary:
product = products_db.get(pid)
if category_id and (not product or product.category_id != category_id):
continue
summary[pid] = {
"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,
"uncosted_revenue": 0.0, "uncosted_items": 0, "order_ids": set(),
}
result = []
for entry in summary.values():
pid = entry["product_id"]
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["trackable_profit"] = round(entry["trackable_profit"], 2)
entry["uncosted_revenue"] = round(entry["uncosted_revenue"], 2)
entry["has_gap"] = entry["uncosted_items"] > 0
entry["waste_qty"] = round(wdata["waste_qty"], 3)
entry["waste_cost"] = round(wdata["waste_cost"], 2)
result.append(entry)
result.sort(key=lambda x: x["qty_sold"], reverse=True)