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:
182
local_backend/routers/waste.py
Normal file
182
local_backend/routers/waste.py
Normal 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"]),
|
||||
}
|
||||
Reference in New Issue
Block a user