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

233
pos/blueprints/crm_bp.py Normal file
View File

@@ -0,0 +1,233 @@
"""CRM Blueprint: activities, tags, loyalty, analytics.
Prefixes:
/pos/api/customers/<id>/activities
/pos/api/customers/<id>/tags
/pos/api/customers/<id>/loyalty
/pos/api/customers/<id>/analytics
/pos/api/tags
/pos/api/rewards
"""
from flask import Blueprint, request, jsonify, g
from middleware import require_auth
from tenant_db import get_tenant_conn
from services.crm_engine import (
log_activity, get_activities,
create_tag, list_tags, assign_tag, remove_tag, get_customer_tags,
add_loyalty_points, redeem_points, get_loyalty_history,
create_reward, list_rewards,
get_customer_analytics,
)
crm_bp = Blueprint('crm', __name__, url_prefix='/pos/api')
# ─── Customer Activities ─────────────────────────────
@crm_bp.route('/customers/<int:customer_id>/activities', methods=['GET'])
@require_auth('customers.view')
def customer_activities(customer_id):
activity_type = request.args.get('type')
limit = min(int(request.args.get('limit', 50)), 200)
conn = get_tenant_conn(g.tenant_id)
try:
activities = get_activities(conn, customer_id, activity_type=activity_type, limit=limit)
return jsonify({'activities': activities})
finally:
conn.close()
@crm_bp.route('/customers/<int:customer_id>/activities', methods=['POST'])
@require_auth('customers.edit')
def add_customer_activity(customer_id):
data = request.get_json() or {}
activity_type = data.get('activity_type', 'note')
conn = get_tenant_conn(g.tenant_id)
try:
activity_id = log_activity(
conn, customer_id, activity_type,
title=data.get('title'),
description=data.get('description'),
metadata=data.get('metadata'),
employee_id=getattr(g, 'employee_id', None),
)
return jsonify({'id': activity_id, 'message': 'Activity logged'}), 201
finally:
conn.close()
# ─── Customer Tags ─────────────────────────────
@crm_bp.route('/tags', methods=['GET'])
@require_auth('customers.view')
def get_tags():
conn = get_tenant_conn(g.tenant_id)
try:
tags = list_tags(conn, g.tenant_id)
return jsonify({'tags': tags})
finally:
conn.close()
@crm_bp.route('/tags', methods=['POST'])
@require_auth('customers.edit')
def create_new_tag():
data = request.get_json() or {}
if not data.get('name'):
return jsonify({'error': 'name is required'}), 400
conn = get_tenant_conn(g.tenant_id)
try:
tag_id = create_tag(conn, g.tenant_id, data['name'],
color=data.get('color', '#6B7280'),
description=data.get('description'))
return jsonify({'id': tag_id, 'message': 'Tag created'}), 201
finally:
conn.close()
@crm_bp.route('/customers/<int:customer_id>/tags', methods=['GET'])
@require_auth('customers.view')
def get_customer_tags_endpoint(customer_id):
conn = get_tenant_conn(g.tenant_id)
try:
tags = get_customer_tags(conn, customer_id)
return jsonify({'tags': tags})
finally:
conn.close()
@crm_bp.route('/customers/<int:customer_id>/tags', methods=['POST'])
@require_auth('customers.edit')
def assign_customer_tag(customer_id):
data = request.get_json() or {}
tag_id = data.get('tag_id')
if not tag_id:
return jsonify({'error': 'tag_id is required'}), 400
conn = get_tenant_conn(g.tenant_id)
try:
assign_tag(conn, customer_id, tag_id, assigned_by=getattr(g, 'employee_id', None))
return jsonify({'message': 'Tag assigned'})
finally:
conn.close()
@crm_bp.route('/customers/<int:customer_id>/tags/<int:tag_id>', methods=['DELETE'])
@require_auth('customers.edit')
def remove_customer_tag(customer_id, tag_id):
conn = get_tenant_conn(g.tenant_id)
try:
remove_tag(conn, customer_id, tag_id)
return jsonify({'message': 'Tag removed'})
finally:
conn.close()
# ─── Loyalty ─────────────────────────────
@crm_bp.route('/customers/<int:customer_id>/loyalty', methods=['GET'])
@require_auth('customers.view')
def get_loyalty(customer_id):
conn = get_tenant_conn(g.tenant_id)
try:
history = get_loyalty_history(conn, customer_id)
# Get current balance
cur = conn.cursor()
cur.execute("SELECT loyalty_points_balance, loyalty_tier FROM customers WHERE id = %s", (customer_id,))
row = cur.fetchone()
cur.close()
return jsonify({
'balance': row[0] or 0,
'tier': row[1] or 'bronze',
'history': history,
})
finally:
conn.close()
@crm_bp.route('/customers/<int:customer_id>/loyalty/add', methods=['POST'])
@require_auth('customers.edit')
def add_loyalty(customer_id):
data = request.get_json() or {}
points = int(data.get('points', 0))
if points <= 0:
return jsonify({'error': 'points must be > 0'}), 400
conn = get_tenant_conn(g.tenant_id)
try:
point_id = add_loyalty_points(
conn, customer_id, points,
points_type=data.get('points_type', 'earned'),
source_type=data.get('source_type'),
source_id=data.get('source_id'),
description=data.get('description'),
expires_at=data.get('expires_at'),
)
return jsonify({'id': point_id, 'message': f'{points} points added'}), 201
finally:
conn.close()
@crm_bp.route('/customers/<int:customer_id>/loyalty/redeem', methods=['POST'])
@require_auth('customers.edit')
def redeem_loyalty(customer_id):
data = request.get_json() or {}
points = int(data.get('points', 0))
if points <= 0:
return jsonify({'error': 'points must be > 0'}), 400
conn = get_tenant_conn(g.tenant_id)
try:
redemption_id = redeem_points(
conn, customer_id, points,
reward_id=data.get('reward_id'),
reward_value=data.get('reward_value'),
description=data.get('description'),
employee_id=getattr(g, 'employee_id', None),
)
return jsonify({'id': redemption_id, 'message': f'{points} points redeemed'}), 201
except ValueError as e:
return jsonify({'error': str(e)}), 400
# ─── Rewards Catalog ─────────────────────────────
@crm_bp.route('/rewards', methods=['GET'])
@require_auth('customers.view')
def get_rewards():
conn = get_tenant_conn(g.tenant_id)
try:
rewards = list_rewards(conn, g.tenant_id)
return jsonify({'rewards': rewards})
finally:
conn.close()
@crm_bp.route('/rewards', methods=['POST'])
@require_auth('customers.edit')
def create_new_reward():
data = request.get_json() or {}
if not data.get('name') or data.get('points_cost') is None:
return jsonify({'error': 'name and points_cost are required'}), 400
conn = get_tenant_conn(g.tenant_id)
try:
reward_id = create_reward(
conn, g.tenant_id, data['name'], int(data['points_cost']),
reward_type=data.get('reward_type', 'discount'),
reward_value=data.get('reward_value'),
description=data.get('description'),
)
return jsonify({'id': reward_id, 'message': 'Reward created'}), 201
finally:
conn.close()
# ─── Customer Analytics ─────────────────────────────
@crm_bp.route('/customers/<int:customer_id>/analytics', methods=['GET'])
@require_auth('customers.view')
def customer_analytics(customer_id):
conn = get_tenant_conn(g.tenant_id)
try:
analytics = get_customer_analytics(conn, customer_id)
return jsonify(analytics)
finally:
conn.close()