Commit d2e3731b by Vanesa Puma Yamo

Merge remote-tracking branch 'origin/master'

parents 757f524f 9a2e7ca9
# 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) {
......
......@@ -14,7 +14,6 @@ import so.planilla_be.rentaquinta.dto.RentaQuintaDetalleCompletoDTO.RegistroHist
import so.planilla_be.rentaquinta.dto.RentaQuintaDetalleCompletoDTO.TramoDTO;
import so.planilla_be.rentaquinta.dto.RentaQuintaResumenDTO;
import so.planilla_be.rentaquinta.entity.RentaQuinta;
import so.planilla_be.rentaquinta.repository.RentaQuintaResumenProjection;
import java.math.BigDecimal;
import java.util.List;
......@@ -23,20 +22,21 @@ import java.util.function.Predicate;
@Component
public class RentaQuintaMapper {
public RentaQuintaResumenDTO toResumen(RentaQuintaResumenProjection p, Integer periodo,
Integer mesCorte, CalculoRentaQuinta calculo) {
public RentaQuintaResumenDTO toResumen(RentaQuinta referencia, Integer codper, Integer periodo, Integer mesCorte,
BigDecimal historialReal, BigDecimal planillaCargada,
BigDecimal retenidoAnterior, CalculoRentaQuinta calculo) {
return new RentaQuintaResumenDTO(
p.getCodper(),
codper,
periodo,
p.getNumeroDocumento(),
nombreCompleto(p.getApellidoPaterno(), p.getApellidoMaterno(), p.getNombres()),
referencia.getNumeroDocumento(),
nombreCompleto(referencia.getApellidoPaterno(), referencia.getApellidoMaterno(), referencia.getNombres()),
mesCorte,
orZero(p.getHistorialReal()),
orZero(p.getPlanillaCargada()),
orZero(historialReal),
orZero(planillaCargada),
calculo.proyeccionFutura(),
calculo.rentaBrutaAnual(),
calculo.impuestoAnual(),
orZero(p.getRetenidoAnterior()),
orZero(retenidoAnterior),
calculo.retencionDelMes());
}
......
package so.planilla_be.rentaquinta.repository;
/**
* Clave de un trabajador que matchea los filtros de la grilla (paginación/orden). La fila
* completa se arma en Java (RentaQuintaServiceImpl), no en esta query.
*/
public interface DocumentoResumenProjection {
String getNumeroDocumento();
}
......@@ -27,44 +27,6 @@ public interface RentaQuintaRepository extends JpaRepository<RentaQuinta, Long>
or upper(concat(r.apellidoPaterno, ' ', r.apellidoMaterno, ' ', r.nombres)) like :texto)
""";
/**
* Total bruto remunerativo de un mes: suma de los 4 bloques de conceptos de la matriz del
* Excel de referencia (docs/calculo-renta-quinta.md) — ingresos fijos, extras y variables,
* vacaciones y liquidación, y anuales excepcionales.
*/
String CONCEPTOS_MES = """
(coalesce(r.basico, 0)
+ coalesce(r.vacacionesGozadasRemVaca, 0)
+ coalesce(r.licenciaGoceHaber, 0)
+ coalesce(r.licenciaSaludFamiliarLey30012, 0)
+ coalesce(r.asignacionFamiliar, 0)
+ coalesce(r.movilidad, 0)
+ coalesce(r.horasExtras25, 0)
+ coalesce(r.horasExtras35, 0)
+ coalesce(r.dominical, 0)
+ coalesce(r.bonoExtraordinario, 0)
+ coalesce(r.bonoProductividad, 0)
+ coalesce(r.bonoDesempeno, 0)
+ coalesce(r.bonoAniversario, 0)
+ coalesce(r.apoyoSalud, 0)
+ coalesce(r.asignacionEstudios, 0)
+ coalesce(r.asignacionFallecimientoFamiliar, 0)
+ coalesce(r.reintegro, 0)
+ coalesce(r.canastaNavidad, 0)
+ coalesce(r.valeConsumo, 0)
+ coalesce(r.obsequios, 0)
+ coalesce(r.canastaNavidad2, 0)
+ coalesce(r.valeConsumoDescuento, 0)
+ coalesce(r.compensacionVacacional, 0)
+ coalesce(r.gratificacionProporcional, 0)
+ coalesce(r.vacacionesTruncas, 0)
+ coalesce(r.sumaGraciosa, 0)
+ coalesce(r.bonificacionExtraordinaria, 0)
+ coalesce(r.bonificacionExtraordinaria2, 0)
+ coalesce(r.gratificacionJulioDiciembre, 0)
+ coalesce(r.utilidades, 0))
""";
/** Meses de un trabajador en un año, ordenados cronológicamente (historial anual). */
List<RentaQuinta> findByCodperAndBoletaAnioOrderByBoletaMesAsc(
Integer codper, Integer boletaAnio);
......@@ -74,49 +36,19 @@ public interface RentaQuintaRepository extends JpaRepository<RentaQuinta, Long>
Integer codper, Integer boletaAnio, Integer boletaMes);
/**
* Mes de referencia por trabajador: el último con boleta real hasta el mes de corte
* (inclusive). Coincide con el mes de corte cuando este sí está cargado; si no, cae al mes
* real anterior y ese pasa a ser la base de la proyección (ver RentaQuintaCalculadora).
*/
String MES_REFERENCIA = """
(select max(r2.boletaMes) from RentaQuinta r2
where r2.numeroDocumento = r.numeroDocumento and r2.boletaAnio = r.boletaAnio
and r2.boletaMes <= :mesCorte)
""";
/**
* Agregado anual por trabajador hasta el mes de corte: acumulado de meses previos
* (historial real / retenido), valores del propio mes de corte (planilla cargada /
* retención del mes, 0 si no está cargado) y valores del mes de referencia (base de
* proyección cuando el mes de corte no tiene boleta real).
* Documentos que matchean los filtros de la grilla, paginados y ordenados por apellidos +
* nombres. Solo trae la clave (numeroDocumento) — el service arma la fila agregando en Java
* sobre {@link #historialHastaMes}, no en SQL.
*/
@Query(value = """
select max(r.codper) as codper,
r.numeroDocumento as numeroDocumento,
select distinct r.numeroDocumento as numeroDocumento,
r.apellidoPaterno as apellidoPaterno,
r.apellidoMaterno as apellidoMaterno,
r.nombres as nombres,
sum(case when r.boletaMes < :mesCorte then """ + CONCEPTOS_MES + """
else 0 end) as historialReal,
sum(case when r.boletaMes = :mesCorte then """ + CONCEPTOS_MES + """
else 0 end) as planillaCargada,
sum(case when r.boletaMes < :mesCorte then coalesce(r.renta5taCategoria, 0) else 0 end) as retenidoAnterior,
sum(case when r.boletaMes = :mesCorte then coalesce(r.renta5taCategoria, 0) else 0 end) as retencionDelMes,
max(""" + MES_REFERENCIA + """
) as mesReferencia,
sum(case when r.boletaMes = """ + MES_REFERENCIA + """
then coalesce(r.basico, 0) else 0 end) as basicoMesCorte,
sum(case when r.boletaMes = """ + MES_REFERENCIA + """
then coalesce(r.asignacionFamiliar, 0) else 0 end) as asignacionFamiliarMesCorte,
sum(case when r.boletaMes = """ + MES_REFERENCIA + """
then coalesce(r.sueldoBruto, 0) else 0 end) as sueldoBrutoMesCorte,
max(case when r.boletaMes = 7 then 1 else 0 end) as julioCargado,
max(case when r.boletaMes = 12 then 1 else 0 end) as diciembreCargado
r.nombres as nombres
from RentaQuinta r
where r.boletaAnio = :periodo
and r.boletaMes <= :mesCorte
""" + FILTROS + """
group by r.numeroDocumento, r.apellidoPaterno, r.apellidoMaterno, r.nombres
order by r.apellidoPaterno asc, r.apellidoMaterno asc, r.nombres asc
""",
countQuery = """
......@@ -125,7 +57,7 @@ public interface RentaQuintaRepository extends JpaRepository<RentaQuinta, Long>
where r.boletaAnio = :periodo
and r.boletaMes <= :mesCorte
""" + FILTROS)
Page<RentaQuintaResumenProjection> buscarResumen(
Page<DocumentoResumenProjection> buscarDocumentos(
@Param("periodo") Integer periodo,
@Param("mesCorte") Integer mesCorte,
@Param("sede") String sede,
......@@ -136,6 +68,19 @@ public interface RentaQuintaRepository extends JpaRepository<RentaQuinta, Long>
@Param("texto") String texto,
Pageable pageable);
/** Boletas reales de un conjunto de documentos hasta el mes de corte (agregación en Java). */
@Query("""
select r from RentaQuinta r
where r.numeroDocumento in :documentos
and r.boletaAnio = :periodo
and r.boletaMes <= :mesCorte
order by r.numeroDocumento asc, r.boletaMes asc
""")
List<RentaQuinta> historialHastaMes(
@Param("documentos") List<String> documentos,
@Param("periodo") Integer periodo,
@Param("mesCorte") Integer mesCorte);
/** Boletas completas de un trabajador en un año, ordenadas por mes (pantalla de historial). */
@Query("""
select r from RentaQuinta r
......
package so.planilla_be.rentaquinta.repository;
import java.math.BigDecimal;
/** Agregado anual por trabajador que alimenta la grilla de retenciones. */
public interface RentaQuintaResumenProjection {
Integer getCodper();
String getNumeroDocumento();
String getApellidoPaterno();
String getApellidoMaterno();
String getNombres();
BigDecimal getHistorialReal();
BigDecimal getPlanillaCargada();
BigDecimal getRetenidoAnterior();
BigDecimal getRetencionDelMes();
/** Último mes con boleta real (<= mes de corte); base de la proyección. */
Integer getMesReferencia();
/** Básico del mes de referencia, usado para proyectar gratificaciones. */
BigDecimal getBasicoMesCorte();
BigDecimal getAsignacionFamiliarMesCorte();
/** Sueldo bruto del mes de referencia, base para proyectar los meses restantes del año. */
BigDecimal getSueldoBrutoMesCorte();
/** 1 si existe boleta real de julio (<= mes de corte), 0 si no. */
Integer getJulioCargado();
/** 1 si existe boleta real de diciembre (<= mes de corte), 0 si no. */
Integer getDiciembreCargado();
}
......@@ -16,15 +16,18 @@ import so.planilla_be.rentaquinta.dto.RentaQuintaResumenDTO;
import so.planilla_be.rentaquinta.entity.RentaQuinta;
import so.planilla_be.rentaquinta.exception.RentaQuintaNoEncontradaException;
import so.planilla_be.rentaquinta.mapper.RentaQuintaMapper;
import so.planilla_be.rentaquinta.repository.DocumentoResumenProjection;
import so.planilla_be.rentaquinta.repository.RentaQuintaRepository;
import so.planilla_be.rentaquinta.repository.RentaQuintaResumenProjection;
import so.planilla_be.rentaquinta.service.RentaQuintaService;
import java.math.BigDecimal;
import java.util.Comparator;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.function.Function;
import java.util.function.Predicate;
import java.util.stream.Collectors;
@Service
@Transactional(readOnly = true)
......@@ -46,7 +49,7 @@ public class RentaQuintaServiceImpl implements RentaQuintaService {
@Override
public Page<RentaQuintaResumenDTO> buscar(RentaQuintaFiltroDTO filtro) {
return repository.buscarResumen(
Page<DocumentoResumenProjection> documentos = repository.buscarDocumentos(
filtro.periodo(),
filtro.mesCorte(),
codigo(filtro.sede()),
......@@ -55,23 +58,33 @@ public class RentaQuintaServiceImpl implements RentaQuintaService {
codigo(filtro.subArea()),
codigo(filtro.cargo()),
patron(filtro.documentoOApellidos()),
toPageable(filtro))
.map(p -> mapper.toResumen(p, filtro.periodo(), filtro.mesCorte(), calcular(p, filtro)));
}
toPageable(filtro));
private CalculoRentaQuinta calcular(RentaQuintaResumenProjection p, RentaQuintaFiltroDTO filtro) {
return calculadora.calcular(new DatosCalculo(
filtro.periodo(),
filtro.mesCorte(),
p.getMesReferencia(),
p.getHistorialReal(),
p.getPlanillaCargada(),
p.getSueldoBrutoMesCorte(),
p.getBasicoMesCorte(),
p.getAsignacionFamiliarMesCorte(),
p.getRetenidoAnterior(),
p.getJulioCargado() != null && p.getJulioCargado() == 1,
p.getDiciembreCargado() != null && p.getDiciembreCargado() == 1));
List<String> numeros = documentos.getContent().stream()
.map(DocumentoResumenProjection::getNumeroDocumento)
.toList();
Map<String, List<RentaQuinta>> porDocumento = numeros.isEmpty() ? Map.of()
: repository.historialHastaMes(numeros, filtro.periodo(), filtro.mesCorte()).stream()
.collect(Collectors.groupingBy(RentaQuinta::getNumeroDocumento));
return documentos.map(d -> {
List<RentaQuinta> historial = porDocumento.getOrDefault(d.getNumeroDocumento(), List.of());
Agregado a = agregar(historial, filtro.mesCorte());
CalculoRentaQuinta calculo = calcular(a, historial, filtro.periodo(), filtro.mesCorte());
// Sin boleta real del mes de corte, planillaCargada muestra el mismo estimado que el
// bloque "actual" del detalle completo (sueldo bruto del mes de referencia + grat.
// 109% si el mes de corte es julio/diciembre), no 0.
BigDecimal planillaCargada = a.mesCorteReal()
? a.planillaCargadaReal()
: calculo.estimadoMesCorteSueldoBruto().add(calculo.estimadoMesCorteGratificacion());
Integer codper = historial.stream()
.map(RentaQuinta::getCodper)
.filter(Objects::nonNull)
.max(Integer::compareTo)
.orElse(null);
return mapper.toResumen(a.referencia(), codper, filtro.periodo(), filtro.mesCorte(),
a.historialReal(), planillaCargada, a.retenidoAnterior(), calculo);
});
}
/** Los catálogos llegan como Integer pero viven en columnas TEXT: se comparan como texto. */
......@@ -113,42 +126,59 @@ public class RentaQuintaServiceImpl implements RentaQuintaService {
List<RentaQuinta> historialAnio = repository.historialAnualPorDocumento(numeroDocumento, anio);
int mesCorte = mes;
// Si el mes de corte no tiene boleta real (ej. julio sin cargar), se usa como referencia
// el mes real más reciente anterior — misma regla que la grilla (mesReferencia).
RentaQuinta boletaMesCorte = historialAnio.stream()
Agregado a = agregar(historialAnio, mesCorte);
if (a.referencia() == null) {
throw new RentaQuintaNoEncontradaException(
"No existe boleta para documento %s, periodo %d-%02d".formatted(numeroDocumento, anio, mes));
}
CalculoRentaQuinta calculo = calcular(a, historialAnio, anio, mesCorte);
return mapper.toDetalleCompleto(a.referencia(), historialAnio, mesCorte, calculo);
}
/**
* Agregado anual de un trabajador hasta el mes de corte, base de {@link DatosCalculo}: usado
* tanto por la grilla como por el detalle completo, para que ambos calculen exactamente igual.
*
* <p>{@code referencia} es la boleta del mes de corte si existe, o si no la del mes real más
* reciente anterior (null solo si el trabajador no tiene ninguna boleta hasta el mes de
* corte).
*/
private Agregado agregar(List<RentaQuinta> historial, int mesCorte) {
RentaQuinta boletaMesCorte = historial.stream()
.filter(r -> r.getBoletaMes().equals(mesCorte))
.findFirst()
.orElse(null);
RentaQuinta referencia = boletaMesCorte != null ? boletaMesCorte
: historialAnio.stream()
: historial.stream()
.filter(r -> r.getBoletaMes() < mesCorte)
.max(Comparator.comparing(RentaQuinta::getBoletaMes))
.orElseThrow(() -> new RentaQuintaNoEncontradaException(
"No existe boleta para documento %s, periodo %d-%02d".formatted(numeroDocumento, anio, mes)));
int mesReferencia = referencia.getBoletaMes();
.orElse(null);
BigDecimal historialReal = mapper.totalBrutoMes(historialAnio, r -> r.getBoletaMes() < mesCorte);
BigDecimal planillaCargada = boletaMesCorte == null ? BigDecimal.ZERO
: mapper.totalBrutoMes(historialAnio, r -> r.getBoletaMes().equals(mesCorte));
BigDecimal retenidoAnterior = sumaMesCorte(historialAnio, r -> r.getBoletaMes() < mesCorte,
BigDecimal historialReal = mapper.totalBrutoMes(historial, r -> r.getBoletaMes() < mesCorte);
BigDecimal planillaCargadaReal = boletaMesCorte == null ? BigDecimal.ZERO
: mapper.totalBrutoMes(historial, r -> r.getBoletaMes().equals(mesCorte));
BigDecimal retenidoAnterior = sumaMesCorte(historial, r -> r.getBoletaMes() < mesCorte,
RentaQuinta::getRenta5taCategoria);
boolean julioPercibida = historial.stream().anyMatch(r -> r.getBoletaMes() == 7 && r.getBoletaMes() <= mesCorte);
boolean diciembrePercibida = historial.stream().anyMatch(r -> r.getBoletaMes() == 12 && r.getBoletaMes() <= mesCorte);
// La grilla agrega todas las filas del documento en el mes de referencia (una persona
// puede tener varios codper/tipo de planilla); la base de proyección aquí hace lo mismo.
BigDecimal sueldoBrutoReferencia = sumaMesCorte(historialAnio, esMesCorte(mesReferencia), RentaQuinta::getSueldoBruto);
BigDecimal basicoReferencia = sumaMesCorte(historialAnio, esMesCorte(mesReferencia), RentaQuinta::getBasico);
BigDecimal asignacionFamiliarReferencia =
sumaMesCorte(historialAnio, esMesCorte(mesReferencia), RentaQuinta::getAsignacionFamiliar);
boolean julioPercibida = historialAnio.stream().anyMatch(r -> r.getBoletaMes() == 7 && r.getBoletaMes() <= mesCorte);
boolean diciembrePercibida = historialAnio.stream().anyMatch(r -> r.getBoletaMes() == 12 && r.getBoletaMes() <= mesCorte);
return new Agregado(referencia, boletaMesCorte != null, historialReal, planillaCargadaReal,
retenidoAnterior, julioPercibida, diciembrePercibida);
}
CalculoRentaQuinta calculo = calculadora.calcular(new DatosCalculo(
anio, mesCorte, mesReferencia, historialReal, planillaCargada,
sueldoBrutoReferencia, basicoReferencia, asignacionFamiliarReferencia, retenidoAnterior,
julioPercibida, diciembrePercibida));
private CalculoRentaQuinta calcular(Agregado a, List<RentaQuinta> historial, Integer periodo, int mesCorte) {
int mesReferencia = a.referencia().getBoletaMes();
// Todas las filas del documento en el mes de referencia (una persona puede tener varios
// codper/tipo de planilla).
BigDecimal sueldoBrutoReferencia = sumaMesCorte(historial, esMesCorte(mesReferencia), RentaQuinta::getSueldoBruto);
BigDecimal basicoReferencia = sumaMesCorte(historial, esMesCorte(mesReferencia), RentaQuinta::getBasico);
BigDecimal asignacionFamiliarReferencia =
sumaMesCorte(historial, esMesCorte(mesReferencia), RentaQuinta::getAsignacionFamiliar);
return mapper.toDetalleCompleto(referencia, historialAnio, mesCorte, calculo);
return calculadora.calcular(new DatosCalculo(
periodo, mesCorte, mesReferencia, a.historialReal(), a.planillaCargadaReal(),
sueldoBrutoReferencia, basicoReferencia, asignacionFamiliarReferencia, a.retenidoAnterior(),
a.julioPercibida(), a.diciembrePercibida()));
}
private Predicate<RentaQuinta> esMesCorte(int mesCorte) {
......@@ -178,4 +208,14 @@ public class RentaQuintaServiceImpl implements RentaQuintaService {
}
return historial;
}
private record Agregado(
RentaQuinta referencia,
boolean mesCorteReal,
BigDecimal historialReal,
BigDecimal planillaCargadaReal,
BigDecimal retenidoAnterior,
boolean julioPercibida,
boolean diciembrePercibida
) {}
}
......@@ -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