8.1 💰 Margen ChessERP (la fórmula maestra)

┌────────────────────────────────────────────────────────────────┐
│              VARIABLES                                          │
├────────────────────────────────────────────────────────────────┤
│  Vta            = SUM(subtotalNeto)                            │
│                   ↑ TODAS las líneas (FC + NC + NDCON + …)     │
│                                                                │
│  Vta_reales     = SUM(subtotalNeto                             │
│                       WHERE dsArticulo IS NOT NULL)            │
│                   ↑ Sólo líneas con artículo                   │
│                                                                │
│  Costo (Compra) = SUM(preciocomprant × cantidadesTotal)        │
│                   ↑ NDCONs aportan 0 (cantidad = 0)            │
├────────────────────────────────────────────────────────────────┤
│              FÓRMULA                                            │
├────────────────────────────────────────────────────────────────┤
│                                                                │
│  Margen $        = Vta_reales − Costo                          │
│                                                                │
│  % Contribución  = Margen / Vta_reales                         │
│                                                                │
└────────────────────────────────────────────────────────────────┘

Por qué la fórmula es compuesta

Los NDCONs (Notas de Débito por cheque rechazado, intereses por mora, recargos, ajustes contables) son movimientos financieros sin mercadería:

  • dsArticulo = NULL
  • cantidadesTotal = 0
  • preciocomprant = NULL

Sí entran en Vta (comercialmente son ingresos al cliente).
NO entran en el margen (sin costo aparecerían como margen 100% distorsionando todo).

Implementación en SQL

const SUM_VENTAS_REALES = "SUM(CASE WHEN dsArticulo IS NOT NULL AND dsArticulo != ''
                                    THEN subtotalNeto ELSE 0 END)";
const SUM_COSTO_NETO    = "SUM(preciocomprant * cantidadesTotal)";
const EXPR_CONTRIBUCION = "(" . SUM_VENTAS_REALES . " - " . SUM_COSTO_NETO . ")";
const EXPR_MARGEN_PCT   = "CASE WHEN " . SUM_VENTAS_REALES . " > 0
                                THEN " . EXPR_CONTRIBUCION . " / " . SUM_VENTAS_REALES . "
                                ELSE 0 END";

Verificaciones con datos reales

CasoChessERPDashboardMatch
ZONAS ARIDAS · Abril 2026 · Vta$ 591.307.925,25$ 591.307.925
ZONAS ARIDAS · Abril 2026 · Compra$ 470.823.825,58$ 470.823.826
ZONAS ARIDAS · Abril 2026 · % Contribución20,34 %20,34 %
Cliente SAMARELLI · % Contribución25,20 %25,20 %
SGO | PABLO MOLINA · % Contribución21,09 %21,09 %

Coherencia con COALESCE en GROUP BY

Para que todos los tabs de Rentabilidad muestren el mismo TOTAL, las queries usan COALESCE en GROUP BY en lugar de filtrar con IS NOT NULL:

SELECT COALESCE(NULLIF(TRIM(<campo>),''), '(SIN ETIQUETA)') AS <alias>,
       SUM(subtotalNeto) AS ventas,
       ...
FROM ventas
WHERE sync_id=? AND anulado='NO'    -- ★ SIN filtro IS NOT NULL
GROUP BY <alias>;

Las líneas sin valor (NDCONs por cheque rechazado, intereses, recargos) se agrupan en una fila etiquetada (SIN ARTÍCULO) / (SIN PROVEEDOR) en lugar de excluirse. Así el TOTAL del tab coincide con el resto del dashboard.

8.2 ⚖️ Peso por línea (cascade)

peso_linea_kg = IFNULL(
                  NULLIF(articulos_raw.peso, 0) * cantidadesTotal,    -- 1) catálogo
                  COALESCE(ventas.pesoTotal, 0)                       -- 2) fallback API
                )
peso_total_kg = SUM(peso_linea_kg)
toneladas     = peso_total_kg / 1000

Diagrama de decisión

   ┌─────────────────────────────────────────────────────────────┐
   │ Para cada línea de venta:                                   │
   ├─────────────────────────────────────────────────────────────┤
   │                                                             │
   │  1) ¿articulos_raw.peso > 0 para idArticulo?                │
   │     ├── SÍ → peso = cat_peso × cantidadesTotal              │
   │     └── NO ↓                                                │
   │                                                             │
   │  2) ¿ventas.pesoTotal NOT NULL y > 0?                       │
   │     ├── SÍ → peso = pesoTotal (vino de la API)              │
   │     └── NO ↓                                                │
   │                                                             │
   │  3) peso = 0                                                │
   │                                                             │
   └─────────────────────────────────────────────────────────────┘

Beneficio: incluso si la API ChessERP no envía pesoTotal (caso común en artículos viejos), el cálculo de toneladas sigue funcionando si el catálogo está cargado.

8.3 🔢 Análisis ABC (Pareto)

Aplicado a 3 categorías: clientes, artículos, proveedores.

Algoritmo

  1. Query top 300 ordenado por monto descendente.
  2. Loop en PHP calculando % acumulado.
  3. Clasificación:
    • A — pct_acum ≤ 80%
    • B — pct_acum ≤ 95%
    • C — pct_acum > 95%

SQL

SELECT $campo AS nombre,
       SUM(subtotalNeto)    AS monto,
       SUM(cantidadesTotal) AS bultos,
       SUM(... peso ...)    AS peso_kg
FROM ventas LEFT JOIN articulos_raw a ...
WHERE sync_id=? AND anulado='NO'
  AND dsArticulo IS NOT NULL    -- excluye NDCONs (no son rankeable)
  AND subtotalNeto >= 0
  AND $campo IS NOT NULL
GROUP BY $campo
ORDER BY monto DESC
LIMIT 300;

Output por ítem

{ nombre, monto, bultos, peso_kg, clase (A|B|C), pct_acum (2 decimales) }

8.4 🎫 Ticket promedio

ticket_promedio = SUM(ventas) / COUNT(DISTINCT comprobante)

// CBTE_KEY = CONCAT(idEmpresa,'-',idDocumento,'-',letra,'-',
//                   IFNULL(serie,0),'-',IFNULL(nrodoc,0))

El comprobante único se identifica con CBTE_KEY (clave compuesta). Una misma FC tiene N líneas (una por artículo) → COUNT(*) contaría duplicados.

En la fila TOTAL del Tabulator

bcTicketPonderado = SUM(ventas) / SUM(cantidad)
// "cantidad" en este contexto = COUNT(DISTINCT CBTE_KEY) agregado de cada fila

Esto da el ticket promedio real en vez de un promedio de promedios (que sería incorrecto).

8.5 📑 IVA crédito y débito

IVA Crédito (compras)

iva_credito_total = SUM(iva_total) FROM caja_raw_movimiento_resumido
                    WHERE (anulado IS NULL OR anulado <> 'SI')
                      AND YEAR(fecha) = ?

// Discriminado por tasa:
iva_21        = SUM(iva_21)
iva_27        = SUM(iva_27)
iva_10_5      = SUM(iva_10_5)
iva_otras     = SUM(iva_otras_tasas)

IVA Débito (ventas) — aproximación

iva_debito_aprox = SUM(subtotalNeto) * 0.21 FROM ventas
                   WHERE sync_id IN (los del año) AND anulado='NO'
⚠️ CAJ-08 · Limitación conocida
El débito se aproxima al 21% genérico. Si la facturación tiene tasas mixtas (productos con 10.5% o exentos) queda subestimado. Mejora futura: discriminar por tipo de comprobante usando una columna como tipoIvaArticulo.

Saldo técnico

saldo_iva = iva_debito - iva_credito
// > 0 → a pagar (típico)
// < 0 → a favor (puede pasar en meses de mucha compra)

8.6 ⏳ Runway (Tesorería · Alertas)

Fórmulas

saldo_actual      = caja_raw_egresos_caja.total_saldo_final (último mes)
egreso_promedio   = AVG(total_egresos) últimos 6 meses
ingreso_promedio  = AVG(total_ingresos) últimos 6 meses

runway_defensivo  = saldo_actual / egreso_promedio
                    ↑ asume INGRESOS = 0 (escenario pesimista)

runway_estresado  = saldo_actual / (egreso_promedio - ingreso_promedio * 0.3)
                    ↑ asume caída del 70% en ingresos

Niveles de alerta

Meses runwayNivelAcción sugerida
< 1🚨 CríticoInyectar capital · congelar compras no esenciales
1 – 3🔶 NaranjaAcelerar cobranza · negociar plazos con proveedores
3 – 6⚠️ AmarilloMonitoreo semanal · revisar costos variables
≥ 6✅ VerdeContinuar normal · evaluar inversiones

8.7 📅 Aging de cartera (clientes)

8 tramos detallados

SELECT CASE
  WHEN antiguedad_dias = 0    THEN '0_al_dia'     -- al día
  WHEN antiguedad_dias <= 7   THEN '1_7'          -- 1 a 7 días
  WHEN antiguedad_dias <= 15  THEN '8_15'
  WHEN antiguedad_dias <= 30  THEN '16_30'
  WHEN antiguedad_dias <= 60  THEN '31_60'
  WHEN antiguedad_dias <= 90  THEN '61_90'
  WHEN antiguedad_dias <= 180 THEN '91_180'
  ELSE 'mas_180'                                  -- >180 días = crítico
END AS tramo,
COUNT(*) AS n, SUM(saldo_total) AS monto
FROM cashflow_cartera_clientes
WHERE fecha_corte = ?
GROUP BY tramo;

5 tramos del CSV original (más simples)

El CSV del ERP viene pre-agregado en 5 tramos del aging (los del KPI principal):

  • a_7_dias — vence en próximos 7 días
  • a_15_dias — vence en 8-15 días
  • a_30_dias — vence en 16-30 días
  • a_60_dias — vence en 31-60 días
  • a_mas_60_dias — vencido (>60 días)

8.8 🧾 Días a presentar (cheques)

dias_a_presentar = DATEDIFF(fecha_presentacion, fecha_corte)

// Interpretación:
// dias > 30  → ✅ Verde   (cheque futuro tranquilo)
// dias 8-30  → 🔵 Azul    (presentar pronto)
// dias 0-7   → 🟡 Ámbar   (HOY o esta semana)
// dias < 0   → 🔴 Rojo    (vencido sin presentar)

Riesgo de prescripción

Si dias_a_presentar < -30 → cheque prescripto (perdido). Crítico monitorear los que están entre -1 y -29 días.

Concentración por cliente emisor

SELECT cliente_id, cliente_nombre, cuit_cliente,
       COUNT(*) AS n_cheques,
       SUM(importe) AS monto_total,
       SUM(importe) / total_general * 100 AS pct_concentracion
FROM cashflow_cheques_cartera
WHERE fecha_corte = ? AND estado = 'EN CARTERA'
GROUP BY cliente_id
HAVING SUM(importe) > 0
ORDER BY monto_total DESC;

Si un cliente concentra >50% → riesgo crítico si se atrasa (cae la cartera entera).

8.9 📊 Calendario semanal de cashflow

Estimación de cobranza por semana

// Mapeo de tramos del aging a semanas futuras:
if (w == 0) cobr = cli.d7;                      // semana 1: todo el d7
if (w == 1) cobr = cli.d15 / 2;                 // semana 2: mitad del d15
if (w == 2) cobr = cli.d15 / 2 + cli.d30 / 4;   // semana 3: resto d15 + 1/4 d30
if (w == 3) cobr = cli.d30 / 4 + cli.d30 / 4;   // semana 4: 2/4 d30

Neto y saldo proyectado

neto_semana = cobranzas_estim
            + cheques_a_presentar_esta_semana
            - pagos_exactos_esta_semana

saldo_w1 = saldo_caja_hoy + neto_w1
saldo_w2 = saldo_w1       + neto_w2
saldo_w3 = saldo_w2       + neto_w3
saldo_w4 = saldo_w3       + neto_w4
⚠️ CSH-02 · Heurística aproximada
La distribución por semana es arbitraria (cuartos). Mejora futura: usar regresión sobre histórico de cobranza por cliente/vendedor (ver §11 P-CSH-4).

8.10 📈 Evolución 12 meses

Para el chart histórico del dashboard, dos queries:

Query 1: identificar los 12 últimos sync_ids

SELECT id, mes, anio FROM sincronizaciones
WHERE estado='completado' ORDER BY anio DESC, mes DESC LIMIT 12;

Query 2: agregar métricas para esos 12

SELECT sync_id,
       SUM(subtotalNeto)         AS ventas,
       SUM_VENTAS_REALES         AS ventas_reales,
       SUM_COSTO_NETO            AS costo,
       EXPR_CONTRIBUCION         AS contribucion,
       COUNT(DISTINCT CBTE_KEY)  AS cantidad,
       SUM(cantidadesTotal)      AS bultos,
       SUM(... peso ...)         AS peso_kg
FROM ventas LEFT JOIN articulos_raw a ...
WHERE sync_id IN (?, ?, ?, ..., ?)
  AND anulado='NO'
GROUP BY sync_id;

Output cronológico

[
  { mes, anio, label: 'May 25',  ventas, costo, contribucion,
    margen: 0.2103, cantidad, bultos, es_actual: false },
  { mes, anio, label: 'Jun 25', ... },
  ...
  { mes, anio, label: 'Abr 26', es_actual: true }
]

8.11 🤝 Comportamiento de cartera

Compara el sync actual vs el anterior. Genera 4 listas:

  • Activos: clientes con compra en sync actual
  • Nuevos: en actual pero no en anterior
  • Recurrentes: en ambos
  • En fuga: en anterior pero no en actual

Query base (se ejecuta 2 veces)

SELECT idCliente,
       MAX(nombreCliente) AS nombre,
       MAX(dsVendedor)    AS vendedor,
       SUM(subtotalNeto)  AS monto
FROM ventas
WHERE sync_id=? AND anulado='NO'
  AND dsArticulo IS NOT NULL AND subtotalNeto > 0
  AND idCliente IS NOT NULL
GROUP BY idCliente;

Luego se cruzan en PHP con array_diff / array_intersect.

Cruce comercial 6 meses (cashflow)

SELECT v.idCliente,
       COUNT(DISTINCT v.sync_id)             AS meses_activo_6m,
       SUM(v.subtotalNeto)                   AS venta_6m,
       MAX(v.fechaComprobate)                AS ultima_compra,
       DATEDIFF(?, MAX(v.fechaComprobate))   AS dias_sin_comprar,
       SUM(CASE WHEN v.dsArticulo IS NULL THEN v.subtotalNeto ELSE 0 END) AS ndcons_monto
FROM ventas v
WHERE v.sync_id IN (últimos 6 meses)
  AND v.anulado = 'NO' AND v.idCliente IS NOT NULL
GROUP BY v.idCliente;

8.12 📊 Margen limpio + $/Tonelada (Finanzas)

Cruza datos de venta con gastos de caja para calcular rentabilidad real:

// Fuentes
Ventas netas     = SUM(subtotalNeto) FROM ventas
Ventas reales    = SUM(subtotalNeto WHERE dsArticulo IS NOT NULL)
Costo mercadería = SUM(preciocomprant * cantidadesTotal)
Peso (ton)       = SUM(cantidadesTotal * peso) / 1000

// Cálculos
Margen bruto     = Ventas reales − Costo mercadería
Margen limpio    = Margen bruto − Gastos de Caja totales
% Margen limpio  = Margen limpio / Ventas reales

// Clasificación de gastos por rubro (heurística)
Costo logístico  = SUM(monto WHERE rubro LIKE '%COMBUSTIBLE%' / '%FLETE%' / ...)
Costo admin      = SUM(monto WHERE rubro LIKE '%SUELDO%' / '%SERVICIO%' / ...)

// Métricas finales
$/Tonelada       = Costo logístico / Peso (ton)
Admin/Venta      = Costo admin / Ventas netas  (%)

Heurísticas de clasificación

$rubrosLogisticos = ['COMBUSTIBLE','FLETE','TRANSPORTE','LUBRICANTE',
                     'PEAJE','GOMERIA','REPUESTO'];

$rubrosAdmin      = ['SALARIO','SUELDO','HONORARIO','SERVICIO','TELEFONIA',
                     'INTERNET','ALQUILER','SEGURO','BANCO','IMPUESTO'];

// Match: UPPER(rubro) LIKE '%KEY%' por cada palabra

Lectura del KPI

  • $/Tonelada bajo → operación logística eficiente
  • $/Tonelada alto → costo combustible/flete creciendo más rápido que volumen
  • Admin/Venta < 8% → estructura administrativa razonable
  • Admin/Venta > 15% → estructura sobre-dimensionada para el volumen actual