feat: agregar CRUD de retencion otros empleadores

Nuevo modulo otrosempleadores: entidad, repositorio, service y controller
para registrar retenciones declaradas por otros empleadores mes a mes.
El listado por anio devuelve numeroDocumento + personal a nivel raiz y
data sin campos repetidos por fila.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01F9cnQJbPqjJ8d7iSQD8ZC5
parent 9e4d5fb3
# API Retención Otros Empleadores — Guía para el Frontend
Base path: `/api/v1/retencion-otros-empleadores`
Swagger UI: `/docs`
CRUD sobre `planilla.retencion_otros_empleadores`: retenciones declaradas por **otros
empleadores** distintos de Saco Oliveros, registradas manualmente mes a mes por trabajador. Clave
primaria compuesta: `documento_trabajador + anio + mes`.
---
## 1. GET `/api/v1/retencion-otros-empleadores/{documentoTrabajador}/{anio}` — Historial anual
Todos los meses registrados para un trabajador en un año.
### Path params
| Parámetro | Tipo | Notas |
|---|---|---|
| `documentoTrabajador` | String | DNI/CE del trabajador |
| `anio` | Integer | Año a consultar |
### Ejemplo
```
GET /api/v1/retencion-otros-empleadores/09742695/2026
```
### Response — `RetencionOtrosEmpleadoresListadoDTO`
Objeto con `numeroDocumento` + `personal` (nombre del trabajador) a nivel raíz, y `data` (array
ordenado por mes, **sin** documento ni nombre repetidos por fila). **No es 404 si `data` viene
vacío** — significa que el trabajador no tiene meses registrados ese año. `numeroDocumento` y
`personal` se envían **siempre**, tenga o no registros.
```json
{
"numeroDocumento": "09742695",
"personal": "PALOMINO ANICAMA YULIANA MARLENY",
"data": [
{
"anio": 2026,
"mes": 3,
"importe": 1500.00,
"retencion": 120.00,
"fechaRegistro": "2026-03-05T10:30:00"
}
]
}
```
Sin registros (mismo trabajador, año sin data):
```json
{
"numeroDocumento": "09742695",
"personal": "PALOMINO ANICAMA YULIANA MARLENY",
"data": []
}
```
| Campo | Tipo | Descripción |
|---|---|---|
| `numeroDocumento` | String | Documento consultado (eco del path param) |
| `personal` | String | Apellido + nombre desde `nuevo.personal` (join por `numero_documento`). `null` si no hay match en `personal`. Presente aunque `data` esté vacío |
| `data` | Array | Meses registrados, ordenados por mes |
#### Objeto de `data[]` — `RetencionOtrosEmpleadoresItemDTO`
| Campo | Tipo | Descripción |
|---|---|---|
| `anio` | Integer | Año del registro |
| `mes` | Integer | 1–12 |
| `importe` | BigDecimal | Importe declarado por el otro empleador |
| `retencion` | BigDecimal | Retención declarada por el otro empleador |
| `fechaRegistro` | LocalDateTime | Fecha/hora en que se creó el registro (la setea el backend) |
---
## 2. POST `/api/v1/retencion-otros-empleadores/{documentoTrabajador}/{anio}/{mes}` — Crear
### Path params
| Parámetro | Tipo | Notas |
|---|---|---|
| `documentoTrabajador` | String | |
| `anio` | Integer | |
| `mes` | Integer | 1–12 |
### Body — `RetencionOtrosEmpleadoresRequestDTO`
```json
{
"importe": 1500.00,
"retencion": 120.00
}
```
Ambos campos obligatorios.
### Ejemplo
```
POST /api/v1/retencion-otros-empleadores/09742695/2026/3
Content-Type: application/json
{ "importe": 1500.00, "retencion": 120.00 }
```
### Response
`201 Created` con el `RetencionOtrosEmpleadoresDTO` creado (`fechaRegistro` la asigna el backend).
`409 Conflict` si ya existe un registro para esa combinación `documentoTrabajador + anio + mes`
(usar PUT para modificarlo).
---
## 3. PUT `/api/v1/retencion-otros-empleadores/{documentoTrabajador}/{anio}/{mes}` — Actualizar
Mismo path y body que el POST. Reemplaza `importe` y `retencion` del registro existente
(`fechaRegistro` no cambia).
### Ejemplo
```
PUT /api/v1/retencion-otros-empleadores/09742695/2026/3
Content-Type: application/json
{ "importe": 1600.00, "retencion": 130.00 }
```
### Response
`200 OK` con el `RetencionOtrosEmpleadoresDTO` actualizado.
`404` si no existe el registro para esa clave.
---
## 4. DELETE `/api/v1/retencion-otros-empleadores/{documentoTrabajador}/{anio}/{mes}` — Eliminar
### Ejemplo
```
DELETE /api/v1/retencion-otros-empleadores/09742695/2026/3
```
### Response
`204 No Content`.
`404` si no existe el registro para esa clave.
---
## 5. Manejo de errores
Mismo shape que el resto del API, sin stack traces:
```json
{
"timestamp": "2026-09-11T15:00:00Z",
"status": 404,
"message": "descripción del error"
}
```
| Status | Causa |
|---|---|
| `404` | PUT o DELETE sobre una clave (`documentoTrabajador`+`anio`+`mes`) que no existe |
| `409` | POST sobre una clave que ya existe |
| `400` | `mes` fuera de rango (1–12) |
| `400` | Body inválido: falta `importe` o `retencion` |
| `400` | Falta un parámetro obligatorio en el path |
El frontend debe leer siempre `message` para mostrar el error al usuario.
---
## 6. `personal` / `nombreCompleto`
Se resuelve con un join a `nuevo.personal` por `numero_documento`. Esa tabla no tiene
`numero_documento` único (hay trabajadores con varios `codper` y algunos documentos placeholder
repetidos muchas veces) — se toma el registro con el `codper` más alto. Si no hay match, el campo
sale `null`.
El listado por año (`GET .../{documentoTrabajador}/{anio}`) resuelve y devuelve este nombre en
`personal` (nivel raíz de la respuesta, junto a `numeroDocumento`) **independientemente de si hay
filas en `data`**. POST y PUT siguen devolviendo el shape plano (`RetencionOtrosEmpleadoresDTO`,
con `documentoTrabajador` y `nombreCompleto` en el propio objeto) — no cambiaron.
## 7. Notas para el frontend
- No hay paginación: el listado por año trae todos los meses de una vez (máximo 12 filas).
- No hay endpoint de búsqueda por documento sin año — siempre se pide `anio` junto con
`documentoTrabajador`.
- Sin autenticación todavía (igual que el resto del API — se integrará con
Trismegisto/Saco Oliveros más adelante).
package so.planilla_be.otrosempleadores.controller;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.validation.Valid;
import jakarta.validation.constraints.Max;
import jakarta.validation.constraints.Min;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.PutMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import so.planilla_be.otrosempleadores.dto.RetencionOtrosEmpleadoresDTO;
import so.planilla_be.otrosempleadores.dto.RetencionOtrosEmpleadoresListadoDTO;
import so.planilla_be.otrosempleadores.dto.RetencionOtrosEmpleadoresRequestDTO;
import so.planilla_be.otrosempleadores.service.RetencionOtrosEmpleadoresService;
@Tag(name = "Retención otros empleadores", description = "CRUD de retenciones declaradas por otros empleadores")
@Validated
@RestController
@RequestMapping("/api/v1/retencion-otros-empleadores")
public class RetencionOtrosEmpleadoresController {
private final RetencionOtrosEmpleadoresService service;
public RetencionOtrosEmpleadoresController(RetencionOtrosEmpleadoresService service) {
this.service = service;
}
@Operation(summary = "Meses del año declarados para un trabajador")
@GetMapping("/{documentoTrabajador}/{anio}")
public RetencionOtrosEmpleadoresListadoDTO listarPorAnio(
@PathVariable String documentoTrabajador,
@PathVariable Integer anio) {
return service.listarPorAnio(documentoTrabajador, anio);
}
@Operation(summary = "Registrar retención de un mes")
@PostMapping("/{documentoTrabajador}/{anio}/{mes}")
public ResponseEntity<RetencionOtrosEmpleadoresDTO> crear(
@PathVariable String documentoTrabajador,
@PathVariable Integer anio,
@PathVariable @Min(1) @Max(12) Integer mes,
@RequestBody @Valid RetencionOtrosEmpleadoresRequestDTO request) {
return ResponseEntity.status(HttpStatus.CREATED)
.body(service.crear(documentoTrabajador, anio, mes, request));
}
@Operation(summary = "Actualizar retención de un mes")
@PutMapping("/{documentoTrabajador}/{anio}/{mes}")
public RetencionOtrosEmpleadoresDTO actualizar(
@PathVariable String documentoTrabajador,
@PathVariable Integer anio,
@PathVariable @Min(1) @Max(12) Integer mes,
@RequestBody @Valid RetencionOtrosEmpleadoresRequestDTO request) {
return service.actualizar(documentoTrabajador, anio, mes, request);
}
@Operation(summary = "Eliminar retención de un mes")
@DeleteMapping("/{documentoTrabajador}/{anio}/{mes}")
public ResponseEntity<Void> eliminar(
@PathVariable String documentoTrabajador,
@PathVariable Integer anio,
@PathVariable @Min(1) @Max(12) Integer mes) {
service.eliminar(documentoTrabajador, anio, mes);
return ResponseEntity.noContent().build();
}
}
package so.planilla_be.otrosempleadores.dto;
import java.math.BigDecimal;
import java.time.LocalDateTime;
public record RetencionOtrosEmpleadoresDTO(
String documentoTrabajador,
String nombreCompleto,
Integer anio,
Integer mes,
BigDecimal importe,
BigDecimal retencion,
LocalDateTime fechaRegistro
) {}
package so.planilla_be.otrosempleadores.dto;
import java.math.BigDecimal;
import java.time.LocalDateTime;
public record RetencionOtrosEmpleadoresItemDTO(
Integer anio,
Integer mes,
BigDecimal importe,
BigDecimal retencion,
LocalDateTime fechaRegistro
) {}
package so.planilla_be.otrosempleadores.dto;
import java.util.List;
public record RetencionOtrosEmpleadoresListadoDTO(
String numeroDocumento,
String personal,
List<RetencionOtrosEmpleadoresItemDTO> data
) {}
package so.planilla_be.otrosempleadores.dto;
import jakarta.validation.constraints.NotNull;
import java.math.BigDecimal;
public record RetencionOtrosEmpleadoresRequestDTO(
@NotNull BigDecimal importe,
@NotNull BigDecimal retencion
) {}
package so.planilla_be.otrosempleadores.entity;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.Id;
import jakarta.persistence.Table;
import lombok.Getter;
import lombok.Setter;
@Getter
@Setter
@Entity
@Table(name = "personal", schema = "nuevo")
public class Personal {
@Id
@Column(name = "codper")
private Integer codper;
@Column(name = "numero_documento", length = 10)
private String numeroDocumento;
@Column(name = "apellido", length = 50)
private String apellido;
@Column(name = "nombre", length = 100)
private String nombre;
}
package so.planilla_be.otrosempleadores.entity;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.Id;
import jakarta.persistence.IdClass;
import jakarta.persistence.Table;
import lombok.Getter;
import lombok.Setter;
import java.math.BigDecimal;
import java.time.LocalDateTime;
@Getter
@Setter
@Entity
@Table(name = "retencion_otros_empleadores")
@IdClass(RetencionOtrosEmpleadoresId.class)
public class RetencionOtrosEmpleadores {
@Id
@Column(name = "documento_trabajador", length = 15)
private String documentoTrabajador;
@Id
@Column(name = "anio")
private Integer anio;
@Id
@Column(name = "mes")
private Integer mes;
@Column(name = "importe")
private BigDecimal importe;
@Column(name = "retencion")
private BigDecimal retencion;
@Column(name = "fecha_registro")
private LocalDateTime fechaRegistro;
}
package so.planilla_be.otrosempleadores.entity;
import java.io.Serializable;
import java.util.Objects;
public class RetencionOtrosEmpleadoresId implements Serializable {
private String documentoTrabajador;
private Integer anio;
private Integer mes;
public RetencionOtrosEmpleadoresId() {
}
public RetencionOtrosEmpleadoresId(String documentoTrabajador, Integer anio, Integer mes) {
this.documentoTrabajador = documentoTrabajador;
this.anio = anio;
this.mes = mes;
}
@Override
public boolean equals(Object o) {
if (this == o) {
return true;
}
if (!(o instanceof RetencionOtrosEmpleadoresId that)) {
return false;
}
return Objects.equals(documentoTrabajador, that.documentoTrabajador)
&& Objects.equals(anio, that.anio)
&& Objects.equals(mes, that.mes);
}
@Override
public int hashCode() {
return Objects.hash(documentoTrabajador, anio, mes);
}
}
package so.planilla_be.otrosempleadores.exception;
public class RetencionOtrosEmpleadoresNoEncontradaException extends RuntimeException {
public RetencionOtrosEmpleadoresNoEncontradaException(String message) {
super(message);
}
}
package so.planilla_be.otrosempleadores.exception;
public class RetencionOtrosEmpleadoresYaExisteException extends RuntimeException {
public RetencionOtrosEmpleadoresYaExisteException(String message) {
super(message);
}
}
package so.planilla_be.otrosempleadores.repository;
import org.springframework.data.jpa.repository.JpaRepository;
import so.planilla_be.otrosempleadores.entity.Personal;
import java.util.Optional;
public interface PersonalRepository extends JpaRepository<Personal, Integer> {
Optional<Personal> findFirstByNumeroDocumentoOrderByCodperDesc(String numeroDocumento);
}
package so.planilla_be.otrosempleadores.repository;
import org.springframework.data.jpa.repository.JpaRepository;
import so.planilla_be.otrosempleadores.entity.RetencionOtrosEmpleadores;
import so.planilla_be.otrosempleadores.entity.RetencionOtrosEmpleadoresId;
import java.util.List;
public interface RetencionOtrosEmpleadoresRepository
extends JpaRepository<RetencionOtrosEmpleadores, RetencionOtrosEmpleadoresId> {
List<RetencionOtrosEmpleadores> findByDocumentoTrabajadorAndAnioOrderByMes(
String documentoTrabajador, Integer anio);
}
package so.planilla_be.otrosempleadores.service;
import so.planilla_be.otrosempleadores.dto.RetencionOtrosEmpleadoresDTO;
import so.planilla_be.otrosempleadores.dto.RetencionOtrosEmpleadoresListadoDTO;
import so.planilla_be.otrosempleadores.dto.RetencionOtrosEmpleadoresRequestDTO;
public interface RetencionOtrosEmpleadoresService {
RetencionOtrosEmpleadoresListadoDTO listarPorAnio(String documentoTrabajador, Integer anio);
RetencionOtrosEmpleadoresDTO crear(String documentoTrabajador, Integer anio, Integer mes,
RetencionOtrosEmpleadoresRequestDTO request);
RetencionOtrosEmpleadoresDTO actualizar(String documentoTrabajador, Integer anio, Integer mes,
RetencionOtrosEmpleadoresRequestDTO request);
void eliminar(String documentoTrabajador, Integer anio, Integer mes);
}
package so.planilla_be.otrosempleadores.service.impl;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import so.planilla_be.otrosempleadores.dto.RetencionOtrosEmpleadoresDTO;
import so.planilla_be.otrosempleadores.dto.RetencionOtrosEmpleadoresItemDTO;
import so.planilla_be.otrosempleadores.dto.RetencionOtrosEmpleadoresListadoDTO;
import so.planilla_be.otrosempleadores.dto.RetencionOtrosEmpleadoresRequestDTO;
import so.planilla_be.otrosempleadores.entity.RetencionOtrosEmpleadores;
import so.planilla_be.otrosempleadores.entity.RetencionOtrosEmpleadoresId;
import so.planilla_be.otrosempleadores.exception.RetencionOtrosEmpleadoresNoEncontradaException;
import so.planilla_be.otrosempleadores.exception.RetencionOtrosEmpleadoresYaExisteException;
import so.planilla_be.otrosempleadores.repository.PersonalRepository;
import so.planilla_be.otrosempleadores.repository.RetencionOtrosEmpleadoresRepository;
import so.planilla_be.otrosempleadores.service.RetencionOtrosEmpleadoresService;
import java.time.LocalDateTime;
import java.util.List;
@Service
@Transactional(readOnly = true)
public class RetencionOtrosEmpleadoresServiceImpl implements RetencionOtrosEmpleadoresService {
private final RetencionOtrosEmpleadoresRepository repository;
private final PersonalRepository personalRepository;
public RetencionOtrosEmpleadoresServiceImpl(RetencionOtrosEmpleadoresRepository repository,
PersonalRepository personalRepository) {
this.repository = repository;
this.personalRepository = personalRepository;
}
@Override
public RetencionOtrosEmpleadoresListadoDTO listarPorAnio(String documentoTrabajador, Integer anio) {
String nombreCompleto = nombreCompleto(documentoTrabajador);
List<RetencionOtrosEmpleadoresItemDTO> data = repository
.findByDocumentoTrabajadorAndAnioOrderByMes(documentoTrabajador, anio).stream()
.map(this::toItemDto)
.toList();
return new RetencionOtrosEmpleadoresListadoDTO(documentoTrabajador, nombreCompleto, data);
}
private String nombreCompleto(String documentoTrabajador) {
return personalRepository.findFirstByNumeroDocumentoOrderByCodperDesc(documentoTrabajador)
.map(p -> "%s %s".formatted(
p.getApellido() == null ? "" : p.getApellido().trim(),
p.getNombre() == null ? "" : p.getNombre().trim()).trim())
.filter(nombre -> !nombre.isBlank())
.orElse(null);
}
@Override
@Transactional
public RetencionOtrosEmpleadoresDTO crear(String documentoTrabajador, Integer anio, Integer mes,
RetencionOtrosEmpleadoresRequestDTO request) {
RetencionOtrosEmpleadoresId id = new RetencionOtrosEmpleadoresId(documentoTrabajador, anio, mes);
if (repository.existsById(id)) {
throw new RetencionOtrosEmpleadoresYaExisteException(
"Ya existe retención de otros empleadores para documento %s, periodo %d-%02d"
.formatted(documentoTrabajador, anio, mes));
}
RetencionOtrosEmpleadores entidad = new RetencionOtrosEmpleadores();
entidad.setDocumentoTrabajador(documentoTrabajador);
entidad.setAnio(anio);
entidad.setMes(mes);
entidad.setImporte(request.importe());
entidad.setRetencion(request.retencion());
entidad.setFechaRegistro(LocalDateTime.now());
return toDto(repository.save(entidad), nombreCompleto(documentoTrabajador));
}
@Override
@Transactional
public RetencionOtrosEmpleadoresDTO actualizar(String documentoTrabajador, Integer anio, Integer mes,
RetencionOtrosEmpleadoresRequestDTO request) {
RetencionOtrosEmpleadores entidad = obtenerOLanzar(documentoTrabajador, anio, mes);
entidad.setImporte(request.importe());
entidad.setRetencion(request.retencion());
return toDto(repository.save(entidad), nombreCompleto(documentoTrabajador));
}
@Override
@Transactional
public void eliminar(String documentoTrabajador, Integer anio, Integer mes) {
RetencionOtrosEmpleadores entidad = obtenerOLanzar(documentoTrabajador, anio, mes);
repository.delete(entidad);
}
private RetencionOtrosEmpleadores obtenerOLanzar(String documentoTrabajador, Integer anio, Integer mes) {
return repository.findById(new RetencionOtrosEmpleadoresId(documentoTrabajador, anio, mes))
.orElseThrow(() -> new RetencionOtrosEmpleadoresNoEncontradaException(
"No existe retención de otros empleadores para documento %s, periodo %d-%02d"
.formatted(documentoTrabajador, anio, mes)));
}
private RetencionOtrosEmpleadoresItemDTO toItemDto(RetencionOtrosEmpleadores entidad) {
return new RetencionOtrosEmpleadoresItemDTO(
entidad.getAnio(),
entidad.getMes(),
entidad.getImporte(),
entidad.getRetencion(),
entidad.getFechaRegistro());
}
private RetencionOtrosEmpleadoresDTO toDto(RetencionOtrosEmpleadores entidad, String nombreCompleto) {
return new RetencionOtrosEmpleadoresDTO(
entidad.getDocumentoTrabajador(),
nombreCompleto,
entidad.getAnio(),
entidad.getMes(),
entidad.getImporte(),
entidad.getRetencion(),
entidad.getFechaRegistro());
}
}
......@@ -8,6 +8,8 @@ import org.springframework.web.bind.MissingServletRequestParameterException;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;
import org.springframework.web.method.annotation.MethodArgumentTypeMismatchException;
import so.planilla_be.otrosempleadores.exception.RetencionOtrosEmpleadoresNoEncontradaException;
import so.planilla_be.otrosempleadores.exception.RetencionOtrosEmpleadoresYaExisteException;
import java.time.Instant;
import java.util.Map;
......@@ -21,6 +23,16 @@ public class GlobalExceptionHandler {
return build(HttpStatus.NOT_FOUND, ex.getMessage());
}
@ExceptionHandler(RetencionOtrosEmpleadoresNoEncontradaException.class)
public ResponseEntity<Map<String, Object>> handleNotFound(RetencionOtrosEmpleadoresNoEncontradaException ex) {
return build(HttpStatus.NOT_FOUND, ex.getMessage());
}
@ExceptionHandler(RetencionOtrosEmpleadoresYaExisteException.class)
public ResponseEntity<Map<String, Object>> handleConflict(RetencionOtrosEmpleadoresYaExisteException ex) {
return build(HttpStatus.CONFLICT, ex.getMessage());
}
/** Filtros fuera de rango (p. ej. mesCorte=13). */
@ExceptionHandler(ConstraintViolationException.class)
public ResponseEntity<Map<String, Object>> handleValidation(ConstraintViolationException ex) {
......
......@@ -15,6 +15,11 @@ spring:
hibernate:
default_schema: planilla
format_sql: true
hbm2ddl:
# nuevo.personal es una foreign table (postgres_fdw): el driver la reporta como
# TABLE_TYPE "FOREIGN TABLE", no "TABLE", y sin esto ddl-auto:validate la da como
# inexistente ("Schema-validation: missing table [nuevo.personal]")
extra_physical_table_types: "FOREIGN TABLE"
threads:
virtual:
enabled: true
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment