Skip to content

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 toma userId desde JwtClaims.
  • Request body: ninguno.
  • Response body (200 OK): { "data": { ...PerfilData } }.
CampoTipoOrigen
portal_user_idintportal_users.id
tenant_idintportal_users.tenant_id
sucursal_idintportal_users.sucursal_id
nombrestring | nullLookup ordcon.cnom por ordcon_id
dnistring | nullportal_users.dni
cuitstring | nullportal_users.cuit
emailstring | nullportal_users.email
telefonostring | nullportal_users.telefono

Códigos de respuesta:

CódigoCaso
200Perfil obtenido correctamente.
401El request no incluye portal_claims válidos (Unauthorized).
404El 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 userId y sucursalId desde JwtClaims.
  • Request body: JSON con los campos opcionales del DTO UpdatePerfilRequest.
CampoTipoObligatorioDescripción
emailstring | nullNoNuevo email. Si es null no se modifica.
telefonostring | nullNoNuevo teléfono. Si es null no se modifica.
  • Response body (200 OK): { "data": null }.

Códigos de respuesta:

CódigoCaso
200Actualización exitosa (incluso con body vacío, el servicio se invoca con ambos campos en null).
401El request no incluye portal_claims válidos o no se pudo extraer userId/sucursalId (Unauthorized).
404El userId no corresponde a un portal_user existente (UserNotFoundException).
422El 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.

CampoTipoOrigen / Descripción
portal_user_idintIdentificador del usuario en la tabla portal_users.
tenant_idintTenant al que pertenece el usuario (portal_users.tenant_id).
sucursal_idintSucursal a la que pertenece el usuario (portal_users.sucursal_id).
nombrestring | nullNombre del cliente (ordcon.cnom) resuelto por ordcon_id. null si el usuario no tiene ordcon_id o si el lookup no encuentra el ordcon.
dnistring | nullportal_users.dni. Read-only desde el portal.
cuitstring | nullportal_users.cuit. Read-only desde el portal.
emailstring | nullportal_users.email. Editable.
telefonostring | nullportal_users.telefono. Editable.

UpdatePerfilRequest (DTO de entrada)

DTO en Portal\Presentation\Perfil\DTOs\UpdatePerfilRequest. Construido vía fromArray($body) desde el parsedBody del request.

CampoTipoObligatorioDescripción
emailstring | nullNoSi está presente y distinto del actual, se valida unicidad y se actualiza.
telefonostring | nullNoSi 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:

  1. Carga el PortalUser desde PortalUserRepositoryInterface::findById($userId).
  2. Si no existe, lanza UserNotFoundException (mapeado a 404 por el controller).
  3. Si el usuario tiene ordcon_id, invoca OrdconLookupInterface::findNombreByOrdconId($ordconId) para resolver el nombre del cliente. Si ordcon_id es null, el lookup se omite y nombre queda en null.
  4. Si el lookup retorna null (el ordcon_id apunta a un registro inexistente), nombre también queda en null. No se considera error — el resto del perfil se devuelve igual.
  5. Construye y retorna PerfilData con 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:

  1. Carga el PortalUser por findById($userId). Si no existe, lanza UserNotFoundException.
  2. Validación de unicidad de email (solo si $email !== null Y $email !== $user->getEmail()):
    • Llama a findByEmail($email, $sucursalId).
    • Si retorna un usuario con id distinto al actual, lanza EmailAlreadyInUseException (mapeada a 422).
    • 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 $email es null, NO se realiza la verificación.
  3. Persistencia transaccional (envuelta en ConnectionManager::beginTransaction('principal')):
    • Invoca PortalUserRepositoryInterface::updateContactInfo($userId, $email, $telefono). El repositorio decide internamente cómo manejar los null (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 cualquier Throwable.
  4. Retorna void — el endpoint responde 200 con data: null.

Notas de comportamiento observadas:

  • Un body vacío ({}) llega al service como updatePerfil($userId, $sucursalId, null, null) y atraviesa el flujo sin error: no hay validación de unicidad y updateContactInfo se invoca con ambos campos en null.
  • 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_userordcon

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 en ordcon.cnom y 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.
  • ordcon es entidad de schema empresa: el lookup es global a nivel empresa, no filtrado por sucursal (ver OrdconLookupInterface).
  • ordcon_id puede ser null: el portal tolera usuarios sin vínculo al ERP (devuelve nombre: null). El portal no rompe si el vínculo se pierde o si el ordcon referenciado 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 tabla portal_users vive 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 de portal_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:

  • getPerfil con ordcon_id válido → resuelve nombre desde lookup.
  • getPerfil con ordcon_id = nullnombre = null, no se invoca el lookup.
  • getPerfil con ordcon_id inexistente (lookup retorna null) → nombre = null.
  • getPerfil con userId inexistente → UserNotFoundException.
  • updatePerfil con email distinto → verifica unicidad y actualiza.
  • updatePerfil solo con telefono → no consulta findByEmail.
  • updatePerfil con email = null → no consulta findByEmail.
  • updatePerfil con email ya tomado por otro usuario → EmailAlreadyInUseException.
  • updatePerfil con email igual al actual → no consulta findByEmail.
  • updatePerfil con userId inexistente → UserNotFoundException.
  • updatePerfil con 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:

  • GET con claims válidos → 200 y body con data poblada.
  • GET sin atributo portal_claims401 Unauthorized.
  • GET con portal_claims de tipo incorrecto → 401 Unauthorized.
  • GET con servicio que lanza UserNotFoundException404.
  • GET con nombre = null (sin ordcon_id) → 200, data.nombre = null.
  • PUT con email + telefono válidos → 200, data: null.
  • PUT sin claims → 401.
  • PUT con claims de tipo incorrecto → 401.
  • PUT con email ya en uso → 422.
  • PUT con usuario inexistente → 404.
  • PUT solo con email → invoca service con telefono = null.
  • PUT solo con teléfono → invoca service con email = null.
  • PUT con body vacío → invoca service con ambos en null, responde 200.

Grupos PHPUnit: unit, portal, perfil. Ejecución dirigida:

vendor/bin/phpunit --group perfil

Ver 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.