📌 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
- 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).
- 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,$filtery$expand. - 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.
- 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">×</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">×</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:
- Auditoría de Red: Abrir DevTools (
Ctrl + Shift + IoF12), ir a la pestaña Network, filtrar porjqueryy recargar conCtrl + F5. Debe existir exactamente un solo archivo de la librería jQuery descargado. - Inspección en Consola: Ejecutar en la consola interactiva:JavaScript
console.log("jQuery version:", $.fn.jquery); console.log("DataTables disponible:", typeof $.fn.DataTable === 'function');Si la segunda sentencia devuelvefalse, existe una sobreescritura de scripts en la página. - 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.
Deja un comentario