FASE 4-5-6: Infraestructura, CRM, Service Orders, Notificaciones, Ahorro, Logistica, API Publica
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
This commit is contained in:
189
pos/services/savings_engine.py
Normal file
189
pos/services/savings_engine.py
Normal file
@@ -0,0 +1,189 @@
|
||||
"""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,
|
||||
}
|
||||
Reference in New Issue
Block a user