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:
2026-06-07 20:46:33 +03:00
parent ac7ec45279
commit d0dfd63415
10 changed files with 287 additions and 31 deletions

View File

@@ -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: