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:
@@ -24,6 +24,7 @@ import models.notes # noqa: F401 — registers SiteNote, SiteTodo
|
||||
import models.expenses # noqa: F401 — registers Contact, Expense, ExpensePayment
|
||||
import models.customers # noqa: F401 — registers Customer
|
||||
import models.tabs # noqa: F401 — registers Tab, TabEntry, TabPayment
|
||||
import models.waste # noqa: F401 — registers WasteLog
|
||||
|
||||
from routers import auth, tables, products, orders, waiters, reports, system, setup as setup_router
|
||||
from routers import business_day as business_day_router
|
||||
@@ -39,6 +40,7 @@ from routers import notes as notes_router
|
||||
from routers.expenses import contacts_router, expenses_router
|
||||
from routers import customers as customers_router
|
||||
from routers import tabs as tabs_router
|
||||
from routers import waste as waste_router
|
||||
|
||||
|
||||
def _run_migrations():
|
||||
@@ -277,6 +279,19 @@ def _run_migrations():
|
||||
# Phase 2B — staff payroll
|
||||
"ALTER TABLE users ADD COLUMN hourly_rate REAL",
|
||||
"ALTER TABLE waiter_shifts ADD COLUMN hourly_rate_snapshot REAL",
|
||||
# Phase 2H — void/waste log
|
||||
"""CREATE TABLE IF NOT EXISTS waste_log (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
product_id INTEGER NOT NULL REFERENCES products(id),
|
||||
quantity REAL NOT NULL,
|
||||
unit_cost_snapshot REAL,
|
||||
total_cost REAL,
|
||||
reason VARCHAR NOT NULL,
|
||||
reason_notes TEXT,
|
||||
logged_by_id INTEGER NOT NULL REFERENCES users(id),
|
||||
business_day_id INTEGER REFERENCES business_days(id),
|
||||
logged_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||
)""",
|
||||
# Phase 2G — pay-later tab system
|
||||
"""CREATE TABLE IF NOT EXISTS tabs (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
@@ -444,3 +459,4 @@ app.include_router(contacts_router, prefix="/api/contacts", tag
|
||||
app.include_router(expenses_router, prefix="/api/expenses", tags=["expenses"])
|
||||
app.include_router(customers_router.router, prefix="/api/customers", tags=["customers"])
|
||||
app.include_router(tabs_router.router, prefix="/api/tabs", tags=["tabs"])
|
||||
app.include_router(waste_router.router, prefix="/api/waste", tags=["waste"])
|
||||
|
||||
26
local_backend/models/waste.py
Normal file
26
local_backend/models/waste.py
Normal file
@@ -0,0 +1,26 @@
|
||||
from sqlalchemy import Column, Integer, String, Float, DateTime, ForeignKey, Text
|
||||
from sqlalchemy.orm import relationship
|
||||
from datetime import datetime, timezone
|
||||
from database import Base
|
||||
|
||||
|
||||
def _utcnow():
|
||||
return datetime.now(timezone.utc)
|
||||
|
||||
|
||||
class WasteLog(Base):
|
||||
__tablename__ = "waste_log"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
product_id = Column(Integer, ForeignKey("products.id"), nullable=False)
|
||||
quantity = Column(Float, nullable=False) # supports decimals
|
||||
unit_cost_snapshot = Column(Float, nullable=True) # product's effective cost at log time
|
||||
total_cost = Column(Float, nullable=True) # unit_cost_snapshot * quantity
|
||||
reason = Column(String, nullable=False) # kitchen_error|dropped|expired|other
|
||||
reason_notes = Column(Text, nullable=True)
|
||||
logged_by_id = Column(Integer, ForeignKey("users.id"), nullable=False)
|
||||
business_day_id = Column(Integer, ForeignKey("business_days.id"), nullable=True)
|
||||
logged_at = Column(DateTime(timezone=True), default=_utcnow)
|
||||
|
||||
product = relationship("Product", foreign_keys=[product_id])
|
||||
logged_by = relationship("User", foreign_keys=[logged_by_id])
|
||||
@@ -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)
|
||||
|
||||
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"]),
|
||||
}
|
||||
37
local_backend/schemas/waste.py
Normal file
37
local_backend/schemas/waste.py
Normal file
@@ -0,0 +1,37 @@
|
||||
from pydantic import BaseModel
|
||||
from typing import Optional
|
||||
from datetime import datetime
|
||||
|
||||
VALID_REASONS = {"kitchen_error", "dropped", "expired", "other"}
|
||||
|
||||
REASON_LABELS = {
|
||||
"kitchen_error": "Λάθος κουζίνας",
|
||||
"dropped": "Έπεσε",
|
||||
"expired": "Έληξε",
|
||||
"other": "Άλλο",
|
||||
}
|
||||
|
||||
|
||||
class WasteLogCreate(BaseModel):
|
||||
product_id: int
|
||||
quantity: float
|
||||
reason: str
|
||||
reason_notes: Optional[str] = None
|
||||
|
||||
|
||||
class WasteLogOut(BaseModel):
|
||||
id: int
|
||||
product_id: int
|
||||
product_name: Optional[str] = None
|
||||
quantity: float
|
||||
unit_cost_snapshot: Optional[float] = None
|
||||
total_cost: Optional[float] = None
|
||||
reason: str
|
||||
reason_label: Optional[str] = None
|
||||
reason_notes: Optional[str] = None
|
||||
logged_by_id: int
|
||||
logged_by_name: Optional[str] = None
|
||||
business_day_id: Optional[int] = None
|
||||
logged_at: datetime
|
||||
|
||||
model_config = {"from_attributes": True}
|
||||
Reference in New Issue
Block a user