FASE 4: - Redis cache de stock con fallback graceful - Multi-moneda (MXN/USD) con contabilidad en MXN - Proveedores y ordenes de compra completo - Meilisearch 1.5M+ partes indexadas - Metabase KPIs con dashboard auto-generado FASE 5: - CRM mejorado: activities, tags, loyalty program, analytics - Imagenes de partes: upload, resize, thumbnails WebP - Ordenes de servicio Kanban: received->diagnosis->repair->ready->delivered - Garantias/RMA, alertas de reorden, multi-sucursal - Stubs BNPL (APLAZO) y ERP Sync (Aspel/Contpaqi) FASE 6: - Notificaciones automaticas: push/WhatsApp/email/in-app - Reportes de ahorro vs retail_price - Logistica + tracking: DHL, FedEx, Estafeta, 99min, Uber - API Publica: API keys, rate limiting, catalog search Migraciones: v1.9-v3.0 Tests: 93/93 pasando Backup: nexus_backup_20260427_045859.tar.gz
190 lines
6.0 KiB
Python
190 lines
6.0 KiB
Python
"""Savings Engine: calculate and track how much customers save vs retail price.
|
|
|
|
Provides:
|
|
- Calculate savings per item at checkout
|
|
- Update customer total savings
|
|
- Generate savings reports
|
|
"""
|
|
|
|
from decimal import Decimal, ROUND_HALF_UP
|
|
|
|
|
|
def calculate_item_savings(unit_price, retail_price, quantity=1):
|
|
"""Calculate savings for a single item.
|
|
|
|
Returns:
|
|
savings_amount (float), savings_pct (float)
|
|
"""
|
|
if not retail_price or retail_price <= 0:
|
|
return 0.0, 0.0
|
|
if not unit_price or unit_price <= 0:
|
|
return 0.0, 0.0
|
|
|
|
savings = (Decimal(str(retail_price)) - Decimal(str(unit_price))) * Decimal(str(quantity))
|
|
savings = savings.quantize(Decimal('0.01'), ROUND_HALF_UP)
|
|
|
|
pct = (savings / (Decimal(str(retail_price)) * Decimal(str(quantity)))) * 100
|
|
pct = float(pct.quantize(Decimal('0.1'), ROUND_HALF_UP))
|
|
|
|
return float(savings), pct
|
|
|
|
|
|
def record_sale_savings(conn, sale_id):
|
|
"""Recalculate and record savings for all items in a sale.
|
|
|
|
Called after sale is created. Updates sale_items.savings_amount and sales.total_savings.
|
|
Also updates customers.total_savings.
|
|
"""
|
|
cur = conn.cursor()
|
|
|
|
# Get all items with their retail prices
|
|
cur.execute("""
|
|
SELECT si.id, si.inventory_id, si.unit_price, si.quantity, i.retail_price
|
|
FROM sale_items si
|
|
LEFT JOIN inventory i ON si.inventory_id = i.id
|
|
WHERE si.sale_id = %s
|
|
""", (sale_id,))
|
|
|
|
total_savings = Decimal('0')
|
|
for row in cur.fetchall():
|
|
item_id, inv_id, unit_price, qty, retail_price = row
|
|
savings, _ = calculate_item_savings(unit_price, retail_price, qty)
|
|
if savings > 0:
|
|
cur.execute("""
|
|
UPDATE sale_items SET savings_amount = %s WHERE id = %s
|
|
""", (savings, item_id))
|
|
total_savings += Decimal(str(savings))
|
|
|
|
# Update sale total savings
|
|
cur.execute("""
|
|
UPDATE sales SET total_savings = %s WHERE id = %s
|
|
""", (total_savings, sale_id))
|
|
|
|
# Update customer total savings
|
|
cur.execute("""
|
|
UPDATE customers
|
|
SET total_savings = COALESCE(total_savings, 0) + %s
|
|
WHERE id = (SELECT customer_id FROM sales WHERE id = %s)
|
|
""", (total_savings, sale_id))
|
|
|
|
conn.commit()
|
|
cur.close()
|
|
return float(total_savings)
|
|
|
|
|
|
def get_customer_savings_report(conn, customer_id, months=12):
|
|
"""Get savings report for a customer."""
|
|
cur = conn.cursor()
|
|
|
|
# Overall savings
|
|
cur.execute("""
|
|
SELECT COALESCE(SUM(total_savings), 0), COUNT(*)
|
|
FROM sales
|
|
WHERE customer_id = %s AND status = 'completed' AND total_savings > 0
|
|
""", (customer_id,))
|
|
total_saved, orders_with_savings = cur.fetchone()
|
|
|
|
# Monthly breakdown
|
|
cur.execute("""
|
|
SELECT
|
|
date_trunc('month', created_at) as month,
|
|
COUNT(*) as orders,
|
|
SUM(total) as spent,
|
|
SUM(total_savings) as saved
|
|
FROM sales
|
|
WHERE customer_id = %s AND status = 'completed' AND total_savings > 0
|
|
AND created_at >= NOW() - interval '%s months'
|
|
GROUP BY date_trunc('month', created_at)
|
|
ORDER BY month DESC
|
|
""", (customer_id, months))
|
|
|
|
monthly = []
|
|
for r in cur.fetchall():
|
|
monthly.append({
|
|
'month': str(r[0])[:7],
|
|
'orders': r[1],
|
|
'spent': float(r[2]) if r[2] else 0,
|
|
'saved': float(r[3]) if r[3] else 0,
|
|
'savings_pct': round(float(r[3]) / float(r[2]) * 100, 1) if r[2] else 0,
|
|
})
|
|
|
|
# Top savings items
|
|
cur.execute("""
|
|
SELECT si.name, si.part_number, si.unit_price, si.retail_price, si.savings_amount
|
|
FROM sale_items si
|
|
JOIN sales s ON s.id = si.sale_id
|
|
WHERE s.customer_id = %s AND s.status = 'completed' AND si.savings_amount > 0
|
|
ORDER BY si.savings_amount DESC
|
|
LIMIT 10
|
|
""", (customer_id,))
|
|
|
|
top_items = []
|
|
for r in cur.fetchall():
|
|
top_items.append({
|
|
'name': r[0], 'part_number': r[1],
|
|
'unit_price': float(r[2]) if r[2] else 0,
|
|
'retail_price': float(r[3]) if r[3] else 0,
|
|
'savings': float(r[4]) if r[4] else 0,
|
|
})
|
|
|
|
cur.close()
|
|
|
|
return {
|
|
'customer_id': customer_id,
|
|
'total_saved': float(total_saved) if total_saved else 0,
|
|
'orders_with_savings': orders_with_savings or 0,
|
|
'monthly_breakdown': monthly,
|
|
'top_items': top_items,
|
|
}
|
|
|
|
|
|
def get_global_savings_stats(conn, tenant_id, from_date=None, to_date=None):
|
|
"""Get global savings stats for a tenant."""
|
|
cur = conn.cursor()
|
|
params = [tenant_id]
|
|
date_filter = ""
|
|
if from_date:
|
|
date_filter += " AND s.created_at >= %s"
|
|
params.append(from_date)
|
|
if to_date:
|
|
date_filter += " AND s.created_at < %s::date + interval '1 day'"
|
|
params.append(to_date)
|
|
|
|
cur.execute(f"""
|
|
SELECT
|
|
COALESCE(SUM(s.total_savings), 0),
|
|
COUNT(DISTINCT s.customer_id),
|
|
COUNT(*) as orders,
|
|
COALESCE(AVG(s.total_savings), 0)
|
|
FROM sales s
|
|
JOIN customers c ON s.customer_id = c.id
|
|
WHERE s.status = 'completed' AND s.total_savings > 0
|
|
{date_filter}
|
|
""", params[1:] if len(params) > 1 else [])
|
|
|
|
total_saved, customers_count, orders, avg_savings = cur.fetchone()
|
|
|
|
cur.execute(f"""
|
|
SELECT c.name, c.id, SUM(s.total_savings) as saved
|
|
FROM sales s
|
|
JOIN customers c ON s.customer_id = c.id
|
|
WHERE s.status = 'completed' AND s.total_savings > 0
|
|
{date_filter}
|
|
GROUP BY c.id, c.name
|
|
ORDER BY saved DESC
|
|
LIMIT 10
|
|
""", params[1:] if len(params) > 1 else [])
|
|
|
|
top_customers = []
|
|
for r in cur.fetchall():
|
|
top_customers.append({'name': r[0], 'id': r[1], 'saved': float(r[2]) if r[2] else 0})
|
|
|
|
cur.close()
|
|
return {
|
|
'total_saved': float(total_saved) if total_saved else 0,
|
|
'customers_count': customers_count or 0,
|
|
'orders_count': orders or 0,
|
|
'avg_savings_per_order': float(avg_savings) if avg_savings else 0,
|
|
'top_customers': top_customers,
|
|
}
|