Files
Autoparts-DB/manager/services/portfolio_service.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

523 lines
20 KiB
Python

"""Portfolio operations service: per-tenant business metrics rollup, health
scores and cross-tenant anomaly detection for the manager.
Design notes
------------
* The daily rollup lives in the **master** database table
``tenant_metrics_daily`` (one row per tenant per day). It is created
idempotently here and by ``scripts/rollup_tenant_metrics.py`` — the tenant
migration runner is intentionally not involved.
* Point-in-time counts (inventory, customers, employees, db size, fitment)
are snapshots taken at collection time; daily activity metrics (sales,
new customers, audit events, API calls) are computed for the requested day.
* Data sources are guarded: optional tables (``audit_log``,
``api_request_logs``, ``inventory_vehicle_compat``) may be absent in an older
tenant and simply yield 0 / NULL instead of failing the rollup.
* DSNs are resolved from environment exactly like the manager config
(``MASTER_DB_URL`` / ``TENANT_DB_URL_TEMPLATE``) with a local fallback, so the
script can run standalone without the manager's required secrets.
"""
import os
from datetime import date as date_cls
from datetime import datetime, time, timedelta
from zoneinfo import ZoneInfo
import psycopg2
from psycopg2.extras import Json
from services.business_metrics import (
DEFAULT_Z,
MIN_COHORT,
compute_health,
detect_portfolio_anomalies,
)
BUSINESS_TZ = os.environ.get("PORTFOLIO_TZ", "America/Mexico_City")
_PGUSER = os.environ.get("PGUSER", "postgres")
MASTER_DB_URL = (
os.environ.get("MASTER_DB_URL")
or os.environ.get("DATABASE_URL")
or f"postgresql://{_PGUSER}@localhost/nexus_autoparts"
)
TENANT_DB_URL_TEMPLATE = (
os.environ.get("TENANT_DB_URL_TEMPLATE")
or f"postgresql://{_PGUSER}@localhost/{{db_name}}"
)
DDL = """
CREATE TABLE IF NOT EXISTS tenant_metrics_daily (
tenant_id INTEGER NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
date DATE NOT NULL,
ventas_count INTEGER NOT NULL DEFAULT 0,
ventas_total NUMERIC(14,2) NOT NULL DEFAULT 0,
ticket_promedio NUMERIC(12,2) NOT NULL DEFAULT 0,
ventas_canceladas INTEGER NOT NULL DEFAULT 0,
clientes_nuevos INTEGER NOT NULL DEFAULT 0,
items_activos INTEGER NOT NULL DEFAULT 0,
skus INTEGER NOT NULL DEFAULT 0,
clientes INTEGER NOT NULL DEFAULT 0,
empleados_activos INTEGER NOT NULL DEFAULT 0,
fitment_pct NUMERIC(5,2),
uso_modulos JSONB NOT NULL DEFAULT '{}'::jsonb,
ai_calls INTEGER NOT NULL DEFAULT 0,
api_calls INTEGER NOT NULL DEFAULT 0,
errores_5xx INTEGER NOT NULL DEFAULT 0,
actividad_count INTEGER NOT NULL DEFAULT 0,
ultima_venta_at TIMESTAMPTZ,
ultima_actividad_at TIMESTAMPTZ,
db_size_mb NUMERIC(12,2),
collected_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
PRIMARY KEY (tenant_id, date)
);
CREATE INDEX IF NOT EXISTS idx_tenant_metrics_daily_date
ON tenant_metrics_daily (date);
"""
UPSERT = """
INSERT INTO tenant_metrics_daily (
tenant_id, date, ventas_count, ventas_total, ticket_promedio,
ventas_canceladas, clientes_nuevos, items_activos, skus, clientes,
empleados_activos, fitment_pct, uso_modulos, ai_calls, api_calls,
errores_5xx, actividad_count, ultima_venta_at, ultima_actividad_at,
db_size_mb, collected_at
) VALUES (
%(tenant_id)s, %(date)s, %(ventas_count)s, %(ventas_total)s,
%(ticket_promedio)s, %(ventas_canceladas)s, %(clientes_nuevos)s,
%(items_activos)s, %(skus)s, %(clientes)s, %(empleados_activos)s,
%(fitment_pct)s, %(uso_modulos)s, %(ai_calls)s, %(api_calls)s,
%(errores_5xx)s, %(actividad_count)s, %(ultima_venta_at)s,
%(ultima_actividad_at)s, %(db_size_mb)s, NOW()
)
ON CONFLICT (tenant_id, date) DO UPDATE SET
ventas_count = EXCLUDED.ventas_count,
ventas_total = EXCLUDED.ventas_total,
ticket_promedio = EXCLUDED.ticket_promedio,
ventas_canceladas = EXCLUDED.ventas_canceladas,
clientes_nuevos = EXCLUDED.clientes_nuevos,
items_activos = EXCLUDED.items_activos,
skus = EXCLUDED.skus,
clientes = EXCLUDED.clientes,
empleados_activos = EXCLUDED.empleados_activos,
fitment_pct = EXCLUDED.fitment_pct,
uso_modulos = EXCLUDED.uso_modulos,
ai_calls = EXCLUDED.ai_calls,
api_calls = EXCLUDED.api_calls,
errores_5xx = EXCLUDED.errores_5xx,
actividad_count = EXCLUDED.actividad_count,
ultima_venta_at = EXCLUDED.ultima_venta_at,
ultima_actividad_at = EXCLUDED.ultima_actividad_at,
db_size_mb = EXCLUDED.db_size_mb,
collected_at = NOW()
"""
def get_master_conn():
return psycopg2.connect(MASTER_DB_URL, connect_timeout=10)
def tenant_dsn(db_name):
return TENANT_DB_URL_TEMPLATE.format(db_name=db_name)
def ensure_metrics_table(conn=None):
"""Create ``tenant_metrics_daily`` if missing. Idempotent."""
own = conn is None
conn = conn or get_master_conn()
try:
cur = conn.cursor()
cur.execute(DDL)
conn.commit()
cur.close()
finally:
if own:
conn.close()
def list_tenants(conn=None, include_inactive=True):
own = conn is None
conn = conn or get_master_conn()
try:
cur = conn.cursor()
where = "" if include_inactive else "WHERE t.is_active = true"
cur.execute(f"""
SELECT t.id, t.name, t.db_name, t.plan, t.is_active, t.created_at,
s.expires_at
FROM tenants t
LEFT JOIN subscriptions s ON s.tenant_id = t.id
{where}
ORDER BY t.id
""")
cols = [d[0] for d in cur.description]
rows = [dict(zip(cols, r)) for r in cur.fetchall()]
cur.close()
return rows
finally:
if own:
conn.close()
# ─── Collection ────────────────────────────────────────────────────────────
def _q(cur, sql, params=None, default=None):
"""Run a scalar query, returning ``default`` if the object is missing."""
try:
cur.execute(sql, params or ())
row = cur.fetchone()
return row[0] if row and row[0] is not None else default
except Exception:
cur.connection.rollback()
return default
def _load_modules(cur):
cfg = {}
try:
cur.execute("SELECT key, value FROM tenant_config")
cfg = dict(cur.fetchall())
except Exception:
cur.connection.rollback()
def flag(key):
return (cfg.get(key) or "").lower() == "true"
return {
"module_whatsapp": flag("module_whatsapp"),
"module_marketplace": flag("module_marketplace"),
"module_meli": flag("module_meli"),
"module_catalog": flag("module_catalog"),
"whatsapp_enabled": flag("whatsapp_enabled"),
"vehicle_compat_source": cfg.get("vehicle_compat_source"),
"facturapi": bool(cfg.get("cfdi_facturapi_key")),
}
def _collect_tenant(tenant, start_dt, end_dt, dates):
"""Collect one tenant's rollup rows for ``dates`` inside [start, end)."""
conn = psycopg2.connect(tenant_dsn(tenant["db_name"]), connect_timeout=10)
cur = conn.cursor()
try:
items_activos = _q(cur, "SELECT COUNT(*) FROM inventory WHERE is_active", default=0)
skus = _q(cur, "SELECT COUNT(DISTINCT part_number) FROM inventory "
"WHERE is_active AND part_number IS NOT NULL", default=0)
clientes = _q(cur, "SELECT COUNT(*) FROM customers WHERE is_active", default=0)
empleados = _q(cur, "SELECT COUNT(*) FROM employees WHERE is_active", default=0)
db_size = _q(cur, "SELECT pg_database_size(current_database()) / 1048576.0")
ultima_venta = _q(cur, "SELECT MAX(created_at) FROM sales "
"WHERE status = 'completed'")
ultima_actividad = _q(cur, "SELECT MAX(created_at) FROM audit_log")
modules = _load_modules(cur)
icv = _q(cur, "SELECT to_regclass('public.inventory_vehicle_compat') IS NOT NULL",
default=False)
if icv:
fit = _q(cur, "SELECT COUNT(*) FROM inventory i WHERE i.is_active AND ("
"i.vehicle_compatibility IS NOT NULL OR EXISTS ("
"SELECT 1 FROM inventory_vehicle_compat c "
"WHERE c.inventory_id = i.id))", default=0)
else:
fit = _q(cur, "SELECT COUNT(*) FROM inventory WHERE is_active "
"AND vehicle_compatibility IS NOT NULL", default=0)
fitment_pct = round(100.0 * fit / items_activos, 2) if items_activos else None
# Daily sales (completed / cancelled).
sales = {}
try:
cur.execute("""
SELECT (created_at AT TIME ZONE %s)::date,
COUNT(*) FILTER (WHERE status = 'completed'),
COALESCE(SUM(total) FILTER (WHERE status = 'completed'), 0),
COUNT(*) FILTER (WHERE status = 'cancelled')
FROM sales
WHERE created_at >= %s AND created_at < %s
GROUP BY 1
""", (BUSINESS_TZ, start_dt, end_dt))
for d, vc, vt, vx in cur.fetchall():
sales[d] = (vc or 0, float(vt or 0), vx or 0)
except Exception:
conn.rollback()
nuevos = {}
try:
cur.execute("""
SELECT (created_at AT TIME ZONE %s)::date, COUNT(*)
FROM customers
WHERE created_at >= %s AND created_at < %s
GROUP BY 1
""", (BUSINESS_TZ, start_dt, end_dt))
nuevos = dict(cur.fetchall())
except Exception:
conn.rollback()
actividad = {}
try:
cur.execute("""
SELECT (created_at AT TIME ZONE %s)::date, COUNT(*)
FROM audit_log
WHERE created_at >= %s AND created_at < %s
GROUP BY 1
""", (BUSINESS_TZ, start_dt, end_dt))
actividad = dict(cur.fetchall())
except Exception:
conn.rollback()
apis = {}
has_api = _q(cur, "SELECT to_regclass('public.api_request_logs') IS NOT NULL",
default=False)
if has_api:
try:
cur.execute("""
SELECT (created_at AT TIME ZONE %s)::date,
COUNT(*),
COUNT(*) FILTER (WHERE status_code >= 500),
COUNT(*) FILTER (WHERE path ~*
'(/ai/|/chat|vision|semantic|classif|categoriz|/ml/)')
FROM api_request_logs
WHERE created_at >= %s AND created_at < %s
GROUP BY 1
""", (BUSINESS_TZ, start_dt, end_dt))
for d, calls, err5, ai in cur.fetchall():
apis[d] = (calls or 0, err5 or 0, ai or 0)
except Exception:
conn.rollback()
finally:
cur.close()
conn.close()
rows = []
for d in dates:
vc, vt, vx = sales.get(d, (0, 0.0, 0))
api_calls, err5, ai = apis.get(d, (0, 0, 0))
rows.append({
"tenant_id": tenant["id"],
"date": d,
"ventas_count": vc,
"ventas_total": round(vt, 2),
"ticket_promedio": round(vt / vc, 2) if vc else 0,
"ventas_canceladas": vx,
"clientes_nuevos": nuevos.get(d, 0),
"items_activos": items_activos or 0,
"skus": skus or 0,
"clientes": clientes or 0,
"empleados_activos": empleados or 0,
"fitment_pct": fitment_pct,
"uso_modulos": Json(modules),
"ai_calls": ai,
"api_calls": api_calls,
"errores_5xx": err5,
"actividad_count": actividad.get(d, 0),
"ultima_venta_at": ultima_venta,
"ultima_actividad_at": ultima_actividad,
"db_size_mb": round(db_size, 2) if db_size is not None else None,
})
return rows
def run_rollup(date=None, days=1, tenant_ids=None, include_inactive=True):
"""Collect and upsert rollup rows. Idempotent on (tenant_id, date).
``date`` is the last day of the range (defaults to today in BUSINESS_TZ);
``days`` extends the range backwards (1 == a single day).
"""
tz = ZoneInfo(BUSINESS_TZ)
end_date = date or datetime.now(tz).date()
if isinstance(end_date, str):
end_date = date_cls.fromisoformat(end_date)
dates = [end_date - timedelta(days=i) for i in range(days - 1, -1, -1)]
start_dt = datetime.combine(dates[0], time.min, tzinfo=tz)
end_dt = datetime.combine(dates[-1] + timedelta(days=1), time.min, tzinfo=tz)
conn = get_master_conn()
try:
ensure_metrics_table(conn)
tenants = list_tenants(conn, include_inactive=include_inactive)
if tenant_ids:
tenants = [t for t in tenants
if t["id"] in tenant_ids or t["db_name"] in tenant_ids]
written = 0
errors = []
per_tenant = []
for t in tenants:
try:
rows = _collect_tenant(t, start_dt, end_dt, dates)
except Exception as e: # noqa: BLE001 — report and continue
errors.append({"tenant_id": t["id"], "db_name": t["db_name"],
"error": str(e)})
continue
cur = conn.cursor()
try:
for row in rows:
cur.execute(UPSERT, row)
conn.commit()
written += len(rows)
per_tenant.append({
"tenant_id": t["id"], "tenant_name": t["name"],
"db_name": t["db_name"], "rows": len(rows),
})
except Exception as e: # noqa: BLE001
conn.rollback()
errors.append({"tenant_id": t["id"], "db_name": t["db_name"],
"error": str(e)})
finally:
cur.close()
finally:
conn.close()
return {
"date_from": str(dates[0]),
"date_to": str(dates[-1]),
"days": days,
"rows_written": written,
"tenants": per_tenant,
"errors": errors,
}
# ─── Readers ───────────────────────────────────────────────────────────────
METRIC_FIELDS = [
"ventas_count", "ventas_total", "ticket_promedio", "ventas_canceladas",
"clientes_nuevos", "items_activos", "skus", "clientes",
"empleados_activos", "fitment_pct", "ai_calls", "api_calls",
"errores_5xx", "actividad_count", "db_size_mb",
]
def _serialize_row(row):
out = {}
for k, v in row.items():
if isinstance(v, (datetime, date_cls)):
out[k] = v.isoformat()
elif hasattr(v, "quantize"): # Decimal
out[k] = float(v)
else:
out[k] = v
return out
def _latest_date(conn):
cur = conn.cursor()
try:
cur.execute("SELECT MAX(date) FROM tenant_metrics_daily")
row = cur.fetchone()
return row[0] if row else None
finally:
cur.close()
def get_tenant_metrics(days=14, include_inactive=True):
"""Rollup window + health score for every tenant (manager API payload)."""
days = max(1, min(int(days), 90))
conn = get_master_conn()
try:
ensure_metrics_table(conn)
latest = _latest_date(conn)
tenants = list_tenants(conn, include_inactive=include_inactive)
if latest is None:
return {
"rollup_available": False,
"date": None,
"window_days": days,
"notes": {
"hint": "Sin datos; ejecuta scripts/rollup_tenant_metrics.py",
"activity_proxy": "audit_log (no se persisten logins individuales)",
},
"tenants": [
{"tenant_id": t["id"], "name": t["name"],
"db_name": t["db_name"], "plan": t["plan"],
"is_active": t["is_active"],
"health": compute_health(t, []), "metrics": None}
for t in tenants
],
}
start = latest - timedelta(days=days - 1)
cur = conn.cursor()
cur.execute("""
SELECT tenant_id, date, ventas_count, ventas_total, ticket_promedio,
ventas_canceladas, clientes_nuevos, items_activos, skus,
clientes, empleados_activos, fitment_pct, uso_modulos,
ai_calls, api_calls, errores_5xx, actividad_count,
ultima_venta_at, ultima_actividad_at, db_size_mb
FROM tenant_metrics_daily
WHERE date >= %s AND date <= %s
ORDER BY tenant_id, date
""", (start, latest))
cols = [d[0] for d in cur.description]
by_tenant = {}
for r in cur.fetchall():
row = dict(zip(cols, r)) # raw: tz-aware datetimes for health
by_tenant.setdefault(row["tenant_id"], []).append(row)
cur.close()
out = []
for t in tenants:
history = by_tenant.get(t["id"], [])
health = compute_health(t, history)
out.append({
"tenant_id": t["id"],
"name": t["name"],
"db_name": t["db_name"],
"plan": t["plan"],
"is_active": t["is_active"],
"health": health,
"metrics": _serialize_row(history[-1]) if history else None,
})
finally:
conn.close()
return {
"rollup_available": True,
"date": str(latest),
"window_days": days,
"notes": {
"activity_proxy": "audit_log (no se persisten logins individuales)",
"point_in_time": "items/skus/clientes/empleados/fitment son snapshot al momento del rollup",
},
"tenants": out,
}
def get_portfolio_anomalies(date=None, z_threshold=DEFAULT_Z, min_tenants=MIN_COHORT,
include_inactive=False):
"""Cross-tenant anomaly report built from the daily rollup."""
conn = get_master_conn()
try:
ensure_metrics_table(conn)
target = date or _latest_date(conn)
if target is None:
return detect_portfolio_anomalies([], date=None,
min_tenants=min_tenants,
z_threshold=z_threshold)
where_active = "" if include_inactive else "AND t.is_active = true"
cur = conn.cursor()
cur.execute(f"""
SELECT m.tenant_id, t.name, m.date,
m.ventas_total, m.ventas_count, m.ticket_promedio,
m.clientes_nuevos, m.actividad_count, m.errores_5xx,
m.items_activos, m.skus, m.clientes, m.fitment_pct
FROM tenant_metrics_daily m
JOIN tenants t ON t.id = m.tenant_id
WHERE m.date = %s {where_active}
ORDER BY m.tenant_id
""", (target,))
cols = [d[0] for d in cur.description]
rows = []
for r in cur.fetchall():
row = _serialize_row(dict(zip(cols, r)))
row["tenant_name"] = row.pop("name")
rows.append(row)
cur.close()
finally:
conn.close()
report = detect_portfolio_anomalies(rows, date=target,
min_tenants=min_tenants,
z_threshold=z_threshold)
report["tenants_scanned"] = len(rows)
return report