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

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

View File

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

View File

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

View File

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

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:

View File

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

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

View File

@@ -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
<PriceInput value={form.base_price} onChange={v => setField('base_price', v)} className="w-full" />
</div>
{/* Phase 2A — Cost section */}
<div className="rounded-xl border border-gray-200 bg-white overflow-hidden">
<div className="flex items-center justify-between px-3 py-2.5 bg-gray-50 border-b border-gray-200">
<span className="text-xs font-semibold text-gray-500 uppercase tracking-widest">Κόστος</span>
<div className="flex gap-1">
{['simple', 'detailed'].map(mode => (
<button key={mode} type="button"
onClick={() => setField('cost_mode', mode)}
className={`px-2 py-0.5 rounded text-xs font-medium transition-colors ${
form.cost_mode === mode
? 'bg-primary-600 text-white'
: 'text-gray-500 hover:bg-gray-100'
}`}>
{mode === 'simple' ? 'Απλό' : 'Ανάλυση'}
</button>
))}
</div>
</div>
<div className="p-3 space-y-2">
{form.cost_mode === 'simple' ? (
<div>
<label className="label text-xs">Κόστος ανά μονάδα ()</label>
<PriceInput value={form.cost_simple} onChange={v => 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 (
<p className={`text-xs mt-1 font-medium ${isError ? 'text-red-500' : 'text-green-600'}`}>
{isError ? '⚠ Κόστος > Τιμή' : `Περιθώριο: ${margin.toFixed(1)}%`}
</p>
)
})()}
</div>
) : (
<div className="space-y-2">
{form.cost_breakdown.map((entry, bi) => (
<div key={bi} className="flex gap-2 items-center">
<input className="input flex-1 text-sm" placeholder="π.χ. Γάλα"
value={entry.label}
onChange={e => setForm(f => ({ ...f, cost_breakdown: f.cost_breakdown.map((x, i) => i === bi ? { ...x, label: e.target.value } : x) }))} />
<PriceInput value={entry.amount}
onChange={v => setForm(f => ({ ...f, cost_breakdown: f.cost_breakdown.map((x, i) => i === bi ? { ...x, amount: v } : x) }))}
className="w-28" />
<button type="button"
onClick={() => setForm(f => ({ ...f, cost_breakdown: f.cost_breakdown.filter((_, i) => i !== bi) }))}
className="text-gray-400 hover:text-red-500 px-1.5 text-sm shrink-0"></button>
</div>
))}
<button type="button"
onClick={() => setForm(f => ({ ...f, cost_breakdown: [...f.cost_breakdown, { label: '', amount: 0 }] }))}
className="text-xs text-primary-600 hover:text-primary-800 font-medium">+ Γραμμή κόστους</button>
{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 (
<div className={`text-xs font-medium ${isError ? 'text-red-500' : 'text-gray-600'}`}>
Σύνολο: {total.toFixed(2)}
{margin !== null && !isError && <span className="text-green-600 ml-2">({margin.toFixed(1)}% περιθώριο)</span>}
{isError && <span className="ml-1"> Κόστος > Τιμή!</span>}
</div>
)
})()}
</div>
)}
</div>
</div>
<div>
<label className="label">Κατηγορία</label>
<select className="input" value={form.category_id} onChange={e => setField('category_id', e.target.value)}>

View File

@@ -845,10 +845,29 @@ function ProductRow({ p, cat, parentCat, selected, onToggleSelect, onEdit, onArc
</div>
</div>
{/* Price */}
<span style={{ fontVariantNumeric: 'tabular-nums', fontWeight: 600, fontSize: 14, color: '#111827', marginRight: 4, whiteSpace: 'nowrap' }}>
{/* Price + cost/margin */}
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'flex-end', gap: 2, marginRight: 4, flexShrink: 0 }}>
<span style={{ fontVariantNumeric: 'tabular-nums', fontWeight: 600, fontSize: 14, color: '#111827', whiteSpace: 'nowrap' }}>
{parseFloat(p.base_price).toFixed(2)}
</span>
{(() => {
const effectiveCost = p.cost_breakdown?.length
? p.cost_breakdown.reduce((s, e) => s + (e.amount || 0), 0)
: (p.cost_simple ?? null)
if (effectiveCost === null) return null
const price = parseFloat(p.base_price)
const isError = effectiveCost > price
const margin = price > 0 ? ((price - effectiveCost) / price * 100) : null
return (
<span style={{
fontSize: 11, fontWeight: 500, whiteSpace: 'nowrap',
color: isError ? '#ef4444' : '#16a34a',
}}>
{isError ? '⚠ κόστος > τιμή' : margin !== null ? `${margin.toFixed(0)}% margin` : null}
</span>
)
})()}
</div>
{/* Actions */}
<div style={{ display: 'flex', alignItems: 'center', gap: 5, flexShrink: 0 }}>

View File

@@ -350,29 +350,87 @@ export default function ProductPerformance() {
</Panel>
<div className="mt-4">
<Panel title="Όλα τα Προϊόντα" subtitle={`${products.length} προϊόντα με πωλήσεις`} padded={false}>
{(() => {
const totalProfit = products.reduce((s, p) => s + (p.trackable_profit || 0), 0)
const totalUncostedRev = products.reduce((s, p) => s + (p.uncosted_revenue || 0), 0)
const hasGap = products.some(p => p.has_gap)
return (
<Panel
title="Όλα τα Προϊόντα"
subtitle={`${products.length} προϊόντα με πωλήσεις`}
padded={false}
>
{hasGap && (
<div className="mx-4 mt-3 mb-1 flex items-start gap-2 rounded-lg border border-amber-200 bg-amber-50 px-3 py-2.5 text-[12px] text-amber-800">
<span className="shrink-0 font-bold"></span>
<span>
Μερικά προϊόντα δεν έχουν κόστος το κέρδος που φαίνεται είναι μερικό.
Ορισμένα έσοδα <strong>{fmtEUR(totalUncostedRev)}</strong> δεν συμπεριλαμβάνονται στο κέρδος.{' '}
<a href="/management?tab=products" className="underline hover:text-amber-900">Ορισμός κόστους προϊόντων </a>
</span>
</div>
)}
{products.length === 0 ? (
<EmptyState title="Δεν βρέθηκαν προϊόντα" description="Δεν υπάρχουν πωλήσεις σε αυτή την περίοδο." />
) : (
<DataTable>
<THead><TH>Προϊόν</TH><TH align="right">Τεμάχια</TH><TH align="right">Έσοδα</TH><TH align="right">% Συνόλου</TH><TH align="right">Παραγγελίες</TH></THead>
<THead>
<TH>Προϊόν</TH>
<TH align="right">Τεμάχια</TH>
<TH align="right">Έσοδα</TH>
<TH align="right">Κέρδος</TH>
<TH align="right">% Συνόλου</TH>
<TH align="right">Παραγγελίες</TH>
</THead>
<tbody>
{products.map(p => {
const pct = totalRev ? (p.revenue / totalRev * 100) : 0
const margin = p.revenue > 0 && !p.has_gap
? ((p.trackable_profit / p.revenue) * 100).toFixed(0) + '%'
: null
return (
<TR key={p.product_id} striped>
<TD className="font-medium text-slate-900">{p.product_name}</TD>
<TD mono align="right">{fmtNum(p.qty_sold)}</TD>
<TD mono align="right" className="font-semibold text-slate-900">{fmtEUR(p.revenue)}</TD>
<TD mono align="right">
{p.has_gap ? (
<span className="text-amber-500 text-[11px]"> χωρίς κόστος</span>
) : (
<span className={p.trackable_profit >= 0 ? 'text-green-700' : 'text-red-600'}>
{fmtEUR(p.trackable_profit)}
{margin && <span className="ml-1 text-[10px] text-slate-400">({margin})</span>}
</span>
)}
</TD>
<TD mono align="right">{pct.toFixed(1)}%</TD>
<TD mono align="right">{fmtNum(p.order_count)}</TD>
</TR>
)
})}
</tbody>
{products.length > 1 && (
<tfoot>
<TR>
<TD className="font-semibold text-slate-700" colSpan={2}>Σύνολο</TD>
<TD mono align="right" className="font-bold">{fmtEUR(totalRev)}</TD>
<TD mono align="right" className="font-bold">
{hasGap ? (
<span className="text-amber-600">{fmtEUR(totalProfit)} {' '}<span className="text-[10px]">+χωρίς κόστος</span></span>
) : (
<span className="text-green-700">{fmtEUR(totalProfit)}</span>
)}
</TD>
<TD mono align="right" className="font-bold">100%</TD>
<TD />
</TR>
</tfoot>
)}
</DataTable>
)}
</Panel>
)
})()}
</div>
</div>