Appearance
Fase 7 — Perfil Backend
Módulo: Portal de Clientes Tipo: Process Estado: Implementado Fecha: 2026-04-27
DOCUMENTACIÓN RETROSPECTIVA — Generada a partir del código implementado el 2026-04-27.
Descripción
Sub-módulo del Portal de Clientes que permite al usuario autenticado consultar y actualizar sus datos de contacto. Combina información del registro portal_users (credenciales y datos del portal) con el nombre del cliente proveniente de la entidad ordcon del ERP.
El perfil expone una vista mixta de campos:
- Identidad (read-only):
portal_user_id,tenant_id,sucursal_id,nombre,dni,cuit. - Contacto (editables):
email,telefono.
El nombre del cliente (nombre) NO se persiste en portal_users; se resuelve en cada lectura mediante un lookup contra ordcon usando ordcon_id como vínculo.
Endpoints
Todos los endpoints viven bajo el prefijo /backend/portal/account/perfil y están protegidos por PortalJwtMiddleware. El controller recibe los datos del usuario autenticado vía el atributo de request portal_claims (instancia de JwtClaims).
GET /backend/portal/account/perfil
Obtiene el perfil completo del usuario autenticado.
- Autenticación: requerida (
PortalJwtMiddleware). El controller tomauserIddesdeJwtClaims. - Request body: ninguno.
- Response body (
200 OK):{ "data": { ...PerfilData } }.
| Campo | Tipo | Origen |
|---|---|---|
portal_user_id | int | portal_users.id |
tenant_id | int | portal_users.tenant_id |
sucursal_id | int | portal_users.sucursal_id |
nombre | string | null | Lookup ordcon.cnom por ordcon_id |
dni | string | null | portal_users.dni |
cuit | string | null | portal_users.cuit |
email | string | null | portal_users.email |
telefono | string | null | portal_users.telefono |
Códigos de respuesta:
| Código | Caso |
|---|---|
| 200 | Perfil obtenido correctamente. |
| 401 | El request no incluye portal_claims válidos (Unauthorized). |
| 404 | El userId extraído del JWT no corresponde a un portal_user existente (UserNotFoundException). |
PUT /backend/portal/account/perfil
Actualiza datos de contacto del usuario autenticado. Ambos campos son opcionales: solo se modifican los provistos (los null se ignoran).
- Autenticación: requerida. El controller toma
userIdysucursalIddesdeJwtClaims. - Request body: JSON con los campos opcionales del DTO
UpdatePerfilRequest.
| Campo | Tipo | Obligatorio | Descripción |
|---|---|---|---|
email | string | null | No | Nuevo email. Si es null no se modifica. |
telefono | string | null | No | Nuevo teléfono. Si es null no se modifica. |
- Response body (
200 OK):{ "data": null }.
Códigos de respuesta:
| Código | Caso |
|---|---|
| 200 | Actualización exitosa (incluso con body vacío, el servicio se invoca con ambos campos en null). |
| 401 | El request no incluye portal_claims válidos o no se pudo extraer userId/sucursalId (Unauthorized). |
| 404 | El userId no corresponde a un portal_user existente (UserNotFoundException). |
| 422 | El email solicitado ya está en uso por otro portal_user dentro de la misma sucursal (EmailAlreadyInUseException). |
Modelo de Datos
PerfilData (DTO de respuesta)
DTO inmutable (final readonly) en Portal\Application\Perfil\DTOs\PerfilData. Todos los campos se exponen vía toArray() con las mismas claves listadas abajo.
| Campo | Tipo | Origen / Descripción |
|---|---|---|
portal_user_id | int | Identificador del usuario en la tabla portal_users. |
tenant_id | int | Tenant al que pertenece el usuario (portal_users.tenant_id). |
sucursal_id | int | Sucursal a la que pertenece el usuario (portal_users.sucursal_id). |
nombre | string | null | Nombre del cliente (ordcon.cnom) resuelto por ordcon_id. null si el usuario no tiene ordcon_id o si el lookup no encuentra el ordcon. |
dni | string | null | portal_users.dni. Read-only desde el portal. |
cuit | string | null | portal_users.cuit. Read-only desde el portal. |
email | string | null | portal_users.email. Editable. |
telefono | string | null | portal_users.telefono. Editable. |
UpdatePerfilRequest (DTO de entrada)
DTO en Portal\Presentation\Perfil\DTOs\UpdatePerfilRequest. Construido vía fromArray($body) desde el parsedBody del request.
| Campo | Tipo | Obligatorio | Descripción |
|---|---|---|---|
email | string | null | No | Si está presente y distinto del actual, se valida unicidad y se actualiza. |
telefono | string | null | No | Si está presente, se actualiza directamente sin validaciones de unicidad. |
Claves ausentes en el body se interpretan como null (no actualizar). El DTO no aplica validaciones de formato; eso queda delegado al service.
Lógica de Negocio
getPerfil(int $userId): PerfilData
Implementado en PortalPerfilService::getPerfil. Flujo:
- Carga el
PortalUserdesdePortalUserRepositoryInterface::findById($userId). - Si no existe, lanza
UserNotFoundException(mapeado a404por el controller). - Si el usuario tiene
ordcon_id, invocaOrdconLookupInterface::findNombreByOrdconId($ordconId)para resolver el nombre del cliente. Siordcon_idesnull, el lookup se omite ynombrequeda ennull. - Si el lookup retorna
null(elordcon_idapunta a un registro inexistente),nombretambién queda ennull. No se considera error — el resto del perfil se devuelve igual. - Construye y retorna
PerfilDatacon los campos del usuario y el nombre resuelto.
Esta operación es de solo lectura: no abre transacción ni registra audit log.
updatePerfil(int $userId, int $sucursalId, ?string $email, ?string $telefono): void
Implementado en PortalPerfilService::updatePerfil. Flujo:
- Carga el
PortalUserporfindById($userId). Si no existe, lanzaUserNotFoundException. - Validación de unicidad de email (solo si
$email !== nullY$email !== $user->getEmail()):- Llama a
findByEmail($email, $sucursalId). - Si retorna un usuario con id distinto al actual, lanza
EmailAlreadyInUseException(mapeada a422). - El alcance de la unicidad es por sucursal, no global.
- Si el email es igual al actual, NO se realiza la verificación (optimización).
- Si
$emailesnull, NO se realiza la verificación.
- Llama a
- Persistencia transaccional (envuelta en
ConnectionManager::beginTransaction('principal')):- Invoca
PortalUserRepositoryInterface::updateContactInfo($userId, $email, $telefono). El repositorio decide internamente cómo manejar losnull(semántica de "no actualizar"). - Registra audit log via
Auditable::registrarAuditoria('UPDATE', 'PORTAL_PERFIL', 'portal_users', (string) $userId). commit()si todo sale bien;rollBack()ante cualquierThrowable.
- Invoca
- Retorna
void— el endpoint responde200condata: null.
Notas de comportamiento observadas:
- Un body vacío (
{}) llega al service comoupdatePerfil($userId, $sucursalId, null, null)y atraviesa el flujo sin error: no hay validación de unicidad yupdateContactInfose invoca con ambos campos ennull. - Si solo se actualiza
telefono, NO se ejecuta lookup de email (verificado por test). - Las excepciones del service son capturadas por el controller y traducidas a códigos HTTP. Cualquier otra excepción no controlada se propaga.
Relación portal_user ↔ ordcon
El modelo es N:1: varios portal_users pueden vincularse al mismo ordcon (cliente del ERP), pero cada portal_user apunta a un único ordcon mediante ordcon_id.
Implicancias para el sub-módulo Perfil:
- El nombre del cliente NO se duplica en
portal_users: vive enordcon.cnomy se resuelve en cada lectura. Esto evita inconsistencias si el ERP modifica el nombre. - Campos read-only desde el portal:
nombre,dni,cuit,tenant_id,sucursal_id,portal_user_id. No existe endpoint para modificarlos desde el portal — son responsabilidad del ERP o del proceso de alta del usuario. ordcones entidad de schema empresa: el lookup es global a nivel empresa, no filtrado por sucursal (verOrdconLookupInterface).ordcon_idpuede sernull: el portal tolera usuarios sin vínculo al ERP (devuelvenombre: null). El portal no rompe si el vínculo se pierde o si elordconreferenciado fue eliminado.
Migración
migrations/migrations/tenancy/20260427100000_add_telefono_to_portal_users.php
- Clase:
AddTelefonoToPortalUsers extends ConfigurableMigration. - Tipo:
MigrationType::BASE(cambio estructural). - Niveles:
LEVEL_EMPRESA(la tablaportal_usersvive en schema empresa). up():ALTER TABLE portal_users ADD COLUMN IF NOT EXISTS telefono VARCHAR(30) NULL.down():ALTER TABLE portal_users DROP COLUMN IF EXISTS telefono.shouldExecute(): requiere nivel correcto y existencia previa deportal_users.
El uso de IF NOT EXISTS / IF EXISTS permite reejecución idempotente.
Tests
Tests/Unit/Portal/Perfil/PortalPerfilServiceTest.php (11 tests)
Mocks: PortalUserRepositoryInterface, OrdconLookupInterface, ConnectionManager, AuditLogger. Casos cubiertos:
getPerfilconordcon_idválido → resuelvenombredesde lookup.getPerfilconordcon_id = null→nombre = null, no se invoca el lookup.getPerfilconordcon_idinexistente (lookup retornanull) →nombre = null.getPerfilconuserIdinexistente →UserNotFoundException.updatePerfilcon email distinto → verifica unicidad y actualiza.updatePerfilsolo contelefono→ no consultafindByEmail.updatePerfilconemail = null→ no consultafindByEmail.updatePerfilcon email ya tomado por otro usuario →EmailAlreadyInUseException.updatePerfilcon email igual al actual → no consultafindByEmail.updatePerfilconuserIdinexistente →UserNotFoundException.updatePerfilcon email y teléfono → actualiza ambos.
Tests/Unit/Portal/Perfil/PortalPerfilControllerTest.php (13 tests)
Mock: PortalPerfilServiceInterface. Construye requests reales con Slim\Psr7\Factory. Casos cubiertos:
GETcon claims válidos →200y body condatapoblada.GETsin atributoportal_claims→401 Unauthorized.GETconportal_claimsde tipo incorrecto →401 Unauthorized.GETcon servicio que lanzaUserNotFoundException→404.GETconnombre = null(sinordcon_id) →200,data.nombre = null.PUTcon email + telefono válidos →200,data: null.PUTsin claims →401.PUTcon claims de tipo incorrecto →401.PUTcon email ya en uso →422.PUTcon usuario inexistente →404.PUTsolo con email → invoca service contelefono = null.PUTsolo con teléfono → invoca service conemail = null.PUTcon body vacío → invoca service con ambos ennull, responde200.
Grupos PHPUnit: unit, portal, perfil. Ejecución dirigida:
vendor/bin/phpunit --group perfilVer también
- Endpoints del Portal de Clientes
- Controller:
Modules/Portal/Presentation/Perfil/Controllers/PortalPerfilController.php - Service:
Modules/Portal/Application/Perfil/Services/PortalPerfilService.php - Contrato del service:
Modules/Portal/Contracts/PortalPerfilServiceInterface.php - Contrato del lookup ERP:
Modules/Portal/Contracts/OrdconLookupInterface.php - DTO de respuesta:
Modules/Portal/Application/Perfil/DTOs/PerfilData.php - DTO de request:
Modules/Portal/Presentation/Perfil/DTOs/UpdatePerfilRequest.php - Aggregate:
Modules/Portal/Domain/Auth/PortalUser.php - Rutas:
Modules/Portal/Infrastructure/Http/Routes/PerfilRoutes.php - Migración:
migrations/migrations/tenancy/20260427100000_add_telefono_to_portal_users.php - Tests:
Tests/Unit/Portal/Perfil/PortalPerfilServiceTest.php,Tests/Unit/Portal/Perfil/PortalPerfilControllerTest.php
NOTA IMPORTANTE: Esta documentación fue generada de manera retrospectiva a partir del código implementado. Validar con stakeholders antes de considerarla final.