Herramientas Informaticas

Etiqueta: PHP Página 1 de 10

🏗️ 1. Arquitectura Híbrida: ¿Por Qué Separar Lectura (ODBC) y Escritura (Service Layer)?

Entrada fija

Cuando abordamos integraciones profesionales con SAP Business One, uno de los errores más graves que cometen los desarrolladores principiantes es intentar escribir directamente en las tablas de la base de datos mediante sentencias SQL INSERT o UPDATE.

⚠️️ El Riesgo de Escribir Directamente en la Base de Datos

Escribir directamente en el motor de base de datos (ya sea SAP HANA o Microsoft SQL Server) no solo viola las directivas de soporte oficial de SAP (invalidando las garantías de mantenimiento del partner), sino que pasa por alto:

  • La ejecución de transacciones concurrentes seguras y bloqueos a nivel de aplicación.
  • Los triggers y mecanismos internos de versionado (LogInst, UserSign, UpdateDate, UpdateTime).
  • La validación de integridad referencial cruzada que los UDOs gestionan internamente.

⚡ El Problema de Usar Service Layer para Consultas Masivas

Por otro lado, depender al 100% de Service Layer para consultar listados extensos, paginaciones en DataTables y filtros complejos con múltiples JOINs puede ralentizar considerablemente la experiencia de usuario. Service Layer es un backend basado en OData/REST que agrega una capa intermedia de serialización JSON y procesamiento HTTP. Para cargar un catálogo con miles de registros en un DataTables interactivo, una llamada OData compleja suele demorar cientos de milisegundos más que una consulta nativa en memoria.

💡 La Solución Elegida: Patrón Híbrido CQRS Simplificado

Nuestra solución adopta lo mejor de ambos mundos mediante una separación limpia de responsabilidades:

                  ┌────────────────────────────────────────────────────────┐
                  │                 Interfaz Web (Frontend)                │
                  │        DataTables / Select2 / Bootstrap / jQuery       │
                  └────────────┬──────────────────────────────▲────────────┘
                               │                              │
                    Lecturas / Filtros AJAX            Respuestas JSON
                               │                              │
                               ▼                              │
┌─────────────────────────────────────────────────────────────┴────────────────────────────────┐
│                       Controlador CodeIgniter 4 (SapUserAuthWHController)                   │
├──────────────────────────────────────────────┬───────────────────────────────────────────────┤
│            FLUJO DE LECTURA (READ)           │           FLUJO DE ESCRITURA (WRITE)          │
│                                              │                                               │
│  - Consultas SELECT optimizadas              │  - Validación de negocio y unicidad           │
│  - Limit / Offset para paginación rápida     │  - Conexión vía cURL a REST API               │
│  - Conexión nativa HDBODBC                   │  - Inyección a UDO oficial 'AutCompra'         │
│  - Consulta directa a HANA en memoria        │  - Login con token de sesión B1SESSION        │
└──────────────────────┬───────────────────────┴───────────────────────▲───────────────────────┘
                       │                                               │
                SQL Nativo (ODBC)                               JSON Payload (REST)
                       │                                               │
                       ▼                                               │
        ┌──────────────────────────────┐                ┌──────────────┴───────────────┐
        │   Base de Datos SAP HANA     │                │   SAP B1 Service Layer       │
        │   OWHS, OUSR, @AUTORIZACOMPRA│                │   Motor OData Transaccional  │
        └──────────────────────────────┘                └──────────────┬───────────────┘
                                                                       │
                                                            Escritura / Validación
                                                                       │
                                                                       ▼
                                                        ┌──────────────────────────────┐
                                                        │   Tablas de Usuario (UDO)    │
                                                        │   @AUTORIZACOMPRA            │
                                                        │   @AUTORIZACOMPRADET         │
                                                        └──────────────────────────────┘
  1. Lectura (Read Engine): Se procesa a través de la extensión odbc de PHP utilizando el driver oficial de SAP HANA (HDBODBC). Ejecuta sentencias SELECT directas contra las tablas maestras (OWHS, OUSR, @AUTORIZACOMPRA, @AUTORIZACOMPRADET) entregando tiempos de respuesta inferiores a 50 milisegundos.
  2. Escritura (Write Engine): Se procesa exclusivamente a través del SAP Service Layer. Las peticiones POST (creación), PATCH (modificación) y DELETE (eliminación) se transmiten en formato JSON respetando la convención de colecciones del UDO registrado.

📊 2. Modelado de Datos: Estructura de las Tablas de Usuario (UDO)

Para comprender cómo interactúa el controlador con el Service Layer, analicemos la estructura exacta de las dos tablas que componen nuestro objeto de negocio.

A. Tabla Cabecera: @AUTORIZACOMPRA (Tipo: Documento / Master Data)

Esta tabla define la entidad principal. Cada registro representa un almacén de SAP que ha sido habilitado para el circuito de autorizaciones:

ColumnaTipo de DatoLongitudDescripción / Función
CodeAlfanumérico50Código del Almacén (FK lógica con OWHS."WhsCode"). Ejemplo: VGZ, PLM.
NameAlfanumérico100Nombre descriptivo del almacén. Ejemplo: VIRTUAL GASOLINA MAZATLAN.
DocEntryNuméricoEnteroIdentificador secuencial autonumérico generado por SAP.
CanceledCarácter1Bandera de cancelación (Y/N).
ObjectAlfanumérico20Identificador del UDO registrado en SAP (AutCompra).
UserSignNuméricoEnteroUsuario que dio de alta el registro.
CreateDateFechaDatetimeFecha de creación del registro en el sistema.
UpdateDateFechaDatetimeFecha de la última modificación.

Un registro real en esta tabla luce de la siguiente manera:

SQL

Code: 'VGZ'
Name: 'VIRUTAL GASOLINA MAZATLAN'
DocEntry: 1
Canceled: 'N'
Object: 'AutCompra'
DataSource: 'I'
CreateDate: '2026-01-05 00:00:00'

B. Tabla Detalle: @AUTORIZACOMPRADET (Tipo: Líneas de Documento)

Esta tabla almacena la relación 1 a N de los usuarios asignados a dicho almacén y las banderas específicas de autorización que poseen:

ColumnaTipo de DatoLongitudDescripción / Función
CodeAlfanumérico50Clave foránea que referencia al Code de la cabecera.
LineIdNuméricoEnteroNúmero consecutivo de línea dentro del documento (1, 2, 3…).
ObjectAlfanumérico20Identificador del UDO (AutCompra).
U_USERIDNumérico/Texto32ID interno del usuario en SAP (corresponde a OUSR."USERID").
U_SolCompCarácter1¿Tiene permiso para crear Solicitudes de Compra? (Y/N).
U_PedidoCarácter1¿Tiene permiso para generar Pedidos / Órdenes de Compra? (Y/N).
U_UserNameAlfanumérico100Nombre real completo del colaborador (ej. EDUARDO GRANADOS).
U_FolioUserAlfanumérico50Código nemotécnico o serie del usuario en SAP (USER_CODE, ej. MZGTEPZA).

Un registro real de línea luce así:

SQL

Code: 'VGZ'
LineId: 1
Object: 'AutCompra'
U_USERID: 235
U_SolComp: 'Y'
U_Pedido: 'Y'
U_UserName: 'EDUARDO GRANADOS'
U_FolioUser: 'MZGTEPZA'

⚙️ 3. El Controlador a Fondo: Implementación de SapUserAuthWHController.php

El controlador es la pieza central encargada de orquestar el flujo de datos. Está diseñado bajo el estándar de CodeIgniter 4, aprovechando traits de respuesta JSON y desacoplando las dependencias mediante modelos modulares.

Examinemos detalladamente los aspectos técnicos más sobresalientes de su código.

3.1 Conexión ODBC y Manejo de Esquemas en SAP HANA

Al conectarse a SAP HANA mediante ODBC, no basta con autenticar la sesión; es imperativo apuntar al esquema exacto donde reside la compañía (companyDB).

PHP

private function connectODBC() {
    $dataConect = $this->serviceLayerModel->first();
    if (!$dataConect) {
        throw new \Exception('No se encontró configuración de conexión SAP.');
    }

    $conn = odbc_connect(
        $dataConect['nameODBC'],
        $dataConect['userODBC'],
        $dataConect['passwordODBC']
    );

    if (!$conn) {
        throw new \Exception('Error conexión ODBC: ' . odbc_errormsg());
    }

    // Fijamos el esquema de la base de datos de SAP en HANA
    if (!odbc_exec($conn, 'SET SCHEMA "' . $dataConect['companyDB'] . '"')) {
        throw new \Exception('Error SET SCHEMA: ' . odbc_errormsg($conn));
    }

    return $conn;
}

Nota de Arquitectura: En SAP HANA, todas las tablas y esquemas deben delimitarse con comillas dobles (") si contienen caracteres especiales o prefijos como @ (ejemplo: "@AUTORIZACOMPRA"). Si se omiten las comillas dobles, el analizador léxico de HANA convertirá el identificador a mayúsculas o arrojará un error de sintaxis inmediata.

3.2 Listado Server-Side para DataTables con Subconsultas

Para evitar el problema de las consultas N+1 al calcular cuántos usuarios tiene configurados cada almacén, implementamos una subconsulta correlacionada directamente en la sentencia de extracción:

PHP

$sql = "
    SELECT
        T0.\"Code\",
        T0.\"Name\",
        T0.\"DocEntry\",
        T0.\"CreateDate\",
        (
            SELECT COUNT(1) 
            FROM \"@AUTORIZACOMPRADET\" D 
            WHERE D.\"Code\" = T0.\"Code\"
        ) AS \"UsersCount\"
    FROM \"@AUTORIZACOMPRA\" T0
    {$whereExtra}
    ORDER BY {$orderBy} {$orderDir}
    LIMIT {$length} OFFSET {$start}
";

Esto permite que el DataTable muestre una insignia dinámica con el número exacto de colaboradores autorizados por cada sucursal sin penalizar el rendimiento del servidor.

3.3 Catálogos Predictivos para Select2: Almacenes y Usuarios SAP

Para proporcionar una experiencia de usuario fluida, el módulo cuenta con dos endpoints ligeros que responden a eventos de búsqueda tipo typeahead (Select2 con AJAX):

Catálogo de Almacenes (getWarehousesAjax)

Consulta los almacenes activos de SAP en la tabla maestra OWHS:

SQL

SELECT "WhsCode", "WhsName"
FROM OWHS
WHERE "Locked" = 'N'
  AND ("WhsCode" LIKE '%BUSQUEDA%' OR "WhsName" LIKE '%BUSQUEDA%')
ORDER BY "WhsCode" ASC
LIMIT 40;

Catálogo de Usuarios (getSapUsersAjax)

Consulta la tabla OUSR para obtener el ID numérico (USERID), el código de usuario (USER_CODE) y el nombre completo (U_NAME). Al mapear la respuesta JSON, asignamos estratégicamente:

PHP

$data[] = [
    'id'        => $userId,                          // 235
    'text'      => $userCode . ' - ' . $userName,    // MZGTEPZA - EDUARDO GRANADOS
    'userCode'  => $userCode,                        // MZGTEPZA
    'userName'  => $userName,                        // EDUARDO GRANADOS
    'folioUser' => $userCode                         // Asignación directa para auto-llenado
];

3.4 Persistencia Transaccional mediante Service Layer

El método save() recibe los datos del formulario, incluyendo el arreglo de filas del detalle serializado en JSON. A continuación, realiza los siguientes pasos críticos:

  1. Validación de Unicidad en Creaciones: Si se trata de un nuevo almacén (isNew === 1), consulta vía ODBC que el Code no exista previamente en @AUTORIZACOMPRA.
  2. Autenticación en Service Layer: Invoca al controlador de autenticación para obtener un SessionId válido y genera la cookie requerida:PHP$cookie = "B1SESSION=" . $conexionSap->SessionId . "; ROUTEID=.node1";
  3. Construcción del Payload OData:En el estándar de Service Layer, cuando un UDO posee tablas hijas, la colección dependiente debe llamarse exactamente con el nombre de la tabla sin el carácter @, seguido del sufijo Collection. Por ende, para @AUTORIZACOMPRADET, la clave obligatoria en el JSON es AUTORIZACOMPRADETCollection:

PHP

$payload = [
    'Name'                        => $name,
    'AUTORIZACOMPRADETCollection' => $linesPayload
];

if ($isNew === 1) {
    $payload['Code'] = $code;
    $url             = $slRoot . "/AutCompra";
    $method          = 'POST';
} else {
    // Al actualizar, se apunta a la clave primaria en la URL
    $url    = $slRoot . "/AutCompra('" . rawurlencode($code) . "')";
    $method = 'PATCH';
}
  1. Registro de Auditoría: Toda operación exitosa se documenta en la bitácora del sistema mediante LogModel, guardando el usuario que ejecutó la acción y la fecha.

💻 4. La Vista Interactiva (sapUserAuthWH.php): Experiencia de Usuario sin Fricción

El frontend fue concebido para minimizar los clics y eliminar la posibilidad de introducir datos inconsistentes. Utiliza Bootstrap 4, AdminLTE 3, DataTables, Select2 y SweetAlert2.

4.1 Estructura del Modal Extendido (modal-xl)

La interfaz del formulario de captura se organiza visualmente en dos bloques bien diferenciados:

  1. Card de Cabecera (Almacén):
    • En modo creación, presenta un selector dinámico Select2 que consulta OWHS. Al seleccionar una bodega, el campo descriptivo Nombre del Almacén se autocompleta inmediatamente.
    • En modo edición, el selector se oculta y en su lugar se presenta un campo de texto plano de solo lectura para evitar alteraciones accidentales de la clave primaria (Code).
  2. Card de Detalle (Usuarios y Derechos):
    • Cuenta con una barra superior de captura rápida compuesta por:
      • Selector AJAX de usuarios SAP (#selectNewUser).
      • Switch de Solicitud de Compra (#checkNewSolComp).
      • Switch de Pedido (#checkNewPedido).
      • Campo de Folio (#inputNewFolioUser), que se auto-rellena con el USER_CODE en el instante en que se selecciona un usuario.
      • Botón de inserción directa con icono +.
    • Una tabla dinámica en memoria (#tableAuthDetails), donde cada fila agregada cuenta con switches activos para alterar permisos sobre la marcha, inputs para ajustar el folio y un botón para eliminar la fila.

4.2 Automatización del Folio de Usuario con Select2

El evento JavaScript que conecta la selección del colaborador con la asignación automática del folio opera de la siguiente manera:

JavaScript

$('#selectNewUser').select2({
    dropdownParent: $('#modalAuthWH'),
    placeholder: 'Buscar usuario SAP...',
    allowClear: true,
    ajax: {
        url: baseControllerUrl + '/getSapUsersAjax',
        dataType: 'json',
        delay: 250,
        data: function (params) {
            return { searchTerm: params.term || '' };
        },
        processResults: function (data) {
            return { results: data.data || [] };
        }
    }
}).on('select2:select', function (e) {
    // Al seleccionar el usuario, recuperamos su USER_CODE y lo asignamos al input
    var selectedData = e.params.data;
    $('#inputNewFolioUser').val(selectedData.userCode || '');
}).on('select2:clear', function () {
    // Si se limpia el selector, reseteamos el campo de folio
    $('#inputNewFolioUser').val('');
});

4.3 Validación de Duplicados en Tiempo Real en el DOM

Para prevenir que un usuario sea dado de alta dos veces en el mismo almacén, antes de insertar la fila se recorre el atributo de datos data-userid de la tabla:

JavaScript

var userId = userData.id;
var exists = false;

$('#tbodyAuthDetails tr').each(function () {
    if ($(this).data('userid') == userId) {
        exists = true;
        return false;
    }
});

if (exists) {
    Swal.fire('Atención', 'El usuario seleccionado ya se encuentra en la lista de autorizaciones.', 'warning');
    return;
}

🚦 5. Configuración de Rutas en CodeIgniter 4

Para integrar este controlador dentro de la estructura de enrutamiento de la aplicación y garantizar que esté protegido por el sistema de control de acceso basado en roles (RBAC), definimos las siguientes directivas dentro del archivo de rutas del módulo:

PHP

// =========================================================================
// RUTAS PARA AUTORIZACIÓN DE ALMACENES SAP (@AUTORIZACOMPRA / AutCompra)
// =========================================================================

// Listado principal y endpoint de datos para DataTables
$routes->get('servicelayer/sapuserauthwh',
    'SapUserAuthWHController::index',
    [
        'filter'    => 'permission:SAPUserAuthWH-permission',
        'namespace' => 'julio101290\boilerplateservicelayer\Controllers'
    ]
);

// Consulta de un almacén y sus usuarios para edición (JSON)
$routes->get('servicelayer/sapuserauthwh/getAuthWH/(:segment)',
    'SapUserAuthWHController::getAuthWH/$1',
    ['namespace' => 'julio101290\boilerplateservicelayer\Controllers']
);

// Catálogo AJAX de almacenes activos (OWHS)
$routes->get('servicelayer/sapuserauthwh/getWarehousesAjax',
    'SapUserAuthWHController::getWarehousesAjax',
    ['namespace' => 'julio101290\boilerplateservicelayer\Controllers']
);

// Catálogo AJAX de usuarios activos de SAP (OUSR)
$routes->get('servicelayer/sapuserauthwh/getSapUsersAjax',
    'SapUserAuthWHController::getSapUsersAjax',
    ['namespace' => 'julio101290\boilerplateservicelayer\Controllers']
);

// Guardado transaccional (Creación POST y Actualización PATCH en Service Layer)
$routes->post('servicelayer/sapuserauthwh/save',
    'SapUserAuthWHController::save',
    ['namespace' => 'julio101290\boilerplateservicelayer\Controllers']
);

// Eliminación de la autorización por almacén
$routes->post('servicelayer/sapuserauthwh/delete/(:segment)',
    'SapUserAuthWHController::delete/$1',
    ['namespace' => 'julio101290\boilerplateservicelayer\Controllers']
);

🔍 6. Casos Reales de Resolución de Problemas (Troubleshooting en SAP HANA)

Durante el desarrollo de esta integración nos topamos con comportamientos particulares de SAP HANA y del Service Layer que vale la pena documentar para ahorrar horas de depuración a otros ingenieros de software.

Problema 1: El Error de Sensibilidad a Mayúsculas en HANA (General error;260 invalid column name: LOCKED)

💥 Síntoma:

Al ejecutar la búsqueda de usuarios de SAP desde el frontend, el backend respondía con un error 500 y el siguiente mensaje de ODBC:

JSON

{
    "data": [],
    "error": true,
    "message": "odbc_exec(): SQL error: [SAP AG][LIBODBCHDB SO][HDBODBC] General error;260 invalid column name: LOCKED: line 4 col 24 (at pos 108), SQL state S1000 in SQLExecDirect"
}

🧐 Causa Raíz:

A diferencia de Microsoft SQL Server (que suele configurarse con intercalaciones Case-Insensitive como SQL_Latin1_General_CP1_CI_AS), SAP HANA es estrictamente Case-Sensitive cuando los nombres de columna se envuelven entre comillas dobles.

En el catálogo interno de SAP, el campo que indica si un usuario está bloqueado se llama "Locked" (con la primera letra mayúscula y el resto minúsculas). Al escribir en el query SQL:

SQL

WHERE "LOCKED" = 'N' -- ❌ Error en HANA

HANA busca literalmente una columna en mayúsculas sostenidas, no la encuentra en el diccionario de datos de OUSR y revienta la ejecución.

✅ Solución:

Ajustar la sentencia respetando la convención PascalCase y considerando valores nulos:

SQL

WHERE ("Locked" = 'N' OR "Locked" IS NULL) -- ✔️ Correcto

Problema 2: Intento de Consulta de Columnas Inexistentes (U_FolioUser en OUSR)

💥 Síntoma:

Al intentar precargar el folio directamente desde la consulta de usuarios:

JSON

{
    "data": [],
    "error": true,
    "message": "odbc_exec(): SQL error: [SAP AG][LIBODBCHDB SO][HDBODBC] General error;260 invalid column name: U_FolioUser: line 2 col 57 (at pos 58), SQL state S1000 in SQLExecDirect"
}

🧐 Causa Raíz:

Se asumió inicialmente que OUSR contaba con un campo de usuario personalizado llamado U_FolioUser. Al realizar una introspección de metadatos sobre la estructura física de OUSR:

SQL

SELECT COLUMN_NAME 
FROM TABLE_COLUMNS 
WHERE TABLE_NAME = 'OUSR' AND COLUMN_NAME LIKE 'U_%';

Se constató que los únicos campos de usuario presentes en esa instalación eran:

  • U_GLO_CostCenter
  • U_empID

El valor de ejemplo que se requería almacenar en U_FolioUser dentro de @AUTORIZACOMPRADET (por ejemplo, MZGTEPZA) no era más que el propio código de inicio de sesión de SAP (USER_CODE).

✅ Solución:

Eliminar la columna inexistente del SELECT y asignar directamente USER_CODE como el valor por defecto para el folio del usuario.

Problema 3: Diferencia de Tipos de Datos en U_USERID (Entero vs String)

💥 Síntoma:

Al enviar el payload a Service Layer, la API retornaba un error HTTP 400 Bad Request:

JSON

{
    "error": {
        "code": -1000,
        "message": {
            "lang": "en-us",
            "value": "Property 'U_USERID' of 'AutCompra' is invalid. Expected type is Edm.String"
        }
    }
}

🧐 Causa Raíz:

Al registrar un campo de usuario (UDF) en SAP Business One mediante la herramienta nativa Herramientas -> Herramientas de personalización -> Campos definidos por el usuario, el administrador puede configurarlo como tipo Alfanumérico (Texto) o tipo Numérico (Entero).

Si en SAP se definió como Alfanumérico, el Service Layer espera recibir "235" en formato cadena, no el entero primitivo 235.

✅ Solución:

Garantizar la compatibilidad en el array de PHP convirtiendo el tipo según corresponda:

PHP

'U_USERID' => (string) $userId, // Si el UDF fue creado como Alfanumérico
// O bien:
'U_USERID' => (int) $userId,    // Si el UDF fue creado como Numérico

📈 7. Impacto y Beneficios de Negocio

La implementación de este módulo no representó únicamente una mejora técnica en el stack de software; supuso una transformación tangible en el día a día operativo de la empresa:

┌───────────────────────────────────────┬───────────────────────────────────────┐
│         ANTES (MÉTODO NATIVO)         │       AHORA (MÓDULO WEB CI4 / SL)     │
├───────────────────────────────────────┼───────────────────────────────────────┤
│ ❌ Requiere licencia activa de SAP    │ ✅ Acceso vía navegador web sin       │
│    Business One para cada operador.   │    consumir licencias profesionales.  │
│                                       │                                       │
│ ❌ Captura manual de USERID numérico  │ ✅ Selectores predictivos Select2     │
│    abriendo ventanas auxiliares.      │    con búsqueda por nombre y código.  │
│                                       │                                       │
│ ❌ Transcripción manual propensa a    │ ✅ Autocompletado inmediato de        │
│    errores del código de folio/serie. │    USER_CODE en el campo de folio.    │
│                                       │                                       │
│ ❌ Tiempos de registro de 5 a 10      │ ✅ Configuración completa en menos    │
│    minutos por almacén.               │    de 30 segundos por sucursal.       │
│                                       │                                       │
│ ❌ Sin control visual consolidado     │ ✅ Vista en tabla con contador de     │
│    de cuántos usuarios están activos. │    usuarios asignados en vivo.        │
└───────────────────────────────────────┴───────────────────────────────────────┘
  1. Eficiencia Temporal: Reducción del 90% en el tiempo necesario para dar de alta o ajustar permisos de compras por sucursal.
  2. Cero Errores de Integridad: La combinación de lectura ODBC y validación Service Layer imposibilita la creación de huérfanos o almacenes inexistentes.
  3. Auditoría Clara: Cada cambio queda registrado en la bitácora interna (LogModel), permitiendo saber con exactitud qué usuario web aplicó las modificaciones.

📋 8. Resumen de Buenas Prácticas para Integraciones con SAP B1

Si estás planificando desarrollar extensiones web o móviles que interactúen con SAP Business One, ten presentes estas directrices aprendidas durante el proyecto:

  • 🛡️ Respeta la Regla de Oro: Utiliza ODBC exclusivamente para consultas de lectura (SELECT). Todas las escrituras deben canalizarse a través de Service Layer o DI API.
  • 🔡 Cuida el Case-Sensitivity en SAP HANA: Comprueba siempre el nombre exacto de tablas y columnas tal y como están registradas en el catálogo del sistema.
  • 📦 Respeta la Nomenclatura de UDOs en Service Layer: Para tablas hijas, recuerda siempre la estructura [NombreTablaSinArroba]Collection.
  • ⚡ Optimiza con Paginación Server-Side: Evita cargar tablas completas en memoria del navegador. Implementa LIMIT y OFFSET en el motor de base de datos para manejar miles de registros sin degradar la experiencia de usuario.
  • 🧩 Modulariza tu Código: Encapsula la lógica de autenticación y consumo de APIs en servicios dedicados para que tus controladores permanezcan limpios y mantenibles.

🌐 Conecta con la Comunidad y Sigue el Proyecto

El desarrollo de integraciones para sistemas ERP como SAP Business One, el software libre y la creación de herramientas de productividad en Linux y PHP son temas que comparto de manera constante en mis plataformas y canales.

Si te interesa profundizar en el código, acceder a repositorios, ver videotutoriales detallados o apoyar el desarrollo de nuevos paquetes de código abierto, te invito a seguirme en todas mis redes oficiales:

¡Déjame en los comentarios tus dudas o cuéntame cómo gestionas las autorizaciones de usuario en tus proyectos de SAP Business One! 🚀💬

🚀 Cómo Crear y Clonar Artículos en SAP Business One con CodeIgniter 4 y Service Layer (Sin Perder la Garantía de tu Proveedor)

Entrada fija

☕ Prólogo: El terror de la póliza de soporte de SAP

Si alguna vez has trabajado como desarrollador en un entorno empresarial con SAP Business One (SAP B1), seguramente conoces el mandamiento número uno tallado en piedra por los consultores e integradores:

“No harás un INSERT ni un UPDATE directo a las tablas de SAP en base de datos, so pena de que el RSP (Remote Support Platform) lo detecte, te anulen la póliza de soporte y el director de finanzas te mire feo el resto de tu vida laboral.” 😱

Y tienen toda la razón del mundo. Las bases de datos de SAP (ya sea sobre SAP HANA o Microsoft SQL Server) son un laberinto de miles de tablas interconectadas (OITM, ITM1, OITB, OUOM, etc.). Un simple INSERT manual en OITM sin actualizar tablas de auditoría (AITM), historiales de costos, listas de precios predeterminadas o esquemas de impuestos deja huérfana la base de datos y destruye la integridad referencial del ERP.

Por otro lado, está la realidad del usuario operativo en planta o almacén: el cliente SAP de escritorio es pesado, consume licencias completas y la captura masiva de catálogos suele ser lenta. Los usuarios necesitan un portal web ágil, responsivo, intuitivo, donde puedan buscar un artículo en milisegundos, clonarlo con un solo clic, asignar un código consecutivo automático y enviarlo a SAP sin fricción.

En este artículo exhaustivo, vamos a desglosar paso a paso cómo construimos un módulo robusto, moderno y a prueba de balas para la gestión de Artículos de Compra e Inventario integrando CodeIgniter 4, consultas analíticas ultra-rápidas mediante ODBC y escrituras seguras a través de la API REST oficial de SAP: el SAP Service Layer.

Ponte cómodo, sírvete un buen café ☕ y acompáñame en esta travesía de arquitectura, código limpio, resolución de errores crípticos y optimización web.

🏛️ 1. La Arquitectura: CQRS Criollo (Lectura Relámpago vs. Escritura Sagrada)

Cuando conectamos una aplicación web moderna a un ERP de clase empresarial, nos enfrentamos a un dilema de rendimiento clásico:

  • Service Layer (OData REST API): Es la vía sagrada y oficial para modificar datos. Valida la lógica de negocio, dispara alarmas, actualiza balances y guarda logs de usuario. Sin embargo, si intentas paginar un catálogo de 80,000 artículos haciendo consultas complejas de búsqueda y ordenamiento dinámico sobre Service Layer, el servidor consumirá recursos innecesarios serializando JSONs masivos.
  • ODBC directo (HANA / SQL Server): Es un rayo ⚡. Las consultas indexadas con SELECT, LIMIT y OFFSET tardan entre 5 y 15 milisegundos. Pero escribir por aquí está terminantemente prohibido.

El Patrón CQRS (Command Query Responsibility Segregation)

Para resolver este desafío de manera limpia y profesional, aplicamos una variante del patrón CQRS:

Plaintext

               ┌────────────────────────────────────────────────────────┐
               │              Navegador Web / DataTables                │
               └───────────┬────────────────────────────────┬───────────┘
                           │ (Lecturas rápidas)             │ (Escritura segura)
                           ▼                                ▼
               ┌───────────────────────┐        ┌───────────────────────┐
               │    Consultas AJAX     │        │  Peticiones Guardar   │
               │   (DataTable/Select2) │        │    (POST / PATCH)     │
               └───────────┬───────────┘        └───────────┬───────────┘
                           │                                │
                           ▼                                ▼
               ┌───────────────────────┐        ┌───────────────────────┐
               │    Conexión ODBC      │        │  SAP Service Layer    │
               │   (SOLO LECTURA)      │        │   (REST / b1s/v1)     │
               └───────────┬───────────┘        └───────────┬───────────┘
                           │ SELECT *                       │ Lógica de Negocio
                           ▼                                ▼
               ┌────────────────────────────────────────────────────────┐
               │           Base de Datos SAP B1 (HANA / SQL)            │
               └────────────────────────────────────────────────────────┘
  1. Query (Lectura): DataTables, filtros de cabecera, buscadores en tiempo real y selects desplegables (grupos de artículos y unidades de medida) consultan directamente las vistas y tablas de SAP mediante ODBC con sentencias SELECT estrictamente protegidas. Cero sobrecarga, máxima velocidad.
  2. Command (Escritura): Al presionar Guardar o Clonar, la petición se procesa en el backend de CodeIgniter 4, valida la integridad de los datos y dispara una solicitud HTTP (POST para altas, PATCH para modificaciones) contra el SAP Service Layer.

Con esta fórmula garantizamos velocidad brutal en la interfaz de usuario y 100% de cumplimiento de la garantía con SAP. ¡Todos felices!

🎨 2. Los Requerimientos del Negocio: ¿Qué Necesitaba el Módulo?

El área de compras y almacén planteó una lista clara de especificaciones:

  1. Exclusividad para Artículos de Compra: Solo se listan y gestionan artículos que sean de compra (PrchseItem = 'Y').
  2. Regla de Tres Banderas: Todo artículo registrado debe nacer obligatoriamente configurado como:
    • Artículo de Compra: Sí (PurchaseItem = 'tYES').
    • Artículo de Inventario: Sí (InventoryItem = 'tYES').
    • Artículo de Venta: No (SalesItem = 'tNO').
  3. Consecutivo Inteligente con 5 Ceros:
    • Si el usuario escribe rmm o rmm00000, al perder el foco el input debe convertirse a mayúsculas (RMM).
    • El sistema debe consultar la base de datos de SAP, buscar cuál fue el último código registrado con ese prefijo (por ejemplo, RMM00002) y asignarle automáticamente el siguiente correlativo con 5 dígitos numéricos (RMM00003).
  4. Catálogos con Autocompletado (Select2):
    • El Grupo de Artículos no puede ser un campo de texto; debe venir del catálogo maestro de grupos de SAP (OITB).
    • La Unidad de Medida debe provenir del catálogo oficial de unidades (OUOM).
  5. Superpoder de Clonación: Un botón en la tabla que permita tomar un artículo existente como plantilla, precargar todos sus datos en el formulario y autocalcular de inmediato el nuevo código correlativo disponible.
  6. Cero Eliminaciones Destructivas: En ERPs contables, borrar artículos huérfanos o con movimientos es un pecado capital; la opción de eliminación directa se retira para salvaguardar la coherencia histórica.
  7. Filtros y Ordenamiento Dinámico: Capacidad de filtrar la tabla por grupo de artículos y permitir que cualquier columna se pueda ordenar ascendentemente o descendentemente sin romper la paginación del servidor.

🔢 3. Algoritmo de Consecutivos Automáticos: Dominando los 5 Ceros

Uno de los mayores dolores de cabeza de los usuarios al capturar códigos en SAP es equivocarse en la cantidad de ceros. Si la nomenclatura de la empresa dicta RMM00001, inevitablemente alguien escribirá RMM0001, rmm000001 o RMM1.

Queríamos que la experiencia de usuario fuera tan fluida como la seda:

  1. El usuario entra al campo y escribe: rmm.
  2. Da clic afuera o presiona Tabulador (blur).
  3. El frontend pasa el texto a mayúsculas, limpia sufijos numéricos anteriores y dispara una petición al endpoint:GET /admin/servicelayer/materials/getNextItemCode/RMM
  4. El servidor responde en menos de 20 milisegundos con:JSON{ "status": 200, "prefix": "RMM", "nextCode": "RMM00003" }
  5. El campo se actualiza al instante con el nuevo código listo para usarse.

La Lógica en el Backend (PHP 8 + CodeIgniter 4)

El backend busca todos los códigos existentes que comiencen con el prefijo, pero no asume que la base de datos ordenará alfabéticamente los números como enteros. Si tienes RMM00009 y RMM00010, un ordenamiento alfabético simple puede engañarte.

Por eso, iteramos sobre los resultados, extraemos la porción numérica posterior a la longitud del prefijo y encontramos el número máximo real en memoria:

PHP

/**
 * Calcula el siguiente ItemCode con prefijo y 5 ceros (ej. RMM -> RMM00003)
 */
public function getNextItemCode($prefix = '') {
    try {
        $prefix = strtoupper(trim(urldecode($prefix)));
        // Sanitizamos para permitir solo caracteres seguros
        $cleanPrefix = preg_replace('/[^A-Z0-9_\-]/', '', $prefix);

        if (empty($cleanPrefix)) {
            return $this->response->setJSON([
                'status'   => 400,
                'nextCode' => ''
            ]);
        }

        $conn = $this->connectODBC();

        // Buscamos los códigos que inicien con ese prefijo
        $sql = "
            SELECT \"ItemCode\"
            FROM OITM
            WHERE \"ItemCode\" LIKE '{$cleanPrefix}%'
            ORDER BY \"ItemCode\" DESC
        ";

        $rs = odbc_exec($conn, $sql);
        $maxNumber = 0;
        $prefixLen = strlen($cleanPrefix);

        if ($rs) {
            while ($row = odbc_fetch_array($rs)) {
                $code = trim($this->toUtf8($row['ItemCode']));
                $numericSuffix = substr($code, $prefixLen);

                // Si lo que resta después del prefijo es puramente numérico
                if (is_numeric($numericSuffix)) {
                    $num = (int) $numericSuffix;
                    if ($num > $maxNumber) {
                        $maxNumber = $num;
                    }
                }
            }
            odbc_free_result($rs);
        }
        odbc_close($conn);

        // Sumamos 1 y rellenamos a la izquierda con 5 ceros
        $nextNumber = $maxNumber + 1;
        $nextCode   = $cleanPrefix . str_pad($nextNumber, 5, '0', STR_PAD_LEFT);

        return $this->response->setJSON([
            'status'   => 200,
            'prefix'   => $cleanPrefix,
            'nextCode' => $nextCode
        ]);

    } catch (\Throwable $e) {
        return $this->response->setJSON([
            'status'   => 500,
            'nextCode' => '',
            'message'  => $e->getMessage()
        ]);
    }
}

La Captura en el Frontend (JavaScript / jQuery)

En la vista, el listener se activa únicamente si el formulario está en modo de alta o clonación (#isNew == 1):

JavaScript

$('#ItemCode').on('blur', function () {
    if ($('#isNew').val() !== '1') return;

    var rawVal = $(this).val().trim().toUpperCase();
    $(this).val(rawVal);
    if (!rawVal) return;

    // Si el usuario tecleó 'RMM00000' o 'rmm12', quitamos los números finales para quedarnos con el prefijo
    var cleanPrefix = rawVal.replace(/[0-9]+$/, '');
    if (!cleanPrefix) {
        cleanPrefix = rawVal;
    }

    $.ajax({
        url: '<?= base_url('admin/servicelayer/materials/getNextItemCode') ?>/' + encodeURIComponent(cleanPrefix),
        method: 'GET',
        dataType: 'json',
        success: function (resp) {
            if (resp.status === 200 && resp.nextCode) {
                $('#ItemCode').val(resp.nextCode);
            }
        }
    });
});

🎯 4. Catálogos Vía Select2: Limpiando la Entrada de Datos

Nada destruye más rápido un sistema que la captura libre de unidades de medida. Unos ponen PZA, otros pza, otros Pza., otros PIEZA y otros Piezas. Cuando el departamento de compras intenta generar un reporte acumulado por unidad, el caos es total.

Para resolverlo, integramos Select2 con búsqueda asíncrona por AJAX contra los catálogos de SAP:

Grupo de Artículos (OITB)

Consultamos la tabla OITB, extrayendo el código del grupo (ItmsGrpCod) y su descripción oficial (ItmsGrpNam):

PHP

public function getItemGroupsAjax() {
    try {
        $search = $this->request->getGet('searchTerm') ?? '';
        $conn   = $this->connectODBC();

        $where = '';
        if (!empty($search)) {
            $searchClean = str_replace("'", "''", trim($search));
            $where = " WHERE \"ItmsGrpNam\" LIKE '%{$searchClean}%' ";
        }

        $sql = "
            SELECT \"ItmsGrpCod\", \"ItmsGrpNam\"
            FROM OITB
            {$where}
            ORDER BY \"ItmsGrpNam\" ASC
        ";

        $rs = odbc_exec($conn, $sql);
        $data = [];
        while ($row = odbc_fetch_array($rs)) {
            $data[] = [
                'id'   => (int) $row['ItmsGrpCod'],
                'text' => $this->toUtf8($row['ItmsGrpCod']) . ' - ' . $this->toUtf8($row['ItmsGrpNam'])
            ];
        }
        odbc_free_result($rs);
        odbc_close($conn);

        return $this->response->setJSON(['data' => $data]);
    } catch (\Throwable $e) {
        return $this->response->setJSON(['data' => [], 'error' => true, 'message' => $e->getMessage()]);
    }
}

Unidades de Medida de Compra (OUOM)

Consultamos el catálogo OUOM, filtrando únicamente las unidades que se encuentren activas (Locked = 'N'):

PHP

public function getUnitsAjax() {
    try {
        $search = $this->request->getGet('searchTerm') ?? '';
        $conn   = $this->connectODBC();

        $where = " WHERE \"Locked\" = 'N' ";
        if (!empty($search)) {
            $searchClean = str_replace("'", "''", trim($search));
            $where .= " AND (\"UomCode\" LIKE '%{$searchClean}%' OR \"UomName\" LIKE '%{$searchClean}%') ";
        }

        $sql = "
            SELECT \"UomCode\", \"UomName\"
            FROM OUOM
            {$where}
            ORDER BY \"UomCode\" ASC
            LIMIT 30
        ";

        $rs = odbc_exec($conn, $sql);
        $data = [];
        while ($row = odbc_fetch_array($rs)) {
            $code = $this->toUtf8($row['UomCode']);
            $name = $this->toUtf8($row['UomName']);
            $data[] = [
                'id'   => $code,
                'text' => $code . ($name ? ' - ' . $name : '')
            ];
        }
        odbc_free_result($rs);
        odbc_close($conn);

        return $this->response->setJSON(['data' => $data]);
    } catch (\Throwable $e) {
        return $this->response->setJSON(['data' => [], 'error' => true, 'message' => $e->getMessage()]);
    }
}

💡 El Truco Clave de Select2 dentro de Modales Bootstrap

Todo desarrollador web ha sufrido este bug al menos una vez en su vida: abres un modal de Bootstrap, haces clic en un Select2 y el buscador no te deja escribir letras o la lista desplegable queda oculta detrás del modal.

¿Por qué ocurre? Porque Bootstrap captura el foco dentro del modal por accesibilidad (aria-hidden).

La solución es configurar siempre la propiedad dropdownParent apuntando al contenedor del modal:

JavaScript

$('#ItmsGrpCod').select2({
    dropdownParent: $('#modalMaterial'),
    placeholder: 'Seleccione un grupo de artículos',
    allowClear: true,
    ajax: {
        url: '<?= base_url('admin/servicelayer/materials/getItemGroupsAjax') ?>',
        dataType: 'json',
        delay: 250,
        data: function (params) {
            return { searchTerm: params.term || '' };
        },
        processResults: function (data) {
            return { results: data.data || [] };
        }
    }
});

⚡ 5. El Botón Mágico: Clonar Artículos en 1 Segundo

En almacenes industriales, cuando compras un tornillo de 1/2 pulgada, es 99% seguro que mañana necesitarás dar de alta el de 3/4 de pulgada, el de 1 pulgada y el de 2 pulgadas. Los parámetros contables, el grupo de artículos, las unidades y los impuestos son idénticos; solo cambia el código y una pequeña palabra en la descripción.

Hacer que el usuario llene el formulario desde cero una y otra vez es una pérdida de tiempo.

¿Cómo funciona la clonación?

  1. Al hacer clic en el botón de Clonar en el renglón de la tabla:HTML<button class="btn btn-info btn-sm btnCloneMaterial" data-itemcode="${itemCode}" title="Clonar"> <i class="fas fa-copy"></i> </button>
  2. La vista hace una llamada GET al backend para traer la radiografía completa del artículo original (getMaterial/ITEMCODE).
  3. Setea la bandera oculta #isNew = 1 (para que el backend sepa que debe hacer un POST de creación, no un PATCH).
  4. Desbloquea el campo #ItemCode (prop('readonly', false)).
  5. Prellena los combos Select2, la descripción, el tipo de artículo y el impuesto.
  6. Extrae el prefijo del código original (por ejemplo, si clonaste RMM00045, extrae RMM).
  7. Llama inmediatamente a getNextItemCode('RMM') y prellena el código con el siguiente consecutivo libre disponible en SAP (por ejemplo, RMM00046).
  8. Cambia el título del modal a: Clonar Artículo (RMM00045).
  9. ¡El usuario solo ajusta la descripción, hace clic en Guardar y el nuevo artículo entra a SAP en menos de 2 segundos!

JavaScript

// Abrir modal Clonar Artículo
$('#tableMaterials tbody').on('click', '.btnCloneMaterial', function () {
    var itemCode = $(this).attr('data-itemcode');
    if (!itemCode) return;

    $.ajax({
        url: '<?= base_url('admin/servicelayer/materials/getMaterial') ?>/' + itemCode,
        method: 'GET',
        dataType: 'json',
        success: function (resp) {
            if (resp.ItemCode) {
                $('#formMaterial')[0].reset();
                $('#isNew').val(1); // Es un alta nueva
                $('#ItemCode').prop('readonly', false);
                $('#itemCodeHelp').show();

                // Copiar datos del artículo molde
                $('#ItemName').val(resp.ItemName || '');
                $('#ItemType').val(resp.ItemType || 'itItems');
                $('#VATLiable').val(resp.VATLiable || 'Y');
                $('#validFor').val('Y');

                // Asignar Select2 de Grupo
                if (resp.ItmsGrpCod) {
                    var optGroup = new Option(resp.ItmsGrpNam || ('Grupo ' + resp.ItmsGrpCod), resp.ItmsGrpCod, true, true);
                    $('#ItmsGrpCod').empty().append(optGroup).trigger('change');
                }

                // Asignar Select2 de Unidad de Medida
                if (resp.BuyUnitMsr) {
                    var optUnit = new Option(resp.BuyUnitMsr, resp.BuyUnitMsr, true, true);
                    $('#BuyUnitMsr').empty().append(optUnit).trigger('change');
                }

                $('#modalMaterialLabel').text('Clonar Artículo (' + resp.ItemCode + ')');
                $('#modalMaterial').modal('show');

                // Calcular consecutivo automático
                var cleanPrefix = resp.ItemCode.replace(/[0-9]+$/, '') || resp.ItemCode;
                $.ajax({
                    url: '<?= base_url('admin/servicelayer/materials/getNextItemCode') ?>/' + encodeURIComponent(cleanPrefix),
                    method: 'GET',
                    dataType: 'json',
                    success: function (nextResp) {
                        if (nextResp.status === 200 && nextResp.nextCode) {
                            $('#ItemCode').val(nextResp.nextCode);
                        }
                    }
                });
            }
        }
    });
});

🛠️ 6. Historias de Guerra: Los Errores que Tuvimos que Superar

Ningún desarrollo con ERPs sale bien a la primera. En el camino nos encontramos con tres obstáculos que merecen su propio análisis forense:

💣 Batalla 1: “URL rejected: No host part in the URL”

Al principio, cuando le dábamos al botón de Guardar, el backend arrojaba de inmediato un error 500:

JSON

{
    "status": 500,
    "message": "Error al iniciar sesión en Service Layer: URL rejected: No host part in the URL"
}

¿Qué pasó?

Al revisar cómo se guardaba la configuración del Service Layer en la base de datos local, la URL estaba almacenada únicamente como:

192.168.15.120

o en algunos registros simplemente el host y puerto sin protocolo. Cuando cURL intentaba concatenar /b1s/v1/Login, la biblioteca libcurl de PHP no reconocía ningún protocolo (http:// o https://) y abortaba inmediatamente con el mensaje “No host part in the URL”.

La solución:

Normalizamos la construcción de la URL asegurando que siempre lleve protocolo, puerto y el endpoint estándar de Service Layer, sin importar cómo haya sido capturada en la tabla de configuración:

PHP

$rawUrl = trim($dataSL['url']);
if (!preg_match('/^https?:\/\//i', $rawUrl)) {
    $rawUrl = 'https://' . $rawUrl;
}
$slRoot = rtrim($rawUrl, '/');
if (stripos($slRoot, '/b1s/v1') === false) {
    $slRoot .= '/b1s/v1';
}

💣 Batalla 2: El Misterioso HTTP 401 “Login failed”

Superado el error de la URL, nos estrellamos de frente contra este mensaje de SAP:

JSON

{
    "status": 500,
    "message": "Error al conectar con Service Layer (https://192.168.0.190:50000/b1s/v1/Login): HTTP 401: {\n   \"error\" : {\n      \"code\" : 100000027,\n      \"message\" : {\n         \"lang\" : \"en-us\",\n         \"value\" : \"Login failed\"\n      }\n   }\n}\n"
}

El usuario juraba que la contraseña de ODBC era correcta, ¡porque la tabla DataTables cargaba los datos sin problemas!

¿Dónde estuvo la trampa?

En SAP Business One, el usuario de base de datos ODBC casi nunca es el mismo que el usuario de la aplicación.

  • Para ODBC en HANA, el usuario suele ser SYSTEM o un usuario administrativo de base de datos como SAP_READER.
  • Para Service Layer, el usuario es un operador de SAP con licencia asignada (por ejemplo manager o un usuario técnico de integración como B1_API).

En nuestro controlador de materiales estábamos tomando por error $dataConect['userODBC'] como fallback en vez de llamar al controlador centralizado SapservicelayerController::login(), que ya gestionaba correctamente el campo username, el puerto port y el almacenamiento de cookies de sesión (B1SESSION y ROUTEID).

Al reutilizar la arquitectura probada del módulo de empleados:

PHP

$conexionSap = $this->serviceLayerController->login(
    $dataSL['url'],
    $dataSL['port'],
    $dataSL['password'],
    $dataSL['username'],
    $dataSL['companyDB']
);

El login pasó con un reluciente código HTTP 200 y obtuvimos nuestro SessionId.

💣 Batalla 3: “Property ‘PriceUnit’ of ‘Item’ is invalid” (Error -1000)

Pensamos que teníamos la victoria en la bolsa, cuando al enviar el JSON de creación a /b1s/v1/Items, SAP nos respondió con este portazo en la cara:

JSON

{
    "status": 400,
    "message": "Property 'PriceUnit' of 'Item' is invalid",
    "body": {
        "error": {
            "code": -1000,
            "message": {
                "lang": "en-us",
                "value": "Property 'PriceUnit' of 'Item' is invalid"
            }
        }
    }
}

¿Por qué falló si en la base de datos la columna PriceUnit sí existe en OITM?

Este es un clásico tropiezo con Service Layer: el esquema OData de SAP B1 no es una copia 1:1 de las tablas de base de datos.

En la tabla OITM de HANA/SQL Server existe la columna "PriceUnit". Sin embargo, en el esquema de la entidad OData Item, la propiedad PriceUnit no existe en la raíz del objeto. Cuando Service Layer recibe un campo que no forma parte de su definición de metadatos, su validador estricto rechaza la petición por completo arrojando el error -1000.

La tentación inicial de un programador con prisa habría sido: “Bueno, lo quito del JSON de Service Layer y luego le meto un UPDATE OITM SET PriceUnit = ... por ODBC.”

¡ERROR CATASTRÓFICO! 🚫

Hacer eso violaría la regla número uno: cero escrituras directas por ODBC. En SAP B1, el Service Layer inicializa automáticamente el factor de unidad de precio con su valor por defecto (1.0) respetando las tablas de listas de precios ITM1.

La solución arquitectónica correcta fue:

  1. Eliminar PriceUnit del payload enviado al Service Layer.
  2. Quitar el input manual de PriceUnit de la vista para no confundir al usuario.
  3. Dejar que Service Layer gestione el ciclo de vida del artículo de forma limpia y transparente.

📊 7. El Ordenamiento Dinámico en DataTables Server-Side

Otro detalle crucial: al inicio, cuando hacías clic en el encabezado “Descripción” o “Grupo” en la tabla, la tabla no se ordenaba.

La Razón Técnica

Cuando DataTables tiene activado serverSide: true, él no ordena los datos en el navegador del cliente; en su lugar, le delega el ordenamiento al servidor enviando por la URL dos parámetros:

  • order[0][column]: El índice numérico de la columna clickeada (0, 1, 2, 3…).
  • order[0][dir]: La dirección (asc o desc).

En nuestro controlador teníamos una cláusula SQL fija:

SQL

ORDER BY T0."ItemCode" ASC

Por lo tanto, la base de datos siempre devolvía los registros ordenados por código de artículo, sin importar dónde hiciera clic el usuario.

La Solución con Whitelist de Seguridad (Prevención de Inyección SQL)

Jamás debes concatenar directamente parámetros de ordenamiento recibidos por GET en tu sentencia SQL. Si un atacante envía order[0][dir] = asc; DROP TABLE OITM;, pondrías en riesgo la base de datos.

Implementamos un mapeo estricto con lista blanca:

PHP

// 1. Obtener parámetros de DataTables
$orderParam    = $this->request->getGet('order');
$orderColIndex = isset($orderParam[0]['column']) ? (int) $orderParam[0]['column'] : 1;
$orderDirRaw   = isset($orderParam[0]['dir']) ? strtolower($orderParam[0]['dir']) : 'asc';
$orderDir      = ($orderDirRaw === 'desc') ? 'DESC' : 'ASC';

// 2. Mapeo seguro de índice a columna real de base de datos
$columnsMap = [
    1 => 'T0."ItemCode"',
    2 => 'T0."ItemName"',
    3 => 'T1."ItmsGrpNam"',
    4 => 'T0."BuyUnitMsr"',
    5 => 'T0."VATLiable"',
    6 => 'T0."validFor"',
];

// 3. Selección segura con fallback
$orderBy = $columnsMap[$orderColIndex] ?? 'T0."ItemCode"';

// 4. Inyección en la consulta SQL
$sql = "
    SELECT
        T0.\"ItemCode\",
        T0.\"ItemName\",
        T0.\"BuyUnitMsr\",
        T0.\"ItmsGrpCod\",
        T1.\"ItmsGrpNam\",
        T0.\"VATLiable\",
        T0.\"validFor\"
    FROM OITM T0
    LEFT JOIN OITB T1 ON T0.\"ItmsGrpCod\" = T1.\"ItmsGrpCod\"
    WHERE T0.\"PrchseItem\" = 'Y'
      {$whereExtra}
    ORDER BY {$orderBy} {$orderDir}
    LIMIT {$length} OFFSET {$start}
";

¡Ahora la tabla responde al clic en cualquiera de sus columnas al instante, manteniendo la paginación impecable!

📦 8. El Código Final Completo: Los Archivos del Módulo

Para que puedas tener una visión completa de la solución, aquí están los cuatro archivos esenciales del módulo perfectamente orquestados.

Archivo 1: Archivo de Idioma (src/Language/es/material.php)

Centralizar los textos en archivos de idioma permite mantener vistas limpias y facilita la internacionalización futura:

PHP

<?php

return [
    'modal_title'      => 'Artículo de Compra',
    'new_title'        => 'Nuevo Artículo de Compra',
    'edit_title'       => 'Editar Artículo',
    'clone_title'      => 'Clonar Artículo',
    'list_title'       => 'Catálogo de Artículos de Compra',
    'btn_new'          => 'Nuevo Artículo',
    'btn_clone'        => 'Clonar',
    'filter_group'     => 'Filtrar por Grupo:',
    'all_groups'       => '-- Todos los Grupos --',
    'yes'              => 'Sí',
    'no'               => 'No',
    'active_yes'       => 'Activo',
    'active_no'        => 'Inactivo',
    'fields'           => [
        'actions'    => 'Acciones',
        'ItemCode'   => 'Código de Artículo',
        'ItemName'   => 'Descripción',
        'ItemType'   => 'Tipo de Artículo',
        'ItmsGrpCod' => 'Grupo de Artículos',
        'ItmsGrpNam' => 'Grupo',
        'BuyUnitMsr' => 'Unidad de Medida',
        'VATLiable'  => '¿Sujeto a Impuesto?',
        'PrchseItem' => '¿Artículo de Compra?',
        'InvntItem'  => '¿Artículo de Inventario?',
        'SellItem'   => '¿Artículo de Venta?',
        'active'     => 'Estado',
    ],
    'messages'         => [
        'saved'          => 'Artículo guardado correctamente',
        'save_error'     => 'Error al guardar el artículo',
        'code_required'  => 'Debes capturar el código de artículo',
        'name_required'  => 'Debes capturar la descripción del artículo',
        'group_required' => 'Debes seleccionar el grupo de artículos',
        'unit_required'  => 'Debes indicar la unidad de medida',
        'code_exists'    => 'El código de artículo ya existe en SAP.',
        'not_found'      => 'Artículo no encontrado',
        'invalid_code'   => 'Código de artículo no válido',
        'server_error'   => 'Error de comunicación con el servidor',
    ],
];

Archivo 2: Rutas de CodeIgniter 4 (Config/Routes.php)

Rutas limpias y semánticas, aprovechando verbos HTTP:

PHP

// ==========================================
// RUTAS PARA ARTÍCULOS SAP (OITM)
// ==========================================

// Consulta rápida vía ODBC para Select2 general
$routes->post('SAPMaterials/getSAPMaterialAjax'
    , 'SapMaterialController::getItemsAjax'
    , ['namespace' => 'julio101290\boilerplateservicelayer\Controllers']
);

// Listado principal y endpoint AJAX de DataTables (con filtro de grupo y ordenamiento)
$routes->get('servicelayer/materials'
    , 'SapMaterialController::index'
    , [
        'filter'    => 'permission:SAPMaterials-permission',
        'namespace' => 'julio101290\boilerplateservicelayer\Controllers'
    ]
);

// Consecutivo automático inteligente con 5 ceros (ej. RMM -> RMM00003)
$routes->get('servicelayer/materials/getNextItemCode/(:segment)'
    , 'SapMaterialController::getNextItemCode/$1'
    , ['namespace' => 'julio101290\boilerplateservicelayer\Controllers']
);

// Catálogo de Grupos de Artículos (OITB) para Select2
$routes->get('servicelayer/materials/getItemGroupsAjax'
    , 'SapMaterialController::getItemGroupsAjax'
    , ['namespace' => 'julio101290\boilerplateservicelayer\Controllers']
);

// Catálogo de Unidades de Medida (OUOM) para Select2
$routes->get('servicelayer/materials/getUnitsAjax'
    , 'SapMaterialController::getUnitsAjax'
    , ['namespace' => 'julio101290\boilerplateservicelayer\Controllers']
);

// Obtener datos detallados de un artículo para modal de Edición / Clonación
$routes->get('servicelayer/materials/getMaterial/(:segment)'
    , 'SapMaterialController::getMaterial/$1'
    , ['namespace' => 'julio101290\boilerplateservicelayer\Controllers']
);

// Guardar y Actualizar exclusivamente mediante Service Layer (POST / PATCH)
$routes->post('servicelayer/materials/save'
    , 'SapMaterialController::save'
    , ['namespace' => 'julio101290\boilerplateservicelayer\Controllers']
);

Archivo 3: La Vista (Views/materials.php)

Una interfaz ligera construida sobre AdminLTE / Bootstrap 4, potenciada con DataTables, Select2, SweetAlert2 y ventanas modales arrastrables:

HTML

<?= $this->include('julio101290\boilerplate\Views\load\select2') ?>
<?= $this->include('julio101290\boilerplate\Views\load\datatables') ?>
<?= $this->extend('julio101290\boilerplate\Views\layout\sweetalert') ?>
<?= $this->extend('julio101290\boilerplate\Views\layout\index') ?>
<?= $this->section('content') ?>

<!-- Modal para agregar / editar / clonar artículo -->
<div class="modal fade" id="modalMaterial" tabindex="-1" role="dialog" aria-hidden="true">
    <div class="modal-dialog modal-lg" role="document">
        <div class="modal-content">
            <div class="modal-header bg-primary text-white">
                <h5 class="modal-title" id="modalMaterialLabel"><?= lang('material.modal_title') ?></h5>
                <button type="button" class="close text-white" data-dismiss="modal" aria-label="Close">
                    <span aria-hidden="true">&times;</span>
                </button>
            </div>
            <div class="modal-body">
                <form id="formMaterial">
                    <input type="hidden" name="isNew" id="isNew" value="1">

                    <div class="alert alert-info py-2 mb-3">
                        <i class="fas fa-info-circle mr-1"></i> 
                        El artículo se registrará automáticamente como <strong>Artículo de Compra</strong> e <strong>Inventario</strong> (No venta).
                    </div>

                    <div class="row">
                        <!-- ItemCode -->
                        <div class="col-md-4">
                            <div class="form-group">
                                <label for="ItemCode"><?= lang('material.fields.ItemCode') ?> <span class="text-danger">*</span></label>
                                <input type="text" class="form-control text-uppercase" name="ItemCode" id="ItemCode" required placeholder="Ej. RMM o RMT">
                                <small class="form-text text-muted" id="itemCodeHelp">Ingresa el prefijo (ej. RMM); al salir se calculará el consecutivo.</small>
                            </div>
                        </div>

                        <!-- ItemName -->
                        <div class="col-md-8">
                            <div class="form-group">
                                <label for="ItemName"><?= lang('material.fields.ItemName') ?> <span class="text-danger">*</span></label>
                                <input type="text" class="form-control" name="ItemName" id="ItemName" required placeholder="Descripción del material">
                            </div>
                        </div>
                    </div>

                    <div class="row">
                        <!-- ItemType -->
                        <div class="col-md-4">
                            <div class="form-group">
                                <label for="ItemType"><?= lang('material.fields.ItemType') ?></label>
                                <select class="form-control" name="ItemType" id="ItemType">
                                    <option value="itItems" selected>Artículos (itItems)</option>
                                    <option value="itLabor">Mano de Obra (itLabor)</option>
                                    <option value="itTravel">Viajes (itTravel)</option>
                                </select>
                            </div>
                        </div>

                        <!-- ItmsGrpCod (Select2) -->
                        <div class="col-md-8">
                            <div class="form-group">
                                <label for="ItmsGrpCod"><?= lang('material.fields.ItmsGrpCod') ?> <span class="text-danger">*</span></label>
                                <select class="form-control" name="ItmsGrpCod" id="ItmsGrpCod" style="width: 100%;" required>
                                    <option value="">Seleccione un grupo...</option>
                                </select>
                            </div>
                        </div>
                    </div>

                    <div class="row">
                        <!-- BuyUnitMsr (Select2) -->
                        <div class="col-md-6">
                            <div class="form-group">
                                <label for="BuyUnitMsr"><?= lang('material.fields.BuyUnitMsr') ?> <span class="text-danger">*</span></label>
                                <select class="form-control" name="BuyUnitMsr" id="BuyUnitMsr" style="width: 100%;" required>
                                    <option value="">Seleccione U. Medida...</option>
                                </select>
                            </div>
                        </div>

                        <!-- VATLiable -->
                        <div class="col-md-3">
                            <div class="form-group">
                                <label for="VATLiable"><?= lang('material.fields.VATLiable') ?></label>
                                <select class="form-control" name="VATLiable" id="VATLiable">
                                    <option value="Y" selected><?= lang('material.yes') ?></option>
                                    <option value="N"><?= lang('material.no') ?></option>
                                </select>
                            </div>
                        </div>

                        <!-- validFor -->
                        <div class="col-md-3">
                            <div class="form-group">
                                <label for="validFor"><?= lang('material.fields.active') ?></label>
                                <select class="form-control" name="validFor" id="validFor">
                                    <option value="Y" selected><?= lang('material.active_yes') ?></option>
                                    <option value="N"><?= lang('material.active_no') ?></option>
                                </select>
                            </div>
                        </div>
                    </div>
                </form>
            </div>
            <div class="modal-footer">
                <button type="button" class="btn btn-secondary" data-dismiss="modal"><?= lang('boilerplate.global.close') ?? 'Cerrar' ?></button>
                <button type="button" class="btn btn-primary" id="btnSaveMaterial">
                    <i class="fas fa-save mr-1"></i> <?= lang('boilerplate.global.save') ?? 'Guardar' ?>
                </button>
            </div>
        </div>
    </div>
</div>

<!-- Tabla Principal -->
<div class="card card-default">
    <div class="card-header">
        <h3 class="card-title"><?= lang('material.list_title') ?></h3>
        <div class="card-tools">
            <button class="btn btn-success btn-sm" id="btnNewMaterial">
                <i class="fas fa-plus"></i> <?= lang('material.btn_new') ?>
            </button>
        </div>
    </div>
    <div class="card-body">
        <!-- Filtro por Grupos -->
        <div class="row mb-3">
            <div class="col-md-4">
                <label for="filterGroup"><i class="fas fa-filter mr-1"></i> <?= lang('material.filter_group') ?></label>
                <select id="filterGroup" class="form-control" style="width: 100%;">
                    <option value=""><?= lang('material.all_groups') ?></option>
                </select>
            </div>
        </div>

        <div class="table-responsive">
            <table id="tableMaterials" class="table table-striped table-hover">
                <thead>
                    <tr>
                        <th width="80"><?= lang('material.fields.actions') ?></th>
                        <th><?= lang('material.fields.ItemCode') ?></th>
                        <th><?= lang('material.fields.ItemName') ?></th>
                        <th><?= lang('material.fields.ItmsGrpNam') ?></th>
                        <th><?= lang('material.fields.BuyUnitMsr') ?></th>
                        <th><?= lang('material.fields.VATLiable') ?></th>
                        <th><?= lang('material.fields.active') ?></th>
                    </tr>
                </thead>
                <tbody></tbody>
            </table>
        </div>
    </div>
</div>

<?= $this->endSection() ?>

<?= $this->section('js') ?>
<script>
    $(function () {
        // 1. Select2 para filtro superior
        $('#filterGroup').select2({
            placeholder: '<?= lang('material.all_groups') ?>',
            allowClear: true,
            ajax: {
                url: '<?= base_url('admin/servicelayer/materials/getItemGroupsAjax') ?>',
                dataType: 'json',
                delay: 250,
                data: function (params) {
                    return { searchTerm: params.term || '' };
                },
                processResults: function (data) {
                    return { results: data.data || [] };
                }
            }
        });

        $('#filterGroup').on('change', function () {
            tableMaterials.ajax.reload();
        });

        // 2. Select2 de Grupo en Modal
        $('#ItmsGrpCod').select2({
            dropdownParent: $('#modalMaterial'),
            placeholder: 'Seleccione un grupo de artículos',
            allowClear: true,
            ajax: {
                url: '<?= base_url('admin/servicelayer/materials/getItemGroupsAjax') ?>',
                dataType: 'json',
                delay: 250,
                data: function (params) {
                    return { searchTerm: params.term || '' };
                },
                processResults: function (data) {
                    return { results: data.data || [] };
                }
            }
        });

        // 3. Select2 de Unidad de Medida en Modal
        $('#BuyUnitMsr').select2({
            dropdownParent: $('#modalMaterial'),
            placeholder: 'Seleccione Unidad de Medida',
            allowClear: true,
            ajax: {
                url: '<?= base_url('admin/servicelayer/materials/getUnitsAjax') ?>',
                dataType: 'json',
                delay: 250,
                data: function (params) {
                    return { searchTerm: params.term || '' };
                },
                processResults: function (data) {
                    return { results: data.data || [] };
                }
            }
        });

        // 4. DataTables Server-Side con Ordenamiento Dinámico
        var tableMaterials = $('#tableMaterials').DataTable({
            processing: true,
            serverSide: true,
            responsive: true,
            autoWidth: false,
            order: [[1, 'asc']],
            ajax: {
                url: '<?= base_url('admin/servicelayer/materials') ?>',
                method: 'GET',
                dataType: 'json',
                data: function (d) {
                    d.groupCode = $('#filterGroup').val();
                },
                dataSrc: function (json) {
                    return json.data || [];
                }
            },
            columnDefs: [
                { targets: 0, orderable: false, searchable: false, width: '80px' }
            ],
            columns: [
                {
                    data: null,
                    render: function (data, type, row) {
                        var itemCode = encodeURIComponent(row.ItemCode || '');
                        return `
                        <div class="btn-group" role="group">
                            <button class="btn btn-warning btn-sm btnEditMaterial" data-itemcode="${itemCode}" title="Editar">
                                <i class="fas fa-edit"></i>
                            </button>
                            <button class="btn btn-info btn-sm btnCloneMaterial" data-itemcode="${itemCode}" title="<?= lang('material.btn_clone') ?>">
                                <i class="fas fa-copy"></i>
                            </button>
                        </div>`;
                    }
                },
                { data: 'ItemCode' },
                { data: 'ItemName' },
                { data: 'ItmsGrpNam', defaultContent: '' },
                { data: 'BuyUnitMsr', defaultContent: '' },
                {
                    data: 'VATLiable',
                    render: function (data) {
                        return data === 'Y'
                            ? '<span class="badge badge-info"><?= lang('material.yes') ?></span>'
                            : '<span class="badge badge-secondary"><?= lang('material.no') ?></span>';
                    }
                },
                {
                    data: 'validFor',
                    render: function (data) {
                        return data === 'Y'
                            ? '<span class="badge badge-success"><?= lang('material.active_yes') ?></span>'
                            : '<span class="badge badge-danger"><?= lang('material.active_no') ?></span>';
                    }
                }
            ],
            language: {
                processing: "Cargando artículos..."
            }
        });

        // 5. Autocalcular consecutivo con 5 ceros al perder foco
        $('#ItemCode').on('blur', function () {
            if ($('#isNew').val() !== '1') return;

            var rawVal = $(this).val().trim().toUpperCase();$(this).val(rawVal);
            if (!rawVal) return;

            var cleanPrefix = rawVal.replace(/[0-9]+$/, '') || rawVal;

            $.ajax({
                url: '<?= base_url('admin/servicelayer/materials/getNextItemCode') ?>/' + encodeURIComponent(cleanPrefix),
                method: 'GET',
                dataType: 'json',
                success: function (resp) {
                    if (resp.status === 200 && resp.nextCode) {
                        $('#ItemCode').val(resp.nextCode);
                    }
                }
            });
        });

        // 6. Modal Nuevo Artículo
        $('#btnNewMaterial').on('click', function () {
            $('#formMaterial')[0].reset();
            $('#isNew').val(1);
            $('#ItemCode').prop('readonly', false);
            $('#itemCodeHelp').show();
            $('#ItmsGrpCod').val(null).trigger('change');
            $('#BuyUnitMsr').val(null).trigger('change');
            $('#ItemType').val('itItems');
            $('#VATLiable').val('Y');
            $('#validFor').val('Y');
            $('#modalMaterialLabel').text('<?= lang('material.new_title') ?>');
            $('#modalMaterial').modal('show');
        });

        // 7. Modal Editar Artículo
        $('#tableMaterials tbody').on('click', '.btnEditMaterial', function () {
            var itemCode = $(this).attr('data-itemcode');
            if (!itemCode) return;

            $.ajax({
                url: '<?= base_url('admin/servicelayer/materials/getMaterial') ?>/' + itemCode,
                method: 'GET',
                dataType: 'json',
                success: function (resp) {
                    if (resp.ItemCode) {
                        $('#isNew').val(0);
                        $('#ItemCode').val(resp.ItemCode).prop('readonly', true);
                        $('#itemCodeHelp').hide();
                        $('#ItemName').val(resp.ItemName || '');
                        $('#ItemType').val(resp.ItemType || 'itItems');
                        $('#VATLiable').val(resp.VATLiable || 'Y');
                        $('#validFor').val(resp.validFor || 'Y');

                        if (resp.ItmsGrpCod) {
                            var optGroup = new Option(resp.ItmsGrpNam || ('Grupo ' + resp.ItmsGrpCod), resp.ItmsGrpCod, true, true);
                            $('#ItmsGrpCod').empty().append(optGroup).trigger('change');
                        } else {
                            $('#ItmsGrpCod').val(null).trigger('change');
                        }

                        if (resp.BuyUnitMsr) {
                            var optUnit = new Option(resp.BuyUnitMsr, resp.BuyUnitMsr, true, true);
                            $('#BuyUnitMsr').empty().append(optUnit).trigger('change');
                        } else {
                            $('#BuyUnitMsr').val(null).trigger('change');
                        }

                        $('#modalMaterialLabel').text('<?= lang('material.edit_title') ?>: ' + resp.ItemCode);
                        $('#modalMaterial').modal('show');
                    } else {
                        Swal.fire('Error', resp.message || '<?= lang('material.messages.not_found') ?>', 'error');
                    }
                },
                error: function () {
                    Swal.fire('Error', '<?= lang('material.messages.server_error') ?>', 'error');
                }
            });
        });

        // 8. Modal Clonar Artículo
        $('#tableMaterials tbody').on('click', '.btnCloneMaterial', function () {
            var itemCode = $(this).attr('data-itemcode');
            if (!itemCode) return;

            $.ajax({
                url: '<?= base_url('admin/servicelayer/materials/getMaterial') ?>/' + itemCode,
                method: 'GET',
                dataType: 'json',
                success: function (resp) {
                    if (resp.ItemCode) {
                        $('#formMaterial')[0].reset();
                        $('#isNew').val(1);
                        $('#ItemCode').prop('readonly', false);
                        $('#itemCodeHelp').show();

                        $('#ItemName').val(resp.ItemName || '');
                        $('#ItemType').val(resp.ItemType || 'itItems');
                        $('#VATLiable').val(resp.VATLiable || 'Y');
                        $('#validFor').val('Y');

                        if (resp.ItmsGrpCod) {
                            var optGroup = new Option(resp.ItmsGrpNam || ('Grupo ' + resp.ItmsGrpCod), resp.ItmsGrpCod, true, true);
                            $('#ItmsGrpCod').empty().append(optGroup).trigger('change');
                        } else {
                            $('#ItmsGrpCod').val(null).trigger('change');
                        }

                        if (resp.BuyUnitMsr) {
                            var optUnit = new Option(resp.BuyUnitMsr, resp.BuyUnitMsr, true, true);
                            $('#BuyUnitMsr').empty().append(optUnit).trigger('change');
                        } else {
                            $('#BuyUnitMsr').val(null).trigger('change');
                        }

                        $('#modalMaterialLabel').text('<?= lang('material.clone_title') ?> (' + resp.ItemCode + ')');
                        $('#modalMaterial').modal('show');

                        var cleanPrefix = resp.ItemCode.replace(/[0-9]+$/, '') || resp.ItemCode;
                        $.ajax({
                            url: '<?= base_url('admin/servicelayer/materials/getNextItemCode') ?>/' + encodeURIComponent(cleanPrefix),
                            method: 'GET',
                            dataType: 'json',
                            success: function (nextResp) {
                                if (nextResp.status === 200 && nextResp.nextCode) {
                                    $('#ItemCode').val(nextResp.nextCode);
                                }
                            }
                        });
                    }
                }
            });
        });

        // 9. Guardar Artículo
        $('#btnSaveMaterial').on('click', function () {
            var formData = $('#formMaterial').serializeArray();
            var data = {};
            $.each(formData, function (i, field) {
                data[field.name] = field.value;
            });

            if (!data.ItemCode || data.ItemCode.trim() === '') {
                Swal.fire('Atención', '<?= lang('material.messages.code_required') ?>', 'warning');
                return;
            }
            if (!data.ItemName || data.ItemName.trim() === '') {
                Swal.fire('Atención', '<?= lang('material.messages.name_required') ?>', 'warning');
                return;
            }
            if (!data.ItmsGrpCod) {
                Swal.fire('Atención', '<?= lang('material.messages.group_required') ?>', 'warning');
                return;
            }
            if (!data.BuyUnitMsr || data.BuyUnitMsr.trim() === '') {
                Swal.fire('Atención', '<?= lang('material.messages.unit_required') ?>', 'warning');
                return;
            }

            var $btn = $(this);$btn.prop('disabled', true).html('<i class="fas fa-spinner fa-spin mr-1"></i> Guardando...');

            $.ajax({
                url: '<?= base_url('admin/servicelayer/materials/save') ?>',
                method: 'POST',
                data: data,
                dataType: 'json',
                success: function (resp) {
                    $btn.prop('disabled', false).html('<i class="fas fa-save mr-1"></i> <?= lang('boilerplate.global.save') ?? 'Guardar' ?>');
                    if (resp.status === 200 || resp.status === 201) {
                        $('#modalMaterial').modal('hide');
                        Swal.fire({
                            toast: true,
                            position: 'top-end',
                            icon: 'success',
                            title: resp.message || '<?= lang('material.messages.saved') ?>',
                            showConfirmButton: false,
                            timer: 2000
                        });
                        tableMaterials.ajax.reload(null, false);
                    } else {
                        Swal.fire('Error', resp.message || '<?= lang('material.messages.save_error') ?>', 'error');
                    }
                },
                error: function (xhr) {
                    $btn.prop('disabled', false).html('<i class="fas fa-save mr-1"></i> <?= lang('boilerplate.global.save') ?? 'Guardar' ?>');
                    var msg = xhr.responseJSON?.message || '<?= lang('material.messages.server_error') ?>';
                    Swal.fire('Error', msg, 'error');
                }
            });
        });

        // 10. Arrastrar modal
        $('#modalMaterial').draggable({
            handle: '.modal-header'
        });
    });
</script>
<?= $this->endSection() ?>

Archivo 4: El Controlador (Controllers/SapMaterialController.php)

El cerebro de la operación: gestiona consultas analíticas ODBC, cálculo de correlativos y despacho seguro de payloads HTTP mediante Service Layer:

PHP

<?php

namespace julio101290\boilerplateservicelayer\Controllers;

use App\Controllers\BaseController;
use CodeIgniter\API\ResponseTrait;
use julio101290\boilerplatelog\Models\LogModel;
use julio101290\boilerplatecompanies\Models\EmpresasModel;
use julio101290\boilerplateservicelayer\Models\SapservicelayerModel;
use julio101290\boilerplateservicelayer\Controllers\SapservicelayerController;
use julio101290\boilerplatebranchoffice\Models\BranchofficesModel;
use julio101290\boilerplateservicelayer\Models\User_sap_linkModel;
use julio101290\boilerplateservicelayer\Models\Link_sap_branchofficeModel;

class SapMaterialController extends BaseController {

    use ResponseTrait;

    protected $log;
    protected $link_sap_branchoffice;
    protected $empresa;
    protected $serviceLayerModel;
    protected $serviceLayerController;
    protected $branchoffice;
    protected $userLinkSap;

    public function __construct() {
        $this->link_sap_branchoffice  = new Link_sap_branchofficeModel();
        $this->log                    = new LogModel();
        $this->empresa                = new EmpresasModel();
        $this->serviceLayerModel      = new SapservicelayerModel();
        $this->serviceLayerController = new SapservicelayerController();
        $this->branchoffice           = new BranchofficesModel();
        $this->userLinkSap            = new User_sap_linkModel();
        helper(['menu', 'utilerias']);
    }

    /**
     * DataTables Server-Side con Ordenamiento Dinámico y Filtro por Grupo
     */
    public function index() {
        helper('auth');

        if ($this->request->isAJAX()) {
            try {
                $conn = $this->connectODBC();

                $draw      = (int) ($this->request->getGet('draw') ?? 1);
                $start     = (int) ($this->request->getGet('start') ?? 0);
                $length    = (int) ($this->request->getGet('length') ?? 10);
                $search    = $this->request->getGet('search')['value'] ?? '';
                $groupCode = $this->request->getGet('groupCode') ?? '';

                // Ordenamiento dinámico
                $orderParam    = $this->request->getGet('order');
                $orderColIndex = isset($orderParam[0]['column']) ? (int) $orderParam[0]['column'] : 1;
                $orderDirRaw   = isset($orderParam[0]['dir']) ? strtolower($orderParam[0]['dir']) : 'asc';
                $orderDir      = ($orderDirRaw === 'desc') ? 'DESC' : 'ASC';

                $columnsMap = [
                    1 => 'T0."ItemCode"',
                    2 => 'T0."ItemName"',
                    3 => 'T1."ItmsGrpNam"',
                    4 => 'T0."BuyUnitMsr"',
                    5 => 'T0."VATLiable"',
                    6 => 'T0."validFor"',
                ];

                $orderBy = $columnsMap[$orderColIndex] ?? 'T0."ItemCode"';

                $whereExtra = '';
                if (!empty($search)) {
                    $searchClean = str_replace("'", "''", trim($search));
                    $whereExtra .= "
                        AND (
                            T0.\"ItemCode\" LIKE '%{$searchClean}%'
                            OR T0.\"ItemName\" LIKE '%{$searchClean}%'
                            OR T1.\"ItmsGrpNam\" LIKE '%{$searchClean}%'
                        )
                    ";
                }

                if (!empty($groupCode)) {
                    $groupClean = (int) $groupCode;
                    $whereExtra .= " AND T0.\"ItmsGrpCod\" = {$groupClean} ";
                }

                // 1. Total sin filtrar
                $sqlTotal = "SELECT COUNT(1) AS \"total\" FROM OITM WHERE \"PrchseItem\" = 'Y'";
                $rsTotal = odbc_exec($conn, $sqlTotal);
                $totalRecords = 0;
                if ($rsTotal && ($rowTotal = odbc_fetch_array($rsTotal))) {
                    $totalRecords = (int) ($rowTotal['total'] ?? $rowTotal['TOTAL'] ?? 0);
                    odbc_free_result($rsTotal);
                }

                // 2. Total con filtros
                $sqlFiltered = "
                    SELECT COUNT(1) AS \"total\" 
                    FROM OITM T0 
                    LEFT JOIN OITB T1 ON T0.\"ItmsGrpCod\" = T1.\"ItmsGrpCod\"
                    WHERE T0.\"PrchseItem\" = 'Y' {$whereExtra}
                ";
                $rsFiltered = odbc_exec($conn, $sqlFiltered);
                $filteredRecords = $totalRecords;
                if ($rsFiltered && ($rowFiltered = odbc_fetch_array($rsFiltered))) {
                    $filteredRecords = (int) ($rowFiltered['total'] ?? $rowFiltered['TOTAL'] ?? 0);
                    odbc_free_result($rsFiltered);
                }

                // 3. Consulta paginada
                $sql = "
                    SELECT
                        T0.\"ItemCode\",
                        T0.\"ItemName\",
                        T0.\"BuyUnitMsr\",
                        T0.\"ItmsGrpCod\",
                        T1.\"ItmsGrpNam\",
                        T0.\"VATLiable\",
                        T0.\"validFor\"
                    FROM OITM T0
                    LEFT JOIN OITB T1 ON T0.\"ItmsGrpCod\" = T1.\"ItmsGrpCod\"
                    WHERE T0.\"PrchseItem\" = 'Y'
                      {$whereExtra}
                    ORDER BY {$orderBy} {$orderDir}
                    LIMIT {$length} OFFSET {$start}
                ";

                $rs = odbc_exec($conn, $sql);
                if (!$rs) {
                    throw new \Exception('Error al consultar artículos: ' . odbc_errormsg($conn));
                }

                $data = [];
                while ($row = odbc_fetch_array($rs)) {
                    $data[] = [
                        'ItemCode'   => $this->toUtf8($row['ItemCode']),
                        'ItemName'   => $this->toUtf8($row['ItemName']),
                        'BuyUnitMsr' => $this->toUtf8($row['BuyUnitMsr'] ?? ''),
                        'ItmsGrpCod' => $this->toUtf8($row['ItmsGrpCod'] ?? ''),
                        'ItmsGrpNam' => $this->toUtf8($row['ItmsGrpNam'] ?? ''),
                        'VATLiable'  => $this->toUtf8($row['VATLiable'] ?? 'Y'),
                        'validFor'   => $this->toUtf8($row['validFor'] ?? 'Y')
                    ];
                }

                odbc_free_result($rs);
                odbc_close($conn);

                return $this->response->setJSON([
                    'draw'            => $draw,
                    'recordsTotal'    => $totalRecords,
                    'recordsFiltered' => $filteredRecords,
                    'data'            => $data
                ]);

            } catch (\Throwable $e) {
                return $this->response->setJSON([
                    'draw'            => (int) ($this->request->getGet('draw') ?? 1),
                    'recordsTotal'    => 0,
                    'recordsFiltered' => 0,
                    'data'            => [],
                    'error'           => true,
                    'message'         => $e->getMessage()
                ]);
            }
        }

        $data = [
            'title'     => 'Artículos SAP',
            'subtitle'  => 'Catálogo de Artículos de Compra',
            'box_title' => 'Listado de Artículos'
        ];

        return view('julio101290\boilerplateservicelayer\Views\materials', $data);
    }

    /**
     * Calcula consecutivo automático inteligente con 5 ceros
     */
    public function getNextItemCode($prefix = '') {
        try {
            $prefix = strtoupper(trim(urldecode($prefix)));
            $cleanPrefix = preg_replace('/[^A-Z0-9_\-]/', '', $prefix);

            if (empty($cleanPrefix)) {
                return $this->response->setJSON(['status' => 400, 'nextCode' => '']);
            }

            $conn = $this->connectODBC();

            $sql = "
                SELECT \"ItemCode\"
                FROM OITM
                WHERE \"ItemCode\" LIKE '{$cleanPrefix}%'
                ORDER BY \"ItemCode\" DESC
            ";

            $rs = odbc_exec($conn, $sql);
            $maxNumber = 0;
            $prefixLen = strlen($cleanPrefix);

            if ($rs) {
                while ($row = odbc_fetch_array($rs)) {
                    $code = trim($this->toUtf8($row['ItemCode']));
                    $numericSuffix = substr($code, $prefixLen);

                    if (is_numeric($numericSuffix)) {
                        $num = (int) $numericSuffix;
                        if ($num > $maxNumber) {
                            $maxNumber = $num;
                        }
                    }
                }
                odbc_free_result($rs);
            }
            odbc_close($conn);

            $nextNumber = $maxNumber + 1;
            $nextCode   = $cleanPrefix . str_pad($nextNumber, 5, '0', STR_PAD_LEFT);

            return $this->response->setJSON([
                'status'   => 200,
                'prefix'   => $cleanPrefix,
                'nextCode' => $nextCode
            ]);

        } catch (\Throwable $e) {
            return $this->response->setJSON([
                'status'   => 500,
                'nextCode' => '',
                'message'  => $e->getMessage()
            ]);
        }
    }

    /**
     * Catálogo de Grupos (OITB) para Select2
     */
    public function getItemGroupsAjax() {
        try {
            $search = $this->request->getGet('searchTerm') ?? '';
            $conn   = $this->connectODBC();

            $where = '';
            if (!empty($search)) {
                $searchClean = str_replace("'", "''", trim($search));
                $where = " WHERE \"ItmsGrpNam\" LIKE '%{$searchClean}%' ";
            }

            $sql = "
                SELECT \"ItmsGrpCod\", \"ItmsGrpNam\"
                FROM OITB
                {$where}
                ORDER BY \"ItmsGrpNam\" ASC
            ";

            $rs = odbc_exec($conn, $sql);
            $data = [];
            while ($row = odbc_fetch_array($rs)) {
                $data[] = [
                    'id'   => (int) $row['ItmsGrpCod'],
                    'text' => $this->toUtf8($row['ItmsGrpCod']) . ' - ' . $this->toUtf8($row['ItmsGrpNam'])
                ];
            }

            odbc_free_result($rs);
            odbc_close($conn);

            return $this->response->setJSON(['data' => $data]);
        } catch (\Throwable $e) {
            return $this->response->setJSON(['data' => [], 'error' => true, 'message' => $e->getMessage()]);
        }
    }

    /**
     * Catálogo de Unidades de Medida (OUOM) para Select2
     */
    public function getUnitsAjax() {
        try {
            $search = $this->request->getGet('searchTerm') ?? '';
            $conn   = $this->connectODBC();

            $where = " WHERE \"Locked\" = 'N' ";
            if (!empty($search)) {
                $searchClean = str_replace("'", "''", trim($search));
                $where .= " AND (\"UomCode\" LIKE '%{$searchClean}%' OR \"UomName\" LIKE '%{$searchClean}%') ";
            }

            $sql = "
                SELECT \"UomCode\", \"UomName\"
                FROM OUOM
                {$where}
                ORDER BY \"UomCode\" ASC
                LIMIT 30
            ";

            $rs = odbc_exec($conn, $sql);
            $data = [];
            while ($row = odbc_fetch_array($rs)) {
                $code = $this->toUtf8($row['UomCode']);
                $name = $this->toUtf8($row['UomName']);
                $data[] = [
                    'id'   => $code,
                    'text' => $code . ($name ? ' - ' . $name : '')
                ];
            }

            odbc_free_result($rs);
            odbc_close($conn);

            return $this->response->setJSON(['data' => $data]);
        } catch (\Throwable $e) {
            return $this->response->setJSON(['data' => [], 'error' => true, 'message' => $e->getMessage()]);
        }
    }

    /**
     * Obtener detalle por ItemCode para edición o clonación
     */
    public function getMaterial($itemCode = null) {
        try {
            if (empty($itemCode)) {
                return $this->response->setJSON(['error' => true, 'message' => lang('material.messages.invalid_code')]);
            }

            $itemCodeClean = str_replace("'", "''", trim(urldecode($itemCode)));
            $conn = $this->connectODBC();

            $sql = "
                SELECT
                    T0.\"ItemCode\",
                    T0.\"ItemName\",
                    T0.\"ItemType\",
                    T0.\"ItmsGrpCod\",
                    T1.\"ItmsGrpNam\",
                    T0.\"BuyUnitMsr\",
                    T0.\"VATLiable\",
                    T0.\"PrchseItem\",
                    T0.\"InvntItem\",
                    T0.\"SellItem\",
                    T0.\"validFor\"
                FROM OITM T0
                LEFT JOIN OITB T1 ON T0.\"ItmsGrpCod\" = T1.\"ItmsGrpCod\"
                WHERE T0.\"ItemCode\" = '{$itemCodeClean}'
            ";

            $rs = odbc_exec($conn, $sql);
            $row = odbc_fetch_array($rs);
            odbc_free_result($rs);
            odbc_close($conn);

            if (!$row) {
                return $this->response->setJSON(['error' => true, 'message' => lang('material.messages.not_found')]);
            }

            $itemType = $this->toUtf8($row['ItemType'] ?? 'itItems');
            if ($itemType === 'I') $itemType = 'itItems';
            if ($itemType === 'L') $itemType = 'itLabor';
            if ($itemType === 'T') $itemType = 'itTravel';

            return $this->response->setJSON([
                'ItemCode'   => $this->toUtf8($row['ItemCode']),
                'ItemName'   => $this->toUtf8($row['ItemName']),
                'ItemType'   => $itemType,
                'ItmsGrpCod' => (int) $row['ItmsGrpCod'],
                'ItmsGrpNam' => $this->toUtf8($row['ItmsGrpNam'] ?? ''),
                'BuyUnitMsr' => $this->toUtf8($row['BuyUnitMsr'] ?? ''),
                'VATLiable'  => $this->toUtf8($row['VATLiable'] ?? 'Y'),
                'validFor'   => $this->toUtf8($row['validFor'] ?? 'Y')
            ]);
        } catch (\Throwable $e) {
            return $this->response->setJSON(['error' => true, 'message' => $e->getMessage()]);
        }
    }

    /**
     * Guardar / Actualizar estrictamente vía SAP Service Layer
     */
    public function save() {
        helper('auth');
        $userName = user()->username;

        $post = $this->request->getPost();

        $isNew      = (int) ($post['isNew'] ?? 1);
        $itemCode   = strtoupper(trim($post['ItemCode'] ?? ''));
        $itemName   = trim($post['ItemName'] ?? '');
        $itemType   = $post['ItemType'] ?? 'itItems';
        $itmsGrpCod = (int) ($post['ItmsGrpCod'] ?? 0);
        $buyUnitMsr = strtoupper(trim($post['BuyUnitMsr'] ?? ''));
        $vatLiable  = ($post['VATLiable'] ?? 'Y') === 'Y' ? 'tYES' : 'tNO';
        $validFor   = ($post['validFor'] ?? 'Y') === 'Y' ? 'tYES' : 'tNO';

        if (empty($itemCode) || empty($itemName)) {
            return $this->respond(['status' => 400, 'message' => lang('material.messages.code_required')], 400);
        }

        if ($itmsGrpCod <= 0) {
            return $this->respond(['status' => 400, 'message' => lang('material.messages.group_required')], 400);
        }

        if (empty($buyUnitMsr)) {
            return $this->respond(['status' => 400, 'message' => lang('material.messages.unit_required')], 400);
        }

        $dataSL = $this->serviceLayerModel->first();
        if (empty($dataSL)) {
            return $this->respond(['status' => 500, 'message' => 'No hay configuración Service Layer'], 500);
        }

        // Validación de unicidad solo para altas (vía ODBC SELECT)
        if ($isNew === 1) {
            try {
                $conn = $this->connectODBC();
                $itemCodeClean = str_replace("'", "''", $itemCode);
                $rsCheck = odbc_exec($conn, "SELECT COUNT(1) AS \"cnt\" FROM OITM WHERE \"ItemCode\" = '{$itemCodeClean}'");
                $exists = 0;
                if ($rsCheck && ($row = odbc_fetch_array($rsCheck))) {
                    $exists = (int) ($row['cnt'] ?? $row['CNT'] ?? 0);
                    odbc_free_result($rsCheck);
                }
                odbc_close($conn);

                if ($exists > 0) {
                    return $this->respond([
                        'status'  => 400,
                        'message' => lang('material.messages.code_exists')
                    ], 400);
                }
            } catch (\Throwable $e) {
                return $this->respond(['status' => 500, 'message' => 'Error al validar código: ' . $e->getMessage()], 500);
            }
        }

        // Login a Service Layer
        try {
            $conexionSap = $this->serviceLayerController->login(
                $dataSL['url'],
                $dataSL['port'],
                $dataSL['password'],
                $dataSL['username'],
                $dataSL['companyDB']
            );
        } catch (\Exception $e) {
            return $this->respond(['status' => 500, 'message' => 'Error login SL: ' . $e->getMessage()], 500);
        }

        if (empty($conexionSap->SessionId)) {
            return $this->respond(['status' => 500, 'message' => 'No se obtuvo SessionId de Service Layer'], 500);
        }

        $cookie = "B1SESSION=" . $conexionSap->SessionId . "; ROUTEID=.node1";
        $slRoot = rtrim($dataSL['url'], '/');
        if (stripos($slRoot, '/b1s/v1') === false) {
            $slRoot .= '/b1s/v1';
        } else {
            $pos    = stripos($slRoot, '/b1s/v1');
            $slRoot = substr($slRoot, 0, $pos) . '/b1s/v1';
        }

        $baseHeaders = [
            "Accept: application/json",
            "Content-Type: application/json",
            "User-Agent: PHP",
            "B1S-CaseInsensitive: true"
        ];

        // Payload estricto y limpio
        $payload = [
            'ItemName'       => $itemName,
            'ItemType'       => $itemType,
            'ItemsGroupCode' => $itmsGrpCod,
            'PurchaseUnit'   => $buyUnitMsr,
            'VatLiable'      => $vatLiable,
            'Valid'          => $validFor,
            'PurchaseItem'   => 'tYES',
            'InventoryItem'  => 'tYES',
            'SalesItem'      => 'tNO'
        ];

        if ($isNew === 1) {
            $payload['ItemCode'] = $itemCode;
            $url    = $slRoot . "/Items";
            $method = 'POST';
        } else {
            $url    = $slRoot . "/Items('" . rawurlencode($itemCode) . "')";
            $method = 'PATCH';
        }

        $jsonPayload = json_encode($payload);

        $ch = curl_init();
        curl_setopt_array($ch, [
            CURLOPT_URL            => $url,
            CURLOPT_PORT           => $dataSL['port'],
            CURLOPT_RETURNTRANSFER => true,
            CURLOPT_CUSTOMREQUEST  => $method,
            CURLOPT_POSTFIELDS     => $jsonPayload,
            CURLOPT_COOKIE         => $cookie,
            CURLOPT_SSL_VERIFYHOST => false,
            CURLOPT_SSL_VERIFYPEER => false,
            CURLOPT_HTTPHEADER     => $baseHeaders,
            CURLOPT_TIMEOUT        => 60
        ]);

        $resp     = curl_exec($ch);
        $err      = curl_error($ch);
        $httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
        curl_close($ch);

        if ($err) {
            return $this->respond(['status' => 500, 'message' => 'cURL Error: ' . $err], 500);
        }

        if ($httpCode < 200 || $httpCode >= 300) {
            $body     = json_decode($resp, true);
            $msgError = $body['error']['message']['value'] ?? $body['error']['message'] ?? 'Error en Service Layer';
            return $this->respond(['status' => $httpCode, 'message' => $msgError, 'body' => $body], $httpCode);
        }

        $dataResp = null;
        if ($method === 'POST') {
            $dataResp = json_decode($resp, true);
        }

        $this->log->save([
            "description" => ($isNew === 1 ? "Creación" : "Actualización") . " de artículo de compra '{$itemCode}'",
            "user"        => $userName
        ]);

        return $this->respond([
            'status'  => 200,
            'message' => ($isNew === 1 ? lang('material.messages.saved') : 'Artículo actualizado correctamente'),
            'data'    => $dataResp
        ], 200);
    }

    private function connectODBC() {
        $dataConect = $this->serviceLayerModel->first();
        if (!$dataConect) {
            throw new \Exception('No se encontró configuración de conexión SAP.');
        }

        $conn = odbc_connect(
            $dataConect['nameODBC'],
            $dataConect['userODBC'],
            $dataConect['passwordODBC']
        );

        if (!$conn) {
            throw new \Exception('Error conexión ODBC: ' . odbc_errormsg());
        }

        if (!odbc_exec($conn, 'SET SCHEMA "' . $dataConect['companyDB'] . '"')) {
            throw new \Exception('Error SET SCHEMA: ' . odbc_errormsg($conn));
        }

        return $conn;
    }

    private function toUtf8($value) {
        if (is_null($value)) return '';
        if (mb_check_encoding($value, 'UTF-8')) return $value;
        return mb_convert_encoding($value, 'UTF-8', 'ISO-8859-1');
    }
}

🏆 9. Conclusión y Buenas Prácticas para Integradores SAP

Desarrollar soluciones satélite alrededor de SAP Business One no tiene por qué ser una pesadilla ni una carrera contra los errores de DI-API.

Aplicando una arquitectura híbrida inteligente:

  1. Lectura directa por ODBC para búsquedas, filtros y reportes instantáneos.
  2. Escritura protegida por Service Layer para mantener la garantía, integridad contable y auditoría interna intactas.
  3. UX centrada en el usuario con autocompletados Select2, cálculo de números consecutivos automáticos y clonación en un clic.

Logramos una herramienta que los almacenistas y compradores adoran usar porque no se traba, y que los administradores de sistemas y consultores de SAP aprueban porque cumple al 100% las políticas del fabricante.

🌐 ¡Sigamos en Contacto y Creando Código!

Si te gustó este artículo o te sirvió para implementar tus integraciones con SAP Business One, te invito a sumarte a la comunidad, seguir mis proyectos de código abierto y no perderte los nuevos tutoriales sobre desarrollo, Linux y bases de datos:

  • 💬 Telegram: Únete al canal y comunidad oficial en t.me/CesarSystems[cite: 1]
  • 🎥 YouTube:
    • Cesar Systems: youtube.com/@cesarsystems[cite: 1] — Tutoriales de desarrollo backend, CodeIgniter 4, SAP Business One, bases de datos y DevOps.
    • Canal Secundario: youtube.com/@rasec555[cite: 1] — Contenido adicional, directos y proyectos de software libre.
  • 💻 GitHub: Revisa y colabora en los módulos y repositorios de código abierto en github.com/julio101290
  • ☕ Patreon: Apoya la creación de contenido técnico independiente en patreon.com/c/u74078772
  • 📺 Odysee & Blog: Cesar Systems y Atardeceres Píxel

¡Nos vemos en el próximo commit y en la comunidad de Telegram! 🚀

Cómo Gestionar Empleados en SAP Business One con CodeIgniter 4, Service Layer y ODBC (Solución Definitiva a ExtEmpNo y Roles)

Entrada fija

Conectar sistemas modernos basados en PHP con un ERP robusto como SAP Business One es uno de los retos más comunes y demandantes en el desarrollo corporativo. Cuando construimos aplicaciones a la medida —como portales de autoservicio para colaboradores, módulos de recursos humanos o sistemas de punto de venta y control operativo— nos topamos con una encrucijada técnica fundamental: ¿cómo lograr que las interfaces sean ultrarrápidas sin comprometer la integridad transaccional de SAP?

La respuesta más sólida es la arquitectura híbrida: utilizar ODBC directo (sobre SAP HANA o SQL Server) para lectura ultrarrápida, consultas complejas y paginación en DataTables, combinándolo con el Service Layer (REST API) para todas las operaciones de escritura (creación, edición y eliminación).

Sin embargo, al implementar el catálogo de empleados (OHEM), surgen problemas comunes que pueden costar días de depuración:

  1. La confusión clásica entre el campo interno automático Code y el número externo ExtEmpNo (Número de Empleado Externo).
  2. La falta de validación de unicidad al capturar códigos manuales.
  3. El borrado accidental de líneas secundarias al actualizar colecciones hijas en Service Layer (como los roles de empleado en HEM6).
  4. Errores 404 al invocar endpoints mal nombrados como /Employees en lugar de /EmployeesInfo.

En esta guía exhaustiva aprenderás a resolver cada uno de estos detalles paso a paso utilizando CodeIgniter 4, integrando un panel administrativo ágil, modular y seguro.

🎯 Objetivos de la Implementación

Antes de revisar las líneas de código, definamos con claridad qué resolvemos con esta arquitectura:

  • 🚀 Rendimiento óptimo en frontend: Cargar listados de cientos o miles de empleados con paginación del lado del servidor (Server-Side DataTables) consumiendo datos mediante ODBC sin sobrecargar el Service Layer.
  • 🛡️ Integridad transaccional absoluta: Delegar todas las altas, bajas y modificaciones al Service Layer de SAP B1, garantizando que se disparen las validaciones de negocio del ERP.
  • 🔢 Diferenciación estricta entre Code y ExtEmpNo: Dejar el campo Code como un identificador interno de solo lectura generado automáticamente por SAP, y permitir la captura manual del ExtEmpNo (ExternalEmployeeNumber), validando en tiempo real que no se duplique en la base de datos.
  • 👥 Gestión limpia de colecciones anidadas (Roles del Empleado): Administrar la relación 1 a N entre el empleado y sus roles (HEM6 / OHTY) sin sobrescribir ni perder el historial al hacer actualizaciones parciales.
  • 📦 Arquitectura desacoplada en CodeIgniter 4: Mantener controladores limpios, uso de traits para respuestas JSON unificadas y trazabilidad total mediante un modelo de logs para auditoría de usuarios.

🏗️ La Arquitectura Híbrida: ODBC para Lectura, Service Layer para Escritura

Cuando desarrollamos sobre SAP Business One, depender exclusivamente del Service Layer para alimentar tablas de datos interactivas suele provocar cuellos de botella. Service Layer es un servicio REST OData pensado para transacciones seguras; procesar consultas con múltiples JOIN, filtros de búsqueda textuales con comodines % y paginaciones concurrentes para decenas de usuarios puede saturar el worker de Node/Apache del servidor SAP.

                  ┌─────────────────────────────────────────┐
                  │          CodeIgniter 4 Backend          │
                  └──────────────┬───────────────────┬──────┘
                                 │                   │
         [Lectura / Server-Side] │                   │ [Escritura / REST]
                 ODBC Directo    │                   │   Service Layer
                                 ▼                   ▼
                  ┌──────────────────┐    ┌──────────────────────────┐
                  │ SAP HANA / MSSQL │    │    SAP Service Layer     │
                  │ (Vistas, Tablas) │    │ (Validaciones de Negocio)│
                  └──────────────────┘    └─────────────┬────────────┘
                                                        │
                                                        ▼
                                          ┌──────────────────────────┐
                                          │  Base de Datos SAP B1    │
                                          └──────────────────────────┘

¿Por qué leer con ODBC?

  • Velocidad nativa: Las consultas se ejecutan directamente en el motor de base de datos relacional (HANA o SQL Server), aprovechando índices y vistas optimizadas.
  • Flexibilidad SQL: Permite utilizar funciones nativas de agregación, uniones complejas entre OHEM (empleados), HEM6 (roles asignados) y OHTY (catálogo de roles) sin lidiar con la sintaxis restrictiva de OData $expand.
  • Compatibilidad con DataTables: Resuelve de manera inmediata los parámetros draw, start, length y search[value].

¿Por qué escribir con Service Layer?

  • Reglas de negocio vivas: Ejecuta las validaciones de integridad referencial, campos obligatorios definidos en parametrizaciones de documento y lógica contable.
  • Seguridad: Evita escribir directamente mediante INSERT o UPDATE por SQL a las tablas de SAP, lo cual viola el soporte y la garantía de SAP.
  • Disparadores de eventos: Permite que add-ons de terceros y flujos de trabajo (workflows) internos de SAP reaccionen a la creación o modificación del colaborador.

🔍 El Dilema en SAP B1: ¿Code o ExtEmpNo?

Uno de los errores más frecuentes al crear la pantalla de captura para empleados es asumir que el campo Code de la tabla OHEM corresponde a la matrícula o clave manual del trabajador.

En la arquitectura de base de datos de SAP Business One:

  1. empID (Employee ID): Es la llave primaria interna autoincremental de tipo entero. Es el identificador único con el que se relacionan documentos como órdenes de fabricación, llamadas de servicio o firmas de autorización.
  2. Code: Es un valor de control alfanumérico interno gestionado por el ERP. En muchas localizaciones y versiones, intentar forzar un valor personalizado aquí puede generar inconsistencias o conflictos de duplicidad interna.
  3. ExtEmpNo (External Employee Number): Es el campo estándar previsto por SAP para almacenar la matrícula de nómina externa, la clave del reloj checador o el identificador del sistema de recursos humanos externo. En Service Layer se representa bajo la propiedad ExternalEmployeeNumber.

⚠️ El Riesgo de Duplicidad

Por defecto, SAP B1 no siempre bloquea la duplicidad de ExtEmpNo a nivel de base de datos con un índice único, dependiendo de la configuración de la sociedad. Si dos trabajadores reciben el mismo número externo, los módulos de importación de nómina, control de asistencia o facturación electrónica pueden colapsar.

Por ello, la validación debe garantizarse en nuestra capa de aplicación en CodeIgniter 4 antes de enviar la petición HTTP al Service Layer.

⚙️ Implementación del Controlador en CodeIgniter 4

Analicemos cómo estructurar el controlador EmployeeSAPController. Este componente orquesta la lectura veloz vía ODBC, la verificación de duplicados y el envío transaccional vía cURL a Service Layer.

1. Validación de Unicidad previa vía ODBC

Para evitar saturar el Service Layer con peticiones que van a rebotar por validaciones o que permitirían duplicados no deseados, ejecutamos una comprobación ligera previa mediante ODBC:

PHP

private function extEmpNoExists($dataConect, string $extEmpNo, int $currentEmpID = 0): bool {
    if (empty($extEmpNo)) {
        return false;
    }

    $conn = @odbc_connect(
        $dataConect["nameODBC"],
        $dataConect["userODBC"],
        $dataConect["passwordODBC"]
    );
    if (!$conn) {
        return false;
    }

    odbc_exec($conn, 'SET SCHEMA "' . $dataConect["companyDB"] . '"');

    $extEmpNoEsc = str_replace("'", "''", trim($extEmpNo));
    $sql = 'SELECT COUNT(*) AS total FROM OHEM WHERE "ExtEmpNo" = \'' . $extEmpNoEsc . '\'';
    
    // Si estamos editando, excluimos al propio empleado evaluado
    if ($currentEmpID > 0) {
        $sql .= ' AND "empID" <> ' . (int) $currentEmpID;
    }

    $stmt = odbc_exec($conn, $sql);
    $exists = false;
    if ($stmt && odbc_fetch_row($stmt)) {
        $exists = ((int) odbc_result($stmt, 1)) > 0;
    }

    if ($stmt) {
        odbc_free_result($stmt);
    }
    odbc_close($conn);

    return $exists;
}

💡 Nota técnica: Observa el uso de SET SCHEMA para bases de datos SAP HANA. En HANA, cada sociedad de Business One reside en su propio esquema. Si utilizas Microsoft SQL Server, asegúrate de utilizar la nomenclatura de catálogo correspondiente (USE [BaseDeDatos]).

2. Guardado Transaccional con Service Layer

El método save() recibe los datos del formulario, ejecuta las comprobaciones requeridas y construye el payload JSON hacia la entidad /EmployeesInfo:

PHP

// Construcción del payload para SAP Service Layer
$payload = [
    'FirstName' => trim($datos['firstName']),
    'LastName' => trim($datos['lastName']),
    'ExternalEmployeeNumber' => $extEmpNo // Mapeo exacto hacia ExtEmpNo
];

if (isset($datos['middleName'])) {
    $payload['MiddleName'] = trim($datos['middleName']);
}

if (isset($datos['Active'])) {
    $activeVal = $datos['Active'];
    // Service Layer espera 'tYES' o 'tNO' para los flags booleanos tipo BoYesNoEnum
    $payload['Active'] = in_array($activeVal, [true, 1, '1', 'Y', 'tYES'], true) ? 'tYES' : 'tNO';
}

if ($empID <= 0) {
    // Creación: POST a la colección raíz
    $url = $slRoot . "/EmployeesInfo";
    $method = 'POST';
} else {
    // Actualización: PATCH a la entidad específica
    $url = $slRoot . "/EmployeesInfo({$empID})";
    $method = 'PATCH';
}

🧩 El Reto de las Colecciones Hijas: Gestión de Roles sin Pérdida de Datos

En SAP Business One, un empleado puede tener múltiples roles asignados (como vendedor, chofer, técnico o almacenista). En la base de datos relacional, esto se almacena en la tabla secundaria HEM6.

En Service Layer, esta relación se modela como una colección hija dentro de EmployeesInfo llamada EmployeeRolesInfoLines.

El Problema del PATCH Estándar

Cuando ejecutas un PATCH sobre /EmployeesInfo(id) enviando un arreglo en EmployeeRolesInfoLines, la API REST de SAP suele aplicar una mezcla (merge) o simplemente agregar elementos nuevos. Si tu intención es eliminar un rol previamente asignado, omitir ese elemento en el arreglo no basta: SAP conservará los que ya estaban en la base de datos a menos que utilices el header de reemplazo completo.

La Solución: Header B1S-ReplaceCollectionsOnPatch

Para que un PATCH reemplace la lista de roles en su totalidad (permitiendo tanto agregar como quitar roles sin dejar basura en HEM6), agregamos la cabecera:

PHP

$patchHeaders = [
    "Accept: application/json",
    "Content-Type: application/json",
    "User-Agent: PHP",
    "B1S-CaseInsensitive: true",
    "B1S-ReplaceCollectionsOnPatch: true" // ¡Indispensable para eliminar líneas omitidas!
];

El flujo completo para quitar un rol queda así:

  1. Hacemos un GET a /EmployeesInfo(id)?$select=EmployeeID,EmployeeRolesInfoLines para conocer los roles vigentes.
  2. Filtramos el arreglo en PHP mediante array_filter(), excluyendo el RoleID que el usuario eliminó en pantalla.
  3. Enviamos un PATCH con la colección depurada y la cabecera B1S-ReplaceCollectionsOnPatch: true. De este modo, SAP sincroniza exactamente las filas resultantes.

💻 El Frontend: Vistas con DataTables, Modales y Select2 Remoto

El frontend combina una tabla con paginación asíncrona, un modal con arrastre dinámico (draggable) y un buscador dinámico de roles con autocompletado en vivo mediante Select2.

Estructura de Campos en el Modal

Para evitar confusiones operativas, organizamos la captura visualmente de manera jerárquica:

  • No. Empleado Externo (ExtEmpNo): Campo editable, con asterisco de obligatoriedad y validación antes del envío.
  • Código SAP (Code): Marcado como readonly con fondo atenuado y el texto “Automático por SAP”. Esto educa al usuario de que ese código no debe inventarse manualmente.
  • Estado (Active): Selector desplegable simple entre Activo (Sí) e Inactivo (No).

HTML

<div class="row">
    <div class="col-md-4">
        <div class="form-group">
            <label for="ExtEmpNo">No. Empleado Externo *</label>
            <input type="text" class="form-control" name="ExtEmpNo" id="ExtEmpNo" required placeholder="Ej. EMP-1045">
        </div>
    </div>
    <div class="col-md-4">
        <div class="form-group">
            <label for="Code">Código SAP (Auto)</label>
            <input type="text" class="form-control bg-light" name="Code" id="Code" readonly placeholder="Automático por SAP">
        </div>
    </div>
    <div class="col-md-4">
        <div class="form-group">
            <label for="Active"><?= lang('employee.fields.active') ?></label>
            <select class="form-control" name="Active" id="Active">
                <option value="Y"><?= lang('employee.active_yes') ?></option>
                <option value="N"><?= lang('employee.active_no') ?></option>
            </select>
        </div>
    </div>
</div>

🛠️ Buenas Prácticas y Puntos Críticos en Producción

Al desplegar este módulo en servidores reales bajo Linux o contenedores Docker, ten en cuenta las siguientes recomendaciones probadas en campo:

1. Manejo de Codificación con utf8ize

Los controladores ODBC en sistemas Linux (unixODBC con FreeTDS o el cliente nativo de SAP HANA) pueden devolver cadenas codificadas en ISO-8859-1 o Windows-1252 si la collation de la base de datos de SAP contiene caracteres especiales (acentos, eñes o diéresis). Utilizar una función recursiva de conversión a UTF-8 antes de serializar hacia JSON evita pantallas en blanco por errores de json_encode():

PHP

private function utf8ize($mixed) {
    if (is_array($mixed)) {
        foreach ($mixed as $key => $value) {
            $mixed[$key] = $this->utf8ize($value);
        }
    } elseif (is_string($mixed)) {
        return mb_convert_encoding($mixed, 'UTF-8', 'UTF-8,ISO-8859-1,Windows-1252');
    }
    return $mixed;
}

2. Gestión de Sesiones en Service Layer

Abrir una sesión de Service Layer por cada petición HTTP es costoso en tiempo de respuesta (~200ms a 500ms solo en el handshake de autenticación). Lo ideal en entornos de alto tráfico es almacenar el B1SESSION y el ROUTEID en una memoria compartida rápida (como Redis o la caché de CodeIgniter 4) con un tiempo de vida (TTL) de 25 minutos, renovándolo únicamente cuando SAP devuelva un error 401 Unauthorized.

3. El Endpoint de Borrado Correcto

En la documentación oficial de SAP Business One Service Layer, muchos ejemplos de prueba utilizan nombres genéricos. La entidad expuesta para empleados es formalmente EmployeesInfo, no Employees. Invocar DELETE sobre /b1s/v1/Employees(12) arrojará invariablemente un error HTTP 404 de recurso no encontrado. La URL canónica es:

$$\text{DELETE } \rightarrow \text{/b1s/v1/EmployeesInfo}(id)$$

📌 Conclusión Práctica

Al dividir las responsabilidades de tu arquitectura —dejando las lecturas complejas a ODBC y las escrituras críticas a Service Layer— logras lo mejor de dos mundos: interfaces web instantáneas para el usuario y cumplimiento estricto de las reglas del ERP.

El correcto mapeo de ExtEmpNo hacia ExternalEmployeeNumber, junto con la validación de duplicados y el uso de cabeceras de reemplazo como B1S-ReplaceCollectionsOnPatch, convierte un módulo propenso a fallas en un componente corporativo confiable, mantenible y escalable.

🌐 Conéctate con Cesar Systems

¿Tienes dudas sobre integraciones con SAP Business One, desarrollo en CodeIgniter 4 o administración de servidores Linux? Sígueme en mis canales y redes oficiales para más tutoriales, código abierto y guías técnicas:

🔧 PHP + SAP HANA ODBC: solución cuando odbc_fetch_array() no devuelve todos los registros

Entrada fija

¿Te ha pasado que una consulta en SAP HANA devuelve cientos de registros correctamente, pero al consumirla desde PHP mediante ODBC solamente recibes uno?

Eso fue exactamente lo que ocurrió en este caso: una consulta en SAP HANA tenía 902 registros, pero desde PHP, al incluir la descripción del artículo OITM."ItemName", odbc_fetch_array() solamente recuperaba 1 registro.

Después de varias pruebas se pudo determinar que no era un problema de memoria, SQL, JOIN, PHP ni del while.

La solución fue configurar correctamente el controlador SAP HANA ODBC para manejar los caracteres mediante UTF-8:

CHAR_AS_UTF8=true

manteniendo:

EnableArrayFetch=1

La configuración se aplicó tanto en Linux Mint como en Windows 10.


🚨 El problema

La consulta original era similar a:

SELECT
    a."ItemCode",
    b."ItemName",
    a."Price",
    a."PriceList",
    c."ListName"
FROM ITM1 a
INNER JOIN OITM b
    ON a."ItemCode" = b."ItemCode"
INNER JOIN OPLN c
    ON a."PriceList" = c."ListNum"
WHERE a."PriceList" = 1
  AND IFNULL(a."Price", 0) > 0;

En SAP HANA la consulta contenía 902 artículos con precio.

Sin embargo, PHP solamente recibía:

1 registro

y no aparecía ningún error ODBC.

El código PHP era completamente normal:

while ($row = odbc_fetch_array($stmt)) {

    $data[] = [
        'ItemCode'  => $row['ItemCode'],
        'ItemName'  => $row['ItemName'],
        'Price'     => (float) $row['Price'],
        'PriceList' => (int) $row['PriceList'],
        'ListName'  => $row['ListName'],
    ];
}

🔍 Primero: comprobar si realmente existen los registros

Antes de culpar a PHP u ODBC, se comprobó directamente en HANA:

SELECT COUNT(*) AS "Total"
FROM ITM1
WHERE "PriceList" = 1
  AND IFNULL("Price", 0) > 0;

Resultado:

902

Por lo tanto, los datos sí existían.


🧪 Diagnóstico paso a paso

Para encontrar el problema se fue simplificando la consulta.

1️⃣ Solamente ITM1

SELECT
    "ItemCode",
    "Price",
    "PriceList"
FROM ITM1
WHERE "PriceList" = 1
  AND IFNULL("Price", 0) > 0;

Desde PHP:

TOTAL: 902

✅ ITM1 funcionaba correctamente.


2️⃣ Agregar el JOIN con OITM

Se añadió:

INNER JOIN OITM b
    ON a."ItemCode" = b."ItemCode"

pero solamente se recuperó el código:

SELECT
    a."ItemCode",
    b."ItemCode"
FROM ITM1 a
INNER JOIN OITM b
    ON a."ItemCode" = b."ItemCode"
WHERE a."PriceList" = 1
  AND IFNULL(a."Price", 0) > 0;

Resultado:

TOTAL: 902

✅ El JOIN con OITM también funcionaba.


⚠️ 3️⃣ Agregar ItemName

El problema apareció cuando se añadió:

b."ItemName"

La consulta:

SELECT
    a."ItemCode",
    b."ItemName"
FROM ITM1 a
INNER JOIN OITM b
    ON a."ItemCode" = b."ItemCode"
WHERE a."PriceList" = 1
  AND IFNULL(a."Price", 0) > 0;

Desde PHP solamente devolvía:

TOTAL: 1

🎯 Aquí quedó localizado el problema.

No era el JOIN.

No era ITM1.

No era OPLN.

No era memoria.

El comportamiento aparecía específicamente al recuperar el contenido de:

OITM."ItemName"

🧠 Una pista importante: los caracteres especiales

Durante las pruebas también apareció algo muy revelador.

En SAP HANA el texto era:

REV 14±2

pero PHP recibía:

REV 14±2

Eso indicaba claramente un problema de codificación de caracteres entre:

SAP HANA
   ↓
HDBODBC
   ↓
PHP
   ↓
JSON
   ↓
DataTables

Además, el problema no generaba un error ODBC tradicional.

Por eso era especialmente difícil de localizar.


🐧 Solución en Linux Mint

Primero se comprobó la configuración de unixODBC:

odbcinst -j

La salida mostraba:

DRIVERS............: /etc/odbcinst.ini
SYSTEM DATA SOURCES: /etc/odbc.ini
USER DATA SOURCES..: /home/usuario/.odbc.ini

El DSN utilizado estaba en:

/etc/odbc.ini

La configuración original era:

[mi_dsn_hana]
Driver=SAP HANA
ServerNode=SERVIDOR_HANA:30015

Se añadió:

CHAR_AS_UTF8=true

Quedando:

[mi_dsn_hana]
Driver=SAP HANA
ServerNode=SERVIDOR_HANA:30015
CHAR_AS_UTF8=true

El driver utilizado estaba definido en:

/etc/odbcinst.ini

con:

[HANA_ODBC]
Description=SAP HANA ODBC Driver
Driver=/ruta/al/cliente-hana/libodbcHDB.so

🔎 Comandos útiles para localizar ODBC en Linux

Para saber dónde están los archivos:

odbcinst -j

Para ver el DSN:

cat /etc/odbc.ini

Para ver los drivers:

cat /etc/odbcinst.ini

Para buscar configuraciones relacionadas con HANA:

grep -RniE "HDB|HANA|CHAR_AS_UTF8|char_as_utf8|ServerNode|Driver" \
/etc/odbc.ini \
/etc/odbcinst.ini \
~/.odbc.ini 2>/dev/null

Para listar los DSN:

odbcinst -q -s

🪟 Solución en Windows 10

En Windows se comprobó primero la arquitectura de PHP:

php -i | findstr /I "Architecture"

Resultado:

Architecture => x64

Por lo tanto se utilizó el administrador ODBC de 64 bits:

C:\Windows\System32\odbcad32.exe

El DSN utilizado era:

mi_dsn_hana

y estaba registrado en:

HKLM\SOFTWARE\ODBC\ODBC.INI\mi_dsn_hana

Al consultar el registro:

reg query "HKLM\SOFTWARE\ODBC\ODBC.INI\mi_dsn_hana" /s

se encontró, entre otros valores:

Driver              C:\Program Files\SAP\hdbclient\libodbcHDB.dll
Host                SERVIDOR_HANA
PortNumber          30015
EnableArrayFetch    1
ArrayFetchSize      5000

💾 Antes de modificar el registro: hacer respaldo

Siempre es recomendable guardar una copia del DSN.

reg export "HKLM\SOFTWARE\ODBC\ODBC.INI\mi_dsn_hana" "%USERPROFILE%\Desktop\mi_dsn_hana-backup.reg"

Windows responderá:

La operación se completó correctamente.

Así se dispone de un archivo de respaldo en el escritorio.


⚙️ Agregar CHAR_AS_UTF8 en Windows

Se agregó directamente al registro:

reg add "HKLM\SOFTWARE\ODBC\ODBC.INI\mi_dsn_hana" /v CHAR_AS_UTF8 /t REG_SZ /d true /f

Para comprobarlo:

reg query "HKLM\SOFTWARE\ODBC\ODBC.INI\mi_dsn_hana" /v CHAR_AS_UTF8

El resultado esperado es:

CHAR_AS_UTF8    REG_SZ    true

✅ Configuración aplicada correctamente.


🔄 Reiniciar Apache

Después de modificar el DSN, es importante reiniciar Apache/XAMPP para que PHP cree una nueva conexión ODBC.

Desde XAMPP:

Stop Apache
Start Apache

O, si Apache está instalado como servicio:

net stop Apache2.4
net start Apache2.4

El nombre del servicio puede variar según la instalación.


⚙️ ¿Qué pasó con EnableArrayFetch?

El DSN de Windows ya tenía:

EnableArrayFetch=1

y:

ArrayFetchSize=5000

Durante las pruebas se decidió mantenerlo activado.

La configuración final quedó:

CHAR_AS_UTF8=true
EnableArrayFetch=1

Esto mismo se mantuvo tanto en Linux como en Windows.

No fue necesario desactivar EnableArrayFetch.


💻 La función PHP no necesitó cambios especiales

Una vez solucionada la configuración del ODBC, la consulta PHP puede seguir siendo una consulta normal:

static public function ctrMostrarListaPrecio($conn, int $id)
{
    $sql = '
        SELECT
            a."ItemCode",
            b."ItemName",
            a."Price",
            a."PriceList",
            c."ListName"
        FROM ITM1 a
        INNER JOIN OITM b
            ON a."ItemCode" = b."ItemCode"
        INNER JOIN OPLN c
            ON a."PriceList" = c."ListNum"
        WHERE a."PriceList" = ?
          AND IFNULL(a."Price", 0) > 0
    ';

    $stmt = odbc_prepare($conn, $sql);

    if (!$stmt) {
        throw new Exception(
            'Error ODBC prepare: ' . odbc_errormsg($conn)
        );
    }

    if (!odbc_execute($stmt, [$id])) {
        throw new Exception(
            'Error ODBC execute: ' . odbc_errormsg($conn)
        );
    }

    $data = [];

    while ($row = odbc_fetch_array($stmt)) {

        $data[] = [
            'ItemCode'  => $row['ItemCode'],
            'ItemName'  => $row['ItemName'],
            'Price'     => (float) $row['Price'],
            'PriceList' => (int) $row['PriceList'],
            'ListName'  => $row['ListName'],
        ];
    }

    return $data;
}

La solución estuvo en la configuración del HDBODBC, no en cambiar la consulta.


🧪 Otra prueba importante: comprobar si era memoria

También se revisó la memoria de PHP:

echo memory_get_usage(true);

Durante las pruebas se obtuvo aproximadamente:

2097152 bytes

Y el pico:

2097152 bytes

No hubo incremento importante al procesar los registros.

Por lo tanto:

❌ No era falta de memoria.

❌ No era que 902 registros fueran demasiados.

❌ No era el while.

❌ No era odbc_prepare().

❌ No era odbc_execute().

❌ No era el JOIN.

✅ El comportamiento estaba relacionado con el tratamiento de caracteres por HDBODBC.


🛠️ Diagnóstico recomendado para futuros problemas

Cuando PHP y SAP HANA devuelvan menos registros de los esperados, no hay que asumir inmediatamente que el problema está en SQL.

Una buena estrategia es ir agregando las columnas progresivamente.

Primero:

SELECT
    "ItemCode"
FROM ITM1
WHERE "PriceList" = 1;

Después:

SELECT
    a."ItemCode",
    b."ItemCode"
FROM ITM1 a
INNER JOIN OITM b
    ON a."ItemCode" = b."ItemCode"
WHERE a."PriceList" = 1;

Después:

SELECT
    a."ItemCode",
    b."ItemName"
FROM ITM1 a
INNER JOIN OITM b
    ON a."ItemCode" = b."ItemCode"
WHERE a."PriceList" = 1;

De esta forma puedes identificar exactamente qué columna provoca el comportamiento.

En este caso:

ITM1                         → 902
ITM1 + OITM.ItemCode         → 902
ITM1 + OITM.ItemName         → 1

Ese pequeño experimento permitió descubrir rápidamente que había que investigar el tratamiento del campo de texto.


📌 Configuración final

Después de todas las pruebas, la configuración que quedó funcionando fue:

CHAR_AS_UTF8=true
EnableArrayFetch=1

Linux Mint

Archivo:

/etc/odbc.ini

Configuración:

[mi_dsn_hana]
Driver=SAP HANA
ServerNode=SERVIDOR_HANA:30015
CHAR_AS_UTF8=true

Windows 10

Registro:

HKLM\SOFTWARE\ODBC\ODBC.INI\mi_dsn_hana

Valor:

CHAR_AS_UTF8    REG_SZ    true

Manteniendo:

EnableArrayFetch    REG_SZ    1

✅ Conclusión

El problema parecía inicialmente un problema de PHP porque:

while ($row = odbc_fetch_array($stmt))

solamente recuperaba un registro.

Sin embargo, las pruebas demostraron que SAP HANA sí tenía 902 registros y que ODBC podía recorrerlos correctamente mientras no se solicitara directamente el contenido de ItemName.

La pista definitiva fue que los textos con caracteres especiales aparecían alterados, por ejemplo:

14±2

se recibía como:

14±2

La corrección fue configurar el controlador de SAP HANA para trabajar con UTF-8:

CHAR_AS_UTF8=true

manteniendo:

EnableArrayFetch=1

La configuración fue aplicada tanto en Linux Mint como en Windows 10.

💡 Moraleja: cuando PHP + ODBC + SAP HANA devuelve menos filas de las esperadas, especialmente al trabajar con campos NVARCHAR o textos con caracteres especiales, conviene revisar primero la configuración del HDBODBC antes de modificar la consulta o asumir que existe un problema de memoria.


🚀 Resumen rápido

Linux

sudo cp /etc/odbc.ini /etc/odbc.ini.bak
sudo nano /etc/odbc.ini

Agregar:

CHAR_AS_UTF8=true

Windows 10

Respaldar:

reg export "HKLM\SOFTWARE\ODBC\ODBC.INI\mi_dsn_hana" "%USERPROFILE%\Desktop\mi_dsn_hana-backup.reg"

Agregar:

reg add "HKLM\SOFTWARE\ODBC\ODBC.INI\mi_dsn_hana" /v CHAR_AS_UTF8 /t REG_SZ /d true /f

Comprobar:

reg query "HKLM\SOFTWARE\ODBC\ODBC.INI\mi_dsn_hana" /v CHAR_AS_UTF8

Resultado esperado:

CHAR_AS_UTF8    REG_SZ    true

Configuración final:

CHAR_AS_UTF8=true
EnableArrayFetch=1

🔧 PHP + SAP HANA + ODBC + UTF-8: un pequeño parámetro del driver puede hacer toda la diferencia.

📦 Cómo Instalar Nextcloud en Linux Mint con Snap (Guía Paso a Paso)

Entrada fija

¿Quieres tener tu propia nube privada en casa o en tu servidor local? Nextcloud es la solución perfecta para almacenar, sincronizar y compartir archivos sin depender de servicios externos como Google Drive o Dropbox.

En esta guía te enseñaré cómo instalar Nextcloud en Linux Mint utilizando Snap, el sistema de paquetes universales que hace que la instalación sea increíblemente sencilla.


📋 Requisitos Previos

  • Linux Mint instalado (cualquier versión reciente)
  • Conexión a internet
  • Acceso de administrador (sudo)
  • Una terminal abierta (Ctrl + Alt + T)

⚠️ Importante: Linux Mint y Snap

Por defecto, Linux Mint bloquea Snap por motivos de política. Así que primero debemos habilitarlo con unos sencillos pasos.


🚀 Pasos de Instalación

1️⃣ Eliminar el bloqueo de Snap

sudo rm /etc/apt/preferences.d/nosnap.pref

2️⃣ Actualizar los repositorios

sudo apt update

3️⃣ Instalar Snap

sudo apt install snapd

4️⃣ Instalar Nextcloud

sudo snap install nextcloud

¡Listo! La instalación tardará unos minutos. Nextcloud incluye automáticamente:

  • ✅ Servidor Apache
  • ✅ PHP 8.1
  • ✅ MySQL 8
  • ✅ Redis para caché

🌐 Acceder a Nextcloud

Una vez instalado, abre tu navegador y visita:

  • Localmente: http://localhost
  • Desde otros dispositivos en tu red: http://tu-ip-local

Para conocer tu IP local:

hostname -I

🔧 Si cambiaste de IP (por DHCP)

Es posible que necesites agregar tu IP como dominio confiable:

sudo nextcloud.occ config:system:set trusted_domains 1 --value=$(hostname -I | awk '{print $1}')

🔑 Crear Cuenta de Administrador

En tu primera visita a Nextcloud, se te pedirá que crees:

  • 👤 Nombre de usuario
  • 🔒 Contraseña de administrador

¡Guarda estos datos en un lugar seguro!


🛠️ Comandos Útiles para la Gestión

ComandoFunción
sudo snap start nextcloudIniciar Nextcloud
sudo snap stop nextcloudDetener Nextcloud
sudo snap restart nextcloudReiniciar Nextcloud
sudo snap services nextcloudVer estado del servicio
sudo snap logs nextcloudVer logs de errores
sudo snap set nextcloud ports.http=8080Cambiar puerto HTTP
sudo nextcloud.enable-httpsHabilitar HTTPS con Let’s Encrypt
sudo nextcloud.occ statusVer estado de Nextcloud

🔐 Restablecer Contraseña de Administrador

Si olvidaste la contraseña de admin:

sudo nextcloud.occ user:resetpassword admin

🖥️ Acceso a Almacenamiento Externo

Si necesitas usar discos externos montados en /media o /mnt:

sudo snap connect nextcloud:removable-media

🎯 Ventajas de Usar Snap para Nextcloud

  • ✅ Instalación sencilla en un solo comando
  • ✅ Actualizaciones automáticas sin complicaciones
  • ✅ Todo incluido (servidor web, base de datos, caché)
  • ✅ Fácil de gestionar con comandos Snap

❓ Solución de Problemas Comunes

No puedo acceder a Nextcloud

  1. Verifica que el servicio esté activo: sudo snap services nextcloud
  2. Comprueba los puertos disponibles: sudo ss -tulpn | grep snap.nextcloud
  3. Revisa los logs en busca de errores: sudo snap logs nextcloud | tail -20

El puerto 80 ya está en uso

Cambia el puerto HTTP:

sudo snap set nextcloud ports.http=8080

La IP cambia constantemente

Agrega el nuevo IP como dominio confiable (como se explicó arriba).


🎉 ¡Listo!

Ahora tienes tu propio servidor de nube privada funcionando en Linux Mint. Puedes:

  • 📁 Almacenar archivos
  • 📱 Sincronizar con el móvil (aplicación Nextcloud)
  • 👥 Compartir carpetas con otros usuarios
  • 🔄 Sincronizar calendarios y contactos

📚 Recursos Adicionales


¿Te ha sido útil esta guía? Déjame tu comentario y compártela con otros usuarios de Linux. ¡La privacidad y el control de tus datos es cosa de todos! 💪


🔖 Etiquetas

#Nextcloud #LinuxMint #Snap #NubePrivada #SelfHosting #Linux #Tecnología #Almacenamiento #SeguridadDigital

🚀 Guía Completa para Administrar y Validar Conexiones SQL Server desde PHP, CodeIgniter y Linux

Entrada fija



Guía Completa para Administrar y Validar Conexiones SQL Server desde PHP, CodeIgniter y Linux

Las bases de datos son el corazón de los sistemas empresariales modernos. SQL Server es una de las plataformas más utilizadas para almacenar información crítica y su integración con PHP y CodeIgniter permite construir soluciones robustas y escalables.

💻 ¿Por qué es importante administrar conexiones?

Cuando una organización utiliza múltiples servidores SQL Server, mantener un catálogo centralizado de conexiones facilita la administración, mejora la seguridad y reduce errores de configuración.

📋 Información almacenada

  • 🏢 Empresa
  • 🌐 Host o servidor
  • 👤 Usuario
  • 🔑 Contraseña
  • 🗄️ Base de datos
  • 🔌 Puerto

✅ Validación automática

Una de las características más útiles es la capacidad de validar en tiempo real si una conexión es válida antes de utilizarla. Esto permite detectar errores de red, credenciales incorrectas o bases de datos inexistentes.

🔍 Beneficios

  • Ahorro de tiempo
  • Menos errores humanos
  • Mayor productividad
  • Seguridad mejorada
  • Escalabilidad

🐧 Compatibilidad

La solución funciona con Linux y Windows, integrándose con PHP y CodeIgniter 4 para proyectos empresariales modernos.

📦 Repositorio del proyecto

https://github.com/julio101290/boilerplatecompac

🎯 Conclusión

Administrar y validar conexiones SQL Server desde una interfaz centralizada simplifica enormemente el mantenimiento de sistemas empresariales y mejora la confiabilidad de las aplicaciones.

🚀 Implementa tu propio sistema de autorización de comprobantes SAP con CodeIgniter 4 y ODBC (Guía práctica + beneficios)

Entrada fija

📝 Por: julio 101290
Especialista en integraciones SAP – PHP – CodeIgniter

¿Cansado de los atascos en la aprobación de gastos, viáticos y facturas? ¿Los comprobantes se pierden en correos electrónicos o en papeles que nunca llegan? En este artículo te muestro cómo construí un módulo de autorización de comprobantes conectado directamente a SAP HANA usando CodeIgniter 4, ODBC y tecnologías web modernas. Además, te cuento los beneficios reales que obtendrás.


📌 Índice

  1. El problema de siempre: autorización manual
  2. La solución técnica: arquitectura limpia y directa
  3. Beneficios clave (✅ productividad, ✅ seguridad, ✅ movilidad)
  4. Código y componentes principales
  5. Lecciones aprendidas (y errores que evitar)
  6. Mejoras futuras y recomendaciones
  7. Conclusión: ¿por qué deberías implementarlo ya?

1. El problema de siempre: autorización manual 📄❌

Imaginemos el día a día:

  • Un empleado genera un comprobante de gasto en SAP (ej. CV__300000366).
  • El comprobante queda con estado U_Status = 2 (pendiente de autorización).
  • El autorizador recibe un correo, imprime, firma, escanea… o peor, usa un Excel compartido.
  • Contabilidad tarda días en enterarse de la aprobación.
  • No hay trazabilidad: ¿quién aprobó? ¿cuándo? ¿por qué?

🔴 Resultado: retrasos, errores, falta de control y equipos frustrados.


2. La solución técnica: arquitectura limpia y directa 🏗️

Decidí construir una interfaz web que se conecte directamente a las tablas de usuario de SAP (@QSYS_GLO_VOUC y @QSYS_GLO_VODE) usando ODBC. No necesitamos API intermedias ni modificar el núcleo de SAP.

🧱 Stack tecnológico elegido

Componente¿Por qué?
CodeIgniter 4Ligero, rápido, con excelente soporte para ODBC y JSON
ODBCConexión nativa a SAP HANA; sin controladores adicionales complicados
DataTablesTablas dinámicas con búsqueda, ordenamiento y paginación server-side
SweetAlert2Diálogos modernos para confirmar autorizaciones
Bootstrap 4Diseño responsivo (funciona en móvil, tableta y escritorio)
jQuery + AJAXComunicación asíncrona sin recargar la página

🔄 Flujo de trabajo

  1. El autorizador ingresa a la URL /admin/refundsauth.
  2. Se carga una DataTable con todos los comprobantes pendientes (U_Status = 2).
  3. Por cada fila, botones: Autorizar y Detalle.
  4. Al hacer clic en Detalle, se abre un modal responsivo que muestra las líneas del comprobante (tabla VODE).
  5. Al hacer clic en Autorizar, se confirma con SweetAlert y se envía una petición AJAX que actualiza U_Status = 4 directamente en SAP.
  6. La fila desaparece de la tabla y queda registrado en una bitácora local.

✅ Todo en tiempo real, sin papeles, sin correos, sin pérdidas.


3. Beneficios clave (✅ productividad, ✅ seguridad, ✅ movilidad)

🚀 Velocidad y productividad

  • De días a segundos: la autorización se hace con un clic.
  • El autorizador ve solo lo que le corresponde (filtros, búsqueda, orden).
  • Autorización masiva (se puede implementar fácilmente).

🔒 Trazabilidad y auditoría

  • Cada autorización queda registrada en la tabla log con usuario, fecha y detalles.
  • SAP conserva el histórico de U_Status (si se audita, se sabe cuándo cambió a 4).
  • Evita fraudes o aprobaciones sin conocimiento.

📱 Acceso móvil y responsivo

  • El diseño con Bootstrap y DataTables se adapta a cualquier dispositivo.
  • El modal de detalle tiene scroll horizontal (ideal para ver muchas columnas).
  • Los gerentes pueden aprobar gastos desde el móvil mientras viajan.

🧩 Integración sin fricción con SAP

  • No se necesita licencia adicional de Service Layer.
  • Las tablas @... son de usuario → se pueden leer/escribir con ODBC con permisos estándar.
  • La actualización es inmediata y consistente.

🛡️ Seguridad basada en roles

  • Cada usuario del sistema se vincula con un sapuser autorizador (tabla user_sap_link).
  • Solo los usuarios con el permiso adecuado pueden ver y autorizar.

💰 Bajo costo y rápido desarrollo

  • Solución implementada en menos de una semana.
  • Código 100% reutilizable y extensible.
  • No requiere comprar add‑ons caros de terceros.

4. Código y componentes principales (fragmentos clave) 🧩

Aquí te muestro las partes más importantes del controlador RefundsAuthController.php.

📡 Conexión ODBC y consulta de pendientes

$conn = odbc_connect($dataConect['nameODBC'], $dataConect['userODBC'], $dataConect['passwordODBC']);
$tableName = '"' . $dataConect['companyDB'] . '"."@QSYS_GLO_VOUC"';
$sql = "SELECT * FROM $tableName WHERE \"U_Status\" = 2 ORDER BY \"U_Date\" DESC";
$result = odbc_exec($conn, $sql);

✏️ Autorización (update directo)

$sqlUpdate = "UPDATE $tableName SET \"U_Status\" = 4 WHERE \"Code\" = $code";
odbc_exec($conn, $sqlUpdate);

📋 Detalle responsivo (modal con scroll)

$.ajax({
    url: base_url + '/admin/servicelayer/refundsauth/showVoucherDetails',
    data: JSON.stringify({ code: code }),
    success: function(resp) {
        var html = '<div class="table-responsive">' +
                   '<table class="table table-sm table-bordered">' +
                   // ... cabeceras y filas ...
                   '</table></div>';
        $('#modalVoucherDetailsBody').html(html);
    }
});

💡 El código completo lo puedes encontrar en mi repositorio de GitHub (link al final).


5. Lecciones aprendidas (y errores que evitar) 🧠

❌ Error 1: Nombres de tablas con @ sin comillas dobles

Solución: Siempre usar "DBNAME"."@TABLA". HANA es sensible a mayúsculas/minúsculas.

❌ Error 2: Usar LIMIT en ODBC sin ROW_NUMBER()

Solución: Emplear la función analítica ROW_NUMBER() OVER (ORDER BY ...) y filtrar por rn.

❌ Error 3: El modal se quedaba en “Cargando…”

Causa: Los IDs del contenedor en el modal no coincidían con los del JavaScript.
Solución: Sincronizar id="modalVoucherDetailsBody" en el HTML y en el JS.

❌ Error 4: La tabla no hacía scroll en móvil

Solución: Envolver la tabla en <div class="table-responsive"> y dar a la tabla un min-width: 700px mediante CSS.

❌ Error 5: Caracteres extraños en JSON

Solución: Aplicar utf8ize() recursivo a cada fila antes de enviarla.


6. Mejoras futuras y recomendaciones 🧪

Si quieres llevar el sistema al siguiente nivel, considera:

  • Autorización por lotes (seleccionar varios y aprobar con un botón).
  • Notificaciones por correo o WhatsApp cuando llegue un nuevo comprobante.
  • Rechazo con motivo y notificación al empleado.
  • Dashboard de indicadores (tiempo promedio de autorización, montos aprobados, etc.).
  • Firma digital para cumplir normativas.
  • Integración con SAP Business Workflow (si la empresa lo requiere).

7. Conclusión: ¿por qué deberías implementarlo ya? 🎯

La combinación CodeIgniter 4 + ODBC + DataTables permite construir en pocos días un sistema de autorización de comprobantes que:

  • Reduce drásticamente los tiempos de espera.
  • Elimina el papeleo y los correos perdidos.
  • Brinda movilidad a los aprobadores.
  • Aporta transparencia y auditoría.
  • Se integra de forma nativa con SAP sin costes adicionales.

¿Tu empresa sigue aprobando gastos con firmas manuales y hojas de cálculo?
Es hora de dar el salto a la automatización web. Yo ya lo hice para varios clientes, y los resultados han sido increíbles: +80% de agilidad, 0 errores de registro y aprobaciones desde el móvil.


🔗 Recursos adicionales


📢 Comparte este artículo si te ha resultado útil
♻️ ¿Conoces a alguien que todavía autoriza comprobantes en papel? Etiquétalo en los comentarios.

#SAP #CodeIgniter #ODBC #AutorizaciónDeGastos #PHP #DesarrolloWeb #TransformaciónDigital

Cómo usar Composer con symlinks para desarrollo local en CodeIgniter 4

Entrada fija

Cuando trabajas con múltiples paquetes en PHP (como módulos propios), llega un punto donde necesitas debuggear directamente el código del paquete y no una copia dentro de vendor.

Si alguna vez terminaste debuggeando en vendor/ en lugar de tu proyecto real… esto es para ti.


🔥 El problema

Por defecto, Composer:

  • Descarga paquetes desde GitHub
  • Los copia en vendor/
  • Xdebug trabaja sobre esa copia

Resultado:

  • Código duplicado
  • Cambios no se reflejan
  • Debug incómodo

🧠 La solución: usar type: path

{
  "repositories": [
    {
      "type": "path",
      "url": "../boilerplateproducts",
      "options": {
        "symlink": true
      }
    }
  ]
}

⚙️ ¿Qué hace esto?

  • Usa tu carpeta local
  • No descarga desde GitHub
  • Crea un symlink en vendor

🔍 Verificar que funciona

composer update

Debes ver:

Installing julio101290/boilerplateproducts: Symlinking from ../boilerplateproducts

Y luego:

ls -l vendor/julio101290/boilerplateproducts

Resultado esperado:

boilerplateproducts -> ../../../boilerplateproducts

🚀 Beneficios

  • Debug directo en tu código
  • Cambios en tiempo real
  • Sin duplicación
  • Flujo más rápido

⚠️ Error común

No mezcles path y vcs:

{
  "type": "path",
  "url": "../boilerplateproducts"
},
{
  "type": "vcs",
  "url": "https://github.com/usuario/boilerplateproducts"
}

Composer puede ignorar el local y usar GitHub.


✅ Desarrollo correcto

rm -rf vendor composer.lock
composer update

🟢 Desarrollo vs Producción

Desarrollo

  • path
  • symlink
  • debug directo

Producción

  • vcs
  • descarga desde GitHub
  • entorno limpio

⚡ Scripts útiles

dev.sh

#!/bin/bash
rm -rf vendor composer.lock
COMPOSER=composer.local.json composer update

prod.sh

#!/bin/bash
rm -rf vendor composer.lock
composer update

🧠 Tip PRO

{
  "repositories": [
    {
      "type": "path",
      "url": "../boilerplateproducts",
      "options": { "symlink": true }
    }
  ],
  "config": {
    "preferred-install": "source"
  }
}

Ejecutar:

COMPOSER=composer.local.json composer update

💡 IDE

Puedes integrar esto con NetBeans usando scripts o herramientas externas.


✅ Conclusión

  • No debuggees código en vendor
  • Usa symlink
  • Automatiza tu flujo

Resultado: desarrollo más rápido y limpio 🚀

🔧 Arquitectura híbrida con Composer + Git: desarrollo profesional de módulos en CodeIgniter 4

Entrada fija

En el desarrollo moderno de aplicaciones PHP con CodeIgniter 4, uno de los mayores retos es mantener un flujo de trabajo eficiente entre desarrollo local, control de versiones y gestión de dependencias.

Este artículo explica una arquitectura híbrida usando Composer + Git + symlinks que permite trabajar módulos de forma profesional sin depender del directorio vendor.


🧠 El problema clásico

Cuando trabajamos con módulos reutilizables (inventarios, CFDI, pagos, etc.), normalmente terminamos instalando todo en vendor/, lo que genera problemas:

  • No se puede editar código fácilmente
  • Debug complicado
  • Cambios se pierden con composer update
  • Duplicación de código
  • Dificultad para mantener múltiples proyectos

💡 La solución: arquitectura híbrida

La solución moderna combina tres herramientas:

  • Composer como gestor de dependencias
  • Git como control de versiones
  • Symlinks para desarrollo en vivo

Estructura típica:

~/fuentes/
   ci4-project/
   boilerplateInventory/
   boilerplateProducts/

⚙️ Configuración en Composer

En el composer.json del proyecto principal:

"repositories": [
  {
    "type": "path",
    "url": "../boilerplateInventory",
    "options": {
      "symlink": true
    }
  },
  {
    "type": "vcs",
    "url": "https://github.com/julio101290/boilerplateInventory"
  }
]

📁 Path repository (desarrollo local)

Este tipo de repositorio indica a Composer que use una carpeta local en lugar de descargar el paquete.

Beneficios:

  • Cambios en tiempo real
  • Debug directo
  • No depende de vendor

🌐 VCS repository (GitHub)

Sirve como respaldo cuando el path no existe o en servidores de producción.


🔥 El problema de versiones

Muchos módulos requieren:

^1.0.0

Pero en desarrollo usamos:

dev-main

Esto genera conflictos.


🛠️ Solución: alias de versión

La solución correcta es:

"julio101290/boilerplateinventory": "dev-main as 1.2.6"

Esto le dice a Composer que trate el código local como versión estable.


⚡ Resultado del sistema

  • ✔ Desarrollo en vivo
  • ✔ Debug funcional
  • ✔ Compatibilidad con dependencias
  • ✔ Sin tocar vendor
  • ✔ Respaldo en GitHub

🧪 Flujo de trabajo real

cd ~/fuentes/boilerplateInventory
git add .
git commit -m "cambios"
git push

Los cambios se reflejan automáticamente en el proyecto principal.


⚠️ Buenas prácticas

  • Usar nombres consistentes en minúsculas
  • No trabajar dentro de vendor
  • Versionar módulos con tags
git tag v1.2.6
git push origin v1.2.6

🚀 Conclusión

Esta arquitectura permite construir sistemas modulares profesionales con:

  • Desarrollo rápido
  • Debug en vivo
  • Control de versiones limpio
  • Escalabilidad real

Es una forma moderna de trabajar con Composer sin depender de vendor como entorno de desarrollo.

🔥 Cómo configurar ONLYOFFICE en Docker con SSL y Nextcloud Snap (guía práctica sin errores) 🔥

Entrada fija

Después de varios intentos y errores típicos (puertos, certificados, Docker, etc.), finalmente logré dejar funcionando ONLYOFFICE Document Server en Docker con acceso HTTPS y listo para integrarse con Nextcloud instalado vía Snap. Aquí te dejo el proceso completo con datos genéricos 👇


🚧 Problemas comunes

  • Error de conexión (connection refused o connection reset)
  • Certificados SSL no detectados
  • Confusión entre Apache, Nginx y Docker
  • Intentar usar HTTPS dentro del contenedor (mala idea 😅)

🧠 Lo importante que debes entender

  • ONLYOFFICE usa Nginx interno, no Apache
  • Docker debe correr en HTTP interno
  • El SSL se maneja mejor fuera del contenedor
  • Nextcloud Snap ya trae su propio Apache (aislado)

👉 La solución correcta: usar Apache HTTP Server del sistema como proxy inverso


⚙️ Configuración final

🐳 1. Ejecutar ONLYOFFICE en HTTP

docker run -d -p 8080:80 onlyoffice/documentserver

🔐 2. Generar certificado SSL con Certbot

sudo snap stop nextcloud
sudo certbot certonly --standalone -d tudominio.com
sudo snap start nextcloud

👉 Los certificados quedarán en algo como:

/etc/letsencrypt/live/tudominio.com-0001/

🌐 3. Configurar Apache como proxy SSL

Archivo:

/etc/apache2/sites-available/onlyoffice.conf

Contenido:

<VirtualHost *:4443>
    ServerName tudominio.com

    SSLEngine on
    SSLCertificateFile /etc/letsencrypt/live/tudominio.com-0001/fullchain.pem
    SSLCertificateKeyFile /etc/letsencrypt/live/tudominio.com-0001/privkey.pem

    ProxyPreserveHost On
    ProxyPass / http://localhost:8080/
    ProxyPassReverse / http://localhost:8080/

    RequestHeader set X-Forwarded-Proto "https"
</VirtualHost>

🔌 4. Habilitar el puerto en Apache

Editar:

/etc/apache2/ports.conf

Agregar:

Listen 4443

⚡ 5. Activar módulos necesarios

sudo a2enmod ssl proxy proxy_http headers

🚀 6. Activar sitio y reiniciar

sudo a2ensite onlyoffice.conf
sudo apachectl configtest
sudo systemctl restart apache2

🔥 7. Abrir firewall

sudo ufw allow 4443

✅ Resultado

Acceso funcionando en:

👉 https://tudominio.com:4443

✔ SSL válido
✔ Proxy funcionando
✔ Docker respondiendo correctamente
✔ Listo para integrarse con Nextcloud


🧪 Pruebas clave

curl http://localhost:8080/healthcheck   # → true
curl -k https://localhost:4443           # → 302 (correcto)

🧠 Conclusión

  • ❌ No uses SSL dentro de Docker
  • ✅ Usa proxy inverso en el host
  • ✅ Mantén servicios separados
  • ✅ Evita conflictos con Snap

🚀 Siguiente paso

Integrar ONLYOFFICE con Nextcloud usando JWT correctamente 🔐


Si estás montando tu propio entorno, esta arquitectura te va a ahorrar horas de debugging 💻🔥

Apóyame

Si esta guía te fue útil y quieres apoyar más contenido como este:

👉 https://www.patreon.com/u74078772?

Página 1 de 10

Creado con WordPress & Tema de Anders Norén