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:
365
scripts/setup_metabase.py
Normal file
365
scripts/setup_metabase.py
Normal 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()
|
||||
Reference in New Issue
Block a user