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

365
scripts/setup_metabase.py Normal file
View File

@@ -0,0 +1,365 @@
#!/usr/bin/env python3
"""Automated Metabase setup for Nexus Autoparts KPIs.
Performs first-time setup if needed, then creates:
- PostgreSQL database connection (master + tenant template)
- Collection "Nexus KPIs"
- Pre-built questions (cards) for common refaccionaria metrics
- Dashboard grouping those cards
Usage:
export METABASE_URL=http://localhost:3000
export METABASE_ADMIN_EMAIL=admin@nexus.local
export METABASE_ADMIN_PASS=changeme123
export MASTER_DB_URL=postgresql://nexus:pass@localhost/nexus_autoparts
python3 scripts/setup_metabase.py
"""
import os
import sys
import json
import time
import requests
METABASE_URL = os.environ.get('METABASE_URL', 'http://localhost:3000').rstrip('/')
ADMIN_EMAIL = os.environ.get('METABASE_ADMIN_EMAIL', 'admin@nexus.local')
ADMIN_PASS = os.environ.get('METABASE_ADMIN_PASS', '')
MASTER_DB_URL = os.environ.get('MASTER_DB_URL', '')
DB_CONFIG_PATH = os.path.expanduser('~/.nexus_metabase_config.json')
def _get(url, session=None):
headers = {}
if session:
headers['X-Metabase-Session'] = session
r = requests.get(url, headers=headers)
return r
def _post(url, data, session=None):
headers = {'Content-Type': 'application/json'}
if session:
headers['X-Metabase-Session'] = session
r = requests.post(url, headers=headers, json=data)
return r
def get_setup_token():
r = _get(f"{METABASE_URL}/api/session/properties")
return r.json().get('setup-token')
def do_setup(token):
"""Create first admin user and initial DB connection."""
if not ADMIN_PASS:
print("ERROR: METABASE_ADMIN_PASS is required for first-time setup.")
sys.exit(1)
# Parse DB connection
db_name = 'nexus_autoparts'
db_user = 'nexus'
db_pass = ''
if MASTER_DB_URL:
# postgresql://user:pass@host/db
rest = MASTER_DB_URL.replace('postgresql://', '')
if '@' in rest:
auth, host_db = rest.split('@', 1)
if ':' in auth:
db_user, db_pass = auth.split(':', 1)
else:
db_user = auth
if '/' in host_db:
host_port, db_name = host_db.split('/', 1)
else:
host_port = host_db
host = host_port.split(':')[0]
else:
host = 'localhost'
else:
host = 'host.docker.internal'
payload = {
'token': token,
'user': {
'first_name': 'Admin',
'last_name': 'Nexus',
'email': ADMIN_EMAIL,
'site_name': 'Nexus Autoparts',
'password': ADMIN_PASS,
},
'prefs': {
'site_name': 'Nexus Autoparts',
'site_locale': 'es',
'allow_tracking': False,
},
'database': {
'engine': 'postgres',
'name': 'Nexus Master DB',
'details': {
'host': host,
'port': 5432,
'dbname': db_name,
'user': db_user,
'password': db_pass,
'ssl': False,
'tunnel-enabled': False,
},
'auto_run_queries': True,
'is_full_sync': True,
'is_on_demand': False,
}
}
r = _post(f"{METABASE_URL}/api/setup", payload)
if r.status_code not in (200, 201):
print(f"Setup failed: {r.status_code} {r.text}")
sys.exit(1)
data = r.json()
session_id = data.get('id')
print(f"Setup complete. Admin user created: {ADMIN_EMAIL}")
return session_id
def login():
"""Login existing user."""
r = _post(f"{METABASE_URL}/api/session", {
'username': ADMIN_EMAIL,
'password': ADMIN_PASS,
})
if r.status_code != 200:
print(f"Login failed: {r.status_code} {r.text}")
return None
return r.json().get('id')
def get_or_create_collection(session, name="Nexus KPIs"):
"""Get existing collection or create new."""
r = _get(f"{METABASE_URL}/api/collection", session)
for c in r.json():
if c.get('name') == name:
return c['id']
r = _post(f"{METABASE_URL}/api/collection", {
'name': name,
'color': '#509EE3',
'description': 'Dashboards y métricas para Nexus Autoparts'
}, session)
return r.json()['id']
def create_question(session, collection_id, name, sql, display='table', visualization_settings=None):
"""Create a native SQL question (card)."""
payload = {
'name': name,
'dataset_query': {
'type': 'native',
'native': {
'query': sql,
'template-tags': {},
},
'database': None, # Will be set from first available DB
},
'display': display,
'collection_id': collection_id,
'visualization_settings': visualization_settings or {},
}
# Find first available database
dbs = _get(f"{METABASE_URL}/api/database", session).json()
if 'data' in dbs and dbs['data']:
payload['dataset_query']['database'] = dbs['data'][0]['id']
else:
print("WARNING: No databases found in Metabase. Skipping question creation.")
return None
r = _post(f"{METABASE_URL}/api/card", payload, session)
if r.status_code in (200, 201):
return r.json()['id']
print(f"Card creation failed ({name}): {r.status_code} {r.text[:200]}")
return None
def create_dashboard(session, collection_id, name):
"""Create a dashboard."""
r = _post(f"{METABASE_URL}/api/dashboard", {
'name': name,
'collection_id': collection_id,
'description': 'KPIs principales de la refaccionaria',
}, session)
if r.status_code in (200, 201):
return r.json()['id']
print(f"Dashboard creation failed: {r.status_code} {r.text[:200]}")
return None
def add_card_to_dashboard(session, dashboard_id, card_id, row, col, size_x=6, size_y=4):
"""Add a card to a dashboard grid."""
payload = {
'cardId': card_id,
'row': row,
'col': col,
'sizeX': size_x,
'sizeY': size_y,
}
r = _post(f"{METABASE_URL}/api/dashboard/{dashboard_id}/cards", payload, session)
return r.status_code in (200, 201)
def main():
print("Nexus Autoparts — Metabase Setup")
print("=" * 50)
# Verify Metabase is reachable
try:
health = requests.get(f"{METABASE_URL}/api/health").json()
if health.get('status') != 'ok':
print("ERROR: Metabase is not ready.")
sys.exit(1)
except Exception as e:
print(f"ERROR: Cannot reach Metabase: {e}")
sys.exit(1)
# Determine if first-time setup is needed
token = get_setup_token()
session = None
if token:
print("First-time setup detected...")
session = do_setup(token)
else:
print("Metabase already set up. Logging in...")
if not ADMIN_PASS:
print("ERROR: METABASE_ADMIN_PASS required to login.")
sys.exit(1)
session = login()
if not session:
print("ERROR: Could not obtain Metabase session.")
sys.exit(1)
# Save config
config = {
'metabase_url': METABASE_URL,
'admin_email': ADMIN_EMAIL,
'session_id': session,
}
with open(DB_CONFIG_PATH, 'w') as f:
json.dump(config, f)
os.chmod(DB_CONFIG_PATH, 0o600)
# Create collection
coll_id = get_or_create_collection(session)
print(f"Collection ID: {coll_id}")
# Create questions
questions = []
q1 = create_question(session, coll_id,
"Ventas por día (últimos 30 días)",
"""
SELECT DATE(created_at) as fecha, COUNT(*) as ventas, SUM(total) as total
FROM sales
WHERE status = 'completed'
AND created_at >= CURRENT_DATE - INTERVAL '30 days'
GROUP BY fecha
ORDER BY fecha DESC;
""",
display='line',
visualization_settings={"graph.dimensions":["fecha"],"graph.metrics":["ventas","total"]}
)
if q1: questions.append((q1, 0, 0, 6, 4))
q2 = create_question(session, coll_id,
"Top 10 productos vendidos",
"""
SELECT si.part_number, si.name, SUM(si.quantity) as cantidad, SUM(si.subtotal) as revenue
FROM sale_items si
JOIN sales s ON si.sale_id = s.id
WHERE s.status = 'completed'
GROUP BY si.part_number, si.name
ORDER BY cantidad DESC
LIMIT 10;
""",
display='bar'
)
if q2: questions.append((q2, 0, 6, 6, 4))
q3 = create_question(session, coll_id,
"Stock bajo (reorder alerts abiertas)",
"""
SELECT i.part_number, i.name, ra.stock_at_alert, ra.threshold, b.name as branch
FROM reorder_alerts ra
JOIN inventory i ON ra.inventory_id = i.id
LEFT JOIN branches b ON ra.branch_id = b.id
WHERE ra.status = 'open'
ORDER BY ra.stock_at_alert ASC;
"""
)
if q3: questions.append((q3, 4, 0, 6, 4))
q4 = create_question(session, coll_id,
"Ventas por sucursal (este mes)",
"""
SELECT b.name as sucursal, COUNT(*) as ventas, SUM(s.total) as total
FROM sales s
LEFT JOIN branches b ON s.branch_id = b.id
WHERE s.status = 'completed'
AND s.created_at >= DATE_TRUNC('month', CURRENT_DATE)
GROUP BY sucursal
ORDER BY total DESC;
""",
display='pie'
)
if q4: questions.append((q4, 4, 6, 6, 4))
q5 = create_question(session, coll_id,
"Clientes con más compras",
"""
SELECT c.name, COUNT(*) as compras, SUM(s.total) as total
FROM sales s
JOIN customers c ON s.customer_id = c.id
WHERE s.status = 'completed'
GROUP BY c.name
ORDER BY total DESC
LIMIT 10;
"""
)
if q5: questions.append((q5, 8, 0, 6, 4))
q6 = create_question(session, coll_id,
"Margen de ganancia por producto",
"""
SELECT si.name,
SUM(si.unit_cost * si.quantity) as costo_total,
SUM(si.subtotal) as venta_total,
ROUND(((SUM(si.subtotal) - SUM(si.unit_cost * si.quantity)) / NULLIF(SUM(si.subtotal), 0)) * 100, 2) as margen_pct
FROM sale_items si
JOIN sales s ON si.sale_id = s.id
WHERE s.status = 'completed'
GROUP BY si.name
HAVING SUM(si.subtotal) > 0
ORDER BY margen_pct DESC
LIMIT 10;
"""
)
if q6: questions.append((q6, 8, 6, 6, 4))
# Create dashboard
if questions:
dash_id = create_dashboard(session, coll_id, "Nexus KPIs — Panel Principal")
if dash_id:
for card_id, row, col, sx, sy in questions:
add_card_to_dashboard(session, dash_id, card_id, row, col, sx, sy)
print(f"Dashboard created: {METABASE_URL}/dashboard/{dash_id}")
else:
print("Dashboard creation skipped.")
else:
print("No questions created (no database connected yet).")
print("\nDone. Access Metabase at:", METABASE_URL)
print(f"Admin email: {ADMIN_EMAIL}")
print(f"Config saved to: {DB_CONFIG_PATH}")
if __name__ == '__main__':
main()