feat: Phase 2A — product cost tracking
- Products: add cost_simple (flat €) and cost_breakdown (JSON line items) - OrderItems: add unit_cost snapshot written at time of order creation - Snapshot logic: breakdown sum takes priority over cost_simple; NULL if neither set - Product schema: serialize/deserialize cost_breakdown as JSON; expose in API - Product performance report: add trackable_profit, uncosted_revenue, has_gap per product - Manager UI: cost section in product edit form (simple / detailed toggle with live margin %) - Products list: show margin % or cost-error badge per product - Product performance report: profit column + gap warning banner Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -262,6 +262,10 @@ def _run_migrations():
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||
)""",
|
||||
# Phase 2A — product cost tracking
|
||||
"ALTER TABLE products ADD COLUMN cost_simple REAL",
|
||||
"ALTER TABLE products ADD COLUMN cost_breakdown TEXT",
|
||||
"ALTER TABLE order_items ADD COLUMN unit_cost REAL",
|
||||
]
|
||||
for sql in migrations:
|
||||
try:
|
||||
|
||||
@@ -75,6 +75,9 @@ class OrderItem(Base):
|
||||
payment_method = Column(String, nullable=True) # 'cash'|'card'|'other' — future use
|
||||
paid_in_shift_id = Column(Integer, ForeignKey("waiter_shifts.id"), nullable=True)
|
||||
|
||||
# Phase 2A — cost snapshot (copied from product at time of order, never updated)
|
||||
unit_cost = Column(Float, nullable=True)
|
||||
|
||||
order = relationship("Order", back_populates="items")
|
||||
product = relationship("Product", back_populates="order_items")
|
||||
added_by_user = relationship("User", foreign_keys=[added_by], back_populates="order_items")
|
||||
|
||||
@@ -46,6 +46,10 @@ class Product(Base):
|
||||
digital_discount = Column(Float, default=0.0, nullable=False)
|
||||
digital_image_url = Column(String, nullable=True)
|
||||
|
||||
# Phase 2A — cost tracking
|
||||
cost_simple = Column(Float, nullable=True)
|
||||
cost_breakdown = Column(Text, nullable=True) # JSON: [{"label": str, "amount": float}]
|
||||
|
||||
category = relationship("Category", back_populates="products")
|
||||
printer_zone = relationship("Printer", back_populates="products")
|
||||
quick_options = relationship("ProductQuickOption", back_populates="product", cascade="all, delete-orphan")
|
||||
|
||||
@@ -270,12 +270,25 @@ def add_items(
|
||||
(o.price_delta or o.extra_cost or 0.0)
|
||||
for o in (item_in.selected_options or [])
|
||||
)
|
||||
# Phase 2A: cost snapshot — breakdown takes priority over simple cost
|
||||
unit_cost = 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:
|
||||
unit_cost = total
|
||||
except Exception:
|
||||
pass
|
||||
if unit_cost is None and product.cost_simple:
|
||||
unit_cost = product.cost_simple
|
||||
item = OrderItem(
|
||||
order_id=order_id,
|
||||
product_id=item_in.product_id,
|
||||
added_by=user.id,
|
||||
quantity=item_in.quantity,
|
||||
unit_price=product.base_price + extra_cost,
|
||||
unit_cost=unit_cost,
|
||||
selected_options=json.dumps([o.model_dump() for o in item_in.selected_options]) if item_in.selected_options else None,
|
||||
removed_ingredients=json.dumps(item_in.removed_ingredients) if item_in.removed_ingredients else None,
|
||||
notes=item_in.notes,
|
||||
|
||||
@@ -235,9 +235,11 @@ def reorder_products(items: List[ProductReorderItem], db: Session = Depends(get_
|
||||
|
||||
@router.post("/", response_model=ProductOut, status_code=status.HTTP_201_CREATED)
|
||||
def create_product(body: ProductCreate, db: Session = Depends(get_db), user: User = Depends(require_manager)):
|
||||
data = body.model_dump(exclude={"quick_options", "options", "ingredients", "preference_sets"})
|
||||
data = body.model_dump(exclude={"quick_options", "options", "ingredients", "preference_sets", "cost_breakdown"})
|
||||
if data.get("sort_order") == 0:
|
||||
data["sort_order"] = db.query(Product).count()
|
||||
if body.cost_breakdown is not None:
|
||||
data["cost_breakdown"] = json.dumps([item.model_dump() for item in body.cost_breakdown])
|
||||
product = Product(**data)
|
||||
db.add(product)
|
||||
db.flush()
|
||||
@@ -277,8 +279,18 @@ def update_product(product_id: int, body: ProductUpdate, db: Session = Depends(g
|
||||
product = db.query(Product).filter(Product.id == product_id).first()
|
||||
if not product:
|
||||
raise HTTPException(status_code=404, detail="Product not found")
|
||||
for field, value in body.model_dump(exclude_none=True, exclude={"quick_options", "options", "ingredients", "preference_sets"}).items():
|
||||
scalar_fields = body.model_dump(
|
||||
exclude_none=True,
|
||||
exclude={"quick_options", "options", "ingredients", "preference_sets", "cost_breakdown"},
|
||||
)
|
||||
for field, value in scalar_fields.items():
|
||||
setattr(product, field, value)
|
||||
# cost_breakdown is a list of objects — serialize to JSON for storage
|
||||
if body.cost_breakdown is not None:
|
||||
product.cost_breakdown = json.dumps([item.model_dump() for item in body.cost_breakdown])
|
||||
elif "cost_breakdown" in body.model_fields_set:
|
||||
# explicitly set to null — clear it
|
||||
product.cost_breakdown = None
|
||||
if body.quick_options is not None:
|
||||
_replace_quick_options(db, product, body.quick_options)
|
||||
if body.options is not None:
|
||||
|
||||
@@ -950,16 +950,29 @@ def product_performance(
|
||||
"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(),
|
||||
}
|
||||
summary[pid]["qty_sold"] += item.quantity
|
||||
summary[pid]["revenue"] += item.unit_price * item.quantity
|
||||
qty = item.quantity
|
||||
revenue = item.unit_price * qty
|
||||
summary[pid]["qty_sold"] += qty
|
||||
summary[pid]["revenue"] += revenue
|
||||
summary[pid]["order_ids"].add(item.order_id)
|
||||
if item.unit_cost is not None:
|
||||
summary[pid]["trackable_profit"] += revenue - (item.unit_cost * qty)
|
||||
else:
|
||||
summary[pid]["uncosted_revenue"] += revenue
|
||||
summary[pid]["uncosted_items"] += qty
|
||||
|
||||
result = []
|
||||
for entry in summary.values():
|
||||
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
|
||||
result.append(entry)
|
||||
|
||||
result.sort(key=lambda x: x["qty_sold"], reverse=True)
|
||||
|
||||
@@ -255,6 +255,11 @@ class PreferenceSetOut(BaseModel):
|
||||
|
||||
# ── Products ──────────────────────────────────────────────────────────────────
|
||||
|
||||
class CostBreakdownItem(BaseModel):
|
||||
label: str
|
||||
amount: float
|
||||
|
||||
|
||||
class ProductBase(BaseModel):
|
||||
name: str
|
||||
description: Optional[str] = None
|
||||
@@ -272,6 +277,9 @@ class ProductBase(BaseModel):
|
||||
digital_price: Optional[float] = None
|
||||
digital_discount: Optional[float] = None
|
||||
digital_image_url: Optional[str] = None
|
||||
# Phase 2A — cost tracking
|
||||
cost_simple: Optional[float] = None
|
||||
cost_breakdown: Optional[List[CostBreakdownItem]] = None
|
||||
|
||||
|
||||
class ProductCreate(ProductBase):
|
||||
@@ -302,6 +310,9 @@ class ProductUpdate(BaseModel):
|
||||
digital_price: Optional[float] = None
|
||||
digital_discount: Optional[float] = None
|
||||
digital_image_url: Optional[str] = None
|
||||
# Phase 2A — cost tracking
|
||||
cost_simple: Optional[float] = None
|
||||
cost_breakdown: Optional[List[CostBreakdownItem]] = None
|
||||
|
||||
|
||||
class ProductReorderItem(BaseModel):
|
||||
@@ -318,3 +329,42 @@ class ProductOut(ProductBase):
|
||||
image_url: Optional[str] = None
|
||||
|
||||
model_config = {"from_attributes": True}
|
||||
|
||||
@model_validator(mode='before')
|
||||
@classmethod
|
||||
def parse_cost_breakdown(cls, data: Any) -> Any:
|
||||
if hasattr(data, 'cost_breakdown'):
|
||||
raw = data.cost_breakdown
|
||||
parsed = None
|
||||
if isinstance(raw, str):
|
||||
try:
|
||||
parsed = json.loads(raw)
|
||||
except Exception:
|
||||
parsed = None
|
||||
# Return a plain dict so other validators still see all fields
|
||||
return {
|
||||
'id': data.id,
|
||||
'name': data.name,
|
||||
'description': data.description,
|
||||
'category_id': data.category_id,
|
||||
'base_price': data.base_price,
|
||||
'is_available': data.is_available,
|
||||
'lifecycle_status': data.lifecycle_status,
|
||||
'printer_zone_id': data.printer_zone_id,
|
||||
'sort_order': data.sort_order,
|
||||
'image_url': data.image_url,
|
||||
'digital_visible': bool(data.digital_visible),
|
||||
'digital_available': bool(data.digital_available),
|
||||
'digital_name': data.digital_name,
|
||||
'digital_description': data.digital_description,
|
||||
'digital_price': data.digital_price,
|
||||
'digital_discount': data.digital_discount,
|
||||
'digital_image_url': data.digital_image_url,
|
||||
'cost_simple': data.cost_simple,
|
||||
'cost_breakdown': parsed,
|
||||
'quick_options': list(data.quick_options),
|
||||
'options': list(data.options),
|
||||
'ingredients': list(data.ingredients),
|
||||
'preference_sets': list(data.preference_sets),
|
||||
}
|
||||
return data
|
||||
|
||||
Reference in New Issue
Block a user