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)

View File

@@ -0,0 +1,182 @@
import json
from fastapi import APIRouter, Depends, HTTPException, status, Query
from sqlalchemy.orm import Session
from typing import Optional
from database import get_db
from models.waste import WasteLog
from models.product import Product
from models.business_day import BusinessDay
from models.user import User
from schemas.waste import WasteLogCreate, WasteLogOut, VALID_REASONS, REASON_LABELS
from routers.deps import require_manager
router = APIRouter()
def _effective_cost(product: Product) -> Optional[float]:
"""Return product's current effective cost (breakdown sum or simple), or None."""
if product.cost_breakdown:
try:
entries = json.loads(product.cost_breakdown)
total = sum(e.get("amount", 0.0) for e in entries if isinstance(e, dict))
if total > 0:
return total
except Exception:
pass
if product.cost_simple:
return product.cost_simple
return None
def _waste_out(w: WasteLog) -> dict:
product_name = w.product.name if w.product else f"#{w.product_id}"
logger_name = None
if w.logged_by:
logger_name = w.logged_by.full_name or w.logged_by.username
return {
"id": w.id,
"product_id": w.product_id,
"product_name": product_name,
"quantity": w.quantity,
"unit_cost_snapshot": w.unit_cost_snapshot,
"total_cost": w.total_cost,
"reason": w.reason,
"reason_label": REASON_LABELS.get(w.reason, w.reason),
"reason_notes": w.reason_notes,
"logged_by_id": w.logged_by_id,
"logged_by_name": logger_name,
"business_day_id": w.business_day_id,
"logged_at": w.logged_at,
}
@router.get("/")
def list_waste(
business_day_id: Optional[int] = None,
from_dt: Optional[str] = Query(default=None, alias="from"),
to_dt: Optional[str] = Query(default=None, alias="to"),
db: Session = Depends(get_db),
user: User = Depends(require_manager),
):
from datetime import datetime
q = db.query(WasteLog).order_by(WasteLog.logged_at.desc())
if business_day_id:
q = q.filter(WasteLog.business_day_id == business_day_id)
elif from_dt and to_dt:
q = q.filter(
WasteLog.logged_at >= datetime.fromisoformat(from_dt),
WasteLog.logged_at <= datetime.fromisoformat(to_dt),
)
entries = q.all()
return {"entries": [_waste_out(w) for w in entries]}
@router.post("/", status_code=status.HTTP_201_CREATED)
def log_waste(
body: WasteLogCreate,
db: Session = Depends(get_db),
user: User = Depends(require_manager),
):
if body.reason not in VALID_REASONS:
raise HTTPException(status_code=400, detail=f"Invalid reason. Valid: {sorted(VALID_REASONS)}")
if body.quantity <= 0:
raise HTTPException(status_code=400, detail="Quantity must be positive")
product = db.query(Product).filter(Product.id == body.product_id).first()
if not product:
raise HTTPException(status_code=404, detail="Product not found")
# Snapshot current effective cost
unit_cost = _effective_cost(product)
total_cost = round(unit_cost * body.quantity, 2) if unit_cost is not None else None
# Attach to open business day if any
active_day = db.query(BusinessDay).filter(BusinessDay.status == "open").first()
entry = WasteLog(
product_id=body.product_id,
quantity=body.quantity,
reason=body.reason,
reason_notes=body.reason_notes,
unit_cost_snapshot=unit_cost,
total_cost=total_cost,
logged_by_id=user.id,
business_day_id=active_day.id if active_day else None,
)
db.add(entry)
db.commit()
db.refresh(entry)
return _waste_out(entry)
@router.delete("/{entry_id}", status_code=status.HTTP_204_NO_CONTENT)
def delete_waste(
entry_id: int,
db: Session = Depends(get_db),
user: User = Depends(require_manager),
):
entry = db.query(WasteLog).filter(WasteLog.id == entry_id).first()
if not entry:
raise HTTPException(status_code=404, detail="Waste entry not found")
# Only allow deletion within the same business day
active_day = db.query(BusinessDay).filter(BusinessDay.status == "open").first()
if active_day and entry.business_day_id != active_day.id:
raise HTTPException(status_code=403, detail="Can only delete waste entries from the current open business day")
db.delete(entry)
db.commit()
@router.get("/summary")
def waste_summary(
business_day_id: Optional[int] = None,
from_dt: Optional[str] = Query(default=None, alias="from"),
to_dt: Optional[str] = Query(default=None, alias="to"),
db: Session = Depends(get_db),
user: User = Depends(require_manager),
):
from datetime import datetime
q = db.query(WasteLog)
if business_day_id:
q = q.filter(WasteLog.business_day_id == business_day_id)
elif from_dt and to_dt:
q = q.filter(
WasteLog.logged_at >= datetime.fromisoformat(from_dt),
WasteLog.logged_at <= datetime.fromisoformat(to_dt),
)
entries = q.all()
total_items = len(entries)
total_cost = round(sum(e.total_cost for e in entries if e.total_cost is not None), 2)
uncosted_count = sum(1 for e in entries if e.total_cost is None)
by_reason: dict = {}
by_product: dict = {}
for e in entries:
r = e.reason
if r not in by_reason:
by_reason[r] = {"reason": r, "label": REASON_LABELS.get(r, r), "count": 0, "total_cost": 0.0}
by_reason[r]["count"] += 1
by_reason[r]["total_cost"] += e.total_cost or 0.0
pid = e.product_id
pname = e.product.name if e.product else f"#{pid}"
if pid not in by_product:
by_product[pid] = {"product_id": pid, "product_name": pname, "qty": 0.0, "total_cost": 0.0}
by_product[pid]["qty"] += e.quantity
by_product[pid]["total_cost"] += e.total_cost or 0.0
for v in by_reason.values():
v["total_cost"] = round(v["total_cost"], 2)
for v in by_product.values():
v["total_cost"] = round(v["total_cost"], 2)
return {
"total_items": total_items,
"total_cost": total_cost,
"uncosted_count": uncosted_count,
"by_reason": sorted(by_reason.values(), key=lambda x: -x["count"]),
"by_product": sorted(by_product.values(), key=lambda x: -x["total_cost"]),
}