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

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