diff --git a/local_backend/main.py b/local_backend/main.py index 33644d5..b9a1837 100644 --- a/local_backend/main.py +++ b/local_backend/main.py @@ -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: diff --git a/local_backend/models/order.py b/local_backend/models/order.py index 2db70fd..69c8074 100644 --- a/local_backend/models/order.py +++ b/local_backend/models/order.py @@ -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") diff --git a/local_backend/models/product.py b/local_backend/models/product.py index d113382..841b002 100644 --- a/local_backend/models/product.py +++ b/local_backend/models/product.py @@ -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") diff --git a/local_backend/routers/orders.py b/local_backend/routers/orders.py index c17dd13..a5be867 100644 --- a/local_backend/routers/orders.py +++ b/local_backend/routers/orders.py @@ -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, diff --git a/local_backend/routers/products.py b/local_backend/routers/products.py index 5b82b78..a1809b3 100644 --- a/local_backend/routers/products.py +++ b/local_backend/routers/products.py @@ -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: diff --git a/local_backend/routers/reports.py b/local_backend/routers/reports.py index 3040a94..cc55120 100644 --- a/local_backend/routers/reports.py +++ b/local_backend/routers/reports.py @@ -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) diff --git a/local_backend/schemas/product.py b/local_backend/schemas/product.py index 6021960..0ca049d 100644 --- a/local_backend/schemas/product.py +++ b/local_backend/schemas/product.py @@ -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 diff --git a/manager_dashboard/src/pages/Management/ProductFormModal.jsx b/manager_dashboard/src/pages/Management/ProductFormModal.jsx index a7b447c..bf2369a 100644 --- a/manager_dashboard/src/pages/Management/ProductFormModal.jsx +++ b/manager_dashboard/src/pages/Management/ProductFormModal.jsx @@ -146,6 +146,12 @@ function buildFormFromProduct(product) { digital_image_url: product.digital_image_url ?? '', digital_price: product.digital_price ?? '', digital_discount: product.digital_discount ?? '', + // Phase 2A — cost tracking + cost_mode: product.cost_breakdown?.length ? 'detailed' : 'simple', + cost_simple: product.cost_simple ?? '', + cost_breakdown: product.cost_breakdown?.length + ? product.cost_breakdown.map(e => ({ label: e.label, amount: e.amount })) + : [], } } @@ -439,6 +445,11 @@ export default function ProductFormModal({ product, categories, printers, onSave digital_image_url: form.digital_image_url || null, digital_price: form.digital_price !== '' ? parseFloat(form.digital_price) : null, digital_discount: form.digital_discount !== '' ? parseFloat(form.digital_discount) : null, + // Phase 2A — cost tracking + cost_simple: form.cost_mode === 'simple' && form.cost_simple !== '' ? parseFloat(form.cost_simple) : null, + cost_breakdown: form.cost_mode === 'detailed' && form.cost_breakdown.length > 0 + ? form.cost_breakdown.map(e => ({ label: e.label, amount: parseFloat(e.amount) || 0 })) + : null, } } @@ -524,6 +535,75 @@ export default function ProductFormModal({ product, categories, printers, onSave setField('base_price', v)} className="w-full" /> + {/* Phase 2A — Cost section */} +
+
+ Κόστος +
+ {['simple', 'detailed'].map(mode => ( + + ))} +
+
+
+ {form.cost_mode === 'simple' ? ( +
+ + setField('cost_simple', v)} className="w-full" /> + {form.cost_simple !== '' && parseFloat(form.base_price) > 0 && parseFloat(form.cost_simple) > 0 && (() => { + const margin = ((parseFloat(form.base_price) - parseFloat(form.cost_simple)) / parseFloat(form.base_price) * 100) + const isError = parseFloat(form.cost_simple) > parseFloat(form.base_price) + return ( +

+ {isError ? '⚠ Κόστος > Τιμή' : `Περιθώριο: ${margin.toFixed(1)}%`} +

+ ) + })()} +
+ ) : ( +
+ {form.cost_breakdown.map((entry, bi) => ( +
+ setForm(f => ({ ...f, cost_breakdown: f.cost_breakdown.map((x, i) => i === bi ? { ...x, label: e.target.value } : x) }))} /> + setForm(f => ({ ...f, cost_breakdown: f.cost_breakdown.map((x, i) => i === bi ? { ...x, amount: v } : x) }))} + className="w-28" /> + +
+ ))} + + {form.cost_breakdown.length > 0 && (() => { + const total = form.cost_breakdown.reduce((s, e) => s + (parseFloat(e.amount) || 0), 0) + const price = parseFloat(form.base_price) || 0 + const isError = total > price && price > 0 + const margin = price > 0 ? ((price - total) / price * 100) : null + return ( +
+ Σύνολο: €{total.toFixed(2)} + {margin !== null && !isError && ({margin.toFixed(1)}% περιθώριο)} + {isError && — Κόστος > Τιμή!} +
+ ) + })()} +
+ )} +
+
+