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.

SKUs en catálogo
~7.870
PK codigo · 66 cols tipadas
Versión
sin v formal
Recomendado bumpear a v1.0.0
Tablas
5
1 principal + 4 catálogos auxiliares
Strategy
snapshot
TRUNCATE + reload completo en cada carga
Cross-module
venta · caja/finanzas · listas
Tiempo carga
~35 s
27 lotes de 300 filas

Usos cross-module reales

  1. Venta: enriquecer peso por línea con fallback. Si el catálogo trae peso > 0 se usa cat_peso × cantidad; si no, fallback a pesoTotal de la API.
  2. Caja/Finanzas: cruzar rentabilidad de venta con costos logísticos requiere peso correcto en toneladas.
  3. Listas: backfill de visible_mobile desde articulos_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:

ColumnaTipoUsada por
codigoINT PKJOIN venta · listas · agrupaciones
descripcionVARCHAR(255)(no usada en queries, sólo display)
pesoDECIMAL(12,4)★ venta (cálculo toneladas)
proveedor_idINTarticulos (filtros)
proveedor_razon_socialVARCHAR(255)articulos (display)
cod_tipo_mercaderiaINTarticulos (segmentación)
desc_tipo_mercaderiaVARCHAR(100)articulos (display)
cod_unidad_medidaINTarticulos
desc_unidad_medidaVARCHAR(50)articulos
unidad_x_bultoINT(potencial: caja → bulto)
bultos_x_palletINT(potencial: logística)
anuladoVARCHAR(5)articulos (filtro vigentes)
usado_disp_movilVARCHAR(5)listas (backfill visible_mobile)
updated_atTIMESTAMPtodos (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

TablaOrigenFilas reales
articulos_raw_proveedoresHoja Proveedores del Excel~71
articulos_raw_tipos_mercaderiaHoja Tipo Mercaderíavacía
articulos_raw_unidades_medidaHoja Unidades de Medidavacía
articulos_raw_tipos_presentacionHoja 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_raw tiene columnas con los mismos nombres que ventas (anulado, peso, etc.) → causaría ambigüedad.
  • Exponer solo codigo y renombrar peso → cat_peso evita 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).

💡 Observación PERF-03
Eliminar el CAST permite que MariaDB use el índice PK de 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álogoCOUNT(*)
  • Vigentesanulado <> 'SI' (verde)
  • Anuladosanulado = 'SI'
  • Con peso cargado — semáforo: 🟢 >80% / 🟡 50-80% / 🔴 <50%
  • Con proveedor — semáforo igual
  • Última actualizaciónMAX(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

IDHallazgoPrioridad
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=1234 con 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 ventas que no están en articulos_raw → flag "código fantasma".
  • SKUs en articulos_raw sin ventas en 6 meses → flag "candidato a descatalogar".
  • SKUs con peso = 0 pero ventas significativas → email al admin del catálogo.