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 = NULLcantidadesTotal = 0preciocomprant = 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
| Caso | ChessERP | Dashboard | Match |
|---|---|---|---|
| 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ón | 20,34 % | 20,34 % | ✓ |
| Cliente SAMARELLI · % Contribución | 25,20 % | 25,20 % | ✓ |
| SGO | PABLO MOLINA · % Contribución | 21,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
- Query top 300 ordenado por monto descendente.
- Loop en PHP calculando % acumulado.
- 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'
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 runway | Nivel | Acción sugerida |
|---|---|---|
| < 1 | 🚨 Crítico | Inyectar capital · congelar compras no esenciales |
| 1 – 3 | 🔶 Naranja | Acelerar cobranza · negociar plazos con proveedores |
| 3 – 6 | ⚠️ Amarillo | Monitoreo semanal · revisar costos variables |
| ≥ 6 | ✅ Verde | Continuar 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íasa_15_dias— vence en 8-15 díasa_30_dias— vence en 16-30 díasa_60_dias— vence en 31-60 díasa_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
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