☕ 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
INSERTni unUPDATEdirecto 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,LIMITyOFFSETtardan 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) │
└────────────────────────────────────────────────────────┘
- 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
SELECTestrictamente protegidas. Cero sobrecarga, máxima velocidad. - 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 (
POSTpara altas,PATCHpara 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:
- Exclusividad para Artículos de Compra: Solo se listan y gestionan artículos que sean de compra (
PrchseItem = 'Y'). - 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').
- Artículo de Compra: Sí (
- Consecutivo Inteligente con 5 Ceros:
- Si el usuario escribe
rmmormm00000, 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).
- Si el usuario escribe
- 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).
- El Grupo de Artículos no puede ser un campo de texto; debe venir del catálogo maestro de grupos de SAP (
- 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.
- 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.
- 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:
- El usuario entra al campo y escribe:
rmm. - Da clic afuera o presiona Tabulador (
blur). - 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 - El servidor responde en menos de 20 milisegundos con:JSON
{ "status": 200, "prefix": "RMM", "nextCode": "RMM00003" } - 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?
- 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> - La vista hace una llamada GET al backend para traer la radiografía completa del artículo original (
getMaterial/ITEMCODE). - Setea la bandera oculta
#isNew = 1(para que el backend sepa que debe hacer unPOSTde creación, no unPATCH). - Desbloquea el campo
#ItemCode(prop('readonly', false)). - Prellena los combos Select2, la descripción, el tipo de artículo y el impuesto.
- Extrae el prefijo del código original (por ejemplo, si clonaste
RMM00045, extraeRMM). - Llama inmediatamente a
getNextItemCode('RMM')y prellena el código con el siguiente consecutivo libre disponible en SAP (por ejemplo,RMM00046). - Cambia el título del modal a:
Clonar Artículo (RMM00045). - ¡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
SYSTEMo un usuario administrativo de base de datos comoSAP_READER. - Para Service Layer, el usuario es un operador de SAP con licencia asignada (por ejemplo
managero un usuario técnico de integración comoB1_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:
- Eliminar
PriceUnitdel payload enviado al Service Layer. - Quitar el input manual de
PriceUnitde la vista para no confundir al usuario. - 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 (ascodesc).
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">×</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:
- Lectura directa por ODBC para búsquedas, filtros y reportes instantáneos.
- Escritura protegida por Service Layer para mantener la garantía, integridad contable y auditoría interna intactas.
- 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! 🚀
Deja un comentario