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:
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user