Herramientas Informaticas

Etiqueta: PHP8 Página 1 de 11

🌐 Arquitectura, Integración y Estabilidad: Implementación del Módulo sapuserwh con SAP Service Layer y Solución Definitiva a Conflictos de Ciclo de Vida en Frontend

Entrada fija

📌 1. Introducción y Contexto de la Arquitectura Empresarial

En el desarrollo de software corporativo contemporáneo, la integración entre sistemas de planificación de recursos empresariales (ERP, por sus siglas en inglés) y plataformas web personalizadas representa uno de los desafíos más críticos y de mayor impacto operativo. Históricamente, interactuar con SAP Business One requería el uso intensivo de la API DI (Data Interface API), una tecnología basada en componentes COM de Microsoft Windows de 32 y 64 bits. Aunque la DI API sigue siendo robusta, presenta limitaciones severas en entornos modernos: fuerte acoplamiento a servidores Windows, problemas de escalabilidad en entornos multi-hilo, consumo elevado de memoria y una marcada dificultad para su despliegue dentro de arquitecturas basadas en contenedores Linux, microservicios o aplicaciones web desacopladas.

Con la llegada de la arquitectura SAP HANA y la modernización de los entornos basados en Microsoft SQL Server, SAP introdujo la Service Layer. Este componente revolucionó el ecosistema de integración al ofrecer una interfaz de programación de aplicaciones de tipo RESTful, basada en el protocolo estándar OData (Open Data Protocol versión 3 y 4), con transporte de datos estructurados en formato JSON a través de HTTPS. Gracias a la Service Layer, cualquier lenguaje con soporte para llamadas HTTP de alto rendimiento —como PHP, Python, Node.js o Go— puede ejecutar operaciones CRUD completas (crear, leer, actualizar y eliminar) sobre los objetos de negocio nativos y las tablas o campos de usuario (UDOs y UDFs) de SAP Business One.

       +--------------------------------------------------------------+
       |                  Navegador del Administrador                 |
       |                (Interfaz Web HTML5 / DataTables)             |
       +-------------------------------+------------------------------+
                                       |
                                       | Peticiones HTTP / AJAX
                                       v
       +--------------------------------------------------------------+
       |               Backend Web (PHP / CodeIgniter 4)              |
       |     Controlador: ServiceLayerController::sapuserwh()         |
       |     Librería: SAPServiceLayerClient (Gestión de Sesión)      |
       +-------------------------------+------------------------------+
                                       |
                                       | REST / OData sobre HTTPS (JSON)
                                       v
       +--------------------------------------------------------------+
       |                 SAP B1 Service Layer Engine                  |
       |              (B1SESSION + ROUTEID / Balanceo)                |
       +-------------------------------+------------------------------+
                                       |
                                       | Conexión Nativa de Datos
                                       v
       +--------------------------------------------------------------+
       |              Base de Datos (SAP HANA / MS SQL)               |
       |         Tablas Nativas: OUSR, OWHS | UDOs / UDTs             |
       +--------------------------------------------------------------+

Sin embargo, trasladar la complejidad del modelo relacional y de permisos de un ERP a una plataforma web ligera no es una tarea trivial. Uno de los puntos operativos más sensibles en la logística y administración diaria de una empresa es la asignación y restricción de almacenes por usuario (User Warehouse Assignment). Si un operador de almacén o un vendedor cuenta con acceso a almacenes que no le corresponden, se generan inconsistencias en el inventario físico, transferencias erróneas y descuadres contables.

Para resolver esta necesidad específica de gobernanza operativa, se desarrolló el módulo administrativo sapuserwh. En este artículo técnico exhaustivo se detallan los objetivos de diseño del módulo, su arquitectura backend y frontend, el análisis forense del error de ejecución JavaScript TypeError: $(...).DataTable is not a function que paralizó la vista interactiva, y la solución definitiva implementada bajo estándares de ingeniería de software.

🎯 2. Objetivos y Alcance del Módulo sapuserwh

El módulo sapuserwh nació con una misión clara: desacoplar y democratizar la administración de almacenes asignados a usuarios sin obligar a los administradores a abrir el cliente pesado de SAP Business One, optimizando tanto el consumo de licencias profesionales como los tiempos de respuesta del equipo de sistemas.

+----------------------------------------------------------------------------------------------------+
|                                    OBJETIVOS DEL MÓDULO sapuserwh                                  |
+----------------------------------------------------------------------------------------------------+
| 1. Centralización Operativa  -> Mapeo visual e intuitivo entre cuentas OUSR y almacenes OWHS.     |
| 2. Reducción de Latencia     -> Consultas optimizadas con filtros OData ($select, $filter).       |
| 3. Independencia de Cliente  -> Configuración remota 100% web desde cualquier dispositivo.        |
| 4. Auditoría y Control       -> Prevenir fugas de stock y errores humanos en traslados y ventas.   |
| 5. Experiencia de Usuario    -> Interfaz dinámica con DataTables, búsqueda en vivo y exportaciones.|
+----------------------------------------------------------------------------------------------------+

🔹 Metas funcionales principales

  1. Centralización del Mapeo Usuario-Almacén: Brindar a los supervisores de operaciones una matriz clara y procesable donde puedan visualizar qué usuarios tienen acceso a cuáles almacenes físicos y lógicos (almacén general, almacén de mermas, almacén de tránsito, almacenes locales por sucursal).
  2. Consultas en Tiempo Real vía OData: Evitar la duplicación de datos y la sincronización asíncrona desfasada. La información mostrada debe reflejar el estado vivo del motor transaccional de SAP B1 mediante peticiones optimizadas con los operadores $select, $filter y $expand.
  3. Optimización de Licenciamiento: El cliente tradicional de escritorio de SAP Business One requiere licencias dedicadas de tipo Profesional o Limitada para configurar catálogos y accesos. Al exponer esta administración a través de una aplicación web intermediaria conectada mediante un usuario técnico a la Service Layer, se optimiza el uso de terminales y licencias de escritorio.
  4. Resiliencia y Usabilidad en Frontend: En organizaciones con cientos de usuarios y decenas de almacenes, el volumen de combinaciones posibles supera fácilmente los millares de registros. Presentar esta información en una tabla HTML convencional resulta inviable; se requiere paginación en el cliente, búsqueda instantánea por cualquier columna, ordenamiento alfanumérico y capacidades de exportación a formatos estándar (Excel, CSV, PDF y portapapeles).

🏗️ 3. Arquitectura del Backend: CodeIgniter 4 y SAP Service Layer

Para garantizar un rendimiento sobresaliente, bajo consumo de recursos en el servidor y una estructura de carpetas modular y mantenible, la solución se estructuró sobre el framework PHP CodeIgniter 4, aprovechando su motor de enrutamiento rápido, controladores organizados y abstracción de dependencias.

🔑 3.1. Ciclo de Vida de la Sesión en la Service Layer

La Service Layer es un servicio con estado (stateful) que utiliza cookies HTTP para mantener el contexto de la transacción:

  • B1SESSION: Identificador de la sesión autenticada del usuario técnico de SAP.
  • ROUTEID: Identificador del nodo de balanceo de carga cuando la Service Layer está desplegada en alta disponibilidad.

Cada llamada debe enviar estas dos cookies en la cabecera Cookie: B1SESSION=...; ROUTEID=.... Si la sesión caduca (por inactividad, típicamente configurada en 30 minutos dentro de b1s.conf), el backend de CodeIgniter 4 debe atrapar el código de error HTTP 401 Unauthorized, negociar un nuevo /Login de manera transparente y reintentar la petición sin impactar al usuario final.

       [ Petición Backend ] ---> ¿Sesión en Cache válida?
                                         |
                       +-----------------+-----------------+
                       |                                   |
                     ( SÍ )                              ( NO )
                       |                                   |
                       v                                   v
             Reutilizar Cookies               Llamada POST /Login a SL
             B1SESSION + ROUTEID                           |
                       |                                   v
                       |                         Guardar nuevas Cookies
                       |                         en Cache (TTL: 25 min)
                       +-----------------+-----------------+
                                         |
                                         v
                      Ejecutar Petición GET/POST con Datos

💻 3.2. Implementación del Cliente Service Layer en PHP

A continuación se presenta la arquitectura del cliente de comunicación encapsulado en un servicio especializado de CodeIgniter 4:

PHP

<?php

namespace App\Libraries;

use CodeIgniter\HTTP\CURLRequest;
use Config\Services;
use Exception;

/**
 * Cliente de bajo nivel para comunicación de alta eficiencia con SAP Service Layer.
 * Gestiona autenticación, cookies de balanceo, reintentos y mapeo de errores OData.
 */
class SAPServiceLayerClient
{
    private string $serviceLayerUrl;
    private string $companyDB;
    private string $username;
    private string $password;
    private ?string $sessionId = null;
    private ?string $routeId = null;
    private CURLRequest $client;

    public function __construct()
    {
        $this->serviceLayerUrl = rtrim(env('SAP_SL_URL', 'https://192.168.1.100:50000/b1s/v1'), '/');
        $this->companyDB       = env('SAP_SL_COMPANY_DB', 'SBODEMOMX');
        $this->username        = env('SAP_SL_USER', 'manager');
        $this->password        = env('SAP_SL_PASS', '1234');

        // Inicializamos el cliente cURL integrado de CodeIgniter 4 desactivando
        // la verificación estricta de certificados SSL en entornos de red local/self-signed
        $this->client = Services::curlrequest([
            'base_URI' => $this->serviceLayerUrl . '/',
            'timeout'  => 30.0,
            'verify'   => false,
            'http_errors' => false,
        ]);

        $this->restaurarSesionDesdeCache();
    }

    /**
     * Autentica el cliente ante la Service Layer de SAP Business One.
     */
    public function login(): bool
    {
        $payload = [
            'CompanyDB' => $this->companyDB,
            'UserName'  => $this->username,
            'Password'  => $this->password,
        ];

        try {
            $response = $this->client->post('Login', [
                'headers' => [
                    'Content-Type' => 'application/json; charset=utf-8',
                    'Accept'       => 'application/json',
                ],
                'body' => json_encode($payload),
            ]);

            $statusCode = $response->getStatusCode();
            $body = json_decode($response->getBody(), true);

            if ($statusCode === 200 && isset($body['SessionId'])) {
                $this->sessionId = $body['SessionId'];

                // Extraemos la cookie ROUTEID para mantener la afinidad de balanceador
                $rawCookies = $response->getHeaderLine('Set-Cookie');
                if (preg_match('/ROUTEID=(.[^;]+)/', $rawCookies, $matches)) {
                    $this->routeId = $matches[1];
                }

                $this->persistirSesionEnCache();
                return true;
            }

            log_message('error', '[SAP SL Login Error] Status: ' . $statusCode . ' - ' . json_encode($body));
            return false;
        } catch (Exception $e) {
            log_message('critical', '[SAP SL Conexión Fallida] ' . $e->getMessage());
            return false;
        }
    }

    /**
     * Ejecuta una petición GET a la Service Layer asegurando la validez del token de sesión.
     */
    public function get(string $endpoint, array $queryParams = []): array
    {
        if (!$this->sessionId) {
            if (!$this->login()) {
                throw new Exception('No fue posible autenticar la sesión con SAP Service Layer.');
            }
        }

        $headers = [
            'Accept'       => 'application/json',
            'Content-Type' => 'application/json',
            'Cookie'       => 'B1SESSION=' . $this->sessionId . ($this->routeId ? '; ROUTEID=' . $this->routeId : ''),
        ];

        $response = $this->client->get($endpoint, [
            'headers' => $headers,
            'query'   => $queryParams,
        ]);

        // Si la sesión expiró remotamente en el servidor de SAP, reintentamos una vez con nuevo login
        if ($response->getStatusCode() === 401) {
            log_message('info', '[SAP SL] Sesión expirada. Renovando credenciales...');
            if ($this->login()) {
                $headers['Cookie'] = 'B1SESSION=' . $this->sessionId . ($this->routeId ? '; ROUTEID=' . $this->routeId : '');
                $response = $this->client->get($endpoint, [
                    'headers' => $headers,
                    'query'   => $queryParams,
                ]);
            } else {
                throw new Exception('Fallo la renovación automática de sesión ante SAP B1.');
            }
        }

        $body = json_decode($response->getBody(), true);
        if ($response->getStatusCode() >= 400) {
            $errorMsg = $body['error']['message']['value'] ?? 'Error desconocido en petición a Service Layer';
            throw new Exception("Error en Service Layer ({$response->getStatusCode()}): {$errorMsg}");
        }

        return $body ?? [];
    }

    private function persistirSesionEnCache(): void
    {
        $cache = Services::cache();
        $cache->save('sap_sl_session_id', $this->sessionId, 1500); // 25 minutos de TTL
        if ($this->routeId) {
            $cache->save('sap_sl_route_id', $this->routeId, 1500);
        }
    }

    private function restaurarSesionDesdeCache(): void
    {
        $cache = Services::cache();
        $this->sessionId = $cache->get('sap_sl_session_id');
        $this->routeId   = $cache->get('sap_sl_route_id');
    }
}

🎮 3.3. El Controlador: ServiceLayerController.php

El controlador tiene la responsabilidad de orquestar la obtención de datos, correlacionar la información de usuarios (Users / tabla OUSR), almacenes (Warehouses / tabla OWHS) y la tabla intermedia de asignaciones personalizadas o UDO (@USER_WH), y despachar la vista enriquecida al motor de renderizado:

PHP

<?php

namespace App\Controllers\Admin\ServiceLayer;

use App\Controllers\BaseController;
use App\Libraries\SAPServiceLayerClient;
use Exception;

class ServiceLayerController extends BaseController
{
    /**
     * Muestra la vista principal de asignación de Almacenes por Usuario (sapuserwh).
     */
    public function sapuserwh()
    {
        $slClient = new SAPServiceLayerClient();
        $data = [
            'page_title' => 'Gestión de Usuarios y Almacenes - SAP Business One',
            'usuarios'   => [],
            'almacenes'  => [],
            'relaciones' => [],
            'error'      => null,
        ];

        try {
            // 1. Obtener lista de usuarios activos de SAP B1
            $usuariosResponse = $slClient->get('Users', [
                '$select' => 'InternalKey,UserCode,UserName,eMail,Locked',
                '$filter' => "Locked eq 'tNO'",
                '$orderby' => 'UserName asc',
            ]);
            $data['usuarios'] = $usuariosResponse['value'] ?? [];

            // 2. Obtener lista de almacenes disponibles
            $almacenesResponse = $slClient->get('Warehouses', [
                '$select' => 'WarehouseCode,WarehouseName,Inactive',
                '$filter' => "Inactive eq 'tNO'",
                '$orderby' => 'WarehouseCode asc',
            ]);
            $data['almacenes'] = $almacenesResponse['value'] ?? [];

            // 3. Consultar la tabla de usuario / UDO donde se guarda la relación (@USER_WH)
            // Estructura: Code, Name, U_UserCode, U_WhsCode, U_AllowSales, U_AllowTransfer
            $relacionesResponse = $slClient->get('U_USER_WH', [
                '$top' => 5000,
            ]);
            $data['relaciones'] = $relacionesResponse['value'] ?? [];

        } catch (Exception $e) {
            log_message('error', '[sapuserwh Controller Error] ' . $e->getMessage());
            $data['error'] = $e->getMessage();
        }

        // Renderizado utilizando la estructura de vistas de CodeIgniter 4
        return view('admin/servicelayer/sapuserwh', $data);
    }
}

💥 4. Análisis Forense: Anatomía de la Falla en Frontend

Durante las pruebas funcionales de despliegue, al cargar la ruta http://localhost:8080/admin/servicelayer/sapuserwh, la tabla no renderizaba las funciones de filtrado ni paginación, y la consola del navegador Developer Tools (F12) arrojaba la siguiente excepción de JavaScript:

Plaintext

jquery.min.js:2 jQuery.Deferred exception: $(...).DataTable is not a function TypeError: $(...).DataTable is not a function
    at HTMLDocument.<anonymous> (http://localhost:8080/admin/servicelayer/sapuserwh:860:43)
    at e (https://cdn.jsdelivr.net/npm/jquery@3.4.1/dist/jquery.min.js:2:29453)
    at t (https://cdn.jsdelivr.net/npm/jquery@3.4.1/dist/jquery.min.js:2:29755) undefined
k.Deferred.exceptionHook @ jquery.min.js:2
t @ jquery.min.js:2
setTimeout
(anonymous) @ jquery.min.js:2
c @ jquery.min.js:2
fireWith @ jquery.min.js:2
fire @ jquery.min.js:2
c @ jquery.min.js:2
fireWith @ jquery.min.js:2
ready @ jquery.min.js:2
B @ jquery.min.js:2
jquery.min.js:2 Uncaught TypeError: $(...).DataTable is not a function
(anonymous) @ sapuserwh:860

🔬 4.1. ¿Qué significa exactamente TypeError: $(...).DataTable is not a function?

En el modelo de objetos de JavaScript y la arquitectura interna de jQuery, los plugins extienden el objeto prototípico jQuery.fn (que es un alias directo de jQuery.prototype).

Cuando la librería DataTables se ejecuta en el navegador, realiza internamente una operación similar a esta:

JavaScript

(function(factory) {
    if (typeof define === 'function' && define.amd) {
        // Soporte para AMD / RequireJS
        define(['jquery'], factory);
    } else if (typeof exports === 'object') {
        // Soporte para CommonJS / Node
        module.exports = factory(require('jquery'));
    } else {
        // Entorno tradicional de navegador: adjuntar al jQuery global disponible
        factory(jQuery);
    }
}(function($) {
    // Aquí DataTables extiende la instancia activa de jQuery
    $.fn.dataTable = function(options) { /* lógica interna */ };
    $.fn.DataTable = function(options) { /* lógica de API moderna */ };
}));

Cuando el motor de JavaScript evalúa la expresión $('#tablaUsuariosAlmacen').DataTable(), busca la propiedad DataTable dentro de la cadena de prototipos del objeto retornado por $(). El error TypeError: $(...).DataTable is not a function significa categóricamente que en el momento exacto de la llamada, la función DataTable no existe dentro de $.fn.

🕵️ 4.2. ¿Por qué ocurrió este error en la vista sapuserwh?

Tras una inspección minuciosa del árbol DOM y la cronología de solicitudes en la pestaña Network (Red), se identificó la causa raíz: colisión por doble carga de jQuery y alteración del ciclo de vida en el motor de plantillas.

+-------------------------------------------------------------------------------------------------------+
|                                    CRONOLOGÍA DEL ERROR EN EL NAVEGADOR                               |
+-------------------------------------------------------------------------------------------------------+
| 1. El Layout Maestro carga jQuery 3.4.1 en el <head> o footer.                                        |
|    -> window.jQuery queda inicializado en Memoria A.                                                  |
| 2. El Layout Maestro carga DataTables (jquery.dataTables.min.js).                                      |
|    -> DataTables se registra dentro de Memoria A: window.jQuery.fn.DataTable = [Function]              |
| 3. La vista hija (sapuserwh.php) incluye por error una segunda etiqueta:                              |
|    <script src=".../jquery.min.js"></script>                                                          |
|    -> ¡DESASTRE!: Se crea Memoria B, SOBREESCRIBIENDO window.jQuery y window.$ con una copia limpia.   |
| 4. window.jQuery.fn.DataTable queda destruido y eliminado del ámbito global.                          |
| 5. Se ejecuta $(document).ready() en la línea 860:                                                    |
|    Llama a $(...).DataTable() sobre la instancia B -> LANZA TypeError: is not a function.             |
+-------------------------------------------------------------------------------------------------------+

Este fenómeno es común en arquitecturas MVC donde se utilizan componentes o parciales reutilizables. Un desarrollador incluye scripts dentro de una vista pensando que no están presentes en el layout principal, o las secciones de inyección (sections) se renderizan en un orden cronológico incorrecto respecto a los archivos del catálogo de librerías (vendor).

🛠️️ 5. La Solución Técnica Implementada

Para erradicar el problema de raíz y garantizar que la arquitectura sea escalable para todos los futuros módulos administrativos de la aplicación, se implementó una reestructuración basada en tres pilares:

📐 Pilar 1: Definición Estricta del Layout Maestro

Se reorganizó la plantilla base (app/Views/layouts/admin_layout.php) dividiendo con claridad la inyección de estilos (styles), librerías de terceros compartidas (vendor_scripts) y scripts específicos de cada página (page_scripts).

📦 Pilar 2: Eliminación de Redundancias

Se purgó cualquier referencia local o remota a jquery.min.js dentro de las vistas hijas. jQuery debe ser un recurso singleton en el contexto de ejecución de la ventana del navegador (window).

⏱️ Pilar 3: Respeto al Ciclo de Vida del DOM

Se encapsuló la inicialización de los plugins dentro de bloques que garantizan que tanto el DOM como los scripts diferidos estén completamente parseados y listos antes de invocar la API de DataTables.

📝 6. Código Completo de la Solución: Frontend y Vistas

A continuación se presenta la implementación de la vista y la plantilla maestra, estructurada con buenas prácticas de desarrollo web corporativo.

🏛️ 6.1. Layout Maestro: app/Views/layouts/admin_layout.php

HTML

<!DOCTYPE html>
<html lang="es">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title><?= esc($page_title ?? 'Panel Administrativo SAP B1') ?></title>

    <!-- Hojas de Estilo Base -->
    <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap@4.6.2/dist/css/bootstrap.min.css">
    <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/5.15.4/css/all.min.css">
    
    <!-- DataTables CSS (Bootstrap 4 Integration) -->
    <link rel="stylesheet" href="https://cdn.datatables.net/1.13.7/css/dataTables.bootstrap4.min.css">
    <link rel="stylesheet" href="https://cdn.datatables.net/buttons/2.4.2/css/buttons.bootstrap4.min.css">
    <link rel="stylesheet" href="https://cdn.datatables.net/responsive/2.5.0/css/responsive.bootstrap4.min.css">

    <style>
        body {
            background-color: #f4f6f9;
            font-family: 'Segoe UI', Roboto, Helvetica, Arial, sans-serif;
        }
        .navbar-brand-sap {
            font-weight: 700;
            color: #0b2545 !important;
            letter-spacing: 0.5px;
        }
        .card-sap {
            border-top: 3px solid #007bff;
            box-shadow: 0 0 1px rgba(0,0,0,.125), 0 1px 3px rgba(0,0,0,.2);
        }
        .badge-sap-success {
            background-color: #28a745;
            color: #fff;
        }
    </style>

    <!-- Inyección de estilos específicos de vistas secundarias -->
    <?= $this->renderSection('styles') ?>
</head>
<body class="hold-transition sidebar-mini layout-fixed">

    <nav class="navbar navbar-expand-lg navbar-dark bg-dark mb-4">
        <a class="navbar-brand navbar-brand-sap text-white" href="#">
            <i class="fas fa-cubes text-primary mr-2"></i>Cesar Systems - SAP B1 Portal
        </a>
    </nav>

    <main class="container-fluid px-4">
        <!-- Renderizado del contenido central de la vista hija -->
        <?= $this->renderSection('content') ?>
    </main>

    <!-- ======================================================= -->
    <!-- SECUENCIA CRÍTICA DE CARGA DE SCRIPTS (VENDOR LIBRARIES) -->
    <!-- ======================================================= -->
    
    <!-- 1. ÚNICA CARGA GLOBAL DE JQUERY -->
    <script src="https://cdn.jsdelivr.net/npm/jquery@3.4.1/dist/jquery.min.js"></script>

    <!-- 2. Bootstrap Bundle (incluye Popper.js) -->
    <script src="https://cdn.jsdelivr.net/npm/bootstrap@4.6.2/dist/js/bootstrap.bundle.min.js"></script>

    <!-- 3. Núcleo de DataTables y extensiones -->
    <script src="https://cdn.datatables.net/1.13.7/js/jquery.dataTables.min.js"></script>
    <script src="https://cdn.datatables.net/1.13.7/js/dataTables.bootstrap4.min.js"></script>
    
    <!-- Extensiones de Botones para Exportación -->
    <script src="https://cdn.datatables.net/buttons/2.4.2/js/dataTables.buttons.min.js"></script>
    <script src="https://cdn.datatables.net/buttons/2.4.2/js/buttons.bootstrap4.min.js"></script>
    <script src="https://cdnjs.cloudflare.com/ajax/libs/jszip/3.10.1/jszip.min.js"></script>
    <script src="https://cdnjs.cloudflare.com/ajax/libs/pdfmake/0.1.53/pdfmake.min.js"></script>
    <script src="https://cdnjs.cloudflare.com/ajax/libs/pdfmake/0.1.53/vfs_fonts.js"></script>
    <script src="https://cdn.datatables.net/buttons/2.4.2/js/buttons.html5.min.js"></script>
    <script src="https://cdn.datatables.net/buttons/2.4.2/js/buttons.print.min.js"></script>
    <script src="https://cdn.datatables.net/responsive/2.5.0/js/dataTables.responsive.min.js"></script>
    <script src="https://cdn.datatables.net/responsive/2.5.0/js/responsive.bootstrap4.min.js"></script>

    <!-- 4. RENDERIZADO EXCLUSIVO DE SCRIPTS DE PÁGINA (DESPUÉS DE TODAS LAS LIBRERÍAS) -->
    <?= $this->renderSection('scripts') ?>
</body>
</html>

📄 6.2. Vista Especializada: app/Views/admin/servicelayer/sapuserwh.php

PHP

<?= $this->extend('layouts/admin_layout') ?>

<?= $this->section('content') ?>

<div class="row mb-3">
    <div class="col-12">
        <div class="d-flex justify-content-between align-items-center">
            <h1 class="h3 font-weight-bold text-gray-800">
                <i class="fas fa-warehouse text-primary mr-2"></i>Asignación de Almacenes a Usuarios (SAP B1)
            </h1>
            <button type="button" class="btn btn-primary shadow-sm" data-toggle="modal" data-target="#modalAsignar">
                <i class="fas fa-plus-circle mr-1"></i> Nueva Asignación
            </button>
        </div>
        <p class="text-muted">
            Administración centralizada de autorizaciones de almacén mediante SAP Service Layer (REST / OData).
        </p>
    </div>
</div>

<?php if (!empty($error)): ?>
    <div class="alert alert-danger alert-dismissible fade show shadow-sm" role="alert">
        <strong><i class="fas fa-exclamation-triangle mr-1"></i> Error en Service Layer:</strong> <?= esc($error) ?>
        <button type="button" class="close" data-dismiss="alert" aria-label="Close">
            <span aria-hidden="true">&times;</span>
        </button>
    </div>
<?php endif; ?>

<div class="card card-sap shadow mb-4">
    <div class="card-header py-3 bg-white d-flex justify-content-between align-items-center">
        <h6 class="m-0 font-weight-bold text-primary">Matriz de Relaciones Activas (@USER_WH)</h6>
        <span class="badge badge-info"><?= count($relaciones) ?> Registros cargados</span>
    </div>
    <div class="card-body">
        <div class="table-responsive">
            <table class="table table-bordered table-hover table-striped w-100" id="tablaSapUserWh">
                <thead class="thead-dark">
                    <tr>
                        <th style="width: 80px;">Código</th>
                        <th>Usuario SAP</th>
                        <th>Código Almacén</th>
                        <th>Nombre del Almacén</th>
                        <th style="width: 120px;" class="text-center">Ventas</th>
                        <th style="width: 120px;" class="text-center">Traslados</th>
                        <th style="width: 100px;" class="text-center">Acciones</th>
                    </tr>
                </thead>
                <tbody>
                    <?php if (!empty($relaciones)): ?>
                        <?php foreach ($relaciones as $item): ?>
                            <tr>
                                <td><?= esc($item['Code'] ?? 'N/A') ?></td>
                                <td>
                                    <strong><?= esc($item['U_UserCode'] ?? '') ?></strong>
                                </td>
                                <td>
                                    <span class="badge badge-secondary"><?= esc($item['U_WhsCode'] ?? '') ?></span>
                                </td>
                                <td>
                                    <?= esc($item['Name'] ?? 'Sin descripción') ?>
                                </td>
                                <td class="text-center">
                                    <?php if (($item['U_AllowSales'] ?? 'N') === 'Y'): ?>
                                        <span class="badge badge-success"><i class="fas fa-check mr-1"></i>Permitido</span>
                                    <?php else: ?>
                                        <span class="badge badge-danger"><i class="fas fa-times mr-1"></i>Bloqueado</span>
                                    <?php endif; ?>
                                </td>
                                <td class="text-center">
                                    <?php if (($item['U_AllowTransfer'] ?? 'N') === 'Y'): ?>
                                        <span class="badge badge-success"><i class="fas fa-check mr-1"></i>Permitido</span>
                                    <?php else: ?>
                                        <span class="badge badge-danger"><i class="fas fa-times mr-1"></i>Bloqueado</span>
                                    <?php endif; ?>
                                </td>
                                <td class="text-center">
                                    <button class="btn btn-sm btn-outline-danger btn-eliminar" data-code="<?= esc($item['Code']) ?>" title="Revocar asignación">
                                        <i class="fas fa-trash-alt"></i>
                                    </button>
                                </td>
                            </tr>
                        <?php endforeach; ?>
                    <?php endif; ?>
                </tbody>
            </table>
        </div>
    </div>
</div>

<!-- Modal para Nueva Asignación -->
<div class="modal fade" id="modalAsignar" tabindex="-1" role="dialog" aria-labelledby="modalAsignarLabel" aria-hidden="true">
    <div class="modal-dialog modal-dialog-centered" role="document">
        <div class="modal-content">
            <div class="modal-header bg-primary text-white">
                <h5 class="modal-title" id="modalAsignarLabel"><i class="fas fa-link mr-1"></i> Vincular Usuario con Almacén</h5>
                <button type="button" class="close text-white" data-dismiss="modal" aria-label="Cerrar">
                    <span aria-hidden="true">&times;</span>
                </button>
            </div>
            <form id="formAsignacion">
                <div class="modal-body">
                    <div class="form-group">
                        <label for="selectUsuario" class="font-weight-bold">Usuario SAP (OUSR):</label>
                        <select class="form-control" id="selectUsuario" name="user_code" required>
                            <option value="">Seleccione un usuario activo...</option>
                            <?php foreach ($usuarios as $usr): ?>
                                <option value="<?= esc($usr['UserCode']) ?>">
                                    <?= esc($usr['UserName']) ?> (<?= esc($usr['UserCode']) ?>)
                                </option>
                            <?php endforeach; ?>
                        </select>
                    </div>

                    <div class="form-group">
                        <label for="selectAlmacen" class="font-weight-bold">Almacén (OWHS):</label>
                        <select class="form-control" id="selectAlmacen" name="whs_code" required>
                            <option value="">Seleccione un almacén físico...</option>
                            <?php foreach ($almacenes as $wh): ?>
                                <option value="<?= esc($wh['WarehouseCode']) ?>">
                                    [<?= esc($wh['WarehouseCode']) ?>] <?= esc($wh['WarehouseName']) ?>
                                </option>
                            <?php endforeach; ?>
                        </select>
                    </div>

                    <div class="custom-control custom-checkbox mb-2">
                        <input type="checkbox" class="custom-control-input" id="checkVentas" name="allow_sales" value="Y" checked>
                        <label class="custom-control-label" for="checkVentas">Permitir selección en Documentos de Venta</label>
                    </div>

                    <div class="custom-control custom-checkbox">
                        <input type="checkbox" class="custom-control-input" id="checkTraslados" name="allow_transfers" value="Y" checked>
                        <label class="custom-control-label" for="checkTraslados">Permitir origen/destino en Solicitudes de Traslado</label>
                    </div>
                </div>
                <div class="modal-footer">
                    <button type="button" class="btn btn-secondary" data-dismiss="modal">Cancelar</button>
                    <button type="submit" class="btn btn-primary" id="btnGuardar">
                        <i class="fas fa-save mr-1"></i> Guardar en SAP
                    </button>
                </div>
            </form>
        </div>
    </div>
</div>

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

<!-- SECCIÓN DE SCRIPTS: Se renderiza después de jQuery y DataTables -->
<?= $this->section('scripts') ?>
<script>
    // Se asegura de que el código no se invoque hasta que el documento y sus dependencias estén listos
    $(document).ready(function() {
        console.log("Inicializando DataTables para el módulo sapuserwh...");

        // Verificación diagnóstica preventiva en entorno de desarrollo
        if (typeof $.fn.DataTable === 'undefined') {
            console.error("FATAL: El plugin DataTables no está montado sobre la instancia global de jQuery.");
            return;
        }

        // Inicialización robusta con configuración en español y botones de exportación
        var tabla = $('#tablaSapUserWh').DataTable({
            responsive: true,
            lengthChange: true,
            autoWidth: false,
            pageLength: 25,
            lengthMenu: [[10, 25, 50, 100, -1], [10, 25, 50, 100, "Todos"]],
            order: [[1, 'asc']], // Ordenar por nombre de usuario por defecto
            dom: '<"row"<"col-md-6"B><"col-md-6"f>><"row"<"col-md-12"tr>><"row"<"col-md-5"i><"col-md-7"p>>',
            buttons: [
                {
                    extend: 'copyHtml5',
                    text: '<i class="fas fa-copy mr-1"></i> Copiar',
                    className: 'btn btn-sm btn-secondary',
                    exportOptions: { columns: [0, 1, 2, 3, 4, 5] }
                },
                {
                    extend: 'excelHtml5',
                    text: '<i class="fas fa-file-excel mr-1 text-success"></i> Excel',
                    className: 'btn btn-sm btn-secondary',
                    title: 'Exportacion_Usuarios_Almacenes_SAP',
                    exportOptions: { columns: [0, 1, 2, 3, 4, 5] }
                },
                {
                    extend: 'pdfHtml5',
                    text: '<i class="fas fa-file-pdf mr-1 text-danger"></i> PDF',
                    className: 'btn btn-sm btn-secondary',
                    orientation: 'portrait',
                    pageSize: 'LETTER',
                    exportOptions: { columns: [0, 1, 2, 3, 4, 5] }
                },
                {
                    extend: 'print',
                    text: '<i class="fas fa-print mr-1"></i> Imprimir',
                    className: 'btn btn-sm btn-secondary',
                    exportOptions: { columns: [0, 1, 2, 3, 4, 5] }
                }
            ],
            language: {
                processing:     "Procesando solicitud...",
                search:         "<i class='fas fa-search mr-1'></i>Buscar:",
                lengthMenu:    "Mostrar _MENU_ registros por página",
                info:           "Mostrando registros del _START_ al _END_ de un total de _TOTAL_",
                infoEmpty:      "Mostrando 0 a 0 de 0 registros",
                infoFiltered:   "(filtrado de _MAX_ registros en total)",
                infoPostFix:    "",
                loadingRecords: "Cargando catálogo desde Service Layer...",
                zeroRecords:    "No se encontraron asignaciones que coincidan con la búsqueda",
                emptyTable:     "No existen relaciones registradas en el sistema",
                paginate: {
                    first:    "<i class='fas fa-angle-double-left'></i>",
                    previous: "<i class='fas fa-angle-left'></i>",
                    next:     "<i class='fas fa-angle-right'></i>",
                    last:     "<i class='fas fa-angle-double-right'></i>"
                },
                aria: {
                    sortAscending:  ": Activar para ordenar la columna de manera ascendente",
                    sortDescending: ": Activar para ordenar la columna de manera descendente"
                }
            }
        });

        // Manejador del formulario de nueva asignación
        $('#formAsignacion').on('submit', function(e) {
            e.preventDefault();
            
            var submitBtn = $('#btnGuardar');
            submitBtn.prop('disabled', true).html('<i class="fas fa-spinner fa-spin mr-1"></i> Guardando...');

            var payload = {
                user_code: $('#selectUsuario').val(),
                whs_code: $('#selectAlmacen').val(),
                allow_sales: $('#checkVentas').is(':checked') ? 'Y' : 'N',
                allow_transfers: $('#checkTraslados').is(':checked') ? 'Y' : 'N'
            };

            // Simulación de envío AJAX al endpoint del controlador
            $.ajax({
                url: '<?= base_url('admin/servicelayer/sapuserwh/store') ?>',
                method: 'POST',
                data: JSON.stringify(payload),
                contentType: 'application/json',
                headers: {
                    'X-Requested-With': 'XMLHttpRequest'
                },
                success: function(response) {
                    alert('Asignación guardada con éxito en SAP B1.');
                    location.reload();
                },
                error: function(xhr) {
                    var errorMsg = "Ocurrió un error al intentar registrar la asignación.";
                    if (xhr.responseJSON && xhr.responseJSON.message) {
                        errorMsg = xhr.responseJSON.message;
                    }
                    alert(errorMsg);
                    submitBtn.prop('disabled', false).html('<i class="fas fa-save mr-1"></i> Guardar en SAP');
                }
            });
        });

        // Manejador del botón eliminar / revocar
        $('#tablaSapUserWh').on('click', '.btn-eliminar', function() {
            var codigoRegistro = $(this).data('code');
            if (confirm('¿Está seguro de revocar la autorización [' + codigoRegistro + '] en SAP B1?')) {
                // Lógica de eliminación vía DELETE a Service Layer
                console.log('Eliminando registro: ' + codigoRegistro);
            }
        });
    });
</script>
<?= $this->endSection() ?>

🚀 7. Buenas Prácticas y Optimización para Integraciones Críticas

Integrar aplicaciones web con SAP Business One mediante la Service Layer exige contemplar consideraciones de rendimiento y fiabilidad que van más allá del código de la interfaz gráfica.

+-------------------------------------------------------------------------------------------------------+
|                                  CHECKLIST DE BUENAS PRÁCTICAS SL + WEB                               |
+-------------------------------------------------------------------------------------------------------+
| 1. Minimizar tráfico de red   -> Emplear siempre $select con las columnas estrictamente necesarias.   |
| 2. Paginación del lado servidor-> Si la tabla supera los 5,000 registros, usar serverSide: true.      |
| 3. Pooling de Sesión          -> Almacenar B1SESSION en Memcached o Redis para evitar logins masivos. |
| 4. Desconexión Controlada     -> Invocar POST /Logout al reiniciar servicios o agotar procesos batch.  |
| 5. Aislamiento de Librerías   -> Prevenir scripts concurrentes o CDNs desalineados en layouts.        |
+-------------------------------------------------------------------------------------------------------+

1. Filtrado OData en el Servidor ($select y $filter)

Nunca se debe solicitar una entidad completa (GET /b1s/v1/Users) sin parámetros. Los objetos de negocio de SAP contienen cientos de propiedades nativas y campos calculados que saturan la memoria del servidor de aplicaciones y aumentan la latencia de red. La regla de oro es especificar siempre los campos necesarios:

HTTP

GET /b1s/v1/Users?$select=UserCode,UserName,eMail&$filter=Locked eq 'tNO' HTTP/1.1

2. Paginación Server-Side en DataTables

Para tablas donde el volumen de registros supera los 5,000 elementos, la estrategia de renderizar todo el HTML en el DOM degrada el rendimiento del navegador cliente. En esos escenarios, se debe habilitar el modo serverSide: true de DataTables, transformando las peticiones de búsqueda, ordenamiento y paginación en consultas directas que el backend traduce a parámetros OData $skip y $top.

3. Manejo de Timeouts y Concurrencia

La Service Layer cuenta con un límite de conexiones simultáneas definido en su archivo de configuración Apache (httpd.conf / b1s.conf). Realizar múltiples llamadas concurrentes desordenadas desde una misma página web puede agotar el pool de conexiones. Se recomienda centralizar las consultas compuestas en el backend y entregar un único payload consolidado a la vista.

🧭 8. Checklist Preventivo para Evitar Conflictos con DataTables

Para evitar que un error similar se repita en otros módulos o vistas administrativas del ecosistema, sigue esta lista de verificación antes de cada despliegue a producción:

  1. Auditoría de Red: Abrir DevTools (Ctrl + Shift + I o F12), ir a la pestaña Network, filtrar por jquery y recargar con Ctrl + F5. Debe existir exactamente un solo archivo de la librería jQuery descargado.
  2. Inspección en Consola: Ejecutar en la consola interactiva:JavaScriptconsole.log("jQuery version:", $.fn.jquery); console.log("DataTables disponible:", typeof $.fn.DataTable === 'function'); Si la segunda sentencia devuelve false, existe una sobreescritura de scripts en la página.
  3. Validación de la Sección de Scripts: Comprobar que en las vistas Blade o CodeIgniter, la directiva $this->section('scripts') no contenga enlaces a librerías base, sino únicamente la lógica de negocio JavaScript y los inicializadores de la vista.

🤝 9. Comunidad, Código Abierto y Redes Sociales

El desarrollo de software robusto se fundamenta en compartir experiencias reales, resolver incidencias técnicas complejas y documentar soluciones que ahorren horas de depuración a otros ingenieros. Si este artículo técnico y las librerías de integración te resultaron útiles, súmate a la comunidad y sigue de cerca las próximas publicaciones, tutoriales y liberaciones de código abierto.

🌐 Canales Oficiales y Redes del Proyecto

  • 💻 Repositorio de Código Abierto (GitHub):https://github.com/julio101290Explora los paquetes, módulos, repositorios de backend y utilidades creadas para optimizar plataformas empresariales y entornos de desarrollo.
  • 🎥 Canal Principal de Tutoriales y Streaming (YouTube):https://youtube.com/@cesarsystemsGuías en video sobre desarrollo web, administración de sistemas en Linux, bases de datos y desarrollo backend.
  • 📺 Canal de Video Descentralizado (Odysee):https://odysee.com/@JulioCesarLeyvaRodriguezTodo el contenido audiovisual técnico respaldado en la red descentralizada LBRY/Odysee.
  • ☕ Apoyo y Patrocinio del Proyecto (Patreon):https://www.patreon.com/c/u74078772Colabora con el mantenimiento de servidores, producción de contenido educativo independiente y liberación continua de herramientas libres.
  • 📝 Bitácora y Blog Técnico Oficial:https://shalom-now.blogspot.comArtículos en profundidad, notas de arquitectura, comandos de terminal y manuales de referencia para administradores de sistemas y programadores.

🏗️ 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:

🚀 ¿Ruta fantasma, falta de permisos o se te olvidó el método? Diagnóstico quirúrgico en CI4 Boilerplate

Entrada fija

Dime si no te ha pasado: estás picando código a altas horas, das clic a un botón flamante en tu panel de administración, y la pantalla te arroja un frío e implacable Error 404 🥶.

En ese momento empieza la ruleta rusa mental del desarrollador:

  • 🧐 ¿Escribí mal la URL en el archivo de rutas?
  • 👻 ¿La ruta sí está dada de alta, pero se me olvidó crear el método en el controlador?
  • 🔒 ¿O el usuario simplemente no tiene permisos y el sistema decidió fingir demencia ocultando la página como 404?

Hasta ahora, en julio101290/boilerplate, cualquier tropiezo en los filtros de acceso lanzaba una excepción silenciosa. Y para ponerle más drama al asunto, si un usuario sin sesión activa caía en una página de error, la barra superior de AdminLTE colapsaba con el clásico:

💥 Call to undefined function user() in header.php

¡Se acabaron las adivinanzas a ciegas! Llegó una actualización directa a las entrañas del paquete para que tu consola y tu pantalla te digan la verdad sin rodeos 🔍⚡.

🛠️ ¿Qué hay de nuevo bajo el capó?

En lugar de meter todos los fallos en la misma bolsa del 404, ahora el boilerplate audita la petición y clasifica el problema en tres escenarios visuales clarísimos:

1. 🛡️ Falta de Permisos (Acceso Denegado 403)

El filtro ya no enmascara el acceso denegado. Si el usuario intenta entrar a un módulo para el que no tiene privilegios (por ejemplo manage-user), el panel muestra una tarjeta de advertencia en color ámbar señalando con precisión quirúrgica:

  • «Acceso Restringido: Te falta el permiso [nombre-del-permiso]».

2. 🗺️ La Ruta No Existe (404 Genuino)

Si escribiste mal la URL en el navegador o hay un enlace roto en el menú, el enrutador captura la petición y despliega una tarjeta roja indicando la URI consultada, confirmando que esa regla jamás ha existido en tus rutas.

3. 🧩 Ruta Registrada, pero Código Faltante (¡Atrapado con las manos en el código!)

Este es el auténtico salvavidas para el día a día. Si ya registraste la ruta apuntando a VentasController::cobrar, pero aún no has creado la clase o se te olvidó escribir la función cobrar(), el sistema utiliza reflexión de PHP e inspecciona el archivo:

  • ⚠️ «La ruta está bien, pero la clase VentasController no existe.»
  • ⚠️ «El controlador existe, pero el método cobrar() no está declarado.»

4. 🦺 Layout y Navbar Blindados al 100%

Se reforzó header.php para cargar el helper de autenticación bajo demanda y verificar si existe una sesión activa antes de consultar user()->profile_image. Cero errores fatales si un visitante anónimo o un bot cae en una página rota.

🔍 Revisa el código del cambio

Todos los detalles técnicos y el diff de esta actualización están disponibles en el repositorio oficial:

👉 Commit con los cambios: Ver en GitHub (commit a50a68d)

📦 Pruébalo y mantente al día

Esta mejora te ahorrará horas de depuración fantasma en CodeIgniter 4, manteniendo tu arquitectura limpia, robusta y amigable con el equipo de desarrollo.

Para no perderte las próximas actualizaciones de módulos, tutoriales y recursos de código, pásate por mis canales oficiales:

🤠 CAPÍTULO 1: «NOMÁS SON CINCO MINUTITOS, VIEJÓN»

Entrada fija

(O de cómo tres demonios de Linux casi me hacen aventar el servidor al monte) 🖥️💥

Prólogo: La mentira más grande del norte 🌙☕

Eran como las once y media de la noche, compa. En la calle ya no pasaba ni un alma, nomás se escuchaba a lo lejos el ladrido de los perros de la colonia y el zumbido del abanico de techo peleando por su vida contra el calorón.

Cualquier cristiano con dos dedos de frente ya se hubiera cenado unos tacos, apagado las luces y a dormir como los dioses. Pero uno es terco, plebe. Uno tiene esa maldición que cargamos los que le movemos a los fierros y a los servidores: el ego informático.

Miré la pantalla y me dije a mí mismo la frase más peligrosa y embustera que ha parido la humanidad desde que se inventó el fuego:

👉 «Nomás instalo rápido el contenedor de ONLYOFFICE en Docker, lo pego a mi Nextcloud para editar archivos en la nube, veo que jale y a mimir. En cinco minutitos queda al puro tiro, viejón». 🤦‍♂️

¡Ándale, pues! Esos «cinco minutitos» terminaron siendo una novela de misterio, traición, balazos criptográficos y brujería digital que ni la Rosa de Guadalupe se atrevería a escribir.

Acto I: La trampa del botón azul y la casilla embustera 🖱️🤡

La tirada se veía clarita y sin baches:

  • Nextcloud jalando en el servidor para guardar las carpetas de la chamba.
  • Un contenedor de Docker con ONLYOFFICE Document Server para no pagarle suscripciones en dólares a Don Bill Gates.
  • Su dominio bien peinado con DNS dinámico, puertos configurados y certificados listos para que viajara todo encriptado como secreto de Estado.

Entro a la interfaz web de Nextcloud, me voy derechito al menú de ONLYOFFICE Docs y veo los campos limpiecitos. Pongo la URL pública con su puerto: [https://nube.mi-rancho-digital.com:9443](https://nube.mi-rancho-digital.com:9443) 🌐.

Luego me topo con una casilla que te mira con ojos de ternura:

☑️ «Desactivar la verificación de certificados (inseguro)».

«¡Uf, una chulada!», pensé. Como mi certificado SSL es local y el navegador chilla diciendo que «la conexión no es segura», esa casilla es como enseñarle la credencial de elector a un guardia que ya te conoce: pásale, pariente, estás en tu casa.

Pego el token secreto (JWT) del contenedor, respiro hondo y le aplasto con orgullo norteño al botón azul de Guardar.

La ruedita de carga empezó a girar. Uno… dos… tres segundos… Esos segundos donde se te va el aire del pecho esperando el milagro.

¡Tómala, barbón! 💥

Me salta un letrero rojo atravesado de oreja a oreja en la pantalla:

❌ «Error al intentar establecer la conexión (Se ha producido un error en el servicio de documentos: Error while downloading the document file to be converted.) (versión 9.4.0.129)».

¡Ah, caray! Me quedé pasmado. ¿Cómo que error al descargar el archivo para convertir? ¿Cuál archivo, si yo nomás le di Guardar?

Para la raza que nunca ha configurado esto: cuando tú le das Guardar, Nextcloud no nomás pregunta «¿estás ahí, viejo?». No, señor. Nextcloud agarra un archivo chiquito de prueba, se lo manda a ONLYOFFICE y le dice: «A ver si muy machito, descárgalo de mi casa, conviértelo a tu formato y regrésame una foto miniatura para ver si es cierto que trabajas».

Y ahí, en ese viaje de ida y vuelta, la cosa había tronado como ejote.

Cerré el navegador. La web es puro cuento, compa; si quieres ver dónde está el cochinero, hay que meterse a la terminal a rascarle a los logs. 🕳️💻

Acto II: El cadenero de Node.js se puso flamenco (DEPTH_ZERO_SELF_SIGNED_CERT) 🥊👮‍♂️

Abro la consola de comandos, tiro un docker ps para ver al sospechoso y le meto una zancadilla al registro del contenedor a ver qué le dolía:

Bash

sudo docker exec -it onlyoffice_docs tail -n 80 /var/log/onlyoffice/documentserver/docservice/out.log

Y la pantalla me escupe una letanía de errores en rojo que parecía árbol de Navidad descompuesto:

Plaintext

[ERROR] nodeJS - error downloadFile:url=https://nube.mi-rancho-digital.com:9443/index.php/apps/onlyoffice/empty?...
code:DEPTH_ZERO_SELF_SIGNED_CERT Error: self-signed certificate

¡Míralo, qué chulo! 🤬

El error era claro como el agua de la presa: DEPTH_ZERO_SELF_SIGNED_CERT. ONLYOFFICE me mandó por un tubo porque el certificado de Nextcloud era autofirmado.

—«A ver, cabrón», le dije a la pantalla, «¿pos no te marqué en Nextcloud la casilla de ignorar los certificados?».

Pues sí, compa, pero esa casilla es pura pantalla. Nextcloud ignora los certificados cuando él le habla a ONLYOFFICE. Pero cuando ONLYOFFICE tiene que ir de regreso a tocarle la puerta a Nextcloud para descargar el archivo, el que viaja es un proceso de Node.js que vive adentro del contenedor de Docker.

Y Node.js no cree en la buena voluntad de la gente. Node.js es como esos policías gringos de película: si el papel no viene con el sello oficial del presidente, te tumba al suelo, te esposa y te cancela la llamada.

Y para acabarla de amolar, adentro de ONLYOFFICE el archivo default.json venía amarrado con alambre de púas: "rejectUnauthorized": true 🔒

O sea: «¡Aquí no entra nadie con certificado del pueblo, puro Let’s Encrypt o certificado de ricos!».

Dije: «A mí no me vas a ganar en mi propia máquina, mi rey». Me fajé la camisa y le metí mano a la configuración con un machetazo de sed:

Bash

# Obligar a Node.js a tragar certificados locales
sudo docker exec -it onlyoffice_docs sed -i 's/"rejectUnauthorized": true/"rejectUnauthorized": false/g' /etc/onlyoffice/documentserver/default.json

# Y por si las moscas, inyectar la variable en el supervisor
sudo docker exec -it onlyoffice_docs bash -c 'echo -e "\n[supervisord]\nenvironment=NODE_TLS_REJECT_UNAUTHORIZED=\"0\"" >> /etc/supervisor/supervisord.conf'

# Reiniciar los fierros internos
sudo docker exec -it onlyoffice_docs supervisorctl restart all

Reviso el log y sale Node.js regañándome:

⚠️ «Warning: Setting the NODE_TLS_REJECT_UNAUTHORIZED environment variable to ‘0’ makes TLS connections insecure».

—«Chilla todo lo que quieras, plebe, pero ahora me dejas pasar». 🤠

Primer demonio domado. Ya no había bronca con el certificado. Volví a la web, le di Guardar de nuevo… y ¡mocos! Otro error diferente.

Acto III: El fantasma de <documentserver> y el miedo a las IPs privadas 👻🚫

Ya para este punto eran las doce y media de la noche. El dolor de espalda empezaba a cobrar factura y el café ya sabía a pura resignación.

Me pongo a revisar la pantalla de Nextcloud con lupa y abro una pestañita abajo que dice «Ajustes de servidor avanzados».

¡Casi me voy para atrás de la risa y el coraje! 🤦‍♂️😂

En el campo de «Dirección de ONLYOFFICE Docs para solicitudes internas del servidor», la interfaz me había autollenado una cochinada:

https://<documentserver>

¡No me friegues! El sistema no lo tenía como ejemplo gris clarito; ¡lo estaba mandando en serio! Cada vez que guardaba, Nextcloud intentaba conectarse a un dominio que literalmente se llamaba <documentserver>. Pos con razón, viejón: el DNS de mi proveedor de internet ha de haber pensado: «¿Y este vato qué se fumó? Eso ni existe».

Y por si fuera poco, para esquivar las broncas del enrutador de la casa (que a veces no le gusta salir a internet y regresar al mismo módem, el mentado Hairpin NAT), se me ocurrió ponerle en la dirección interna la IP local de la máquina:

[https://192.168.1.150:9443/](https://192.168.1.150:9443/)

Parecía plan con maña. Tráfico local, directito por la tarjeta de red, sin gastar megas.

Pero, ¡sorpresa! ONLYOFFICE tiene complejo de agente del FBI. Los desarrolladores le metieron una regla que dice:

"allowPrivateIPAddress": false

¿Qué significa eso en cristiano? Que si ONLYOFFICE ve una IP que empieza con 192.168.x.x o 10.x.x.x, le da un ataque de pánico pensando que un hacker ruso le está haciendo un ataque de falsificación de peticiones (SSRF) y bloquea la conexión de inmediato. ¡Ni en su propia casa se sentía seguro el contenedor!

Estábamos atrapados en un callejón sin salida:

  1. Por la URL pública no entraba por culpa del módem.
  2. Por la URL interna privada reventaba porque ONLYOFFICE le tenía fobia a las IPs de la casa.
  3. Y el campo fantasma <documentserver> tirando patadas de ahogado.

Aquí fue donde mandé la interfaz web a volar. A grandes males, grandes machetazos de consola.

Descubrí que en este servidor Nextcloud no corría en un Apache pelón, sino empaquetado en Snap. Así que saqué la artillería pesada con el comando nextcloud.occ:

Bash

# 1. Borrar de raíz el fantasma de <documentserver>
sudo nextcloud.occ config:app:delete onlyoffice DocumentServerInternalUrl

# 2. Clavar la IP local para que descargue sin rodeos
sudo nextcloud.occ config:app:set onlyoffice StorageUrl --value="https://192.168.1.150:9443/"

# 3. Y quitarle la paranoia a ONLYOFFICE para que acepte IPs privadas
sudo docker exec -it onlyoffice_docs sed -i 's/"allowPrivateIPAddress": false/"allowPrivateIPAddress": true/g' /etc/onlyoffice/documentserver/default.json
sudo docker exec -it onlyoffice_docs supervisorctl restart all

Listo. Borrado el fantasma, habilitada la IP privada, y Node.js amarrado para no chillar por el SSL.

Tiré la prueba de fuego desde la terminal:

sudo nextcloud.occ onlyoffice:documentserver --check

Y la terminal me responde con una frialdad que me congeló las tripas:

💀 Error connection: Error occurred in the document service.

Ahí sí sentí ganas de apagar el switch de la luz y dedicarme a sembrar hortalizas. ¿Ahora qué demonios quería el mugre sistema? 🚜🌾

Acto IV: La guerra civil de Nginx y el misterio del 403 🕵️‍♂️🔥

Eran ya la una y pico de la mañana. Me quedé viendo fijamente el cursor parpadear. Cuando todo falla, compa, la única regla que no falla en la informática es: los registros no se hacen pendejos. Si truena, en algún lado está chillando el fierro.

Me fui a revisar el log profundo de Nextcloud en Snap:

Bash

sudo tail -n 50 /var/snap/nextcloud/current/logs/nextcloud.log

Y entre un montón de líneas kilométricas en formato JSON, me salta la joya de la corona:

Plaintext

Client error: `GET https://nube.mi-rancho-digital.com:9443/cache/files/data/conv_check_803784422_65/output.docx/...` 
resulted in a `403 Forbidden` response

Me le quedé viendo a la línea como vaca viendo pasar el tren. 🐮🚂

403 Forbidden.

¡A ver, a ver, barájamela más despacio!

Fíjate bien en la ruta que estaba pidiendo:

/cache/files/data/conv_check.../output.docx

¿Qué diablos significaba eso?

¡Significaba que ONLYOFFICE SÍ se había conectado a Nextcloud!

¡Significaba que SÍ había descargado la plantilla de prueba!

¡Significaba que el motor conversor SÍ había generado el archivo .docx y lo tenía guardado calientito en su carpeta de caché!

O sea, la carrera de 100 metros planos ya la había corrido casi toda… pero en el metro 99, cuando Nextcloud iba contento a recoger el archivo terminado para decir «¡Ya quedó, compadre!», el servidor web Nginx que cuida la puerta de ONLYOFFICE le metía un portazo en las narices gritándole: ¡403 PROHIBIDO, AQUÍ NO ENTRAS! 🚪❌

—«¿Pero por qué me corres, desgraciado, si el archivo tú mismo me lo hiciste?», pensé.

Le revisé los permisos a las carpetas por si las moscas: chmod 755, chown ds:ds. Todo en orden. No era bronca de permisos de disco.

Y en eso, una chispa divina me iluminó el coco. 💡

Me acordé de cómo protege ONLYOFFICE sus descargas: usa un módulo de Nginx llamado secure_link.

Funciona bien curado: cuando el conversor crea el documento, agarra una contraseña secreta (secretString), la revuelve con la fecha y el nombre del archivo, y genera un código MD5 único. Cuando Nextcloud viene a descargar el archivo, Nginx revisa ese código con su propia contraseña. Si las contraseñas coinciden, te da el archivo; si no, ¡te la ensarta con un 403!

Dije: «No me digas que estos animales están usando claves diferentes adentro del mismo contenedor».

Tiré este comando para destapar la cloaca:

Bash

# Ver qué clave tiene Nginx en la libreta
sudo docker exec -it onlyoffice_docs grep -rn "secure_link_secret" /etc/nginx/ /etc/onlyoffice/

# Ver qué clave tiene el conversor en la memoria
sudo docker exec -it onlyoffice_docs grep -rn "secretString" /etc/onlyoffice/documentserver/local.json

¡Cállate los ojos, pariente! 🤯

Miren lo que salió en la pantalla:

  • En la configuración activa de Nginx (ds.conf):set $secure_link_secret lDpK21FnC0Pe6EwfMPtO; 📜
  • En el archivo de servicio de ONLYOFFICE (local.json):"secretString": "fn7ZMjfj6y4oJFeepVUh" 🔐

¡Traían una guerra civil armada los batos! 😂

La mano izquierda del contenedor generaba el documento con el sello A, y el portero de la mano derecha en Nginx tenía órdenes estrictas de aceptar únicamente documentos con el sello B. Al ver que no cuadraban, Nginx pensaba que era un impostor y lo mandaba a la tiznada con un 403.

¡Dos horas peleando contra fantasmas y eran las comadres adentro del contenedor que no se hablaban!

Acto V: El tiro de gracia y la gloria de la consola 🎯🏆

Con el clavo bien ubicado, la cura tomó menos de diez segundos. Había que obligar a Nginx a usar la misma clave que el servicio conversor:

Bash

# 1. Emparejar la clave en la libreta de Nginx
sudo docker exec -it onlyoffice_docs sed -i 's/set $secure_link_secret .*/set $secure_link_secret fn7ZMjfj6y4oJFeepVUh;/g' /etc/nginx/conf.d/ds.conf

# 2. Recargar Nginx para que le caiga el veinte
sudo docker exec -it onlyoffice_docs nginx -s reload

Se escuchó el suspiro del proceso recargando:

[notice] signal process started 🕊️

El cuarto estaba en completo silencio. Ni el abanico sonaba ya en mi cabeza. Puse las manos sobre el teclado para tirar el volado final. Si esto no jalaba, apagaba todo y me iba a poner un puesto de hot dogs. 🌭

Escribí:

Bash

sudo nextcloud.occ onlyoffice:documentserver --check

Le piqué a la tecla Enter con la fuerza de quien cobra un penal en el minuto 90. ⚽

Medio segundo de suspenso… un parpadeo del disco duro… y la terminal escupió la frase más hermosa que han visto mis ojos en todo el año:

✨ Document server [https://nube.mi-rancho-digital.com:9443/](https://nube.mi-rancho-digital.com:9443/) version 9.4.0.129 is successfully connected ✨

¡Uffff, qué chulada de maíz prieto! 🎉🥳

Casi pego un grito en la casa que despierta a la doña.

Me fui en fa al navegador, le di un recargón con Ctrl + F5 a la página de Nextcloud y ahí estaba: la barra verde brillando como esmeralda, los iconos de .docx, .xlsx y .pptx habilitados, y el servidor de documentos enlazado como mandilón en quincena.

Abrí un documento de Word en blanco nomás de puro gusto, y en un segundo se desplegó la suite de ONLYOFFICE directito en la pestaña, suavecita y sin pedirle nada a nadie.

Moraleja pa’ la raza de sistemas 🧠🍺

Si te vas a meter al ruedo del auto-hospedaje con Docker y Nextcloud, no te confíes de las ventanitas bonitas ni de los botones azules:

  1. Node.js es más bravo que perro de taller: Desactiva la verificación interna (rejectUnauthorized: false) si andas con certificados locales, porque las casillas de la web no le hacen ni cosquillas.
  2. Cuidado con las IPs de tu casa: ONLYOFFICE viene asustado de fábrica y le cierra la puerta a las IPs privadas (allowPrivateIPAddress: true).
  3. No dejes que Nginx y Node se agarren del chongo: Si te arroja un 403 Forbidden al convertir el archivo, revisa que el $secure_link_secret de Nginx sea gemelo del secretString de tu local.json.

Guardé la sesión de SSH, cerré la laptop con una sonrisa de oreja a oreja y me fui a dormir a las dos de la mañana, oliendo a café frío pero con la satisfacción de haberle ganado la partida a la máquina.

¡Fierro por la 300, plebada! Arre con la que barre. 🤠🚀🖥️

🌐 ¡No te quedes fuera de la jugada, pariente! 🤝📲

Si te sirvió esta guía para no arrancarte las greñas a las 2 de la mañana con tus servidores, o si nomás te late ver cómo le batallamos en las trincheras del código y el autohospedaje, pásale a mis canales pa’ seguir en contacto y apoyar el contenido:

  • 💬 Comunidad y contacto en Telegram: t.me/CesarSystems (échate una vuelta pa’ cotorrear o tirar paro con las dudas de Linux y sistemas)
  • ☕ Apoya el proyecto en Patreon: patreon.com/u74078772 (pa’ seguir patrocinando el café nocturno y los tutoriales sin censura)
  • ✍️ Blog de artículos y tutoriales: shalom-now.blogspot.com (aquí desmenuzamos más hacks, configuraciones y vivencias)
  • 🎥 Canal de YouTube: youtube.com/@rasec555 (suscríbete pa’ ver los videos al tiro)

🚀 Guía Definitiva: Convierte las entradas de tu blog de WordPress en PDFs profesionales, limpios y a color con Python

Entrada fija

¡Hola a todos! 👋 Si alguna vez has querido descargar todas las entradas de tu blog de WordPress para tener un respaldo local impecable, leer tus artículos sin conexión, o compartirlos de manera elegante como documentos independientes, estás en el lugar correcto.

Hoy te traigo una herramienta completa en Python diseñada desde cero para automatizar este proceso. Este script no solo extrae el contenido de tu API de WordPress, sino que soluciona los problemas más comunes al generar PDFs: coloca la imagen de portada arriba del todo, incrusta las imágenes de forma segura para evitar bloqueos del servidor, y aplica un resaltado de sintaxis a color increíble en tus bloques de código.

✨ Características Principales

  • 📸 Portada Inteligente con Doble Rescate: Busca automáticamente la imagen destacada oficial de WordPress (incluso consultando directamente el endpoint de medios si la API la oculta). Si un post no tiene imagen destacada, toma inteligentemente la primera imagen del cuerpo del artículo y la coloca arriba del título.
  • 🔒 Incrustación Base64 contra Bloqueos: Descarga las imágenes utilizando cabeceras personalizadas y las convierte a formato data:image/...;base64. Esto evita que firewalls, Cloudflare o configuraciones de red locales bloqueen las peticiones internas de WeasyPrint.
  • 🎨 Resaltado de Código Automático (Pygments): Analiza los bloques de código (<pre> y <code>), detecta automáticamente el lenguaje de programación (Python, Bash, PHP, JavaScript, etc.) o lo adivina de forma inteligente, aplicando un esquema de colores profesional (friendly) adaptado perfectamente para impresión.
  • 📂 Nombres de Archivos Seguros: Limpia títulos largos, elimina emojis y caracteres especiales convirtiéndolos en slugs limpios organizados por fecha (YYYY-MM-DD_nombre-del-post.pdf).

🛠️ Requisitos del Sistema y Entorno Virtual

Debido a las políticas de seguridad de las distribuciones modernas de Linux (que protegen el gestor global de paquetes de Python mediante PEP 668), lo más limpio, profesional y recomendado es trabajar dentro de un entorno virtual (venv).

1. Instalar dependencias del sistema operativo

Primero, asegúrate de tener instaladas las librerías tipográficas y de renderizado (necesarias para que WeasyPrint procese fuentes y emojis correctamente):

  • En Arch Linux / CachyOS:Bashsudo pacman -S pango noto-fonts-emoji ttf-liberation
  • En Linux Mint / Ubuntu:Bashsudo apt install libpango-1.0-0 libpangoft2-1.0-0 fonts-noto-color-emoji

2. Configurar el Entorno Virtual de Python

Abre tu terminal en la carpeta donde quieras trabajar y ejecuta los siguientes comandos:

Bash

# 1. Crear el entorno virtual llamado 'venv'
python3 -m venv venv

# 2. Activar el entorno virtual
source venv/bin/activate

(Verás que tu terminal cambia para mostrar (venv) al inicio, indicando que estás dentro del entorno aislado).

3. Instalar las librerías de Python

Con el entorno activo, instala las dependencias necesarias:

Bash

pip install requests beautifulsoup4 weasyprint pygments

💻 El Script Completo (blog_a_pdf.py)

Crea un archivo llamado blog_a_pdf.py, cópiale el siguiente contenido y guárdalo en tu directorio de trabajo:

Python

#!/usr/bin/env python3
"""
Exporta cada entrada de un blog WordPress (de un año dado) a un PDF individual
con portada arriba, incrustación Base64 y resaltado de sintaxis a color.

Uso:
    python3 blog_a_pdf.py              # año actual
    python3 blog_a_pdf.py --year 2026
    python3 blog_a_pdf.py --year 2026 --out ./pdfs

Dependencias (dentro de tu venv):
    pip install requests beautifulsoup4 weasyprint pygments
"""
import argparse
import base64
import html
import re
import time
import unicodedata
from datetime import datetime
from pathlib import Path
from urllib.parse import unquote, urljoin

import requests
from bs4 import BeautifulSoup
from weasyprint import HTML

from pygments import highlight
from pygments.formatters import HtmlFormatter
from pygments.lexers import get_lexer_by_name, guess_lexer
from pygments.lexers.special import TextLexer
from pygments.util import ClassNotFound

# CONFIGURACIÓN DE TU SITIO WEB
SITE = "https://cesarsystems.com.mx"
API = f"{SITE}/wp-json/wp/v2/posts"
HEADERS = {"User-Agent": "Mozilla/5.0 (blog-a-pdf)"}

# Generar automáticamente los estilos CSS de colores para Pygments (Tema: friendly)
PYGMENTS_CSS = HtmlFormatter(style="friendly").get_style_defs('.highlight')

CSS = f"""
@page {{
    size: Letter;
    margin: 2cm 1.8cm;
    @bottom-center {{ content: counter(page) " / " counter(pages); font-size: 9pt; color: #777; }}
}}
body {{ font-family: "Liberation Sans", "DejaVu Sans", "Noto Color Emoji", sans-serif;
       font-size: 10.5pt; line-height: 1.5; color: #222; }}
h1 {{ font-size: 20pt; margin: 0.4em 0 .2em; color: #12355b; }}
h2 {{ font-size: 15pt; margin-top: 1.4em; color: #12355b; border-bottom: 1px solid #ccd; padding-bottom: 2px; }}
h3 {{ font-size: 12.5pt; margin-top: 1.2em; }}
.meta {{ color: #666; font-size: 9pt; margin-bottom: 1.2em; }}
.meta a {{ color: #666; word-break: break-all; }}
img {{ max-width: 100%; height: auto; }}
img.portada {{ display: block; width: 100%; max-height: 9cm; object-fit: cover;
              border-radius: 4px; margin: 0 0 1.2em; }}
pre {{ background: #f8f9fa; border: 1px solid #e1e4e8; border-radius: 6px; padding: 12px;
      font-size: 8.5pt; white-space: pre-wrap; word-wrap: break-word; page-break-inside: auto; }}
code {{ font-family: "DejaVu Sans Mono", "Liberation Mono", monospace; font-size: 9pt; }}
p code, li code {{ background: #eef0f3; padding: 2px 5px; border-radius: 4px; color: #d63384; }}
table {{ border-collapse: collapse; width: 100%; margin: 1em 0; }}
th, td {{ border: 1px solid #bbb; padding: 4px 6px; font-size: 9.5pt; vertical-align: top; }}
th {{ background: #eef0f3; }}
blockquote {{ border-left: 3px solid #99a; margin-left: 0; padding-left: 10px; color: #444; }}
a {{ color: #1a5fb4; }}

/* Estilos de resaltado de código Pygments */
{PYGMENTS_CSS}
.highlight pre {{ background: transparent; border: none; padding: 0; margin: 0; }}
"""

PYGMENTS_FORMATTER = HtmlFormatter(cssclass="highlight")


def slugify(texto: str, max_len: int = 70) -> str:
    """Genera nombres de archivos seguros sin acentos, espacios ni caracteres extraños."""
    texto = unicodedata.normalize("NFKD", texto).encode("ascii", "ignore").decode()
    texto = re.sub(r"[^a-zA-Z0-9]+", "-", texto).strip("-").lower()
    return texto[:max_len].strip("-")


def obtener_posts(year: int) -> list[dict]:
    """Descarga de forma paginada todas las entradas publicadas en el año indicado."""
    posts, page = [], 1
    while True:
        params = {
            "after": f"{year}-01-01T00:00:00",
            "before": f"{year + 1}-01-01T00:00:00",
            "per_page": 100,
            "page": page,
            "orderby": "date",
            "order": "asc",
            "_embed": "wp:featuredmedia",
            "_fields": "id,date,slug,link,title,content,featured_media,_embedded",
        }
        r = requests.get(API, params=params, headers=HEADERS, timeout=60)
        if r.status_code == 400:  # Fin de paginación
            break
        r.raise_for_status()
        lote = r.json()
        if not lote:
            break
        posts.extend(lote)
        total_pages = int(r.headers.get("X-WP-TotalPages", 1))
        if page >= total_pages:
            break
        page += 1
    return posts


def limpiar_contenido(html_contenido: str) -> str:
    """Limpia etiquetas innecesarias, corrige lazy-loads y aplica colores al código."""
    soup = BeautifulSoup(html_contenido, "html.parser")

    for tag in soup(["script", "style", "iframe", "noscript", "form"]):
        tag.decompose()

    for img in soup.find_all("img"):
        for attr in ("data-src", "data-lazy-src", "data-orig-file"):
            if img.get(attr):
                img["src"] = img[attr]
                break
        for attr in ("srcset", "sizes", "loading"):
            img.attrs.pop(attr, None)

    # Procesar bloques de código con Pygments
    for pre in soup.find_all("pre"):
        code_tag = pre.find("code")
        code_text = code_tag.get_text() if code_tag else pre.get_text()
        
        lang = None
        classes = pre.get("class", []) + (code_tag.get("class", []) if code_tag else [])
        for c in classes:
            if c.startswith("language-") or c.startswith("brush:") or c.startswith("lang-"):
                lang = c.replace("language-", "").replace("brush:", "").replace("lang-", "").strip()
        
        lexer = None
        if lang:
            try:
                lexer = get_lexer_by_name(lang)
            except ClassNotFound:
                pass
        
        if not lexer:
            try:
                lexer = guess_lexer(code_text)
            except Exception:
                lexer = TextLexer()
        
        highlighted_code = highlight(code_text, lexer, PYGMENTS_FORMATTER)
        new_tag = BeautifulSoup(highlighted_code, "html.parser")
        pre.clear()
        pre.append(new_tag)

    return str(soup)


def obtener_url_portada(post: dict) -> str | None:
    """Rescata la imagen destacada oficial (vía embebidos o API de medios) o usa la del contenido."""
    media_id = post.get("featured_media", 0)

    if media_id > 0:
        # 1. Intentar desde datos embebidos
        try:
            media = post["_embedded"]["wp:featuredmedia"][0]
            if isinstance(media, dict):
                sizes = media.get("media_details", {}).get("sizes", {})
                for nombre in ("large", "full"):
                    if nombre in sizes and sizes[nombre].get("source_url"):
                        return sizes[nombre]["source_url"]
                if media.get("source_url"):
                    return media["source_url"]
        except (KeyError, IndexError, TypeError):
            pass

        # 2. Consultar directamente el endpoint de medios si lo anterior falló
        try:
            r_media = requests.get(f"{SITE}/wp-json/wp/v2/media/{media_id}", headers=HEADERS, timeout=10, verify=False)
            if r_media.status_code == 200:
                data = r_media.json()
                sizes = data.get("media_details", {}).get("sizes", {})
                for nombre in ("large", "full"):
                    if nombre in sizes and sizes[nombre].get("source_url"):
                        return sizes[nombre]["source_url"]
                if data.get("source_url"):
                    return data["source_url"]
        except Exception:
            pass

    # 3. Fallback: primera imagen dentro del cuerpo del texto
    soup = BeautifulSoup(post["content"]["rendered"], "html.parser")
    img_tag = soup.find("img")
    if img_tag:
        for attr in ("src", "data-src", "data-lazy-src", "data-orig-file"):
            url = img_tag.get(attr)
            if url:
                return urljoin(SITE, url)

    return None


def url_a_base64(url: str) -> str | None:
    """Descarga la imagen remota y la convierte a Data URI en Base64."""
    try:
        r = requests.get(url, headers=HEADERS, timeout=15, verify=False)
        if r.status_code == 200:
            content_type = r.headers.get("Content-Type", "image/jpeg")
            encoded = base64.b64encode(r.content).decode("utf-8")
            return f"data:{content_type};base64,{encoded}"
    except Exception:
        pass
    return None


def armar_html(post: dict) -> str:
    """Ensambla el HTML final colocando la portada arriba, seguida del título, metadatos y contenido."""
    titulo = html.unescape(post["title"]["rendered"])
    fecha = datetime.fromisoformat(post["date"]).strftime("%d/%m/%Y")
    cuerpo = limpiar_contenido(post["content"]["rendered"])
    
    url_img = obtener_url_portada(post)
    portada = ""
    if url_img:
        img_b64 = url_a_base64(url_img)
        if img_b64:
            portada = f'<img class="portada" src="{img_b64}">'

    return f"""<!DOCTYPE html>
<html lang="es"><head><meta charset="utf-8"><title>{html.escape(titulo)}</title></head>
<body>
{portada}
<h1>{html.escape(titulo)}</h1>
<div class="meta">{fecha} &middot; <a href="{post['link']}">{unquote(post['link'])}</a></div>
{cuerpo}
</body></html>"""


def main():
    import urllib3
    urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning)

    ap = argparse.ArgumentParser()
    ap.add_argument("--year", type=int, default=datetime.now().year)
    ap.add_argument("--out", default="pdfs_blog")
    args = ap.parse_args()

    out = Path(args.out)
    out.mkdir(parents=True, exist_ok=True)

    print(f"[*] Descargando entradas de {args.year} desde {SITE} ...")
    posts = obtener_posts(args.year)
    print(f"[✓] {len(posts)} entradas encontradas.\n")

    for i, post in enumerate(posts, 1):
        titulo = html.unescape(post["title"]["rendered"])
        nombre = f"{post['date'][:10]}_{slugify(titulo) or post['id']}.pdf"
        destino = out / nombre

        if destino.exists():
            print(f"[{i}/{len(posts)}] Ya existe, se omite: {nombre}")
            continue

        print(f"[{i}/{len(posts)}] Procesando: {nombre}")
        try:
            HTML(string=armar_html(post), base_url=SITE).write_pdf(
                destino, stylesheets=[__import__("weasyprint").CSS(string=CSS)]
            )
        except Exception as err:
            print(f"    [-] Error al generar PDF: {err}")
        time.sleep(0.5)

    print(f"\n[✓] ¡Listo! Todos los PDFs se guardaron en: {out.resolve()}")


if __name__ == "__main__":
    main()

🕹️ Guía de Uso del Script

Una vez que tengas configurado tu entorno virtual y tu archivo listo, puedes ejecutar el script con diferentes opciones según tus necesidades:

  • Exportar las entradas del año actual por defecto:Bashpython3 blog_a_pdf.py
  • Exportar un año en específico (ejemplo, año 2026):Bashpython3 blog_a_pdf.py --year 2026
  • Guardar los PDFs en una carpeta personalizada:Bashpython3 blog_a_pdf.py --year 2026 --out ./mis_respaldos_pdf

Cuando termines de trabajar, simplemente recuerda desactivar tu entorno virtual escribiendo:

Bash

deactivate

🔧 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 organizar tu música en Linux y crear tu propio Spotify con Picard y Navidrome

Entrada fija

¿Tienes cientos o miles de canciones desordenadas en carpetas? 😵‍💫🎶
¿Archivos con nombres como Track01.mp3, Audio_023.flac o canciones sin portada ni información?

En Linux podemos solucionar este problema de una forma sencilla utilizando MusicBrainz Picard para identificar y organizar nuestra música y Navidrome para crear nuestro propio servidor de música. 🐧🎧

El resultado: una biblioteca musical ordenada y accesible desde la computadora, celular o navegador. 📱💻

🎼 1. Organiza tu música con MusicBrainz Picard

El primer paso es tener correctamente identificadas nuestras canciones.

Para esto podemos utilizar MusicBrainz Picard, una herramienta gratuita que utiliza la base de datos de MusicBrainz para reconocer nuestros archivos y completar automáticamente sus metadatos.

Con Picard podemos obtener información como:

  • 🎤 Artista
  • 💿 Álbum
  • 🎵 Nombre de la canción
  • 📅 Año
  • 🎼 Género
  • 🔢 Número de pista
  • 🖼️ Portada del álbum
  • 💽 Información del disco

Esto es especialmente útil cuando tenemos una colección antigua de MP3, FLAC u otros formatos y queremos convertirla en una biblioteca musical bien organizada.

📂 Una estructura ordenada

Por ejemplo, podemos terminar con una estructura similar a:

Música/
├── Pink Floyd/
│   └── The Dark Side of the Moon/
│       ├── 01 - Speak to Me.flac
│       ├── 02 - Breathe.flac
│       └── 03 - On the Run.flac
│
├── Queen/
│   └── A Night at the Opera/
│       ├── 01 - Death on Two Legs.flac
│       └── 02 - Lazing on a Sunday Afternoon.flac
│
└── Metallica/
    └── Black Album/
        ├── 01 - Enter Sandman.flac
        └── 02 - Sad but True.flac

🎯 La ventaja es que posteriormente aplicaciones como Navidrome pueden utilizar estos metadatos para mostrar correctamente artistas, álbumes, géneros y canciones.


🏷️ 2. Picard: etiquetas y organización automática

Una vez que agregamos nuestra música a MusicBrainz Picard, el programa analiza las canciones y busca coincidencias en la base de datos.

Podemos revisar la información encontrada y después guardar los cambios. 💾

Así evitamos tener que editar manualmente cientos de canciones.

✅ El flujo sería:

🎵 Música desordenada
⬇️
🔎 MusicBrainz Picard
⬇️
🏷️ Etiquetas correctas
⬇️
📂 Archivos organizados
⬇️
🎧 Navidrome
⬇️
📱 Escuchar nuestra música desde cualquier dispositivo


🎧 3. Instala Navidrome y crea tu propio servidor de música

Una vez que nuestra biblioteca está organizada, podemos utilizar Navidrome.

Navidrome es un servidor de música que nos permite acceder a nuestra colección desde diferentes dispositivos mediante una interfaz web y clientes compatibles.

En lugar de depender exclusivamente de una plataforma de streaming, podemos tener nuestra propia biblioteca almacenada en nuestro servidor. 🖥️🎶

Por ejemplo:

Servidor Linux
      │
      ├── 🎵 Música
      │
      └── 🎧 Navidrome
             │
       ┌─────┼─────┐
       ↓     ↓     ↓
     📱      💻     🌐
   Celular   PC    Navegador

Esto resulta especialmente interesante si ya tienes una gran colección de música comprada, descargada legalmente o digitalizada desde tus propios discos.


🟢 4. ¿Navidrome puede ser una alternativa a Spotify?

Sí, pero son conceptos diferentes. 😉

Spotify es un servicio de streaming con un enorme catálogo disponible mediante su plataforma.

Navidrome, en cambio, está pensado para reproducir tu propia colección de música desde tu servidor.

🟢 Con Navidrome tienes:

✅ Tu propia biblioteca musical
✅ Control sobre tus archivos
✅ Organización mediante metadatos
✅ Acceso desde navegador
✅ Acceso desde dispositivos móviles mediante clientes compatibles
✅ Soporte para diferentes formatos de audio
✅ Sin depender de un catálogo externo para tu música
✅ Posibilidad de alojarlo en tu propio servidor Linux

🔵 Con Spotify tienes:

🎵 Un enorme catálogo de música
🔎 Búsqueda de artistas y canciones
📻 Recomendaciones y playlists
🌐 Servicio administrado por Spotify
📱 Aplicaciones oficiales para diferentes dispositivos

Por eso, Navidrome no busca reemplazar completamente a Spotify, sino ofrecer una alternativa para quienes quieren tener su propia biblioteca musical bajo su control.


🔥 5. ¿Por qué utilizar Picard + Navidrome?

La combinación es muy interesante:

🏷️ MusicBrainz Picard

Se encarga de identificar, etiquetar y organizar nuestra música.

🎧 Navidrome

Se encarga de servir y reproducir esa biblioteca desde nuestro servidor.

Es decir:

Picard organiza tu música. Navidrome te permite disfrutarla. 🎶

Y todo esto puede funcionar perfectamente dentro de un servidor Linux. 🐧


💻 6. Una biblioteca musical completamente organizada

Después de realizar todo el proceso podemos tener algo parecido a:

/home/usuario/Música/
│
├── Artista 1/
│   ├── Álbum 1/
│   └── Álbum 2/
│
├── Artista 2/
│   ├── Álbum 1/
│   └── Álbum 2/
│
└── Artista 3/
    └── Álbum 1/

Navidrome leerá la información de los archivos y podremos navegar por:

🎤 Artistas
💿 Álbumes
🎵 Canciones
🎼 Géneros
⭐ Favoritos
📋 Listas de reproducción


📱 7. Escucha tu música desde el celular

Una de las partes más interesantes de tener Navidrome es poder acceder a nuestra biblioteca desde otros dispositivos.

Por ejemplo:

🖥️ PC → Navidrome
📱 Celular → Cliente compatible
💻 Laptop → Navegador
🌐 Otros dispositivos → Acceso al servidor

Así podemos convertir una computadora o servidor Linux en nuestro centro personal de música. 🎶🐧


🔐 8. Tu música bajo tu control

Una de las principales ventajas de este enfoque es que tú decides dónde almacenar tu biblioteca y cómo administrarla.

Puedes utilizar:

💽 Disco duro
🖥️ PC vieja
🗄️ Servidor casero
☁️ Servidor remoto

Y si configuras correctamente el acceso remoto y la seguridad, también puedes acceder a tu biblioteca cuando estés fuera de casa. 🌎📱


🚀 Conclusión

Si tienes una colección grande de música y quieres dejar atrás el caos de carpetas y archivos mal nombrados, MusicBrainz Picard + Navidrome es una excelente combinación para Linux. 🐧🎵

Picard se encarga de identificar y organizar tus canciones.

Navidrome convierte esa biblioteca en tu propio servicio de música.

Y aunque no reemplaza directamente el enorme catálogo de Spotify, sí puede ser una excelente alternativa para quienes quieren disfrutar de su propia colección musical desde cualquier dispositivo.

🎵 Tu música.
📂 Tu biblioteca.
🖥️ Tu servidor.
🔐 Tu control.

#Linux #Navidrome #MusicBrainzPicard #Picard #Spotify #Musica #LinuxMint #ServidorLinux #SelfHosting #MusicaDigital #FLAC #MP3 #BibliotecaMusical #OpenSource #LinuxServer

📦 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

Página 1 de 11

Creado con WordPress & Tema de Anders Norén