- 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
523 lines
20 KiB
Python
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
|