Files
bonamin 9cfbde0e3e 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>
2026-06-08 03:19:33 +03:00

183 lines
6.3 KiB
Python

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"]),
}