3.1 🎯 Overview del módulo

El módulo Venta es el más complejo y el más maduro del sistema. Sincroniza las ventas desde la API ChessERP, las persiste en la tabla ventas (~160 columnas), y las analiza con un dashboard rico en KPIs, márgenes, ABC, comportamiento de cartera y evolución de 12 meses.

Versión
v2.4.0
2026-05-21 · cards enriquecidas + sticky navigator
Períodos cargados
22
Ago 2024 → May 2026
Filas totales
515.811
~23K líneas/mes promedio
Funciones de query
20+
_apiKpis, _apiDiarias, _apiMargen*
Tabs Tabulator
6
Rentabilidad: Vend · Sup · Dep · Cli · Art · Prov
dashboard.php
147 KB
~3.200 líneas (HTML+JS+PHP mezclado)

3.2 📂 Archivos del módulo y su rol

venta/
├── config/database.php             ← Constantes BD + API ChessERP + SITE_VERSION
├── includes/
│   ├── chess_api_client.php        ← Cliente HTTP (login, getVentas)
│   ├── db_reportes.php             ← PDO singleton + bootstrap articulos
│   ├── data_api.php                ← ★ 20+ funciones de queries
│   └── functions.php               ← Formatters PHP
├── assets/
│   ├── css/style.css               ← Estilos del módulo
│   └── js/app.js                   ← Helpers UI (fmtMoney, openTab, fullscreen)
├── api/
│   └── filter.php                  ← Endpoint drill-down cross-filter
├── sql/
│   └── schema_reportes.sql         ← DDL de la tabla ventas
│
├── index.php                       ← Listado de períodos sincronizados
├── dashboard.php                   ← ★ Dashboard principal (147 KB)
├── sync_api.php                    ← UI sync individual
├── sync_api_process.php            ← Backend AJAX lote × lote
├── reinit_import.php               ← Importación masiva (reset / resume)
├── monitor.php                     ← Monitor en vivo (auto-refresh 3s)
└── _debug_samarelli.php            ← ⚠️ Debug ad-hoc (debe removerse)

Mapa de dependencias internas

                                    ┌──────────────────┐
                                    │  config/         │
                                    │  database.php    │
                                    └────────┬─────────┘
                                             │ const DB_* + API_*
              ┌──────────────────────────────┤
              │             │                │
              ▼             ▼                ▼
       db_reportes.php   functions.php  chess_api_client.php
       (PDO singleton)   (formatters)   (HTTP a ChessERP)
              │                                │
              │ getApiDB()                     │ ChessApiClient
              ▼                                ▼
       data_api.php  ◄─── functions ◄──── sync_api_process.php
       (queries)                              reinit_import.php
              │
              │ loadDashboardDataFromApi()
              ▼
       dashboard.php (UI) ──── filter.php (cross-filter API)
       index.php
       monitor.php

3.3 🔄 Flujo de sincronización mensual

El sync usa AJAX lote × lote en vez de SSE porque Hostinger mata HTTP largas a los ~4 minutos. El navegador orquesta la secuencia.

Diagrama secuencial

 Usuario          Browser (sync_api.php)        Server (sync_api_process.php)        ChessERP API
   │                       │                              │                              │
   │ click "Sincronizar"   │                              │                              │
   ├──────────────────────►│                              │                              │
   │                       │ POST action=init             │                              │
   │                       │ {mes, anio}                  │                              │
   │                       ├─────────────────────────────►│                              │
   │                       │                              │ DELETE sync previo           │
   │                       │                              │ INSERT sincronizaciones      │
   │                       │                              │   estado='procesando'        │
   │                       │                              │                              │
   │                       │◄─────────────────────────────┤ {ok, sync_id}                │
   │                       │                              │                              │
   │                       │ ╔═══════ LOOP lote=1..N ═════╗                              │
   │                       │ ║                            ║                              │
   │                       │ ║ POST action=lote           ║                              │
   │                       │ ║ {sync_id, lote, mes, anio} ║                              │
   │                       │ ║───────────────────────────►║                              │
   │                       │ ║                            ║ login() (si 1er lote)        │
   │                       │ ║                            ║──────────────────────────────►│
   │                       │ ║                            ║◄─── sessionId ───────────────┤
   │                       │ ║                            ║ GET /ventas?nroLote=N        │
   │                       │ ║                            ║──────────────────────────────►│
   │                       │ ║                            ║◄─── rows[] (200-2000) ───────┤
   │                       │ ║                            ║ INSERT ventas (lote completo)│
   │                       │ ║                            ║                              │
   │                       │ ║◄───────────────────────────║ {ok, filas, finalizado}      │
   │                       │ ║                            ║                              │
   │                       │ ║ progress bar update        ║                              │
   │                       │ ║ si finalizado==false →     ║                              │
   │                       │ ║   lote++ y repite          ║                              │
   │                       │ ╚════════════════════════════╝                              │
   │                       │                              │                              │
   │                       │ POST action=finalizar        │                              │
   │                       │ {sync_id, total_filas, total_lotes}                         │
   │                       ├─────────────────────────────►│                              │
   │                       │                              │ UPDATE sincronizaciones      │
   │                       │                              │   estado='completado'        │
   │                       │                              │   fecha_fin=NOW()            │
   │                       │◄─────────────────────────────┤ {ok}                         │
   │                       │                              │                              │
   │ ✅ "Mes X/Y cargado"  │                              │                              │
   │◄──────────────────────┤                              │                              │
   │                       │                              │                              │

Timing real (medido del dump)

Mes/AñoFilasLotesDuraciónFilas/seg
Ago 20248.00449 s889
Sep 202418.582822 s845
Abr 202622.546837 s609
May 2026 (parcial)13.556625 s542

Promedio: ~700 filas/seg. Un sync mensual completo (~20K filas) toma 25-40 segundos.

Modo importación masiva (reinit_import.php)

Dos modos:

  1. Reset: action=reset → DROP ventas + sincronizaciones → CREATE → loop 21 meses.
  2. Resume (?resume=1): action=estado → JS saltea completados, importa sólo pendientes.

monitor.php?json=1 permite ver el estado en vivo desde otra pestaña con auto-refresh cada 3s.

3.4 ⚙️ includes/data_api.php — el núcleo

50 KB. Define la fórmula ChessERP como constantes reutilizables y 20+ funciones de queries.

Constantes maestras

// Alias de comprobante único (evita duplicados al COUNT DISTINCT)
const CBTE_KEY = "CONCAT(idEmpresa,'-',idDocumento,'-',letra,'-',IFNULL(serie,0),'-',IFNULL(nrodoc,0))";

// Fórmula ChessERP del margen — clave maestra
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";

Helpers privados

  • _sucursalWhere($s) → devuelve "AND dsSucursal = ?" o vacío.
  • _sucursalParams($sid, $s) → devuelve [$sid] o [$sid, $s].
  • _getPrevSyncId($db, $sid) → busca el sync del mes anterior (para comparativas MoM).

Inventario completo de funciones

FunciónOutputUsado en
getSucursales($syncId)Lista de sucursales únicasSelector de filtro
getSincronizaciones($sucursal)Todos los períodos completos con KPIsindex.php (cards)
getSincronizacionById($id)Un sync puntualdashboard.php
loadDashboardDataFromApi($sid, $suc)★ Bundle completo del dashboarddashboard.php
_apiKpis($db, $sid, $suc)Ventas, costo, margen, comprobantes, bultos, peso, etc.Resumen Ejecutivo
_apiDiarias($db, $sid, $suc)Serie temporal de ventas/díaChart "Ventas Diarias"
_apiSupervisores / _apiProvincias / _apiEmpresas / _apiLocalidadesAgregados por dimensiónTablas + charts
_apiProvSup / _apiProvDepCruce proveedor × supervisor/depósitoResumen Cruzado
_apiRankings($db, $sid, $suc)Top 10 / Bottom 10 por 5 categoríasSección Rankings
_apiEvolucion12M($db, $sid, $suc)12 meses con vta, margen, comp.Chart histórico
_apiMargenVendedores / Supervisores / DepositosMargen por estructuraTabs Rentabilidad (3 de 6)
_apiMargenClientes / Articulos / ProveedoresMargen por entidadTabs Rentabilidad (3 de 6)
_apiMercaderiaSinCargoBonificaciones / promosKPI específico
_apiABCDataPareto A/B/C × 3 catsSección ABC
_apiClientesComportamientoActivos / Nuevos / Recurrentes / En fugaGestión Comercial
_apiAnuladosComprobantes anuladosSección Anulados
_apiDevolucionesNotas de crédito (subtotal < 0)Sección Devoluciones
_apiMovimientosSinArticuloNDCONs informativosKPI card

Patrón típico de query (margen por dimensión)

SELECT
    COALESCE(NULLIF(TRIM(<campo>),''), '(SIN ETIQUETA)') AS clave,
    SUM(subtotalNeto)         AS ventas,
    SUM_VENTAS_REALES         AS ventas_reales,
    SUM_COSTO_NETO            AS costo,
    EXPR_CONTRIBUCION         AS contribucion,
    EXPR_MARGEN_PCT           AS margen,
    COUNT(DISTINCT CBTE_KEY)  AS cantidad,
    SUM(cantidadesTotal)      AS bultos,
    SUM(IFNULL(NULLIF(a.cat_peso,0) * cantidadesTotal,
               COALESCE(pesoTotal,0))) AS peso_kg
FROM ventas
LEFT JOIN (SELECT codigo, peso AS cat_peso FROM articulos_raw) a
       ON a.codigo = CAST(idArticulo AS UNSIGNED)
WHERE sync_id = ?
  AND anulado = 'NO'
  -- ★ NO se aplica filtro IS NOT NULL acá: los NDCONs se agrupan en "(SIN ...)"
GROUP BY clave
ORDER BY ventas DESC;

Esto garantiza que el TOTAL del tab coincida con el resto del dashboard (incluyendo NDCONs en Vta pero excluyéndolos del margen).

3.5 📊 dashboard.php

Layout completo

┌─────────────────────────────────────────────────────────────┐
│ HEADER (sticky)                                             │
│  · Logo · Título · Año/Mes botonera · Tema · Volver         │
├─────────────────────────────────────────────────────────────┤
│ ── FILTROS ──                                               │
│  Sucursal · Buscador global                                 │
├─────────────────────────────────────────────────────────────┤
│ ⚡ RESUMEN EJECUTIVO (6 KPI cards)                          │
│  Vta · Margen · Comp. · Bultos · Tn · Clientes              │
├─────────────────────────────────────────────────────────────┤
│ 📊 INDICADORES (más KPIs + supervisores + empresas)         │
├─────────────────────────────────────────────────────────────┤
│ 📈 VENTAS DIARIAS (Chart.js bar + línea)                    │
├─────────────────────────────────────────────────────────────┤
│ 🏢 EMPRESAS (cards)        🧑‍💼 SUPERVISORES (tabla)        │
├─────────────────────────────────────────────────────────────┤
│ 🗺️ PROVINCIAS (tabla expandible + chart)                   │
├─────────────────────────────────────────────────────────────┤
│ 📊 % PARTICIPACIÓN SUPERVISORES (doughnut)                  │
├─────────────────────────────────────────────────────────────┤
│ 💹 RENTABILIDAD DESAGREGADA — 6 TABS Tabulator              │
│   ▸ Vendedor · Supervisor · Depósito                        │
│   ▸ Cliente · Artículo · Proveedor                          │
│   Cada tab: Top 10 verde · Bottom 5 rojo · TOTAL al pie     │
├─────────────────────────────────────────────────────────────┤
│ 🔢 ABC (Pareto) — clientes · artículos · proveedores        │
├─────────────────────────────────────────────────────────────┤
│ 🤝 GESTIÓN COMERCIAL (4 cards clicables: Act/Nuev/Rec/Fug)  │
├─────────────────────────────────────────────────────────────┤
│ 📈 EVOLUCIÓN 12 MESES (chart escala dual)                   │
├─────────────────────────────────────────────────────────────┤
│ 🔙 DEVOLUCIONES · 🚫 ANULADOS · 📋 RESUMEN CRUZADO          │
└─────────────────────────────────────────────────────────────┘

Formatters JS embebidos en dashboard.php

  • tabFmtMoney(val)$ 6.340.570 (negativos en rojo entre paréntesis)
  • tabFmtPct(val)21,09 %
  • tabFmtBultos(val)1.625
  • tabFmtTons(val)471,32 tn
  • tabFmtTotal(val) — fila TOTAL en strong + ámbar
  • tabFmtMargenBar(cell) — barra horizontal con color semáforo

bottomCalc (cómo agregar cada columna en fila TOTAL)

HelperLógicaAplica a
bcSumSuma absolutaBultos · Tn · Comp · Vta · Costo · Contrib
bcMargenPonderadoSUM(contrib) / SUM(ventas)% Margen
bcTicketPonderadoSUM(vta) / SUM(cant)Ticket promedio
bcPctTotalSiempre 100,00 %% Participación
bcMaxDistinctDevuelve null (NO se puede sumar)Clientes · SKUs · Vendedores

Highlight Top 10 / Bottom 5

Sólo en tablas largas (>15 filas). El highlight se calcula sobre el dataset ordenado por ventas descendente y queda fijo: si el usuario reordena la tabla por otra columna, los rows top/bottom mantienen su color.

.tabulator-row.rent-row-top {
  background: rgba(34,197,94, 0.12);   /* verde */
  border-left: 3px solid #22c55e;
}
.tabulator-row.rent-row-bot {
  background: rgba(239,68,68, 0.10);   /* rojo */
  border-left: 3px solid #ef4444;
}

3.6 🌐 chess_api_client.php

class ChessApiClient {
    public function login(): string;
    public function logout(string $sessionId): void;
    public function getVentas(string $sessionId, string $desde,
                              string $hasta, int $nroLote): array;
}

Endpoints externos

  • Login: POST /auth/login{"sessionId":"PHPSESSID=…"}
  • Ventas: GET /ventas/?fechaDesde=…&fechaHasta=…&detallado=true&nroLote=N

Comportamiento

  • El sessionId se reusa por header Cookie: en llamadas subsiguientes.
  • HTTP 401 → sesión expirada → requiere nuevo login (manejado por insertarFilas()).
  • Array [] vacío como respuesta → último lote alcanzado.
  • Típico: 3-15 lotes por mes, 200-2000 filas por lote.
⚠️ Sin retry / timeout / circuit breaker
Si ChessERP da timeout o 5xx el sync falla y queda en estado procesando. El usuario debe ir a monitor.php + reinit_import.php?resume=1 para retomar. Mejora propuesta en §11 RES-01.

3.7 🧮 Fórmula ChessERP del margen (en detalle)

┌────────────────────────────────────────────────────────────────┐
│                    FÓRMULA CHESSERP                            │
├────────────────────────────────────────────────────────────────┤
│                                                                │
│  Vta            = SUM(subtotalNeto)                            │
│                   ↑ TODAS las líneas (FC, NC, NDCON…)          │
│                                                                │
│  Compra         = SUM(preciocomprant × cantidadesTotal)        │
│                   ↑ Costo real (NDCONs aportan 0)              │
│                                                                │
│  Vta_reales     = SUM(subtotalNeto                             │
│                       WHERE dsArticulo IS NOT NULL)            │
│                   ↑ Sólo líneas con artículo (sin NDCONs)      │
│                                                                │
│  Margen $       = Vta_reales − Compra                          │
│                                                                │
│  % 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 financieros, ajustes contables) son movimientos financieros sin mercadería. Tienen dsArticulo = NULL, cantidadesTotal = 0, preciocomprant = NULL.

  • Sí entran en Vta (comercialmente son ingresos al cliente).
  • NO entran en el margen (no tienen costo de mercadería; aparecerían como margen 100% distorsionando todo).

Verificación 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 %

3.8 🎯 Cross-filter (drill-down) — api/filter.php

Endpoint AJAX que devuelve métricas filtradas cuando el usuario hace clic en una fila/celda.

Tipos soportados

  • supervisor — métricas del supervisor + top 15 proveedores que vende
  • deposito — métricas del depósito + top 15 proveedores
  • provincia — métricas de la provincia + top localidades
  • vendedor — métricas del vendedor + posición en ranking

Request / Response típico

// Request
GET /reportes/venta/api/filter.php
    ?id=28              -- sync_id
    &tipo=supervisor
    &valor=LAR | ANDREA SANCHEZ (SUPERVISOR)
    &sucursal=ZONAS ARIDAS

// Response
{
  "success": true,
  "tipo": "supervisor",
  "valor": "LAR | ANDREA SANCHEZ (SUPERVISOR)",
  "ventas":       125640870,
  "costo":         98432110,
  "contribucion":  26408760,
  "margen":              0.2103,
  "bultos":          12340,
  "cantidad":          1432,
  "proveedores": [
    {"proveedor": "PURINA", "ventas": …, "margen": …},
    …  // top 15
  ]
}

Validación de entrada

  • filter_input(INPUT_GET, …, FILTER_VALIDATE_INT) para id.
  • ✅ Whitelist de $tiposValidos = ['supervisor', 'deposito', 'provincia', 'vendedor'].
  • ✅ Prepared statements para todo.
  • ✅ Verifica que el sync existe y está completado antes de seguir.
  • htmlspecialchars() en el valor del eco.

Este archivo es el mejor ejemplo de validación del proyecto. Sería un buen template para refactorizar los demás endpoints (ver §11 SEC-02).

3.9 🐛 Hallazgos específicos del módulo

IDHallazgoLíneaPrioridad
VEN-01 _debug_samarelli.php en producción — archivo de debug ad-hoc. todo el archivo Alto
VEN-02 Sin auth en reinit_import.php — cualquiera con la URL puede borrar todas las ventas. reinit_import.php:1-10 Crítico
VEN-03 Sin set_time_limit(0) en sync_api_process.php — el límite es 180s, podría chocar con lotes lentos. sync_api_process.php:16 Medio
VEN-04 Constantes SUM_VENTAS_REALES, SUM_COSTO_NETO, EXPR_* redefinidas en filter.php (no usa data_api.php). filter.php:17-21 vs data_api.php:27-30 Medio
VEN-05 dashboard.php mezcla PHP + HTML + JS en 147 KB de un solo archivo — muy difícil de mantener. todo Medio
VEN-06 JOIN con CAST(idArticulo AS UNSIGNED) impide uso de índice en articulos_raw.codigo. todas las queries Medio
VEN-07 Sin paginación en dashboard.php: si un período tiene 50K líneas, el HTML inicial pesa > 5 MB. todo el dashboard Bajo
VEN-08 functions.php · cleanSupervisor() hardcodea sufijo (SUPERVISOR) — frágil si el ERP cambia el formato. functions.php Bajo
VEN-09 reinit_import.php hardcodea el rango de meses en JS: for(m=8..12,2024) … for(m=1..5,2026). Cada vez que pasa un mes hay que editarlo manualmente. reinit_import.php Bajo

3.10 🚀 Propuestas específicas del módulo

P-VEN-1 · Refactor de dashboard.php a partials

Dividir el 147 KB en archivos por sección:

venta/dashboard.php (shell)
└── partials/
    ├── _kpis.php
    ├── _diarias.php
    ├── _supervisores.php
    ├── _provincias.php
    ├── _rent_tabs.php        ← los 6 tabs de Rentabilidad
    ├── _abc.php
    ├── _comportamiento.php
    ├── _evolucion12m.php
    ├── _devoluciones.php
    └── _resumen_cruzado.php

Mismo HTML resultante, pero mantenible. Esfuerzo: 1 día.

P-VEN-2 · Extraer la constante del cálculo a un trait/clase

Eliminar la duplicación de las constantes entre data_api.php y filter.php.

// includes/venta_formulas.php
class VentaFormulas {
    public const CBTE_KEY         = "CONCAT(idEmpresa,…)";
    public const SUM_VENTAS_REALES = "SUM(CASE WHEN dsArticulo …)";
    public const SUM_COSTO_NETO    = "SUM(preciocomprant * cantidadesTotal)";
    public const EXPR_CONTRIBUCION = "(" . self::SUM_VENTAS_REALES . " - " . self::SUM_COSTO_NETO . ")";
    public const EXPR_MARGEN_PCT   = "CASE WHEN " . self::SUM_VENTAS_REALES . " > 0 THEN " . self::EXPR_CONTRIBUCION . " / " . self::SUM_VENTAS_REALES . " ELSE 0 END";
}

Esfuerzo: 2 h.

P-VEN-3 · Auto-discover de meses pendientes en reinit_import.php

En vez de hardcodear el rango, calcularlo desde la API:

// Pseudocódigo
const startDate = new Date(2024, 7, 1);  // Ago 2024
const endDate   = new Date();             // hoy
for (let d = startDate; d <= endDate; d.setMonth(d.getMonth()+1)) {
    MESES.push({ mes: d.getMonth()+1, anio: d.getFullYear() });
}

P-VEN-4 · Cambio de tipo de ventas.idArticulo

Actualmente es INT (¡ya es int!), pero el código hace CAST(idArticulo AS UNSIGNED) por hábito legacy. Eliminar el CAST → el optimizer usará el índice de articulos_raw.codigo directamente.

-- En vez de:
LEFT JOIN articulos_raw a ON a.codigo = CAST(idArticulo AS UNSIGNED)
-- Usar:
LEFT JOIN articulos_raw a ON a.codigo = idArticulo

Verificar antes: ¿hay filas con idArticulo negativo o no numérico? El CAST devuelve 0 en esos casos → quizás eso es deseado.

P-VEN-5 · Cron job para sync automático

# Hostinger cron (todos los días a las 03:00 → cargar mes en curso)
0 3 * * *  cd /home/u120688891/public_html/reportes/venta && \
           php sync_cron.php --mes $(date +%-m) --anio $(date +%Y) \
           >> /var/log/za_sync.log 2>&1

Requiere crear sync_cron.php (CLI-only, sin HTML) que llame la misma lógica que sync_api_process.php.

P-VEN-6 · Validación post-sync

Al finalizar un sync, comparar totales con un endpoint del ERP que devuelva el total esperado. Si no coincide → flag estado='warning' + email al admin.

P-VEN-7 · Borrar _debug_samarelli.php

Esfuerzo: 30 seg. Hacerlo ya.