6.1 🎯 Overview del módulo
Módulo aparentemente chico (6 archivos, ~1.600 LOC) pero
estratégicamente crítico: enriquece todas las queries de venta con
peso real, proveedor canónico y tipo de mercadería. Es el único módulo que usa
cross-module bootstrap: venta/db_reportes.php dispara
articulos_ensure_schema() al crear el PDO.
codigo · 66 cols tipadasventa · caja/finanzas · listasUsos cross-module reales
- Venta: enriquecer peso por línea con fallback. Si el catálogo trae
peso > 0se usacat_peso × cantidad; si no, fallback apesoTotalde la API. - Caja/Finanzas: cruzar rentabilidad de venta con costos logísticos requiere peso correcto en toneladas.
- Listas: backfill de
visible_mobiledesdearticulos_raw.usado_disp_movil.
6.2 🗄️ Schema (5 tablas)
articulos_raw (principal, 66 columnas)
Columnas tipadas según los tipos sugeridos por el ERP en la fila 2 del Excel (ENTERO, CARACTER, LOGICO, FECHA, DECIMAL). Las más usadas en cross-module:
| Columna | Tipo | Usada por |
|---|---|---|
codigo | INT PK | JOIN venta · listas · agrupaciones |
descripcion | VARCHAR(255) | (no usada en queries, sólo display) |
peso | DECIMAL(12,4) | ★ venta (cálculo toneladas) |
proveedor_id | INT | articulos (filtros) |
proveedor_razon_social | VARCHAR(255) | articulos (display) |
cod_tipo_mercaderia | INT | articulos (segmentación) |
desc_tipo_mercaderia | VARCHAR(100) | articulos (display) |
cod_unidad_medida | INT | articulos |
desc_unidad_medida | VARCHAR(50) | articulos |
unidad_x_bulto | INT | (potencial: caja → bulto) |
bultos_x_pallet | INT | (potencial: logística) |
anulado | VARCHAR(5) | articulos (filtro vigentes) |
usado_disp_movil | VARCHAR(5) | listas (backfill visible_mobile) |
updated_at | TIMESTAMP | todos (staleness) |
Índices
PRIMARY KEY (codigo)
KEY idx_proveedor (proveedor_id)
KEY idx_anulado (anulado)
KEY idx_tipo_merc (cod_tipo_mercaderia)
KEY idx_unidad_med (cod_unidad_medida)
Tablas auxiliares
| Tabla | Origen | Filas reales |
|---|---|---|
articulos_raw_proveedores | Hoja Proveedores del Excel | ~71 |
articulos_raw_tipos_mercaderia | Hoja Tipo Mercadería | vacía |
articulos_raw_unidades_medida | Hoja Unidades de Medida | vacía |
articulos_raw_tipos_presentacion | Hoja Tipos de Presentación | ~1 |
-- Estructura común de las auxiliares
CREATE TABLE articulos_raw_<aux> (
codigo INT PRIMARY KEY, -- VARCHAR(50) para tipos_presentacion
descripcion VARCHAR(255),
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
);
6.3 🔗 Cross-module JOIN con venta
Bootstrap automático
// venta/includes/db_reportes.php
require_once __DIR__ . '/../../articulos/api/_schema.php';
function getApiDB(): PDO {
static $pdo = null;
if ($pdo) return $pdo;
$pdo = new PDO(...);
// ★ Garantiza que articulos_raw exista aunque nunca se haya cargado
articulos_ensure_schema($pdo);
return $pdo;
}
Patrón del JOIN en venta
SELECT ...
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 ...
Lógica del peso (cascade)
┌──────────────────────────────────────────────────────────────┐ │ Para cada línea de venta: │ ├──────────────────────────────────────────────────────────────┤ │ │ │ 1) ¿articulos_raw.peso > 0 para este código? │ │ ├── SÍ → peso_linea = cat_peso × cantidadesTotal │ │ └── NO ↓ │ │ │ │ 2) ¿ventas.pesoTotal NOT NULL? │ │ ├── SÍ → peso_linea = pesoTotal (vino de la API) │ │ └── NO ↓ │ │ │ │ 3) peso_linea = 0 │ │ │ └──────────────────────────────────────────────────────────────┘
Por qué subquery derivada
El JOIN usa (SELECT codigo, peso AS cat_peso FROM articulos_raw) a en vez
de articulos_raw a directamente porque:
articulos_rawtiene columnas con los mismos nombres queventas(anulado,peso, etc.) → causaría ambigüedad.- Exponer solo
codigoy renombrarpeso → cat_pesoevita conflictos sin tocar el resto del SQL.
CAST(idArticulo AS UNSIGNED)
En el dump real, ventas.idArticulo es INT (no VARCHAR como dice
el doc). El CAST es legacy y puede eliminarse para que el optimizer use el índice
directamente. Si idArticulo no es numérico o es 0, el JOIN no matchea
(los códigos válidos empiezan en 1).
articulos_raw
(rango → punto). En tablas de 515K filas × 7.870 SKUs el costo del JOIN se reduce
~30% (estimación con EXPLAIN teórico).
6.4 📤 sync.php — carga del Excel
Flujo completo
Usuario Browser (sync.php) Server (api/*.php)
│ │ │
│ drag plantillaArticulosAR.xlsx │
├──────────────────────►│ │
│ │ SheetJS XLSX.read(buffer) │
│ │ → wb (workbook en memoria) │
│ │ │
│ click "Cargar" │ │
├──────────────────────►│ │
│ │ │
│ │ POST init.php {truncate:true}│
│ ├─────────────────────────────►│
│ │ │ articulos_ensure_schema()
│ │ │ TRUNCATE 5 tablas
│ │◄─────────────────────────────┤ {ok, total_articulos: 0}
│ │ │
│ │ sheetToRows(wb, 'articulos') │
│ │ → 7.870 rows (desde fila 3) │
│ │ │
│ │ ╔══ LOOP lote 300 filas ══╗ │
│ │ ║ POST save.php ║ │
│ │ ║ {tabla:'articulos', ║ │
│ │ ║ rows: lote} ║──┼──► INSERT ... ON DUP KEY UPDATE
│ │ ║ ║ │ (66 columnas mapeadas)
│ │ ║ 27 lotes × ~1.3 seg ║ │
│ │ ╚═════════════════════════╝ │
│ │ │
│ │ Procesar 4 hojas auxiliares │
│ │ (proveedores, tipos_merc, │
│ │ unidades_med, tipos_pres) │
│ ├─────────────────────────────►│ INSERT ... ON DUP KEY UPDATE
│ │ │
│ ✅ "7.870 SKUs" │ │
│◄──────────────────────┤ │
Helpers de coerción en save.php
function f($v) // string → float (descarta separadores raros)
function i($v) // string → int o null
function s($v, $max=null) // trim + opcional mb_substr
function d($v) // serial Excel | DD/MM/YYYY | YYYY-MM-DD → YYYY-MM-DD
Identificación tolerante de hojas
function sheetToRows(wb, name) {
// Busca hoja por substring case-insensitive
// Tolera "Artículos" / "Articulos" / "ARTICULOS"
const target = name.toLowerCase();
const sn = wb.SheetNames.find(s => s.toLowerCase().includes(target));
if (!sn) return null;
return XLSX.utils.sheet_to_json(wb.Sheets[sn], { header: 1, defval: '' });
}
Esto evita errores cuando el ERP cambia el case o quita acentos en futuras exports.
6.5 📋 index.php — landing del módulo
6 KPIs
- Total catálogo —
COUNT(*) - Vigentes —
anulado <> 'SI'(verde) - Anulados —
anulado = 'SI' - Con peso cargado — semáforo: 🟢 >80% / 🟡 50-80% / 🔴 <50%
- Con proveedor — semáforo igual
- Última actualización —
MAX(updated_at)
3 tablas
- Top 15 Proveedores (con barra de participación)
- Top 10 Tipos de Mercadería
- Top 6 Unidades de Medida
Empty state
📦 El catálogo está vacío
Subí el archivo plantillaArticulosAR.xlsx exportado desde Chess ERP
para inicializar el catálogo maestro de artículos.
[⬆ Cargar catálogo ahora]
6.6 ⚠️ Hallazgos específicos del módulo
| ID | Hallazgo | Prioridad |
|---|---|---|
ART-01 |
Sin versionado (version.php no existe). Cada cambio queda sin trazabilidad. |
Medio |
ART-02 |
Catálogos tipos_mercaderia y unidades_medida vacíos en BD pese a estar declarados. |
Bajo |
ART-03 |
sync.php hace TRUNCATE al inicio — si falla la carga subsiguiente el catálogo queda vacío. |
Medio |
ART-04 |
Sin auth en sync.php · cualquiera puede borrar el catálogo. |
Crítico |
ART-05 |
66 columnas mapeadas por posición en el row JSON — si el Excel cambia el orden de columnas, todo desalinea silenciosamente. | Alto |
ART-06 |
Sin history table: no se puede ver "qué peso tenía SKU X hace 3 meses". | Bajo |
6.7 🚀 Propuestas específicas del módulo
P-ART-1 · Crear version.php
// articulos/version.php
<?php
define('ARTICULOS_VERSION', '1.0.0');
define('ARTICULOS_VERSION_DATE', '2026-05-21');
define('ARTICULOS_VERSION_LABEL', 'v' . ARTICULOS_VERSION . ' · ' . ARTICULOS_VERSION_DATE);
P-ART-2 · TRUNCATE seguro con transaction
// En vez de TRUNCATE inmediato:
$pdo->beginTransaction();
try {
$pdo->exec("CREATE TABLE articulos_raw_tmp LIKE articulos_raw");
// INSERT en _tmp
$pdo->exec("RENAME TABLE articulos_raw TO articulos_raw_old,
articulos_raw_tmp TO articulos_raw");
$pdo->exec("DROP TABLE articulos_raw_old");
$pdo->commit();
} catch (Exception $e) {
$pdo->rollback();
// El catálogo viejo sigue intacto
}
P-ART-3 · Header-based column mapping
En vez de mapear por posición:
// Frontend: enviar headers en vez de array
{
tabla: 'articulos',
rows: [{
codigo: 1234, descripcion: "...", peso: 0.5, ...
}]
}
// Backend: insertar con dictionary lookup
$stmt->execute([
$r['codigo'] ?? null,
$r['descripcion'] ?? null,
...
]);
Resistente a cambios de orden de columnas en el Excel.
P-ART-4 · Tabla de history
CREATE TABLE articulos_raw_history (
id BIGINT PK AI,
codigo INT NOT NULL,
snapshot_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
changed_by VARCHAR(50),
diff_json JSON, -- columnas que cambiaron
full_row JSON -- snapshot completo (denormalizado)
);
-- Trigger AFTER UPDATE en articulos_raw que inserta el row anterior
-- Permite ver "¿qué peso tenía SKU 1234 el 2025-12-01?"
P-ART-5 · UI de detalle por SKU
Hoy index.php solo muestra agregados. Agregar:
articulos/sku.php?codigo=1234con todas las 66 columnas.- Historial de ventas del SKU (últimos 12 meses) via cross-JOIN con
ventas. - Botón "editar" (cuando exista auth) para corregir peso/proveedor sin re-subir el Excel completo.
P-ART-6 · Cron de validación cruzada
Job nocturno que cruza:
- SKUs en
ventasque no están enarticulos_raw→ flag "código fantasma". - SKUs en
articulos_rawsin ventas en 6 meses → flag "candidato a descatalogar". - SKUs con
peso = 0pero ventas significativas → email al admin del catálogo.