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.
_apiKpis, _apiDiarias, _apiMargen* …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ño | Filas | Lotes | Duración | Filas/seg |
|---|---|---|---|---|
| Ago 2024 | 8.004 | 4 | 9 s | 889 |
| Sep 2024 | 18.582 | 8 | 22 s | 845 |
| Abr 2026 | 22.546 | 8 | 37 s | 609 |
| May 2026 (parcial) | 13.556 | 6 | 25 s | 542 |
Promedio: ~700 filas/seg. Un sync mensual completo (~20K filas) toma 25-40 segundos.
Modo importación masiva (reinit_import.php)
Dos modos:
- Reset:
action=reset→ DROPventas+sincronizaciones→ CREATE → loop 21 meses. - 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ón | Output | Usado en |
|---|---|---|
getSucursales($syncId) | Lista de sucursales únicas | Selector de filtro |
getSincronizaciones($sucursal) | Todos los períodos completos con KPIs | index.php (cards) |
getSincronizacionById($id) | Un sync puntual | dashboard.php |
loadDashboardDataFromApi($sid, $suc) | ★ Bundle completo del dashboard | dashboard.php |
_apiKpis($db, $sid, $suc) | Ventas, costo, margen, comprobantes, bultos, peso, etc. | Resumen Ejecutivo |
_apiDiarias($db, $sid, $suc) | Serie temporal de ventas/día | Chart "Ventas Diarias" |
_apiSupervisores / _apiProvincias / _apiEmpresas / _apiLocalidades | Agregados por dimensión | Tablas + charts |
_apiProvSup / _apiProvDep | Cruce proveedor × supervisor/depósito | Resumen Cruzado |
_apiRankings($db, $sid, $suc) | Top 10 / Bottom 10 por 5 categorías | Sección Rankings |
_apiEvolucion12M($db, $sid, $suc) | 12 meses con vta, margen, comp. | Chart histórico |
_apiMargenVendedores / Supervisores / Depositos | Margen por estructura | Tabs Rentabilidad (3 de 6) |
_apiMargenClientes / Articulos / Proveedores | Margen por entidad | Tabs Rentabilidad (3 de 6) |
_apiMercaderiaSinCargo | Bonificaciones / promos | KPI específico |
_apiABCData | Pareto A/B/C × 3 cats | Sección ABC |
_apiClientesComportamiento | Activos / Nuevos / Recurrentes / En fuga | Gestión Comercial |
_apiAnulados | Comprobantes anulados | Sección Anulados |
_apiDevoluciones | Notas de crédito (subtotal < 0) | Sección Devoluciones |
_apiMovimientosSinArticulo | NDCONs informativos | KPI 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.625tabFmtTons(val)—471,32 tntabFmtTotal(val)— fila TOTAL en strong + ámbartabFmtMargenBar(cell)— barra horizontal con color semáforo
bottomCalc (cómo agregar cada columna en fila TOTAL)
| Helper | Lógica | Aplica a |
|---|---|---|
bcSum | Suma absoluta | Bultos · Tn · Comp · Vta · Costo · Contrib |
bcMargenPonderado | SUM(contrib) / SUM(ventas) | % Margen |
bcTicketPonderado | SUM(vta) / SUM(cant) | Ticket promedio |
bcPctTotal | Siempre 100,00 % | % Participación |
bcMaxDistinct | Devuelve 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
sessionIdse reusa por headerCookie: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.
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
| 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 % | ✓ |
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 vendedeposito— métricas del depósito + top 15 proveedoresprovincia— métricas de la provincia + top localidadesvendedor— 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)paraid. - ✅ 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
| ID | Hallazgo | Línea | Prioridad |
|---|---|---|---|
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.