Commit 41b66e29 by Vanesa Puma Yamo

feat: gratificacion por fecha de contrato y excepcion DOC2 diciembre

- ADM y DOC2: gratificacion prorrateada por fecha_inicio_contrato con
  dias del mes segun planilla.pl_calendario (docs/create-calendario.sql).
- Ingreso en junio despues del dia 1: sin gratificacion de julio; los
  dias de junio se suman a la gratificacion de diciembre.
- DOC2: si el DNI esta en planilla.pl_docente_grati_diciembre_completa
  para el anio, diciembre va completo (30 dias) en vez de 16.
- Mantenimiento /api/v1/docente-grati-diciembre-completa (GET, POST
  upsert por dni + anio, DELETE). Tabla en
  docs/create-docente-grati-diciembre-completa.sql.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
parent 1e9d8757
......@@ -91,7 +91,7 @@ PK). Esa columna la crea `docs/alter-pl-renta-quinta-tipo-pago.sql`: correrlo an
| tipoPlanilla | boleta_tipo_personal | tipo_pago | Modalidad | Proyección del sueldo bruto |
|---|---|---|---|---|
| `ADM` | `ADM` | — | `MONTO_FIJO` | remuneración de referencia × meses restantes |
| `DOC2` | `DOC` | `2` | `MONTO_FIJO_DICIEMBRE_PROPORCIONAL` | igual que ADM, pero diciembre (sueldo bruto y gratificación) entra ÷ 30 × 16: en la proyección, en el estimado del mes de corte 12 y en la gratificación de diciembre pendiente. Una boleta real de diciembre no se toca |
| `DOC2` | `DOC` | `2` | `MONTO_FIJO_DICIEMBRE_PROPORCIONAL` | igual que ADM, pero el sueldo bruto de diciembre entra ÷ 30 × 16 (proyección y estimado del mes de corte 12) y en su gratificación diciembre vale 16 días (5 meses + 16 días, ver reglas de dominio). Una boleta real de diciembre no se toca |
| `DOC1` | `DOC` | `1` | `DOCENTE_POR_HORA` | De `planilla.pl_proyeccion_renta_docentehora` (mismo documento, `boleta_tipo_personal='DOC'`, `proyeccion_anio` = año): `remun_bruta_futura` del mes consultado = sueldo bruto de la planilla en curso (reemplaza al de la boleta); suma de `remun_bruta_futura` de los meses **posteriores** = proyección del sueldo bruto. Sin fila = 0. La gratificación de julio/diciembre pendiente es el campo `gratificacion` de las filas `boleta_tipo_personal='GRA'` en `proyeccion_mes` 7 / 12 (sin fórmula × 1.09; sin fila = 0) y va en gratificación ordinaria de la proyección |
En DOC1 cambian la proyección del sueldo y las gratificaciones; UIT, tramos y divisor son iguales. Detalle
......@@ -125,6 +125,23 @@ que la duplicaba en la grilla.
1. Proyección = remuneración del mes de corte × meses restantes + gratificaciones de julio y
diciembre aún no percibidas, cada una `(básico + asignación familiar) × 1.09`
- **ADM**: la gratificación se prorratea por `fecha_inicio_contrato` (boleta ADM del mes de
referencia, **obligatoria**: sin fecha la gratificación es 0):
`((base/6 × meses) + (base/6/30 × días)) × 1.09`. Contrato anterior al semestre
o ingreso en enero → semestre completo (6 meses); posterior → 0. Ingreso en otro mes del
semestre: meses completos después del mes de ingreso (día 1 = mes completo) + días del mes
de ingreso (días según `planilla.pl_calendario` − día + 1). Sin corte de 16 días. DOC1 no cambia.
Ingreso en junio después del día 1 (ADM y DOC2): gratificación de julio = 0 y los días de
junio se suman a la de diciembre (semestre completo + días de junio).
- **DOC2**: igual que ADM (fecha de su boleta DOC), pero en la gratificación de diciembre,
diciembre cuenta 16 días: semestre completo = `((base/6 × 5) + (base/6/30 × 16)) × 1.09`. La tabla la crea
`docs/create-calendario.sql` (2026): correrlo antes de desplegar o `validate` no arranca.
Excepción: si el DNI está en `planilla.pl_docente_grati_diciembre_completa` para el año
(PK `dni + anio`), su gratificación de diciembre DOC2 va con diciembre completo (30 días,
igual que ADM). Solo la gratificación: el sueldo bruto de diciembre sigue ÷ 30 × 16. La crea
`docs/create-docente-grati-diciembre-completa.sql` (correr antes de desplegar). Mantenimiento
en `/api/v1/docente-grati-diciembre-completa` (paquete `docentegratidiciembre`, ver
`docs/api-docente-grati-diciembre-completa.md`).
2. Renta bruta anual = historial + planilla cargada + proyección
3. Renta neta = bruta − **7 UIT**
4. Impuesto anual = tramos progresivos 8% / 14% / 17% / 20% / 30% (5, 20, 35 y 45 UIT)
......
# API de docentes con gratificación de diciembre completa (DOC2)
Mantenimiento para **grabar** docentes en `planilla.pl_docente_grati_diciembre_completa`. Solo
escribe en esa tabla. La renta de quinta la lee para decidir cómo calcula la gratificación de
diciembre en la planilla **DOC2**.
Paquete: `so.planilla_be.docentegratidiciembre`.
## Para qué sirve
En DOC2 la gratificación de diciembre se calcula con **diciembre a 16 días**: el semestre completo
es 5 meses (julio a noviembre) + 16 días. Si el DNI del trabajador está en esta tabla **para el año
consultado**, diciembre va **completo (30 días)** y la gratificación se calcula igual que ADM:
semestre completo = 6 meses.
| | No está en la tabla | Está en la tabla (mismo año) |
|---|---|---|
| Semestre completo | `((base/6 × 5) + (base/6/30 × 16)) × 1.09` | `(base/6 × 6) × 1.09` = `base × 1.09` |
| Ingreso en junio después del día 1 | 5 meses + días de junio + 16 días | 6 meses + días de junio |
| Ingreso en diciembre | 16 − día + 1 días | 30 días del mes − día + 1 (si entra el día 1, mes completo) |
- **Solo DOC2.** ADM y DOC1 no miran esta tabla.
- **Solo la gratificación.** El sueldo bruto de diciembre en DOC2 sigue entrando ÷ 30 × 16.
- **Por año.** Un DNI registrado en 2026 no aplica a 2027; hay que registrarlo en cada año.
- `seleccion` no interviene en el cálculo: basta que exista la fila DNI + año.
Ejemplo (DNI 73180595, ingreso 02/06/2026, base 3,852.18): sin registro la gratificación de
diciembre es **4,548.78**; registrado en 2026 es (3,852.18 + 29 × 21.401) × 1.09 = **4,875.36**.
## Endpoints
| Método | Ruta | Descripción |
|---|---|---|
| GET | `/api/v1/docente-grati-diciembre-completa/{anio}` | Docentes registrados en el año |
| POST | `/api/v1/docente-grati-diciembre-completa` | Graba una lista de docentes |
| DELETE | `/api/v1/docente-grati-diciembre-completa/{dni}/{anio}` | Elimina un docente del año |
Swagger: `/docs`, tag **Docente gratificación diciembre completa**.
## Estructura de la tabla
| Columna | Tipo | Nulo | Notas |
|---|---|---|---|
| `dni` | varchar(15) | NO | PK (con `anio`) |
| `anio` | integer | NO | PK (con `dni`). Año de la planilla al que aplica |
| `apellido_paterno` | varchar(100) | SÍ | |
| `apellido_materno` | varchar(100) | SÍ | |
| `nombres` | varchar(150) | SÍ | |
| `seleccion` | integer | SÍ | Campo libre del mantenimiento |
La crea `docs/create-docente-grati-diciembre-completa.sql`. **Correrlo antes de desplegar**: con
`ddl-auto: validate` la app no arranca si falta la tabla.
## GET `/{anio}`
Llena la grilla de la pantalla. Ordena por apellido paterno, materno y nombres. Sin filas devuelve
`[]` (200).
```json
[
{
"anio": 2026,
"dni": "73180595",
"apellidoPaterno": "ACEVEDO",
"apellidoMaterno": "CUITO",
"nombres": "MAURICIO JUNIO",
"seleccion": 1
}
]
```
## POST
Body: arreglo JSON, no vacío.
| Campo | Tipo | Obligatorio | Regla |
|---|---|---|---|
| `anio` | Integer | sí | 2000 a 2100 |
| `dni` | String | sí | no vacío, máx. 15 (se hace `trim`) |
| `apellidoPaterno` | String | no | máx. 100 |
| `apellidoMaterno` | String | no | máx. 100 |
| `nombres` | String | no | máx. 150 |
| `seleccion` | Integer | no | |
Apellidos y nombres se graban con `trim` y en **mayúsculas**; vacíos se graban como `null`.
```json
[
{
"anio": 2026,
"dni": "73180595",
"apellidoPaterno": "Acevedo",
"apellidoMaterno": "Cuito",
"nombres": "Mauricio Junio",
"seleccion": 1
}
]
```
### Comportamiento
- **Insertar o actualizar:** si ya existe la fila `dni + anio`, se actualizan apellidos, nombres y
`seleccion`. Si no existe, se inserta. No se duplica.
- **Todo o nada:** una sola transacción. Si una fila falla, no se graba ninguna.
- **Solo esta tabla:** no toca `pl_renta_quinta` ni ninguna otra. La retención ya grabada en
`pl_renta_quinta` no se recalcula sola: hay que volver a consultar/grabar la renta de quinta.
### Response `200`
Lista de las filas grabadas, en el mismo orden del request (mismo formato que el GET).
## DELETE `/{dni}/{anio}`
`204` sin cuerpo si se elimina; `404` si no existe la fila; `400` si `anio` no es numérico.
## Errores
Formato `{timestamp, status, message}`, igual que el resto del API.
| Status | Causa |
|---|---|
| 400 | Lista vacía, `dni` o `anio` ausente, `anio` fuera de 2000–2100, texto más largo que la columna, `anio` no numérico en la ruta |
| 404 | `DELETE` de un DNI + año que no existe |
## Ejemplo
```bash
curl -X POST http://localhost:8080/api/v1/docente-grati-diciembre-completa \
-H "Content-Type: application/json" \
-d '[{"anio":2026,"dni":"73180595","apellidoPaterno":"ACEVEDO","apellidoMaterno":"CUITO","nombres":"MAURICIO JUNIO","seleccion":1}]'
curl http://localhost:8080/api/v1/docente-grati-diciembre-completa/2026
curl -X DELETE http://localhost:8080/api/v1/docente-grati-diciembre-completa/73180595/2026
```
## Archivos
```
docentegratidiciembre/
├── controller/DocenteGratiDiciembreCompletaController
├── dto/DocenteGratiDiciembreCompletaRequestDTO, DocenteGratiDiciembreCompletaDTO
├── entity/DocenteGratiDiciembreCompleta + DocenteGratiDiciembreCompletaId PK dni + anio
├── exception/DocenteGratiDiciembreCompletaNoEncontradaException
├── repository/DocenteGratiDiciembreCompletaRepository existsByDniAndAnio (lo usa renta de quinta)
└── service/ + service/impl/
```
En renta de quinta: `RentaQuintaServiceImpl#calcular` arma `gratificacionDiciembreCompleta` (DOC2 +
fila DNI + año) y `RentaQuintaCalculadora#gratificacionProporcional` deja de aplicar los 16 días.
-- Calendario mensual usado por el cálculo de la gratificación proporcional (ADM): de aquí sale
-- cuántos días tiene el mes de ingreso del trabajador. Una fila por año + mes.
-- semestre 1 = enero-junio (gratificación de julio), 2 = julio-diciembre (de diciembre)
-- mes_gratificacion mes en que se paga la gratificación de ese semestre (7 o 12)
--
-- Ejecutar ANTES de desplegar el backend: con ddl-auto: validate, la app no arranca si falta la
-- tabla. Para un año nuevo, repetir el INSERT cambiando el año en generate_series.
CREATE TABLE planilla.pl_calendario (
anio integer NOT NULL,
mes integer NOT NULL CHECK (mes BETWEEN 1 AND 12),
nombre_mes varchar(20) NOT NULL,
fecha_inicio date NOT NULL,
fecha_fin date NOT NULL,
dias integer NOT NULL,
semestre integer NOT NULL CHECK (semestre IN (1, 2)),
mes_gratificacion integer NOT NULL CHECK (mes_gratificacion IN (7, 12)),
PRIMARY KEY (anio, mes)
);
INSERT INTO planilla.pl_calendario (anio, mes, nombre_mes, fecha_inicio, fecha_fin, dias, semestre, mes_gratificacion)
SELECT extract(year FROM d)::int,
extract(month FROM d)::int,
(ARRAY['ENERO', 'FEBRERO', 'MARZO', 'ABRIL', 'MAYO', 'JUNIO', 'JULIO', 'AGOSTO',
'SETIEMBRE', 'OCTUBRE', 'NOVIEMBRE', 'DICIEMBRE'])[extract(month FROM d)::int],
d::date,
(d + interval '1 month' - interval '1 day')::date,
extract(day FROM d + interval '1 month' - interval '1 day')::int,
CASE WHEN extract(month FROM d) <= 6 THEN 1 ELSE 2 END,
CASE WHEN extract(month FROM d) <= 6 THEN 7 ELSE 12 END
FROM generate_series(DATE '2026-01-01', DATE '2026-12-01', interval '1 month') AS d;
COMMENT ON TABLE planilla.pl_calendario IS
'Meses del año con sus días y semestre de gratificación. Lo usa el cálculo de renta de quinta.';
-- Docentes DOC2 (DOC + tipo_pago 2) cuya gratificación de diciembre del año se calcula con
-- diciembre completo (30 días) en vez de 16 días. Si el DNI está en esta tabla para el año, la
-- gratificación de diciembre de su planilla DOC2 se prorratea igual que ADM (semestre completo =
-- 6 meses). No cambia el sueldo bruto de diciembre (sigue ÷ 30 × 16) ni afecta a ADM ni a DOC1.
-- anio año de la planilla al que aplica (una fila por DNI + año)
-- seleccion campo numérico libre para el mantenimiento; el cálculo solo mira si existe la fila.
--
-- Ejecutar ANTES de desplegar el backend: con ddl-auto: validate, la app no arranca si falta la
-- tabla. Se graba desde POST /api/v1/docente-grati-diciembre-completa.
CREATE TABLE planilla.pl_docente_grati_diciembre_completa (
dni varchar(15) NOT NULL,
anio integer NOT NULL,
apellido_paterno varchar(100),
apellido_materno varchar(100),
nombres varchar(150),
seleccion integer,
PRIMARY KEY (dni, anio)
);
COMMENT ON TABLE planilla.pl_docente_grati_diciembre_completa IS
'Docentes DOC2 con gratificación de diciembre a 30 días (no 16) por año. Lo usa el cálculo de renta de quinta.';
-- Ejemplo:
-- INSERT INTO planilla.pl_docente_grati_diciembre_completa (dni, anio, apellido_paterno, apellido_materno, nombres, seleccion)
-- VALUES ('73180595', 2026, 'ACEVEDO', 'CUITO', 'MAURICIO JUNIO', 1);
package so.planilla_be.docentegratidiciembre.controller;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.validation.Valid;
import jakarta.validation.constraints.NotEmpty;
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.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import so.planilla_be.docentegratidiciembre.dto.DocenteGratiDiciembreCompletaDTO;
import so.planilla_be.docentegratidiciembre.dto.DocenteGratiDiciembreCompletaRequestDTO;
import so.planilla_be.docentegratidiciembre.service.DocenteGratiDiciembreCompletaService;
import java.util.List;
@Tag(name = "Docente gratificación diciembre completa",
description = "Grabado de pl_docente_grati_diciembre_completa (DOC2 con diciembre a 30 días)")
@Validated
@RestController
@RequestMapping("/api/v1/docente-grati-diciembre-completa")
public class DocenteGratiDiciembreCompletaController {
private final DocenteGratiDiciembreCompletaService service;
public DocenteGratiDiciembreCompletaController(DocenteGratiDiciembreCompletaService service) {
this.service = service;
}
@Operation(summary = "Docentes registrados en el año")
@GetMapping("/{anio}")
public List<DocenteGratiDiciembreCompletaDTO> listarPorAnio(@PathVariable Integer anio) {
return service.listarPorAnio(anio);
}
@Operation(summary = "Grabar docentes (inserta o actualiza por DNI + año)")
@PostMapping
public List<DocenteGratiDiciembreCompletaDTO> grabar(
@RequestBody @NotEmpty List<@Valid DocenteGratiDiciembreCompletaRequestDTO> filas) {
return service.grabar(filas);
}
@Operation(summary = "Eliminar un docente del año")
@DeleteMapping("/{dni}/{anio}")
public ResponseEntity<Void> eliminar(@PathVariable String dni, @PathVariable Integer anio) {
service.eliminar(dni, anio);
return ResponseEntity.noContent().build();
}
}
package so.planilla_be.docentegratidiciembre.dto;
public record DocenteGratiDiciembreCompletaDTO(
Integer anio,
String dni,
String apellidoPaterno,
String apellidoMaterno,
String nombres,
Integer seleccion
) {}
package so.planilla_be.docentegratidiciembre.dto;
import jakarta.validation.constraints.Max;
import jakarta.validation.constraints.Min;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Size;
/**
* Una fila de pl_docente_grati_diciembre_completa. La clave es dni + anio: si ya existe se
* actualizan apellidos, nombres y seleccion.
*/
public record DocenteGratiDiciembreCompletaRequestDTO(
@NotNull @Min(2000) @Max(2100) Integer anio,
@NotBlank @Size(max = 15) String dni,
@Size(max = 100) String apellidoPaterno,
@Size(max = 100) String apellidoMaterno,
@Size(max = 150) String nombres,
Integer seleccion
) {}
package so.planilla_be.docentegratidiciembre.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;
/**
* Docente DOC2 cuya gratificación de diciembre del año va con diciembre completo (30 días) y no a
* 16 días (planilla.pl_docente_grati_diciembre_completa, una fila por DNI + año). La crea
* docs/create-docente-grati-diciembre-completa.sql; se graba desde el mantenimiento de este módulo
* y la renta de quinta solo la lee.
*/
@Getter
@Setter
@Entity
@Table(name = "pl_docente_grati_diciembre_completa")
@IdClass(DocenteGratiDiciembreCompletaId.class)
public class DocenteGratiDiciembreCompleta {
@Id
@Column(name = "dni")
private String dni;
@Id
@Column(name = "anio")
private Integer anio;
@Column(name = "apellido_paterno")
private String apellidoPaterno;
@Column(name = "apellido_materno")
private String apellidoMaterno;
@Column(name = "nombres")
private String nombres;
/** Campo libre del mantenimiento; el cálculo solo mira si existe la fila DNI + año. */
@Column(name = "seleccion")
private Integer seleccion;
}
package so.planilla_be.docentegratidiciembre.entity;
import java.io.Serializable;
public record DocenteGratiDiciembreCompletaId(String dni, Integer anio) implements Serializable {}
package so.planilla_be.docentegratidiciembre.exception;
public class DocenteGratiDiciembreCompletaNoEncontradaException extends RuntimeException {
public DocenteGratiDiciembreCompletaNoEncontradaException(String message) {
super(message);
}
}
package so.planilla_be.docentegratidiciembre.repository;
import org.springframework.data.jpa.repository.JpaRepository;
import so.planilla_be.docentegratidiciembre.entity.DocenteGratiDiciembreCompleta;
import so.planilla_be.docentegratidiciembre.entity.DocenteGratiDiciembreCompletaId;
import java.util.List;
public interface DocenteGratiDiciembreCompletaRepository
extends JpaRepository<DocenteGratiDiciembreCompleta, DocenteGratiDiciembreCompletaId> {
/** Docentes registrados en el año, por apellidos y nombres. */
List<DocenteGratiDiciembreCompleta> findByAnioOrderByApellidoPaternoAscApellidoMaternoAscNombresAsc(Integer anio);
/** Renta de quinta DOC2: true si el DNI tiene diciembre completo (30 días) en el año. */
boolean existsByDniAndAnio(String dni, Integer anio);
}
package so.planilla_be.docentegratidiciembre.service;
import so.planilla_be.docentegratidiciembre.dto.DocenteGratiDiciembreCompletaDTO;
import so.planilla_be.docentegratidiciembre.dto.DocenteGratiDiciembreCompletaRequestDTO;
import java.util.List;
public interface DocenteGratiDiciembreCompletaService {
/** Docentes registrados en el año. Sin filas: lista vacía. */
List<DocenteGratiDiciembreCompletaDTO> listarPorAnio(Integer anio);
/**
* Graba las filas recibidas. La clave es dni + anio: si ya existe se actualizan los datos, si
* no se inserta. Todo o nada.
*/
List<DocenteGratiDiciembreCompletaDTO> grabar(List<DocenteGratiDiciembreCompletaRequestDTO> filas);
/** Elimina la fila dni + anio. Lanza DocenteGratiDiciembreCompletaNoEncontradaException si no existe. */
void eliminar(String dni, Integer anio);
}
package so.planilla_be.docentegratidiciembre.service.impl;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import so.planilla_be.docentegratidiciembre.dto.DocenteGratiDiciembreCompletaDTO;
import so.planilla_be.docentegratidiciembre.dto.DocenteGratiDiciembreCompletaRequestDTO;
import so.planilla_be.docentegratidiciembre.entity.DocenteGratiDiciembreCompleta;
import so.planilla_be.docentegratidiciembre.entity.DocenteGratiDiciembreCompletaId;
import so.planilla_be.docentegratidiciembre.exception.DocenteGratiDiciembreCompletaNoEncontradaException;
import so.planilla_be.docentegratidiciembre.repository.DocenteGratiDiciembreCompletaRepository;
import so.planilla_be.docentegratidiciembre.service.DocenteGratiDiciembreCompletaService;
import java.util.List;
@Service
public class DocenteGratiDiciembreCompletaServiceImpl implements DocenteGratiDiciembreCompletaService {
private final DocenteGratiDiciembreCompletaRepository repository;
public DocenteGratiDiciembreCompletaServiceImpl(DocenteGratiDiciembreCompletaRepository repository) {
this.repository = repository;
}
@Override
@Transactional(readOnly = true)
public List<DocenteGratiDiciembreCompletaDTO> listarPorAnio(Integer anio) {
return repository.findByAnioOrderByApellidoPaternoAscApellidoMaternoAscNombresAsc(anio).stream()
.map(this::toDto)
.toList();
}
@Override
@Transactional
public List<DocenteGratiDiciembreCompletaDTO> grabar(List<DocenteGratiDiciembreCompletaRequestDTO> filas) {
return filas.stream()
.map(this::grabarFila)
.toList();
}
@Override
@Transactional
public void eliminar(String dni, Integer anio) {
DocenteGratiDiciembreCompleta entidad = repository
.findById(new DocenteGratiDiciembreCompletaId(dni.trim(), anio))
.orElseThrow(() -> new DocenteGratiDiciembreCompletaNoEncontradaException(
"No existe docente con gratificación de diciembre completa para DNI %s, año %d"
.formatted(dni.trim(), anio)));
repository.delete(entidad);
}
private DocenteGratiDiciembreCompletaDTO grabarFila(DocenteGratiDiciembreCompletaRequestDTO fila) {
String dni = fila.dni().trim();
DocenteGratiDiciembreCompleta entidad = repository
.findById(new DocenteGratiDiciembreCompletaId(dni, fila.anio()))
.orElseGet(DocenteGratiDiciembreCompleta::new);
entidad.setDni(dni);
entidad.setAnio(fila.anio());
entidad.setApellidoPaterno(texto(fila.apellidoPaterno()));
entidad.setApellidoMaterno(texto(fila.apellidoMaterno()));
entidad.setNombres(texto(fila.nombres()));
entidad.setSeleccion(fila.seleccion());
return toDto(repository.save(entidad));
}
/** Apellidos y nombres en mayúsculas y sin espacios sobrantes; vacío se graba como null. */
private String texto(String valor) {
if (valor == null || valor.isBlank()) {
return null;
}
return valor.trim().toUpperCase();
}
private DocenteGratiDiciembreCompletaDTO toDto(DocenteGratiDiciembreCompleta e) {
return new DocenteGratiDiciembreCompletaDTO(
e.getAnio(),
e.getDni(),
e.getApellidoPaterno(),
e.getApellidoMaterno(),
e.getNombres(),
e.getSeleccion());
}
}
......@@ -5,6 +5,7 @@ import so.planilla_be.rentaquinta.calculo.CalculoRentaQuinta.TramoCalculado;
import java.math.BigDecimal;
import java.math.RoundingMode;
import java.time.LocalDate;
import java.util.ArrayList;
import java.util.List;
/**
......@@ -41,6 +42,9 @@ public class RentaQuintaCalculadora {
private static final BigDecimal DIAS_MES = BigDecimal.valueOf(30);
private static final BigDecimal DIAS_DICIEMBRE_PROPORCIONAL = BigDecimal.valueOf(16);
/** Gratificación proporcional: meses de un semestre. */
private static final BigDecimal MESES_SEMESTRE = BigDecimal.valueOf(6);
private final UitCache uit;
public RentaQuintaCalculadora(UitCache uit) {
......@@ -65,11 +69,13 @@ public class RentaQuintaCalculadora {
if (!mesCorteReal) {
estimadoSueldoBruto = porHora ? orZero(datos.sueldoBrutoMesCorteDocenteHora())
: montoDelMes(datos, orZero(datos.remuneracionReferencia()), datos.mesCorte());
// DOC2 en diciembre: la gratificación sale del sueldo ya proporcional (÷ 30 × 16).
// Misma base que las gratificaciones pendientes (basicoReferencia), para que el mes de
// corte 7/12 dé lo mismo que se proyectaba antes. En DOC2 los 16 días de diciembre ya
// los aplica gratificacionProporcional.
if (datos.mesCorte() == 7 || datos.mesCorte() == 12) {
estimadoGratificacion = porHora
? gratificacionDocenteHora(datos, datos.mesCorte())
: gratificacion(estimadoSueldoBruto);
: gratificacionProporcional(datos, orZero(datos.basicoReferencia()), datos.mesCorte());
}
}
BigDecimal estimadoMesCorte = estimadoSueldoBruto.add(estimadoGratificacion);
......@@ -150,25 +156,95 @@ public class RentaQuintaCalculadora {
* boleta real ni el estimado del propio mes de corte cuando este es julio/diciembre).
*
* <p>Base = {@code basicoReferencia} × 1.09: monto_basico de pl_renta_basico si el trabajador
* tiene registro; si no, básico + asignación familiar de pl_planilla.
* tiene registro; si no, básico + asignación familiar de pl_planilla. En ADM se prorratea
* según la fecha de inicio de contrato (ver {@link #gratificacionProporcional}).
*/
private BigDecimal gratificacionesPendientes(DatosCalculo datos, boolean julioCubierto, boolean diciembreCubierto) {
boolean porHora = datos.modalidad() == ModalidadCalculo.DOCENTE_POR_HORA;
BigDecimal gratificacion = gratificacion(orZero(datos.basicoReferencia()));
BigDecimal base = orZero(datos.basicoReferencia());
BigDecimal total = BigDecimal.ZERO;
if (!julioCubierto) {
total = total.add(porHora ? gratificacionDocenteHora(datos, 7) : gratificacion);
total = total.add(porHora ? gratificacionDocenteHora(datos, 7) : gratificacionProporcional(datos, base, 7));
}
if (!diciembreCubierto) {
total = total.add(porHora ? gratificacionDocenteHora(datos, 12) : montoDelMes(datos, gratificacion, 12));
total = total.add(porHora ? gratificacionDocenteHora(datos, 12) : gratificacionProporcional(datos, base, 12));
}
return total;
}
/**
* DOC2 ({@link ModalidadCalculo#MONTO_FIJO_DICIEMBRE_PROPORCIONAL}): el sueldo bruto y la
* gratificación de diciembre son proporcionales, monto ÷ 30 × 16. Cualquier otro mes o
* modalidad devuelve el monto sin cambios.
* CONDICIÓN FECHA DE CONTRATO (ADM y DOC2) — gratificación de julio (semestre enero-junio) o
* de diciembre (julio-diciembre) según cuánto del semestre cubre el contrato:
* <pre>((base / 6 × meses) + (base / 6 / 30 × días)) × 1.09</pre>
* <ul>
* <li>DOC1 / DOC sin tipo de pago: sin prorrateo → base × 1.09.</li>
* <li>Sin fecha de inicio de contrato: 0 (la fecha es obligatoria para calcularla).</li>
* <li>Contrato anterior al semestre: semestre completo (6 meses, 0 días → base × 1.09).</li>
* <li>Contrato posterior al semestre: 0.</li>
* <li>Ingreso dentro del semestre: meses = meses completos después del de ingreso hasta
* fin del semestre (si entra el día 1, el mes de ingreso también cuenta completo).</li>
* <li>Días = días del mes en planilla.pl_calendario − día de ingreso + 1 (0 si entra el
* día 1), en cualquier mes de ingreso salvo enero: ingreso en enero → solo meses
* (semestre completo). Igual en ADM y DOC2.</li>
* <li>DOC2, diciembre: diciembre no es mes completo sino 16 días. Semestre completo =
* 5 meses (julio-noviembre) + 16 días; ingreso en diciembre → 16 − día + 1 días.
* Excepción: si el DNI está en planilla.pl_docente_grati_diciembre_completa, diciembre
* va completo (30 días) y se calcula igual que ADM.</li>
* <li>Ingreso en junio después del día 1 (ADM y DOC2): no hay gratificación de julio; los
* días de junio (días del mes − día + 1) se suman a la de diciembre, que además toma su
* semestre completo (6 meses en ADM; 5 meses + 16 días en DOC2).</li>
* </ul>
*/
private BigDecimal gratificacionProporcional(DatosCalculo datos, BigDecimal base, int mesGratificacion) {
if (!datos.gratificacionPorContrato()) {
return gratificacion(base);
}
LocalDate inicio = datos.fechaInicioContrato();
if (inicio == null) {
return BigDecimal.ZERO;
}
boolean docenteMontoFijo = datos.modalidad() == ModalidadCalculo.MONTO_FIJO_DICIEMBRE_PROPORCIONAL;
boolean diciembreProporcional = docenteMontoFijo && mesGratificacion == 12
&& !datos.gratificacionDiciembreCompleta();
int primerMes = mesGratificacion == 7 ? 1 : 7;
int ultimoMes = mesGratificacion == 7 ? 6 : 12;
int ultimoMesCompleto = diciembreProporcional ? 11 : ultimoMes;
int diasFinales = diciembreProporcional ? DIAS_DICIEMBRE_PROPORCIONAL.intValue() : 0;
int anio = datos.periodo();
int mesIngreso = inicio.getMonthValue();
boolean ingresoJunioPosteriorAlDia1 = inicio.getYear() == anio && mesIngreso == 6 && inicio.getDayOfMonth() > 1;
int meses;
int dias;
if (ingresoJunioPosteriorAlDia1) {
if (mesGratificacion == 7) {
return BigDecimal.ZERO;
}
meses = ultimoMesCompleto - primerMes + 1;
dias = datos.diasMesInicioContrato() - inicio.getDayOfMonth() + 1 + diasFinales;
} else if (inicio.getYear() < anio || mesIngreso < primerMes || mesIngreso == 1) {
meses = ultimoMesCompleto - primerMes + 1;
dias = diasFinales;
} else if (inicio.getYear() > anio || mesIngreso > ultimoMes) {
return BigDecimal.ZERO;
} else if (diciembreProporcional && mesIngreso == 12) {
meses = 0;
dias = Math.max(0, diasFinales - inicio.getDayOfMonth() + 1);
} else {
boolean mesCompleto = inicio.getDayOfMonth() == 1;
meses = ultimoMesCompleto - mesIngreso + (mesCompleto ? 1 : 0);
dias = (mesCompleto ? 0 : datos.diasMesInicioContrato() - inicio.getDayOfMonth() + 1) + diasFinales;
}
BigDecimal porMes = base.divide(MESES_SEMESTRE, 10, RoundingMode.HALF_UP);
BigDecimal porDia = porMes.divide(DIAS_MES, 10, RoundingMode.HALF_UP);
return gratificacion(porMes.multiply(BigDecimal.valueOf(meses)).add(porDia.multiply(BigDecimal.valueOf(dias))));
}
/**
* DOC2 ({@link ModalidadCalculo#MONTO_FIJO_DICIEMBRE_PROPORCIONAL}): el sueldo bruto de
* diciembre es proporcional, monto ÷ 30 × 16 (la gratificación de diciembre aplica los 16 días
* en {@link #gratificacionProporcional}). Cualquier otro mes o modalidad devuelve el monto sin
* cambios.
*/
private BigDecimal montoDelMes(DatosCalculo datos, BigDecimal monto, int mes) {
if (mes != 12 || datos.modalidad() != ModalidadCalculo.MONTO_FIJO_DICIEMBRE_PROPORCIONAL) {
......@@ -293,7 +369,11 @@ public class RentaQuintaCalculadora {
* {@code proyeccionDocenteHora} (suma de remun_bruta_futura de los meses posteriores) y
* {@code sueldoBrutoMesCorteDocenteHora} (remun_bruta_futura del mes de corte) solo se usan en
* {@link ModalidadCalculo#DOCENTE_POR_HORA}; {@code sueldoBrutoRealMesCorte} es el sueldo
* bruto de la boleta real del mes de corte que ese monto reemplaza. La
* bruto de la boleta real del mes de corte que ese monto reemplaza.
* {@code gratificacionPorContrato} es true en ADM y DOC2: la gratificación se prorratea por
* {@code fechaInicioContrato} (sin fecha = 0); {@code diasMesInicioContrato} son los días de
* ese mes según planilla.pl_calendario. {@code gratificacionDiciembreCompleta} es true en DOC2
* cuando el DNI está en planilla.pl_docente_grati_diciembre_completa. La
* UIT, las 7 UIT, los tramos y el divisor son de ley e iguales para todas las planillas.
*/
public record DatosCalculo(
......@@ -319,6 +399,10 @@ public class RentaQuintaCalculadora {
BigDecimal otrosEmpleadoresImporteProyeccion,
BigDecimal otrosEmpleadoresRetencionAnterior,
BigDecimal otrosEmpleadoresRetencionMesActual,
BigDecimal otrosEmpleadoresRetencionProyeccion
BigDecimal otrosEmpleadoresRetencionProyeccion,
boolean gratificacionPorContrato,
LocalDate fechaInicioContrato,
int diasMesInicioContrato,
boolean gratificacionDiciembreCompleta
) {}
}
package so.planilla_be.rentaquinta.dto;
import java.math.BigDecimal;
import java.time.LocalDate;
/**
* Fila de la grilla "Proyección y control de quinta categoría".
......@@ -14,6 +15,7 @@ public record RentaQuintaResumenDTO(
Integer periodo,
String numeroDocumento,
String nombreCompleto,
LocalDate fechaInicioContrato,
Integer mesProceso,
BigDecimal historialReal,
BigDecimal planillaCargada,
......
package so.planilla_be.rentaquinta.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.time.LocalDate;
/**
* Mes del calendario (planilla.pl_calendario, una fila por año + mes). Lo carga
* docs/create-calendario.sql; el API solo lo lee.
*/
@Getter
@Setter
@Entity
@Table(name = "pl_calendario")
@IdClass(CalendarioId.class)
public class Calendario {
@Id
@Column(name = "anio")
private Integer anio;
@Id
@Column(name = "mes")
private Integer mes;
@Column(name = "nombre_mes")
private String nombreMes;
@Column(name = "fecha_inicio")
private LocalDate fechaInicio;
@Column(name = "fecha_fin")
private LocalDate fechaFin;
@Column(name = "dias")
private Integer dias;
/** 1 = enero-junio (gratificación de julio), 2 = julio-diciembre (de diciembre). */
@Column(name = "semestre")
private Integer semestre;
@Column(name = "mes_gratificacion")
private Integer mesGratificacion;
}
package so.planilla_be.rentaquinta.entity;
import java.io.Serializable;
public record CalendarioId(Integer anio, Integer mes) implements Serializable {}
......@@ -8,6 +8,7 @@ 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.docentegratidiciembre.exception.DocenteGratiDiciembreCompletaNoEncontradaException;
import so.planilla_be.otrosempleadores.exception.RetencionOtrosEmpleadoresNoEncontradaException;
import so.planilla_be.otrosempleadores.exception.RetencionOtrosEmpleadoresYaExisteException;
import so.planilla_be.proyecciondocentehora.exception.ProyeccionDocenteHoraNoEncontradaException;
......@@ -34,6 +35,11 @@ public class GlobalExceptionHandler {
return build(HttpStatus.NOT_FOUND, ex.getMessage());
}
@ExceptionHandler(DocenteGratiDiciembreCompletaNoEncontradaException.class)
public ResponseEntity<Map<String, Object>> handleNotFound(DocenteGratiDiciembreCompletaNoEncontradaException 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());
......
......@@ -30,6 +30,7 @@ public class RentaQuintaMapper {
periodo,
referencia.getNumeroDocumento(),
nombreCompleto(referencia.getApellidoPaterno(), referencia.getApellidoMaterno(), referencia.getNombres()),
referencia.getFechaInicioContrato(),
mesCorte,
orZero(historialReal),
orZero(planillaCargada),
......
package so.planilla_be.rentaquinta.repository;
import org.springframework.data.jpa.repository.JpaRepository;
import so.planilla_be.rentaquinta.entity.Calendario;
import so.planilla_be.rentaquinta.entity.CalendarioId;
public interface CalendarioRepository extends JpaRepository<Calendario, CalendarioId> {
}
......@@ -5,6 +5,7 @@ import org.springframework.data.domain.PageRequest;
import org.springframework.data.domain.Pageable;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import so.planilla_be.docentegratidiciembre.repository.DocenteGratiDiciembreCompletaRepository;
import so.planilla_be.proyecciondocentehora.repository.ProyeccionDocenteHoraRepository;
import so.planilla_be.rentaquinta.calculo.CalculoRentaQuinta;
import so.planilla_be.rentaquinta.calculo.ModalidadCalculo;
......@@ -23,6 +24,9 @@ import so.planilla_be.rentaquinta.entity.RentaQuintaGrabada;
import so.planilla_be.rentaquinta.entity.RentaQuintaGrabadaId;
import so.planilla_be.rentaquinta.exception.RentaQuintaNoEncontradaException;
import so.planilla_be.rentaquinta.mapper.RentaQuintaMapper;
import so.planilla_be.rentaquinta.entity.Calendario;
import so.planilla_be.rentaquinta.entity.CalendarioId;
import so.planilla_be.rentaquinta.repository.CalendarioRepository;
import so.planilla_be.rentaquinta.repository.DocumentoResumenProjection;
import so.planilla_be.rentaquinta.repository.RentaQuintaGrabadaRepository;
import so.planilla_be.rentaquinta.repository.RentaQuintaRepository;
......@@ -35,6 +39,8 @@ import so.planilla_be.utilidades.entity.Utilidad;
import so.planilla_be.utilidades.repository.UtilidadRepository;
import java.math.BigDecimal;
import java.time.LocalDate;
import java.time.YearMonth;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.List;
......@@ -65,6 +71,8 @@ public class RentaQuintaServiceImpl implements RentaQuintaService {
private final UtilidadRepository utilidadRepository;
private final RentaQuintaGrabadaRepository rentaQuintaGrabadaRepository;
private final ProyeccionDocenteHoraRepository proyeccionDocenteHoraRepository;
private final CalendarioRepository calendarioRepository;
private final DocenteGratiDiciembreCompletaRepository docenteGratiDiciembreCompletaRepository;
public RentaQuintaServiceImpl(RentaQuintaRepository repository, RentaQuintaMapper mapper,
RentaQuintaCalculadora calculadora,
......@@ -72,7 +80,9 @@ public class RentaQuintaServiceImpl implements RentaQuintaService {
RentaBasicoRepository rentaBasicoRepository,
UtilidadRepository utilidadRepository,
RentaQuintaGrabadaRepository rentaQuintaGrabadaRepository,
ProyeccionDocenteHoraRepository proyeccionDocenteHoraRepository) {
ProyeccionDocenteHoraRepository proyeccionDocenteHoraRepository,
CalendarioRepository calendarioRepository,
DocenteGratiDiciembreCompletaRepository docenteGratiDiciembreCompletaRepository) {
this.repository = repository;
this.mapper = mapper;
this.calculadora = calculadora;
......@@ -81,6 +91,8 @@ public class RentaQuintaServiceImpl implements RentaQuintaService {
this.utilidadRepository = utilidadRepository;
this.rentaQuintaGrabadaRepository = rentaQuintaGrabadaRepository;
this.proyeccionDocenteHoraRepository = proyeccionDocenteHoraRepository;
this.calendarioRepository = calendarioRepository;
this.docenteGratiDiciembreCompletaRepository = docenteGratiDiciembreCompletaRepository;
}
/**
......@@ -396,6 +408,23 @@ public class RentaQuintaServiceImpl implements RentaQuintaService {
BigDecimal sueldoBrutoRealMesCorte = a.mesCorteReal()
? mapper.sueldoBrutoMes(historial, esMesCorte(mesCorte))
: BigDecimal.ZERO;
// Gratificación proporcional por fecha de contrato: ADM y DOC2, y obligatoria (sin fecha la
// gratificación es 0). Se toma de la boleta del mismo tipo (ADM / DOC) del mes de referencia.
boolean gratificacionPorContrato = tipoPlanilla == TipoPlanilla.MENSUAL_MONTO_FIJO
|| modalidad == ModalidadCalculo.MONTO_FIJO_DICIEMBRE_PROPORCIONAL;
LocalDate fechaInicioContrato = gratificacionPorContrato
? historial.stream()
.filter(esMesCorte(mesReferencia))
.filter(r -> TipoPlanilla.deBoleta(r) == tipoPlanilla)
.map(RentaQuinta::getFechaInicioContrato)
.filter(Objects::nonNull)
.findFirst()
.orElse(null)
: null;
// DOC2: si el DNI está en pl_docente_grati_diciembre_completa para el año, la gratificación
// de diciembre va con diciembre completo (30 días) en vez de 16.
boolean gratificacionDiciembreCompleta = modalidad == ModalidadCalculo.MONTO_FIJO_DICIEMBRE_PROPORCIONAL
&& docenteGratiDiciembreCompletaRepository.existsByDniAndAnio(numeroDocumento.trim(), periodo);
return calculadora.calcular(new DatosCalculo(
periodo, modalidad, proyeccionDocenteHora, sueldoBrutoMesCorteDocenteHora, sueldoBrutoRealMesCorte,
......@@ -406,7 +435,22 @@ public class RentaQuintaServiceImpl implements RentaQuintaService {
otrosEmpleadores.importeAnterior(), otrosEmpleadores.importeMesActual(),
otrosEmpleadores.importeProyeccion(),
otrosEmpleadores.retencionAnterior(), otrosEmpleadores.retencionMesActual(),
otrosEmpleadores.retencionProyeccion()));
otrosEmpleadores.retencionProyeccion(),
gratificacionPorContrato, fechaInicioContrato, diasDelMes(fechaInicioContrato),
gratificacionDiciembreCompleta));
}
/**
* Días del mes de la fecha según planilla.pl_calendario; si ese año aún no está cargado en el
* calendario, los del mes real (java.time).
*/
private int diasDelMes(LocalDate fecha) {
if (fecha == null) {
return 0;
}
return calendarioRepository.findById(new CalendarioId(fecha.getYear(), fecha.getMonthValue()))
.map(Calendario::getDias)
.orElseGet(() -> YearMonth.from(fecha).lengthOfMonth());
}
/**
......
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