Si estás integrando SAP Business One Service Layer con una aplicación propia en PHP o CodeIgniter 4 para gestionar empleados y sus roles, es muy probable que en algún momento te topes con este comportamiento extraño: haces un PATCH a EmployeesInfo quitando un rol del arreglo EmployeeRolesInfoLines, Service Layer te responde 204 (éxito), pero al consultar de nuevo el empleado… el rol sigue ahí.
No es un bug de tu código. Es un comportamiento documentado (pero poco conocido) de cómo Service Layer maneja las colecciones hijas en las peticiones PATCH. En este post te explico la causa exacta y la solución con un ejemplo completo en PHP.
El síntoma
Un flujo típico para quitar un rol asignado a un empleado se ve así:
- Haces
GETaEmployeesInfo(empID)y traes la colecciónEmployeeRolesInfoLines. - Filtras en tu código el rol que quieres eliminar.
- Mandas un
PATCHde vuelta con el arreglo ya sin ese rol. - Service Layer responde
HTTP 204 No Content— es decir, “todo salió bien”. - Vuelves a consultar el empleado… y el rol que “eliminaste” sigue en la lista.
El código no marca ningún error. El log de tu aplicación dice que la operación fue exitosa. Y sin embargo, en SAP no pasó nada.
La causa real: cómo funciona PATCH en Service Layer
Aquí está la clave que casi nadie documenta claramente: PATCH en SAP Business One Service Layer nunca elimina elementos de una colección hija por el simple hecho de omitirlos del arreglo.
Por definición, PATCH es una actualización parcial: aplica los cambios que le mandas y conserva silenciosamente todo lo que no esté presente en el payload. Si en tu arreglo EmployeeRolesInfoLines ya no incluyes el rol que querías borrar, Service Layer no lo interpreta como “bórralo” — simplemente lo ignora y lo deja como estaba.
Este mismo comportamiento se ha reportado en la comunidad de desarrolladores de SAP al trabajar con líneas de documentos (DocumentLines en órdenes de venta), y aplica exactamente igual a cualquier colección hija expuesta por Service Layer, incluyendo los roles de un empleado.
La solución: el header B1S-ReplaceCollectionsOnPatch
Service Layer sí tiene una forma de decirle “trata este arreglo como el reemplazo completo de la colección, no como una actualización parcial”: el header HTTP B1S-ReplaceCollectionsOnPatch: true.
Al incluir este header en tu petición PATCH, cualquier elemento que no esté presente en el arreglo que envías sí se elimina de la colección hija en SAP. Es exactamente el comportamiento que necesitas para dar de baja un rol.
⚠️ Importante antes de usarlo
Este header cambia el comportamiento para todas las colecciones hijas incluidas en ese PATCH, no solo la que te interesa. Si tu payload incluyera más de una colección hija, cualquiera que no mandes completa corre el riesgo de perder elementos. La recomendación es:
- Usarlo únicamente en los endpoints donde manipulas directamente
EmployeeRolesInfoLines(agregar/quitar roles). - No incluirlo en el
PATCHgeneral de datos del empleado (nombre, departamento, estatus), donde no estás tocando colecciones hijas.
Ejemplo completo en PHP (CodeIgniter 4)
Así queda la función completa para eliminar un rol de un empleado, con el flujo GET → filtrar → PATCH con el header correcto:
php
public function removeEmployeeRole($empID, $roleID) {
helper('auth');
$userName = user()->username;
$empID = (int) $empID;
$roleID = (int) $roleID;
if ($empID <= 0 || $roleID <= 0) {
return $this->respond(['status' => 400, 'message' => 'Faltan datos'], 400);
}
$dataSL = $this->serviceLayerModel->first();
if (empty($dataSL)) {
return $this->respond(['status' => 500, 'message' => 'No hay configuración Service Layer'], 500);
}
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'], 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';
}
$getHeaders = [
"Accept: application/json",
"Content-Type: application/json",
"User-Agent: PHP",
"B1S-CaseInsensitive: true"
];
// Header clave: le dice a Service Layer que reemplace por completo
// la colección hija, en vez de solo actualizar/agregar elementos
$patchHeaders = array_merge($getHeaders, [
"B1S-ReplaceCollectionsOnPatch: true"
]);
// 1) GET de los roles actuales
$getUrl = $slRoot . "/EmployeesInfo({$empID})?" . http_build_query([
'$select' => 'EmployeeID,EmployeeRolesInfoLines'
]);
$ch = curl_init();
curl_setopt_array($ch, [
CURLOPT_URL => $getUrl,
CURLOPT_PORT => $dataSL['port'],
CURLOPT_RETURNTRANSFER => true,
CURLOPT_COOKIE => $cookie,
CURLOPT_SSL_VERIFYHOST => false,
CURLOPT_SSL_VERIFYPEER => false,
CURLOPT_HTTPHEADER => $getHeaders,
CURLOPT_TIMEOUT => 60
]);
$getResp = curl_exec($ch);
$getErr = curl_error($ch);
$getHttp = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
if ($getErr || $getHttp < 200 || $getHttp >= 300) {
return $this->respond([
'status' => 500,
'message' => 'Error al obtener roles actuales: ' . ($getErr ?: "HTTP $getHttp: $getResp")
], 500);
}
$current = json_decode($getResp, true);
$roles = $current['EmployeeRolesInfoLines'] ?? [];
// 2) Filtrar quitando el rol indicado
$newRoles = array_values(array_filter($roles, function ($r) use ($roleID) {
return (int) ($r['RoleID'] ?? 0) !== $roleID;
}));
if (count($newRoles) === count($roles)) {
return $this->respond([
'status' => 404,
'message' => 'El empleado no tiene asignado ese rol'
], 404);
}
// 3) PATCH con la colección ya sin ese rol + header de reemplazo total
$patchUrl = $slRoot . "/EmployeesInfo({$empID})";
$payload = ['EmployeeRolesInfoLines' => $newRoles];
$ch = curl_init();
curl_setopt_array($ch, [
CURLOPT_URL => $patchUrl,
CURLOPT_PORT => $dataSL['port'],
CURLOPT_RETURNTRANSFER => true,
CURLOPT_CUSTOMREQUEST => 'PATCH',
CURLOPT_POSTFIELDS => json_encode($payload),
CURLOPT_COOKIE => $cookie,
CURLOPT_SSL_VERIFYHOST => false,
CURLOPT_SSL_VERIFYPEER => false,
CURLOPT_HTTPHEADER => $patchHeaders,
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);
return $this->respond(['status' => $httpCode, 'message' => 'Error al eliminar', 'body' => $body], $httpCode);
}
$this->log->save([
"description" => "Eliminación de rol ID '$roleID' del empleado $empID (vía SL, EmployeesInfo/EmployeeRolesInfoLines)",
"user" => $userName
]);
// Normalizado a 200 siempre en éxito, sin importar si SAP regresó 204
return $this->respond(['status' => 200, 'message' => 'Rol eliminado correctamente'], 200);
}Puntos clave para recordar
PATCHen Service Layer nunca borra por omisión. Si un elemento de una colección hija no aparece en el arreglo que envías, se conserva tal cual.B1S-ReplaceCollectionsOnPatch: trueconvierte ese comportamiento en un reemplazo completo: lo que no incluyas en el arreglo, se elimina.- El patrón correcto para editar colecciones hijas (agregar, actualizar o quitar) sigue siendo GET → modificar en tu código → PATCH con la colección completa — la diferencia está en agregar este header cuando el objetivo es que también se eliminen elementos.
- Aplica este header únicamente en los endpoints que manipulan la colección específica; no lo agregues de forma global a todos tus
PATCHcontraEmployeesInfo. - Un
204 No Contentde Service Layer confirma que la petición fue válida, no que hizo lo que tú esperabas — siempre vale la pena verificar el resultado real, sobre todo mientras ajustas la lógica de colecciones hijas.
Si estás integrando otros módulos de SAP B1 vía Service Layer (órdenes de venta, líneas de documento, direcciones de socios de negocio), este mismo principio aplica: cualquier colección hija (DocumentLines, Addresses, ContactEmployees, etc.) se comporta igual ante un PATCH normal.

























