Files
Autoparts-DB/pos/services/crm_engine.py
consultoria-as a386ca5b4a
Some checks failed
CI / lint-and-test (3.11) (push) Has been cancelled
CI / lint-and-test (3.13) (push) Has been cancelled
Mejoras de sesion: estabilizacion, IA, Nexus Yonke y marketplace B2B
- F0 blindaje: Redis/Celery, pg_hba sin trust LAN, secretos a env 600, backups, webhook auth, jobs muertos
- F1 caja: bugs POS, reembolsos al corte, apartados, CFDI, topes de descuento en cotizacion/apartado
- F2/F7 IA de catalogo: autocategorizacion, dedup de SKUs con embeddings, OCR de facturas, descripciones, claves SAT, limpieza de fitment
- F3 IA en el flujo: vision Yonke, preguntas MeLi, venta cruzada, anomalias, sinonimos, busqueda semantica (rerank/embeddings)
- F4/F8 escala y cartera: /metrics+Prometheus+Grafana, pool, score de credito, churn, metricas multi-tenant
- Nexus Yonke (M1-M6): modelo de datos 1-de-1, alta por donador, vitrina publica, MeLi usado, consignacion, garantia por dias, reportes, etiquetas
- Marketplace B2B entre yonkes (catalogo + pedidos cross-tenant)
- Seguridad: auth en /api/admin/* (35 endpoints), alertas por tenant
- Migraciones v4.27/v4.28; timers expire-demos y rollup-metrics
2026-10-12 00:57:31 +00:00

449 lines
15 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""CRM Engine: customer activities, tags, loyalty, analytics.
Provides:
- Activity timeline logging and retrieval
- Customer tag management and assignment
- Loyalty points accrual, redemption, and balance tracking
- Customer analytics (LTV, frequency, churn risk)
"""
from datetime import datetime, timedelta, timezone
def _aware(dt):
"""Normalize a possibly-naive datetime (psycopg2 may return either) to UTC."""
if dt is None:
return None
if dt.tzinfo is None:
return dt.replace(tzinfo=timezone.utc)
return dt
# ─── Customer Activities ─────────────────────────────
def log_activity(conn, customer_id, activity_type, title=None, description=None,
metadata=None, employee_id=None):
"""Log a customer activity to the timeline."""
cur = conn.cursor()
cur.execute("""
INSERT INTO customer_activities
(customer_id, activity_type, title, description, metadata, employee_id)
VALUES (%s, %s, %s, %s, %s, %s)
RETURNING id
""", (customer_id, activity_type, title, description,
metadata if metadata else None, employee_id))
activity_id = cur.fetchone()[0]
conn.commit()
cur.close()
return activity_id
def get_activities(conn, customer_id, activity_type=None, limit=50):
"""Get customer activity timeline."""
cur = conn.cursor()
params = [customer_id]
type_filter = ""
if activity_type:
type_filter = "AND activity_type = %s"
params.append(activity_type)
cur.execute(f"""
SELECT a.id, a.activity_type, a.title, a.description, a.metadata,
a.employee_id, e.name as employee_name, a.created_at
FROM customer_activities a
LEFT JOIN employees e ON a.employee_id = e.id
WHERE a.customer_id = %s {type_filter}
ORDER BY a.created_at DESC
LIMIT %s
""", params + [limit])
activities = []
for r in cur.fetchall():
activities.append({
'id': r[0], 'activity_type': r[1], 'title': r[2],
'description': r[3], 'metadata': r[4],
'employee_id': r[5], 'employee_name': r[6],
'created_at': str(r[7]),
})
cur.close()
return activities
# ─── Customer Tags ─────────────────────────────
def create_tag(conn, tenant_id, name, color='#6B7280', description=None):
cur = conn.cursor()
cur.execute("""
INSERT INTO customer_tags (tenant_id, name, color, description)
VALUES (%s, %s, %s, %s)
RETURNING id
""", (tenant_id, name, color, description))
tag_id = cur.fetchone()[0]
conn.commit()
cur.close()
return tag_id
def list_tags(conn, tenant_id):
cur = conn.cursor()
cur.execute("""
SELECT id, name, color, description, created_at
FROM customer_tags
WHERE tenant_id = %s
ORDER BY name
""", (tenant_id,))
tags = []
for r in cur.fetchall():
tags.append({
'id': r[0], 'name': r[1], 'color': r[2],
'description': r[3], 'created_at': str(r[4]),
})
cur.close()
return tags
def assign_tag(conn, customer_id, tag_id, assigned_by=None):
cur = conn.cursor()
cur.execute("""
INSERT INTO customer_tag_assignments (customer_id, tag_id, assigned_by)
VALUES (%s, %s, %s)
ON CONFLICT (customer_id, tag_id) DO NOTHING
""", (customer_id, tag_id, assigned_by))
conn.commit()
cur.close()
return True
def remove_tag(conn, customer_id, tag_id):
cur = conn.cursor()
cur.execute("""
DELETE FROM customer_tag_assignments
WHERE customer_id = %s AND tag_id = %s
""", (customer_id, tag_id))
conn.commit()
cur.close()
return True
def get_customer_tags(conn, customer_id):
cur = conn.cursor()
cur.execute("""
SELECT t.id, t.name, t.color
FROM customer_tags t
JOIN customer_tag_assignments a ON t.id = a.tag_id
WHERE a.customer_id = %s
ORDER BY t.name
""", (customer_id,))
tags = [{'id': r[0], 'name': r[1], 'color': r[2]} for r in cur.fetchall()]
cur.close()
return tags
# ─── Loyalty Program ─────────────────────────────
def add_loyalty_points(conn, customer_id, points, points_type='earned',
source_type=None, source_id=None, description=None,
expires_at=None):
"""Add loyalty points to a customer. Updates denormalized balance."""
cur = conn.cursor()
cur.execute("""
INSERT INTO loyalty_points
(customer_id, points, points_type, source_type, source_id, description, expires_at)
VALUES (%s, %s, %s, %s, %s, %s, %s)
RETURNING id
""", (customer_id, points, points_type, source_type, source_id, description, expires_at))
point_id = cur.fetchone()[0]
# Update denormalized balance
_recalculate_loyalty_balance(conn, customer_id, cur)
conn.commit()
cur.close()
return point_id
def redeem_points(conn, customer_id, points_to_use, reward_id=None,
reward_value=None, description=None, employee_id=None):
"""Redeem loyalty points. Returns redemption_id or raises ValueError."""
cur = conn.cursor()
# Check available balance
cur.execute("""
SELECT COALESCE(SUM(points), 0)
FROM loyalty_points
WHERE customer_id = %s
AND (expires_at IS NULL OR expires_at > NOW())
""", (customer_id,))
available = cur.fetchone()[0] or 0
if available < points_to_use:
cur.close()
raise ValueError(f"Insufficient points: available={available}, requested={points_to_use}")
# Record redemption
cur.execute("""
INSERT INTO loyalty_redemptions
(customer_id, reward_id, points_used, reward_value, description, employee_id)
VALUES (%s, %s, %s, %s, %s, %s)
RETURNING id
""", (customer_id, reward_id, points_to_use, reward_value, description, employee_id))
redemption_id = cur.fetchone()[0]
# Deduct points (record negative entry)
cur.execute("""
INSERT INTO loyalty_points
(customer_id, points, points_type, source_type, description)
VALUES (%s, %s, 'redeemed', 'redemption', %s)
""", (customer_id, -points_to_use, description))
_recalculate_loyalty_balance(conn, customer_id, cur)
conn.commit()
cur.close()
return redemption_id
def _recalculate_loyalty_balance(conn, customer_id, cur=None):
should_close = cur is None
if should_close:
cur = conn.cursor()
# Calculate total non-expired points
cur.execute("""
SELECT COALESCE(SUM(points), 0)
FROM loyalty_points
WHERE customer_id = %s
AND (expires_at IS NULL OR expires_at > NOW())
""", (customer_id,))
balance = cur.fetchone()[0] or 0
# Determine tier
tier = 'bronze'
if balance >= 5000:
tier = 'platinum'
elif balance >= 2000:
tier = 'gold'
elif balance >= 500:
tier = 'silver'
cur.execute("""
UPDATE customers
SET loyalty_points_balance = %s, loyalty_tier = %s
WHERE id = %s
""", (balance, tier, customer_id))
if should_close:
conn.commit()
cur.close()
def get_loyalty_history(conn, customer_id, limit=50):
cur = conn.cursor()
cur.execute("""
SELECT id, points, points_type, source_type, source_id,
description, expires_at, created_at
FROM loyalty_points
WHERE customer_id = %s
ORDER BY created_at DESC
LIMIT %s
""", (customer_id, limit))
history = []
for r in cur.fetchall():
history.append({
'id': r[0], 'points': r[1], 'points_type': r[2],
'source_type': r[3], 'source_id': r[4],
'description': r[5], 'expires_at': str(r[6]) if r[6] else None,
'created_at': str(r[7]),
})
cur.close()
return history
def create_reward(conn, tenant_id, name, points_cost, reward_type='discount',
reward_value=None, description=None):
cur = conn.cursor()
cur.execute("""
INSERT INTO loyalty_rewards
(tenant_id, name, description, points_cost, reward_type, reward_value)
VALUES (%s, %s, %s, %s, %s, %s)
RETURNING id
""", (tenant_id, name, description, points_cost, reward_type, reward_value))
reward_id = cur.fetchone()[0]
conn.commit()
cur.close()
return reward_id
def list_rewards(conn, tenant_id):
cur = conn.cursor()
cur.execute("""
SELECT id, name, description, points_cost, reward_type, reward_value, is_active
FROM loyalty_rewards
WHERE tenant_id = %s AND is_active = true
ORDER BY points_cost
""", (tenant_id,))
rewards = []
for r in cur.fetchall():
rewards.append({
'id': r[0], 'name': r[1], 'description': r[2],
'points_cost': r[3], 'reward_type': r[4], 'reward_value': float(r[5]) if r[5] else None,
'is_active': r[6],
})
cur.close()
return rewards
# ─── Customer Analytics ─────────────────────────────
def get_customer_analytics(conn, customer_id):
"""Compute LTV, purchase frequency, favorite categories, churn risk."""
cur = conn.cursor()
# LTV and frequency
cur.execute("""
SELECT
COALESCE(SUM(total), 0) as ltv,
COUNT(*) as total_orders,
MIN(created_at) as first_purchase,
MAX(created_at) as last_purchase,
COALESCE(AVG(total), 0) as avg_order_value
FROM sales
WHERE customer_id = %s AND status = 'completed'
""", (customer_id,))
ltv, total_orders, first_purchase, last_purchase, aov = cur.fetchone()
first_purchase = _aware(first_purchase)
last_purchase = _aware(last_purchase)
now = datetime.now(timezone.utc)
# Days since last purchase
days_since_last = None
if last_purchase:
days_since_last = (now - last_purchase).days
# Purchase frequency (orders per month)
frequency = 0.0
if first_purchase and last_purchase and total_orders > 1:
months = max(1, (last_purchase - first_purchase).days / 30.0)
frequency = round(total_orders / months, 2)
# Real inter-purchase interval per customer (A12). Uses the gaps between
# consecutive completed sales instead of fixed 60/90-day thresholds.
cur.execute("""
SELECT created_at FROM sales
WHERE customer_id = %s AND status = 'completed'
ORDER BY created_at
""", (customer_id,))
purchase_dates = [_aware(r[0]) for r in cur.fetchall() if r[0] is not None]
intervals = [
(purchase_dates[i] - purchase_dates[i - 1]).days
for i in range(1, len(purchase_dates))
]
intervals = [d for d in intervals if d >= 0]
avg_interval = round(sum(intervals) / len(intervals), 1) if intervals else None
# Next-purchase prediction + churn probability.
predicted_next_purchase_date = None
days_until_next_purchase = None
churn_probability = 0.0
churn_reason = ''
if avg_interval and last_purchase and avg_interval > 0:
predicted = last_purchase + timedelta(days=round(avg_interval))
predicted_next_purchase_date = predicted
days_until_next_purchase = (predicted - now).days
# How many of its own typical cycles the customer has been silent.
ratio = (days_since_last or 0) / max(float(avg_interval), 1.0)
if ratio > 2.0:
churn_risk = 'high'
churn_probability = 0.9
churn_reason = (f"Lleva {days_since_last} días sin comprar, más de 2× "
f"su intervalo habitual ({avg_interval:.0f} días).")
elif ratio > 1.5:
churn_risk = 'high'
churn_probability = 0.7
churn_reason = (f"Rebasó 1.5× su intervalo habitual de compra "
f"({avg_interval:.0f} días).")
elif ratio > 1.0:
churn_risk = 'medium'
churn_probability = 0.4
churn_reason = f"Ya superó su intervalo habitual de compra ({avg_interval:.0f} días)."
else:
churn_risk = 'low'
churn_probability = 0.15
churn_reason = f"Dentro de su intervalo habitual de compra ({avg_interval:.0f} días)."
else:
# Fewer than two purchases: not enough history for a cadence. Fall back
# to the legacy fixed thresholds but report that we lacked data.
if days_since_last is None:
churn_risk = 'low'
churn_probability = 0.0
churn_reason = 'Sin compras registradas.'
elif days_since_last > 90:
churn_risk = 'high'
churn_probability = 0.75
churn_reason = f"Sin compras desde hace {days_since_last} días."
elif days_since_last > 60:
churn_risk = 'medium'
churn_probability = 0.45
churn_reason = f"Sin compras desde hace {days_since_last} días."
else:
churn_risk = 'low'
churn_probability = 0.15
churn_reason = f"Compra reciente (hace {days_since_last} días)."
# Favorite categories (from inventory via sale_items)
cur.execute("""
SELECT COALESCE(i.category_id::text, 'Uncategorized') as category,
COUNT(*) as cnt, SUM(si.subtotal) as revenue
FROM sale_items si
JOIN sales s ON s.id = si.sale_id
LEFT JOIN inventory i ON si.inventory_id = i.id
WHERE s.customer_id = %s AND s.status = 'completed'
GROUP BY i.category_id
ORDER BY revenue DESC
LIMIT 5
""", (customer_id,))
categories = []
for r in cur.fetchall():
categories.append({
'category': r[0] or 'Uncategorized',
'order_count': r[1],
'revenue': float(r[2]) if r[2] else 0,
})
# Loyalty status
cur.execute("""
SELECT loyalty_points_balance, loyalty_tier
FROM customers WHERE id = %s
""", (customer_id,))
row = cur.fetchone()
loyalty_balance = row[0] or 0
loyalty_tier = row[1] or 'bronze'
cur.close()
return {
'ltv': float(ltv) if ltv else 0,
'total_orders': total_orders or 0,
'avg_order_value': float(aov) if aov else 0,
'first_purchase': str(first_purchase) if first_purchase else None,
'last_purchase': str(last_purchase) if last_purchase else None,
'days_since_last_purchase': days_since_last,
'purchase_frequency_monthly': frequency,
'churn_risk': churn_risk,
# A12 additions: real inter-purchase cadence + prediction.
'avg_days_between_purchases': avg_interval,
'purchase_intervals_count': len(intervals),
'predicted_next_purchase_date': (
predicted_next_purchase_date.isoformat() if predicted_next_purchase_date else None
),
'days_until_next_purchase': days_until_next_purchase,
'churn_probability': churn_probability,
'churn_reason': churn_reason,
'favorite_categories': categories,
'loyalty': {
'points_balance': loyalty_balance,
'tier': loyalty_tier,
}
}