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:
Nexus Dev
2026-04-27 05:23:30 +00:00
parent b70cb3042b
commit 9ff3dc4c8b
71 changed files with 10939 additions and 420 deletions

View 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,
}