Herramientas Informaticas

Etiqueta: Service Layer Página 1 de 2

🌐 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! 🚀

Leer consultas Personalizadas en Service Layer SAP

Ahora lo importante, como ejecutar la consulta personalizada para leer la información

Hacer la siguiente petición y entre paréntesis ponemos el código de la consulta

https://localhost:50000/b1s/v1/SQLQueries('sql02')/List

En el body ponemos el JSON con los parámetros

{
 "ParamList": "listaPrecios=6"
}

Quedando de la siguiente forma

Modificar consultas Personalizadas en Service Layer SAP

Como no todo queda a la primera o se tenga que modificar por cualquier razón la consulta personalizada, les dejo el ejemplo básico de como modificar una consulta personalizada

Para modificar la consulta creada anteriormente hacemos la siguiente petición de tipo PATH

https://localhost:50000/b1s/v1/SQLQueries('sql02')

En el body metemos el JSON ya modificado

{
  "SqlCode":"sql02",
  "SqlName":"listPrices",
  "SqlText":"select a.ItemCode
                   ,a.PriceList
                   ,a.Price 
                   ,b.ListName
                   ,z.[ItemName]
            from [ITM1] a
                , [OPLN]	b
                , [OITM] z
            where a.[PriceList] =:listaPrecios
            and a.[Price] <>0
            AND a.[PriceList]  = b.[ListNum]
            AND a.[ItemCode]  = z.[ItemCode]
  ",
  "ParamList" : "listaPrecios"

}

Quedaría de la siguiente forma

Crear Consultas Personalizadas en Service Layer SAP

En el service layer también tenemos la forma para crear nuestras consultas personalizadas, aunque tiene algunas limitaciones pero nos pueden ser muy útiles.

Para ello utilizaremos SQLQueries y el ejemplo básico seria de la siguiente manera

Hacemos la petición POST

https://localhost:50000/b1s/v1/SQLQueries

En el body le metemos el contenido JSON

{
  
  "SqlCode":"sql02",
  "SqlName":"listPrices",
  "SqlText":"select ItemCode,PriceList 
            from [ITM1]  
            where [PriceList] =:listaPrecios ",
  "ParamList" : "listaPrecios"
  
  
}

Al final quedaría de la siguiente forma

Obtener la lista de precios de SAP a través Service Layer

Para traer la el catalogo de listas de precios usaremos la siguiente URL en GET

https://localhost:50000/b1s/v1/PriceLists?$select=PriceListNo,PriceListName&$filter=Active eq 'tYES'  and substringof('TRIT',PriceListNo) or substringof('TRIT',PriceListName)

Como pueden observar usamos la funcion substringof como el equivalente al like de SQL

Quedaría mas o menos de esta forma

Ahora la función quedaría de la siguiente forma

    /* =============================================
      MOSTRAR
      ============================================= */

    static public function ctrMostrar($cookie, $filtro) {


        $curl = curl_init();

        curl_setopt_array($curl, [
            CURLOPT_PORT => "50000",
            CURLOPT_URL => "https://localhost:50000/b1s/v1/PriceLists?%24select=PriceListNo%2CPriceListName&%24filter=Active%20eq%20'tYES'%20%20and%20substringof('$filtro'%2CPriceListNo)%20or%20substringof('$filtro'%2CPriceListName)",
            CURLOPT_RETURNTRANSFER => true,
            CURLOPT_ENCODING => "",
            CURLOPT_COOKIE => $cookie,
            CURLOPT_SSL_VERIFYHOST => false,
            CURLOPT_SSL_VERIFYPEER => false,
            CURLOPT_MAXREDIRS => 10,
            CURLOPT_TIMEOUT => 30,
            CURLOPT_HTTP_VERSION => CURL_HTTP_VERSION_1_1,
            CURLOPT_CUSTOMREQUEST => "GET",
            CURLOPT_HTTPHEADER => [
                "Accept: */*",
                "User-Agent: Thunder Client (https://www.thunderclient.com)"
            ],
        ]);

        $response = curl_exec($curl);
        $err = curl_error($curl);

        curl_close($curl);

        if ($err) {
            return "cURL Error #:" . $err;
        } else {
            return json_decode($response);
        }


        $response = json_decode($response);

        return $response;
    }

Y bien saben el resto es historia

Obtener lista de usuarios SAP a través de Service Layer

Lo que haremos en esta ocasión en hacer la consulta al service layer para usarlo en un Select2 vía AJAX y PHP primera mente usaremos la siguiente petición de tipo GET URL

https://localhost:50000/b1s/v1/Users?$select=InternalKey,UserCode,Group,UserName&$filter=Group eq 'ug_Regular'  and substringof('Julio',UserName)  or substringof('Julio',UserCode)&$orderby=InternalKey

Como ven usamos la función substringof que seria el equivalente en SQL al like ‘%%’

Quedando mas o menos asi

Ahora transformamos la petición a una función de PHP en la que recibimos la cookie y el filtro para buscar los usuarios

    /* =============================================
      MOSTRAR
      ============================================= */

    static public function ctrMostrar($cookie, $filtro) {


        $curl = curl_init();

        curl_setopt_array($curl, [
            CURLOPT_PORT => "50000",
            CURLOPT_URL => "https://localhost:50000/b1s/v1/Users?%24select=InternalKey%2CUserCode%2CGroup%2CUserName&%24filter=Group%20eq%20'ug_Regular'%20%20and%20substringof('$filtro'%2CUserName)%20%20or%20substringof('$filtro'%2CUserCode)&%24orderby=InternalKey",
            CURLOPT_RETURNTRANSFER => true,
            CURLOPT_ENCODING => "",
            CURLOPT_COOKIE => $cookie,
            CURLOPT_SSL_VERIFYHOST => false,
            CURLOPT_SSL_VERIFYPEER => false,
            CURLOPT_MAXREDIRS => 10,
            CURLOPT_TIMEOUT => 30,
            CURLOPT_HTTP_VERSION => CURL_HTTP_VERSION_1_1,
            CURLOPT_CUSTOMREQUEST => "GET",
            CURLOPT_HTTPHEADER => [
                "Accept: */*",
                "User-Agent: Thunder Client (https://www.thunderclient.com)"
            ],
        ]);

        $response = curl_exec($curl);
        $err = curl_error($curl);

        curl_close($curl);

        if ($err) {
            return "cURL Error #:" . $err;
        } else {
            return json_decode($response);
        }


        $response = json_decode($response);

        return $response;
    }

Ahora en el archivo de consulta se usaría de la siguiente forma, hacemos el Login, consultamos, hacemos el Logout, y formamos la cadena JSON de la siguiente forma

   $busqueda = $_POST["searchTerm"];

    $conexionSAP = ServiceLayer::login();

    $cookie = "B1SESSION=" . $conexionSAP->SessionId . ";  ROUTEID=.node1";

    $usuariosSAP = ControladorUsuariosSAP::ctrMostrar($cookie, $busqueda);

    $usuariosSAPLista = $usuariosSAP->value;

    $usuariosSAP = ServiceLayer::logout($cookie);

    $jsonVariable = ' { "results": [';

    foreach ($usuariosSAPLista as $keyUsuarios1 => $valueUsuarios1) {


        $jsonVariable .= ' {
      "id": "' . $valueUsuarios1->UserCode . '",
      "text": "' . utf8_encode($valueUsuarios1->UserCode . " - " . $valueUsuarios1->UserName) . '"
    },';
    }

    $jsonVariable = substr($jsonVariable, 0, -1);

    $jsonVariable .= ' ]
}';

    echo ($jsonVariable);

Y listo ya esto obtenemos los datos requeridos

Haciendo Logout en Service Layer en SAP y pasar el código en PHP 8.3

Bien ahora vamos a mostrar como hacer el logout, es decir, cerrar la sesión, vamos hacer la petición tipo GET con la siguiente URL

https://localhost:50000/b1s/v1/Logout

Quedando de la siguiente forma

La función en PHP seria la siguiente

 static public function logout($cookie) {

        $curl = curl_init();

        curl_setopt_array($curl, [
            CURLOPT_PORT => "50000",
            CURLOPT_URL => "https://hanagusa:50000/b1s/v1/Logout",
            CURLOPT_RETURNTRANSFER => true,
            CURLOPT_ENCODING => "",
            CURLOPT_MAXREDIRS => 10,
            CURLOPT_TIMEOUT => 30,
            CURLOPT_COOKIE => $cookie,
            CURLOPT_SSL_VERIFYHOST => false,
            CURLOPT_SSL_VERIFYPEER => false,
            CURLOPT_HTTP_VERSION => CURL_HTTP_VERSION_1_1,
            CURLOPT_CUSTOMREQUEST => "POST",
            CURLOPT_HTTPHEADER => [
                "Accept: */*",
                "User-Agent: Gisa Web Client (https://www.thunderclient.com)"
            ],
        ]);

        $response = curl_exec($curl);
        $err = curl_error($curl);

        curl_close($curl);

        if ($err) {
            return "cURL Error #:" . $err;
        } else {
            return $response;
        }
    }

Ya al momento de usarlo en PHP desde el Login has ta el Logout seria de la siguiente forma

$conexionSAP = ServiceLayer::login();

// Construimos la cookie

$cookie = "B1SESSION=" . $conexionSAP->SessionId . ";  ROUTEID=.node1";

$sucursalesSAP = ControladorSucursalSAP::ctrMostrar($cookie);

$sucursalesSAP = $sucursalesSAP->value;

// Cerramos la session enviando la Cookie
$conexionSAP = ServiceLayer::logout($cookie);

Ejemplos Service Layer SAP

A continuación pondremos a su disposición algunos ejemplos de peticiones al Service Layer del SAP así como el código en PHP

  1. Postman en Visual Studio Code
  2. Haciendo Login en Service Layer en SAP y pasar el código en PHP 8.3
  3. Haciendo Logout en Service Layer en SAP y pasar el código en PHP 8.3
  4. Obtener lista de usuarios SAP a través de Service Layer
  5. Obtener la lista de precios de SAP a través Service Layer
  6. Crear Consultas Personalizadas en Service Layer SAP
  7. Modificar consultas Personalizadas en Service Layer SAP
  8. Leer consultas Personalizadas en Service Layer SAP

Página 1 de 2

Creado con WordPress & Tema de Anders Norén