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