Commit 68910855 by Vanesa Puma Yamo

se agregamo modifiaciones

parent 4bf1eb8a
...@@ -68,9 +68,26 @@ internamente `RentaQuintaFiltroDTO` y `DatosCalculo` sigan llamándolos así. ...@@ -68,9 +68,26 @@ internamente `RentaQuintaFiltroDTO` y `DatosCalculo` sigan llamándolos así.
`anio` y `mes` son **obligatorios**; el resto es opcional y, si no se envía, no filtra. Todos son `anio` y `mes` son **obligatorios**; el resto es opcional y, si no se envía, no filtra. Todos son
`Integer` salvo `documentoOApellidos`, que es texto (busca por `numero_documento` o por apellidos `Integer` salvo `documentoOApellidos`, que es texto (busca por `numero_documento` o por apellidos
+ nombres, con `LIKE` en mayúsculas). + nombres, con `LIKE` en mayúsculas), y `tipoPlanilla`.
Los filtros de catálogo (`sede`, `tipoPlanilla`, `areaGeneral`, `subArea`, `cargo`) llegan como `tipoPlanilla` acepta **solo `ADM`, `DOC1`, `DOC2`** (`DOC` sin dígito responde 400). Lo reciben `listado`, `detallado`, `grabar` y `grabar-masivo`. Las letras son
`boleta_tipo_personal` (enum `calculo/TipoPlanilla`) y el dígito es `pl_planilla.tipo_pago`
(`char(1)`, enum `calculo/TipoPago`); filtra por ambas columnas y `calculo/ModalidadCalculo`
decide la proyección. Si no se envía, se toma de la boleta. En `pl_renta_quinta` se graba
`boleta_tipo_personal` (`ADM`/`DOC`) y el dígito en `tipo_pago` (`1`/`2`, vacío en ADM; es parte de la
PK). Esa columna la crea `docs/alter-pl-renta-quinta-tipo-pago.sql`: correrlo antes de desplegar o
`ddl-auto: validate` no deja arrancar.
| 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` | igual que ADM |
| `DOC1` | `DOC` | `1` | `DOCENTE_POR_HORA` | `remun_bruta_futura` de `planilla.pl_proyeccion_renta_docentehora` del mes consultado (mismo documento, `boleta_tipo_personal='DOC'`, `proyeccion_anio` = año, `proyeccion_mes` = mes). Sin fila = 0 |
Solo cambia la proyección del sueldo; gratificaciones, UIT, tramos y divisor son iguales. Detalle
en `docs/circuito-api-renta-quinta.md`.
Los filtros de catálogo (`sede`, `areaGeneral`, `subArea`, `cargo`) llegan como
número y se comparan **como texto** contra columnas TEXT: el service los convierte con número y se comparan **como texto** contra columnas TEXT: el service los convierte con
`codigo()`. Las columnas `char(n)` se comparan con `trim()` por el padding de PostgreSQL. `codigo()`. Las columnas `char(n)` se comparan con `trim()` por el padding de PostgreSQL.
......
-- tipo_pago en planilla.pl_renta_quinta, tal como quedó en la base de pruebas (prueba):
-- boleta_tipo_personal = 'ADM', tipo_pago = ' ' (vacío) -> administrativo
-- boleta_tipo_personal = 'DOC', tipo_pago = '1' -> DOC1, docente costo por hora
-- boleta_tipo_personal = 'DOC', tipo_pago = '2' -> DOC2, docente monto fijo
--
-- tipo_pago es char(1), NOT NULL y parte de la PK: en ADM se graba vacío, nunca NULL.
-- Ejecutar ANTES de desplegar el backend en otro entorno: con ddl-auto: validate, la app no
-- arranca si falta la columna.
ALTER TABLE planilla.pl_renta_quinta
ADD COLUMN tipo_pago char(1) NOT NULL DEFAULT '';
ALTER TABLE planilla.pl_renta_quinta
DROP CONSTRAINT pl_renta_quinta_pkey,
ADD PRIMARY KEY (numero_documento, anio, mes, boleta_tipo_personal, tipo_pago);
COMMENT ON COLUMN planilla.pl_renta_quinta.tipo_pago IS
'Solo DOC: 1 = docente costo por hora (DOC1), 2 = docente monto fijo (DOC2). Vacío en ADM.';
# Circuito del API de renta de quinta, con tipo de planilla
Este documento sigue el recorrido de una petición **archivo por archivo y función por función**:
desde que el cliente (C#) llama al API hasta que recibe el JSON. También marca en qué puntos el
código cambia según el **tipo de planilla**.
> Todas las rutas empiezan en `src/main/java/so/planilla_be/`. El formato `archivo:línea` lleva
> directo a la función.
---
## 1. Tipos de planilla
El tipo de planilla sale de la columna **`boleta_tipo_personal`** de `planilla.pl_planilla`.
| Código en BD | Constante en Java | Planillas que cubre |
|---|---|---|
| `ADM` | `TipoPlanilla.MENSUAL_MONTO_FIJO` | Planilla mensual (monto fijo) |
| `DOC` | `TipoPlanilla.DOCENTE` | Docentes: monto fijo o por hora, según `tipo_pago` |
📄 Definición: `rentaquinta/calculo/TipoPlanilla.java`
El tipo de planilla filtra la grilla y es parte de la clave de `pl_renta_quinta`. La regla del
cálculo la decide **`ModalidadCalculo`** (📄 `rentaquinta/calculo/ModalidadCalculo.java`), que
combina el tipo con la columna **`tipo_pago`** (`char(1)`) de `pl_planilla`:
| boleta_tipo_personal | tipo_pago | `ModalidadCalculo` | Proyección del sueldo bruto |
|---|---|---|---|
| `ADM` | (vacío) | `MONTO_FIJO` | Remuneración de referencia × meses restantes |
| `DOC` | `2` | `MONTO_FIJO` | Igual que ADM |
| `DOC` | `1` | `DOCENTE_POR_HORA` | `remun_bruta_futura` de `planilla.pl_proyeccion_renta_docentehora` del mes consultado |
En DOC, el `tipo_pago` se lee de las boletas DOC del mes de referencia; vacío o desconocido = `MONTO_FIJO`.
| Función / variable | Para qué sirve |
|---|---|
| `MENSUAL_MONTO_FIJO` / `DOCENTE` | Tipos de planilla. En el código: `switch (tipoPlanilla) { case MENSUAL_MONTO_FIJO -> …; case DOCENTE -> …; }` |
| `getCodigo()` | Devuelve `"ADM"` o `"DOC"` |
| `esMensualOMontoFijo()` / `esDocente()` | Para escribir condiciones del tipo `if (tipo.esDocente())` |
| `TipoPago.desdeParametro("DOC1")` | Extrae el dígito de `DOC1`/`DOC2` → `POR_HORA` / `MONTO_FIJO` |
| `ModalidadCalculo.de(tipo, tipoPago)` | Devuelve `MONTO_FIJO` o `DOCENTE_POR_HORA` |
| `desdeParametro(String)` `:56` | Convierte el parámetro que manda C# (`"ADM"`, `"doc"`, `" DOC "`…) en el enum. Vacío = no se envió. Un código desconocido responde **400** |
| `deBoleta(RentaQuinta)` `:74` | Lee el tipo desde la boleta (hace `trim` porque la columna es `char(3)`). Si viene vacío o desconocido, usa `MENSUAL_MONTO_FIJO` |
¿Por qué `switch` y no `if`? Con `switch` sobre un enum, **Java no compila si falta un caso**.
Si mañana se agrega un tercer tipo de planilla, el compilador obliga a decidir qué hace en cada
punto condicionado, y así ninguno se olvida.
---
## 2. Cómo manda C# el tipo de planilla
Es el parámetro de query **`tipoPlanilla`** (opcional en listado/detallado, obligatorio al grabar):
| Valor | Qué calcula | Filtra por |
|---|---|---|
| `ADM` | Administrativo, cálculo normal | `boleta_tipo_personal='ADM'` |
| `DOC1` | Docente costo por hora: proyección = `remun_bruta_futura` del mes consultado | `'DOC'` + `tipo_pago='1'` |
| `DOC2` | Docente monto fijo: igual que ADM | `'DOC'` + `tipo_pago='2'` |
Las letras son `boleta_tipo_personal` (`TipoPlanilla`) y el dígito es `tipo_pago` (`TipoPago`).
No distingue mayúsculas (`doc1` vale). `DOC` sin dígito responde 400. En `pl_renta_quinta` se graba `boleta_tipo_personal` (`ADM`/`DOC`) y `tipo_pago` (`1`/`2`, vacío en ADM).
| Endpoint | Ejemplo |
|---|---|
| `GET /api/v1/renta-quinta/listado` | `?anio=2026&mes=8&tipoPlanilla=ADM` |
| `GET /api/v1/renta-quinta/detallado` | `?numeroDocumento=12345678&anio=2026&mes=8&tipoPlanilla=DOC1` |
| `POST /api/v1/renta-quinta/grabar` | `?numeroDocumento=12345678&anio=2026&mes=8&tipoPlanilla=DOC2` |
| `POST /api/v1/renta-quinta/grabar-masivo` | `?anio=2026&mes=8&tipoPlanilla=DOC1` |
**Regla de resolución** (función `tipoPlanilla(...)` en `RentaQuintaServiceImpl.java:151`):
```
¿C# envió tipoPlanilla?
├─ SÍ → se usa ese tipo (y en listado / grabar-masivo además FILTRA a los trabajadores de ese tipo)
└─ NO → se usa el boleta_tipo_personal de la boleta de referencia del trabajador
```
Si llega un valor inválido, por ejemplo `tipoPlanilla=XYZ`, la respuesta es:
```json
{ "timestamp": "...", "status": 400,
"message": "tipoPlanilla 'XYZ' no válido. Valores permitidos: ADM, DOC1, DOC2" }
```
---
## 3. Mapa de los puntos condicionados
| # | Punto | Archivo:línea | Qué decide | ADM | DOC |
|---|---|---|---|---|---|
| **C1** | Lectura del parámetro | `controller/RentaQuintaController.java:59, 78, 106, 123` | Convierte `"ADM"/"DOC"` en `TipoPlanilla` | ✔ | ✔ |
| **C2** | Filtro de la grilla | `repository/RentaQuintaRepository.java:21` (lo invoca `service/impl/RentaQuintaServiceImpl.java:90`) | Solo trae trabajadores con `trim(boleta_tipo_personal) = 'ADM'/'DOC'` | ✔ | ✔ |
| **C3** | Modalidad del trabajador | `service/impl/RentaQuintaServiceImpl.java:367` `modalidad()` | `ModalidadCalculo.de(tipo, tipo_pago)` | `MONTO_FIJO` | tipo_pago 2 → `MONTO_FIJO`; 1 → `DOCENTE_POR_HORA` (y lee `pl_proyeccion_renta_docentehora` en `calcular()` `:332`) |
| **C4** | Proyección del sueldo | `calculo/RentaQuintaCalculadora.java:160` `proyeccionSueldoBruto()` | Meses posteriores al de corte | Remuneración de referencia × meses que faltan | Tipo 2: igual que ADM. Tipo 1: `remun_bruta_futura` del mes consultado |
La base de la gratificación (`baseGratificacion()` `:451`) y su monto (`gratificacion()` `:173`,
base × 1.09) son **iguales** para ADM y DOC. Para encontrar los puntos, busca
`CONDICIÓN MODALIDAD` en el código.
### Qué NO se condiciona, y por qué
| Regla | Dónde | Motivo |
|---|---|---|
| UIT del año | `UitCache.valorPara` | Parámetro SUNAT, igual para todos |
| Deducción de 7 UIT | `RentaQuintaCalculadora.java:94` | Ley (art. 46 LIR) |
| Tramos 8/14/17/20/30 % | `impuestoSobre()` `:179` | Ley (art. 53 LIR) |
| Divisor del mes | `divisorDelMes()` `:221` | Reglamento (art. 40) |
| Retenciones previas y otros empleadores | `calcular()` `:101`, `otrosEmpleadores()` | Se descuentan igual para todo trabajador |
| Conceptos que suman como ingreso | `mapper/RentaQuintaMapper.java:177` `bloque()` | Salen de las mismas columnas de `pl_planilla` en ambos tipos. **Si el docente por hora necesita otros conceptos (p. ej. `horas_adicionales`), este sería el sexto punto a condicionar** |
---
## 4. Circuito detallado: `GET /listado`
Ejemplo: C# llama a `GET /api/v1/renta-quinta/listado?anio=2026&mes=8&tipoPlanilla=ADM`
### Paso 1 · Controller: recibe la petición
📄 `rentaquinta/controller/RentaQuintaController.java:47`, función `listado(...)`
1. Spring valida `anio` (obligatorio) y `mes` (1..12). Si falla → `exception/GlobalExceptionHandler.java` → 400.
2. **[C1]** `TipoPlanilla.desdeParametro("ADM")` → `MENSUAL_MONTO_FIJO`.
3. Arma el `RentaQuintaFiltroDTO` (📄 `dto/RentaQuintaFiltroDTO.java`), que ahora trae `TipoPlanilla tipoPlanilla`.
4. Llama a `service.buscar(filtro)`.
> `service` es la interfaz `service/RentaQuintaService.java`. Spring inyecta la clase real,
> `service/impl/RentaQuintaServiceImpl.java`.
### Paso 2 · Service: busca a los trabajadores
📄 `service/impl/RentaQuintaServiceImpl.java:84`, función `buscar(filtro)`
| Orden | Llamada | Archivo:línea | Qué hace |
|---|---|---|---|
| 2.1 | `codigo(filtro.tipoPlanilla())` | ServiceImpl `:143` | `MENSUAL_MONTO_FIJO` → `"ADM"` |
| 2.2 | `patron()`, `toPageable()` | ServiceImpl | Texto → `%TEXTO%`; página y tamaño (máx. 100) |
| 2.3 | **[C2]** `repository.buscarDocumentos(...)` | Repository `:60` (filtro en `:21`) | SQL: DNI + nombres de `pl_planilla` con `boleta_anio = 2026`, `boleta_mes <= 8`, `trim(boleta_tipo_personal) = 'ADM'` y el resto de filtros. Paginado |
| 2.4 | `repository.historialHastaMes(...)` | Repository `:79` | SQL: todas las boletas de esos DNI, de enero al mes 8 |
| 2.5 | `aplicarBasicoYUtilidades(...)` | ServiceImpl `:381` | Lee `pl_renta_basico` y `pl_utilidades`. Por cada boleta llama a **[C3]** `baseGratificacion()` `:407`, que según el `boleta_tipo_personal` **de esa boleta** fija `basicoGratificacion` |
| 2.6 | `obtenerRetencionesMesAnterior(...)` | ServiceImpl `:417` | Retención grabada del mes 7 en `pl_renta_quinta` |
### Paso 3 · Service: por cada trabajador de la página
Dentro de `documentos.map(...)`, en `RentaQuintaServiceImpl.java:109-134`:
| Orden | Llamada | Archivo:línea | Qué hace |
|---|---|---|---|
| 3.1 | `agregar(historial, mes)` | ServiceImpl `:291` | Boleta de referencia, `historialReal` (meses < 8), `planillaCargadaReal` (mes 8), `retenidoAnterior`, ¿ya cobró julio/diciembre? Para sumar los ingresos usa `mapper.totalBrutoMes()` → `bloque()` (Mapper `:173`, `:177`) |
| 3.2 | `tipoPlanilla(filtro.tipoPlanilla(), a.referencia())` | ServiceImpl `:112` → `:151` | **Decide el tipo del trabajador**: el enviado por C#, o el de su boleta |
| 3.3 | `calcular(a, historial, anio, mes, tipoPlanilla)` | ServiceImpl `:314` | Suma sueldo, `basicoGratificacion` y asignación del mes de referencia, y llama a `otrosEmpleadores()` `:342` (1 SQL por trabajador). Arma `DatosCalculo` **con el `tipoPlanilla`** y llama a la calculadora |
### Paso 4 · Calculadora: impuesto y retención
📄 `calculo/RentaQuintaCalculadora.java:46`, función `calcular(datos)`
```
calcular(datos) datos.tipoPlanilla() = MENSUAL_MONTO_FIJO
│
├─ UIT del año ............................ UitCache.valorPara(2026)
│
├─ ¿El mes 8 tiene boleta real?
│ NO → estimado = remuneración de referencia
│ si el mes es 7 o 12 → + gratificacion(estimado) = × 1.09 :173
│
├─ [C4] proyeccionSueldoBruto(datos) .................................... :160
│ switch (datos.modalidad())
│ MONTO_FIJO → remuneración × (12 − 8) (ADM y DOC tipo_pago 2)
│ DOCENTE_POR_HORA → remun_bruta_futura del mes 8 (DOC tipo_pago 1)
│
├─ gratificacionesPendientes(...) ....................................... :140
│ por cada julio / diciembre no cobrado:
│ gratificacion(basicoReferencia) = base × 1.09 (igual para todos)
│
├─ Renta bruta anual = historial + mes actual + proyección (+ otros empleadores)
├─ Renta neta = bruta − 7 UIT ................................... :94 (ley, no condicionado)
├─ Impuesto anual = impuestoSobre() tramos ........................... :179 (ley, no condicionado)
├─ Saldo = impuesto − retenido − otros empleadores ......... :101
└─ Retención del mes = saldo ÷ divisorDelMes(8) ......................... :221 (ley, no condicionado)
→ CalculoRentaQuinta (calculo/CalculoRentaQuinta.java)
```
### Paso 5 · Mapper: arma la fila
📄 `mapper/RentaQuintaMapper.java:25`, función `toResumen(...)` → `RentaQuintaResumenDTO`
(📄 `dto/RentaQuintaResumenDTO.java`). Spring lo convierte en JSON y lo devuelve a C#.
### Resumen visual
```
C# ── GET /listado?anio=2026&mes=8&tipoPlanilla=ADM ──►
RentaQuintaController.listado [C1] desdeParametro("ADM")
└► RentaQuintaServiceImpl.buscar
├► RentaQuintaRepository.buscarDocumentos [C2] trim(boleta_tipo_personal)='ADM'
├► RentaQuintaRepository.historialHastaMes
├► aplicarBasicoYUtilidades
│ └► baseGratificacion (por boleta)
├► obtenerRetencionesMesAnterior
└► por cada trabajador:
├► agregar → mapper.totalBrutoMes → bloque
├► tipoPlanilla(solicitado, referencia) ← decide el tipo del trabajador
├► calcular → otrosEmpleadores
│ ├► modalidad(tipo, tipo_pago) [C3]
│ ├► (DOC por hora) ProyeccionDocenteHoraRepository.remunBrutaFutura
│ └► RentaQuintaCalculadora.calcular
│ ├► proyeccionSueldoBruto [C4]
│ ├► gratificacion (estimado)
│ ├► gratificacionesPendientes → gratificacion
│ ├► impuestoSobre (ley)
│ └► divisorDelMes (ley)
└► mapper.toResumen
◄── JSON Page<RentaQuintaResumenDTO> ── C#
```
---
## 5. Circuito de `GET /detallado`
Ejemplo: `?numeroDocumento=12345678&anio=2026&mes=8&tipoPlanilla=DOC`
| Orden | Llamada | Archivo:línea |
|---|---|---|
| 1 | `detallado(...)` + **[C1]** `desdeParametro("DOC")` → `DOCENTE` | Controller `:73`, `:78` |
| 2 | `obtenerDetalleCompleto(doc, anio, mes, tipo)` | ServiceImpl `:195` |
| 3 | `repository.historialAnualPorDocumento` (todo el año del DNI) | ServiceImpl `:197` |
| 4 | `aplicarBasicoYUtilidades` → `baseGratificacion` | ServiceImpl `:451` |
| 5 | `agregar` → si no hay boleta de referencia, 404 | ServiceImpl `:291` |
| 6 | `tipoPlanilla(tipoSolicitado, referencia)` | ServiceImpl `:206` |
| 7 | `calcular(..., tipo)` → **[C3]** `modalidad` → calculadora **[C4]** | ServiceImpl `:332` |
| 8 | `mapper.toDetalleCompleto` (bloques pasado / actual / proyección, liquidación, tramos, control) | ServiceImpl `:208` → Mapper `:82` |
`POST /grabar` pasa por la misma cadena (`grabarRetencion` → `obtenerDetalleCompleto`).
⚠️ Hoy el `upsertRetencion` está **comentado**: calcula, pero no graba.
`POST /grabar-masivo` → `grabarSegunFiltro` (ServiceImpl `:246`) repite el circuito del listado
en lotes de 200, con **[C2]** filtrando y `tipoPlanilla(...)` en `:275`, y graba con `upsertRetencion`.
## 6. Endpoints sin cálculo
`GET /{id}`, `GET /boleta` y `GET /historial/{codper}/{anio}` no reciben `tipoPlanilla`: solo
leen la boleta. Pasan por `aplicarBasicoYUtilidades` y la
devuelven con `mapper.toDetalle` / `mapper.toHistorial`.
---
## 7. Docente por hora (DOC, tipo_pago 1)
Solo cambia la **proyección del sueldo bruto** [C4]. Se lee de
`planilla.pl_proyeccion_renta_docentehora` (📄 `proyecciondocentehora/`):
```sql
select coalesce(sum(remun_bruta_futura), 0)
from planilla.pl_proyeccion_renta_docentehora
where numero_documento = :doc
and trim(boleta_tipo_personal) = 'DOC'
and proyeccion_anio = :anio
and proyeccion_mes = :mes -- mes consultado
```
- Se toma la fila del **mes consultado**: su `remun_bruta_futura` es la proyección del sueldo
bruto de ese mes de corte, sin multiplicar ni sumar otros meses.
- Sin fila para el docente → proyección 0 (no cae a remuneración × meses).
- DOC2 (`tipo_pago = 2`) sigue el flujo de siempre: remuneración de referencia × meses restantes.
- Historial, mes actual, estimado del mes de corte sin boleta, gratificaciones, UIT, tramos y
divisor: igual que ADM.
## 8. Decisiones a confirmar
1. **Trabajador con boletas ADM y DOC en el mismo año**: hoy el ingreso **suma todas sus
boletas** (mismo empleador; la ley calcula sobre el total). El tipo solo define **qué reglas**
se aplican: el enviado por C#, o el de la boleta de referencia.
2. **Código vacío o desconocido en `boleta_tipo_personal`**: se trata como `ADM`.
3. **`tipoPlanilla` es opcional**: si C# no lo envía, el tipo se toma de la boleta. Si debe ser
obligatorio, basta con poner `@RequestParam` sin `required = false` en el controller.
# Origen del básico para la gratificación (`basicoGratificacion`)
Este campo solo se usa para calcular la **gratificación pendiente** (julio/diciembre) en
`RentaQuintaCalculadora.gratificacionesPendientes`. Se llena en
`RentaQuintaServiceImpl.aplicarBasicoYUtilidades` (líneas 361-382):
```java
Map<String, BigDecimal> basicoPorDocumento = rentaBasicoRepository
.findByAnioAndNumeroDocumentoIn(anio, documentos).stream()
.collect(Collectors.toMap(RentaBasico::getNumeroDocumento, RentaBasico::getMontoBasico, (a, b) -> a));
for (RentaQuinta r : historial) {
BigDecimal basicoGratificacion = basicoPorDocumento.get(r.getNumeroDocumento());
r.setBasicoGratificacion(basicoGratificacion != null ? basicoGratificacion : r.getBasico());
...
}
```
La regla es simple: **¿existe algún registro en `pl_renta_basico` para ese trabajador en ese
año?** (no filtra por mes, así que si hay varios se queda con uno arbitrario, por el
`(a, b) -> a` del `toMap`).
- Si existe → se usa `montoBasico` de `pl_renta_basico`.
- Si no existe → se usa `basico` de la propia boleta (`pl_planilla`) del mes de referencia.
---
## Caso 1 — usa `pl_renta_basico`
Trabajador `12345678`, periodo 2026, mes de corte julio.
**`pl_renta_basico`** tiene un registro para este trabajador en 2026:
| numero_documento | anio | mes | monto_basico |
|---|---|---|---|
| 12345678 | 2026 | 3 | 2,000.00 |
**`pl_planilla`** (boleta de junio, mes de referencia porque julio aún no está cargado):
| numero_documento | boleta_anio | boleta_mes | basico |
|---|---|---|---|
| 12345678 | 2026 | 6 | 1,800.00 |
`basicoPorDocumento.get("12345678")` devuelve `2000.00` (no es `null`) →
```java
r.setBasicoGratificacion(2000.00); // gana pl_renta_basico
```
Gratificación pendiente = `2000.00 × 1.09 = 2,180.00` (por julio, y otra vez si diciembre
tampoco fue percibida).
---
## Caso 2 — usa `pl_planilla`
Mismo escenario, pero el trabajador `87654321` **no tiene ningún registro** en
`pl_renta_basico` para 2026.
**`pl_renta_basico`**: sin filas para `87654321` en 2026 (o directamente la tabla no tiene
datos suyos).
**`pl_planilla`** (boleta de junio, mes de referencia):
| numero_documento | boleta_anio | boleta_mes | basico |
|---|---|---|---|
| 87654321 | 2026 | 6 | 1,800.00 |
`basicoPorDocumento.get("87654321")` devuelve `null` →
```java
r.setBasicoGratificacion(r.getBasico()); // cae al básico de pl_planilla: 1,800.00
```
Gratificación pendiente = `1,800.00 × 1.09 = 1,962.00`.
---
## Nota
El resto de la boleta (sueldo bruto, asignación familiar, etc.) siempre sale de `pl_planilla`;
`pl_renta_basico` **solo** puede pisar el básico usado específicamente para proyectar la
gratificación de julio/diciembre — nunca reemplaza el `basico` que se muestra en la boleta ni
afecta el sueldo bruto del mes.
# Parámetros de los endpoints — renta de quinta categoría # Endpoints — renta de quinta categoría
Solo documenta parámetros de entrada (query/path). Las respuestas no cambiaron de estructura.
Base: `/api/v1/renta-quinta` Base: `/api/v1/renta-quinta`
`anio` y `mes` son los nombres usados en **todos** los endpoints — antes `/listado` usaba `anio` y `mes` son los nombres usados en **todos** los endpoints.
`periodo`/`mesCorte`, ahora unificado.
## Tipo de planilla (`tipoPlanilla`)
Solo se aceptan tres valores (no distingue mayúsculas). `DOC` sin dígito responde 400.
| `tipoPlanilla` | Filtra en `pl_planilla` | Proyección del sueldo bruto | Se graba en `pl_renta_quinta` |
|---|---|---|---|
| `ADM` | `boleta_tipo_personal = 'ADM'` | Remuneración de referencia × meses restantes | `boleta_tipo_personal = 'ADM'`, `tipo_pago` vacío (`' '`) |
| `DOC1` | `boleta_tipo_personal = 'DOC'` y `tipo_pago = '1'` | `remun_bruta_futura` de `pl_proyeccion_renta_docentehora` del mes consultado | `boleta_tipo_personal = 'DOC'`, `tipo_pago = '1'` |
| `DOC2` | `boleta_tipo_personal = 'DOC'` y `tipo_pago = '2'` | Igual que ADM | `boleta_tipo_personal = 'DOC'`, `tipo_pago = '2'` |
- `DOC1` = profesor costo por hora. `DOC2` = profesor monto fijo.
- Solo cambia la proyección del sueldo bruto; gratificaciones, UIT, tramos y divisor son iguales.
- En `listado` y `detallado` es opcional: si no se envía, no filtra y el tipo se toma de la boleta
del trabajador. En `grabar` y `grabar-masivo` es **obligatorio**.
---
## GET `/listado` ## GET `/listado`
...@@ -15,8 +29,8 @@ Grilla paginada de retenciones, con cálculo. ...@@ -15,8 +29,8 @@ Grilla paginada de retenciones, con cálculo.
|---|---|---|---| |---|---|---|---|
| `anio` | Integer | sí | Periodo (año) de la grilla | | `anio` | Integer | sí | Periodo (año) de la grilla |
| `mes` | Integer (1-12) | sí | Mes de corte | | `mes` | Integer (1-12) | sí | Mes de corte |
| `tipoPlanilla` | String (`ADM` / `DOC1` / `DOC2`) | no | Filtra y decide el cálculo (ver tabla arriba) |
| `sede` | Integer | no | Filtro de catálogo (comparado como texto) | | `sede` | Integer | no | Filtro de catálogo (comparado como texto) |
| `tipoPlanilla` | Integer | no | Filtro de catálogo (comparado como texto) |
| `areaGeneral` | Integer | no | Filtro de catálogo (comparado como texto) | | `areaGeneral` | Integer | no | Filtro de catálogo (comparado como texto) |
| `subArea` | Integer | no | Filtro de catálogo (comparado como texto) | | `subArea` | Integer | no | Filtro de catálogo (comparado como texto) |
| `cargo` | Integer | no | Filtro de catálogo (comparado como texto) | | `cargo` | Integer | no | Filtro de catálogo (comparado como texto) |
...@@ -24,24 +38,75 @@ Grilla paginada de retenciones, con cálculo. ...@@ -24,24 +38,75 @@ Grilla paginada de retenciones, con cálculo.
| `page` | Integer | no (default 0) | Página | | `page` | Integer | no (default 0) | Página |
| `size` | Integer | no (default 20, tope 100) | Tamaño de página | | `size` | Integer | no (default 20, tope 100) | Tamaño de página |
## GET `/{id}` ```
GET /api/v1/renta-quinta/listado?anio=2026&mes=8&tipoPlanilla=DOC1&page=0&size=20
```
Boleta completa por `id_renta_quinta`. ## GET `/detallado`
Detalle completo del cálculo (boleta del mes + historial de ingresos, parámetros SUNAT,
liquidación anual, escala por tramos y control de retenciones).
| Parámetro | Tipo | Obligatorio | Descripción | | Parámetro | Tipo | Obligatorio | Descripción |
|---|---|---|---| |---|---|---|---|
| `id` (path) | Long | sí | `id_renta_quinta` | | `numeroDocumento` | String | sí | Documento del trabajador |
| `anio` | Integer | sí | Periodo (año) |
| `mes` | Integer (1-12) | sí | Mes de corte |
| `tipoPlanilla` | String (`ADM` / `DOC1` / `DOC2`) | no | Reglas del cálculo; si no se envía, se toma de la boleta |
## GET `/detallado` ```
GET /api/v1/renta-quinta/detallado?numeroDocumento=42564692&anio=2026&mes=8&tipoPlanilla=DOC1
```
Detalle completo del cálculo (boleta del mes + historial de ingresos, parámetros SUNAT, ## POST `/grabar`
liquidación anual, escala por tramos y control de retenciones).
Calcula y graba (upsert) la retención del mes de un trabajador en `pl_renta_quinta`. Responde
`200` sin cuerpo.
| Parámetro | Tipo | Obligatorio | Descripción | | Parámetro | Tipo | Obligatorio | Descripción |
|---|---|---|---| |---|---|---|---|
| `numeroDocumento` | String | sí | Documento del trabajador | | `numeroDocumento` | String | sí | Documento del trabajador |
| `anio` | Integer | sí | Periodo (año) | | `anio` | Integer | sí | Periodo (año) |
| `mes` | Integer (1-12) | sí | Mes de corte | | `mes` | Integer (1-12) | sí | Mes de corte |
| `tipoPlanilla` | String (`ADM` / `DOC1` / `DOC2`) | **sí** | Decide el cálculo y lo que se graba en `boleta_tipo_personal` + `tipo_pago` |
```
POST /api/v1/renta-quinta/grabar?numeroDocumento=42564692&anio=2026&mes=8&tipoPlanilla=DOC2
```
## POST `/grabar-masivo`
Graba (upsert) la retención del mes de **todos** los trabajadores que cumplen los filtros (mismos
que `listado`, sin paginar).
| Parámetro | Tipo | Obligatorio | Descripción |
|---|---|---|---|
| `anio` | Integer | sí | Periodo (año) |
| `mes` | Integer (1-12) | sí | Mes de corte |
| `tipoPlanilla` | String (`ADM` / `DOC1` / `DOC2`) | **sí** | Filtra, decide el cálculo y lo que se graba |
| `sede` | Integer | no | Filtro de catálogo |
| `areaGeneral` | Integer | no | Filtro de catálogo |
| `subArea` | Integer | no | Filtro de catálogo |
| `cargo` | Integer | no | Filtro de catálogo |
| `documentoOApellidos` | String | no | `numero_documento` o apellidos + nombres |
```
POST /api/v1/renta-quinta/grabar-masivo?anio=2026&mes=8&tipoPlanilla=ADM
```
Respuesta:
```json
{ "trabajadoresGrabados": 3 }
```
## GET `/{id}`
Boleta completa por `id_renta_quinta`.
| Parámetro | Tipo | Obligatorio | Descripción |
|---|---|---|---|
| `id` (path) | Long | sí | `id_renta_quinta` |
## GET `/boleta` ## GET `/boleta`
...@@ -61,3 +126,16 @@ Meses del trabajador en el periodo. ...@@ -61,3 +126,16 @@ Meses del trabajador en el periodo.
|---|---|---|---| |---|---|---|---|
| `codper` (path) | Integer | sí | Código de personal | | `codper` (path) | Integer | sí | Código de personal |
| `anio` (path) | Integer | sí | Periodo (año) | | `anio` (path) | Integer | sí | Periodo (año) |
---
## Errores
Formato: `{ "timestamp", "status", "message" }`.
| Caso | Status | `message` |
|---|---|---|
| `tipoPlanilla` inválido (p. ej. `DOC`, `DOC3`) | 400 | `tipoPlanilla 'DOC' no válido. Valores permitidos: ADM, DOC1, DOC2` |
| Falta `tipoPlanilla` en `grabar` / `grabar-masivo` | 400 | `Falta el parámetro obligatorio 'tipoPlanilla'` |
| `mes` fuera de 1-12 u otro parámetro obligatorio ausente | 400 | Detalle del parámetro |
| Trabajador sin boleta en el periodo (`detallado`, `grabar`) | 404 | `No existe boleta para documento ..., periodo ...` |
# Grabado de retención en `pl_renta_quinta` — Guía para el Frontend
Base path: `/api/v1/renta-quinta`
Swagger UI: `/docs`
`GET /detallado` sigue siendo **solo consulta**: calcula y devuelve el reporte, no graba nada.
El grabado en `pl_renta_quinta` es una acción **aparte**, disparada por un botón — no ocurre
automáticamente al consultar el detallado.
---
## Flujo recomendado en pantalla
1. El usuario consulta el reporte:
`GET /detallado?numeroDocumento={doc}&anio={anio}&mes={mes}`
→ se muestra el detalle completo, incluida la fila **"RETENCIÓN DEL MES ACTUAL"**
(`controlRetenciones.retencionMesActual`).
2. El usuario aprieta el botón **"Grabar"** (o "Guardar retención") de esa pantalla.
3. El frontend llama a `POST /grabar` con los **mismos** `numeroDocumento`, `anio` y `mes` que
ya está mostrando en pantalla — no hace falta volver a calcular nada en el front, el backend
recalcula internamente con la misma lógica.
```
POST /api/v1/renta-quinta/grabar?numeroDocumento={doc}&anio={anio}&mes={mes}
```
Respuesta: `200 OK`, sin body.
El valor que queda grabado en `pl_renta_quinta.retencion_mes_actual` es exactamente
`controlRetenciones.retencionMesActual` del `/detallado` — el mismo número que el usuario vio en
pantalla antes de apretar el botón.
## Grabado masivo (grilla)
Para un botón "Grabar todo" desde la grilla (`/listado`), usar el mismo filtro que la grilla,
sin el tope de página:
```
POST /api/v1/renta-quinta/grabar-masivo?anio={anio}&mes={mes}&sede=...&tipoPlanilla=...&areaGeneral=...&subArea=...&cargo=...&documentoOApellidos=...
```
Todos los filtros salvo `anio` y `mes` son opcionales, igual que en `/listado`. Respuesta:
```json
{ "trabajadoresGrabados": 3 }
```
## `mes` = mes de corte / proyección, no "último mes con boleta real"
`mes` es el mes que el usuario está consultando/proyectando — puede ser un mes sin boleta real
todavía (el backend lo estima con el sueldo del mes de referencia). Ejemplo: si los datos reales
llegan hasta julio y el usuario quiere ver/grabar la proyección de agosto, el frontend manda
`mes=8`, no `mes=7`.
## Cada mes es una fila independiente (no pisa las anteriores)
La PK de `pl_renta_quinta` es `(numero_documento, anio, mes, boleta_tipo_personal)`. Grabar `mes=9` no borra ni
modifica la fila de `mes=8` del mismo trabajador — cada mes consultado queda como su propia fila
(upsert por esa combinación). Si el usuario recalcula el mismo `mes` dos veces, se actualiza esa
misma fila, no se duplica.
## Errores
Si `numeroDocumento` + `anio` + `mes` no tiene ningún dato (ni boleta real ni mes anterior de
referencia), el backend responde `404` con el body estándar `{timestamp, status, message}` de
`GlobalExceptionHandler` — no graba nada.
# Circuito de grabado en `planilla.pl_renta_quinta`
Este documento describe el flujo del endpoint `POST /api/v1/renta-quinta/grabar`, que persiste
la retención de quinta categoría calculada para un trabajador en un mes puntual.
## Tabla destino
`planilla.pl_renta_quinta` — **no** es una boleta completa (eso sigue viviendo en
`pl_planilla`). Solo guarda:
| Columna | Tipo | Notas |
|---|---|---|
| `numero_documento` | `varchar(8)` | PK (compuesta) |
| `anio` | `integer` | PK (compuesta) |
| `mes` | `integer` | PK (compuesta) |
| `boleta_tipo_personal` | `char(3)` | PK (compuesta): `ADM` / `DOC` (`calculo/TipoPlanilla`) |
| `retencion_mes_actual` | `numeric` | resultado del cálculo, NOT NULL |
| `tipo_pago` | `char(1)` | Solo DOC: `1` = DOC1 (costo por hora), `2` = DOC2 (monto fijo). Vacío (`' '`) en ADM. PK (compuesta), NOT NULL (`calculo/TipoPago`) |
| `tipoPlanilla` enviado | `boleta_tipo_personal` | `tipo_pago` |
|---|---|---|
| `ADM` | `ADM` | vacío (`' '`) |
| `DOC1` | `DOC` | `1` |
| `DOC2` | `DOC` | `2` |
PK compuesta `(numero_documento, anio, mes, boleta_tipo_personal, tipo_pago)`: una fila por trabajador,
periodo y tipo de planilla; `tipo_pago` es parte de la PK y NOT NULL, por eso ADM graba un espacio y no NULL. El tipo grabado es **siempre** el
`tipoPlanilla` enviado por el cliente (obligatorio en `/grabar` y `/grabar-masivo`; sin él o
vacío responde 400). No se toma de la boleta ni se rellena por defecto. `ddl-auto: validate`,
así que el mapeo tiene que calzar exacto con esas 6 columnas (`boleta_tipo_personal` y
`tipo_pago` con `columnDefinition = "bpchar"`). La columna `tipo_pago` se crea con
`docs/alter-pl-renta-quinta-tipo-pago.sql`, **antes** de desplegar.
## Endpoint
```
POST /api/v1/renta-quinta/grabar?numeroDocumento={doc}&anio={anio}&mes={mes}&tipoPlanilla={ADM|DOC1|DOC2}
```
`RentaQuintaController.grabar` (`controller/RentaQuintaController.java`) valida `anio` obligatorio
y `mes` entre 1 y 12, y delega en el service. Responde `200 OK` sin body.
## Circuito paso a paso
```
POST /grabar
│
▼
RentaQuintaController.grabar(numeroDocumento, anio, mes)
│
▼
RentaQuintaServiceImpl.grabarRetencion(numeroDocumento, anio, mes) [@Transactional]
│
├─► obtenerDetalleCompleto(numeroDocumento, anio, mes)
│ │ (el MISMO cálculo que usa GET /detallado — no hay una fórmula aparte)
│ ├─ repository.historialAnualPorDocumento(numeroDocumento, anio)
│ ├─ aplicarBasicoYUtilidades(historial, anio) → básico de pl_planilla / pl_renta_basico,
│ │ básicoGratificacion, utilidades de pl_utilidades
│ ├─ agregar(historial, mesCorte) → historialReal, planillaCargada, retenidoAnterior
│ ├─ calcular(...) → RentaQuintaCalculadora.calcular(DatosCalculo)
│ │ → renta bruta anual, renta neta, impuesto anual,
│ │ retencionDelMes (impuesto anual − retenido previo) ÷ divisor del mes
│ └─ mapper.toDetalleCompleto(...) → RentaQuintaDetalleCompletoDTO
│ (control.retencionMesActual = retencionDelMes)
│
├─► detalle.controlRetenciones().retencionMesActual() ← el número que se graba
│
├─► rentaQuintaGrabadaRepository.findById(numeroDocumento, anio, mes)
│ existe fila → la reutiliza (UPDATE)
│ no existe → RentaQuintaGrabada nueva (INSERT)
│
└─► rentaQuintaGrabadaRepository.save(...) → UPSERT en pl_renta_quinta
```
## Piezas involucradas
| Capa | Clase |
|---|---|
| Controller | `RentaQuintaController.grabar` |
| Service (interfaz) | `RentaQuintaService.grabarRetencion` |
| Service (impl) | `RentaQuintaServiceImpl.grabarRetencion` |
| Entity destino | `entity.RentaQuintaGrabada` (`@Table(name = "pl_renta_quinta")`) |
| PK compuesta | `entity.RentaQuintaGrabadaId` |
| Repositorio | `repository.RentaQuintaGrabadaRepository` |
## Por qué reusa `obtenerDetalleCompleto`
Para que la retención grabada sea siempre idéntica a la que se muestra en
`GET /detallado` (misma boleta, mismos otros empleadores, mismo básico de gratificación, etc.),
no se repite el cálculo: se llama al mismo método que arma el DTO completo y se toma solo el
campo `controlRetenciones.retencionMesActual` para persistirlo.
## Idempotencia
El `findById` + `save` sobre la PK compuesta hace que grabar dos veces el mismo
`numeroDocumento` + `anio` + `mes` + tipo de planilla actualice la misma fila (UPSERT), no duplique registros.
...@@ -19,7 +19,7 @@ Grilla "Proyección y control de quinta categoría". Cada fila es **un trabajado ...@@ -19,7 +19,7 @@ Grilla "Proyección y control de quinta categoría". Cada fila es **un trabajado
| `periodo` | Integer | **Sí** | — | Año a consultar | | `periodo` | Integer | **Sí** | — | Año a consultar |
| `mesCorte` | Integer | **Sí** | — | 1–12. Mes de corte de la proyección | | `mesCorte` | Integer | **Sí** | — | 1–12. Mes de corte de la proyección |
| `sede` | Integer | No | — | Filtro de catálogo (ver nota abajo) | | `sede` | Integer | No | — | Filtro de catálogo (ver nota abajo) |
| `tipoPlanilla` | Integer | No | — | Filtro de catálogo | | `tipoPlanilla` | String | No | — | `ADM` (administrativo), `DOC1` (docente costo por hora, proyección = `remun_bruta_futura` del mes consultado), o `DOC2` (docente monto fijo, cálculo como ADM). `DOC` sin dígito → 400 |
| `areaGeneral` | Integer | No | — | Filtro de catálogo | | `areaGeneral` | Integer | No | — | Filtro de catálogo |
| `subArea` | Integer | No | — | Filtro de catálogo | | `subArea` | Integer | No | — | Filtro de catálogo |
| `cargo` | Integer | No | — | Filtro de catálogo | | `cargo` | Integer | No | — | Filtro de catálogo |
......
package so.planilla_be.proyecciondocentehora.entity;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.Id;
import jakarta.persistence.Table;
import lombok.Getter;
import lombok.Setter;
import java.math.BigDecimal;
/**
* Remuneración bruta proyectada por mes de un docente por hora (DOC con tipo_pago = 1), en
* planilla.pl_proyeccion_renta_docentehora. Reemplaza a "remuneración × meses restantes" en la
* proyección del sueldo bruto de renta de quinta. Solo lectura: la carga es externa.
*/
@Getter
@Setter
@Entity
@Table(name = "pl_proyeccion_renta_docentehora")
public class ProyeccionDocenteHora {
@Id
@Column(name = "id_proyeccion")
private Long idProyeccion;
@Column(name = "proyeccion_anio", nullable = false)
private Integer proyeccionAnio;
@Column(name = "proyeccion_mes", nullable = false)
private Integer proyeccionMes;
@Column(name = "numero_documento", length = 15, nullable = false)
private String numeroDocumento;
@Column(name = "boleta_tipo_personal", length = 10, nullable = false)
private String boletaTipoPersonal;
@Column(name = "remun_bruta_futura", precision = 12, scale = 2)
private BigDecimal remunBrutaFutura;
}
package so.planilla_be.proyecciondocentehora.repository;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
import so.planilla_be.proyecciondocentehora.entity.ProyeccionDocenteHora;
import java.math.BigDecimal;
public interface ProyeccionDocenteHoraRepository extends JpaRepository<ProyeccionDocenteHora, Long> {
/**
* remun_bruta_futura del docente para el mes consultado: es directamente la proyección del
* sueldo bruto de ese mes de corte. 0 si no hay fila.
*/
@Query("""
select coalesce(sum(p.remunBrutaFutura), 0)
from ProyeccionDocenteHora p
where p.numeroDocumento = :numeroDocumento
and trim(p.boletaTipoPersonal) = :tipoPersonal
and p.proyeccionAnio = :anio
and p.proyeccionMes = :mes
""")
BigDecimal remunBrutaFutura(@Param("numeroDocumento") String numeroDocumento,
@Param("tipoPersonal") String tipoPersonal,
@Param("anio") Integer anio,
@Param("mes") int mes);
}
package so.planilla_be.rentaquinta.calculo;
/**
* Regla con la que se proyecta el sueldo bruto de un trabajador, a partir de su
* {@link TipoPlanilla} ({@code boleta_tipo_personal}) y del {@code tipo_pago} de pl_planilla:
*
* <table>
* <tr><th>boleta_tipo_personal</th><th>tipo_pago</th><th>Modalidad</th></tr>
* <tr><td>ADM</td><td>(cualquiera)</td><td>{@link #MONTO_FIJO}</td></tr>
* <tr><td>DOC</td><td>2</td><td>{@link #MONTO_FIJO}</td></tr>
* <tr><td>DOC</td><td>1</td><td>{@link #DOCENTE_POR_HORA}</td></tr>
* </table>
*
* <p>El tipo de pago lo envía el cliente como sufijo de {@code tipoPlanilla} ({@code DOC1} /
* {@code DOC2}, ver {@link TipoPago}); si no lo envía,
* se lee de la boleta. Un DOC sin tipo de pago se trata como {@link #MONTO_FIJO}.
*/
public enum ModalidadCalculo {
/** Proyección = remuneración del mes de referencia × meses restantes. */
MONTO_FIJO,
/**
* Proyección = {@code remun_bruta_futura} de pl_proyeccion_renta_docentehora del mes
* consultado. El resto del cálculo es igual que {@link #MONTO_FIJO}.
*/
DOCENTE_POR_HORA;
public static ModalidadCalculo de(TipoPlanilla tipoPlanilla, TipoPago tipoPago) {
return switch (tipoPlanilla) {
case MENSUAL_MONTO_FIJO -> MONTO_FIJO;
case DOCENTE -> tipoPago == TipoPago.POR_HORA ? DOCENTE_POR_HORA : MONTO_FIJO;
};
}
}
...@@ -56,15 +56,14 @@ public class RentaQuintaCalculadora { ...@@ -56,15 +56,14 @@ public class RentaQuintaCalculadora {
if (!mesCorteReal) { if (!mesCorteReal) {
estimadoSueldoBruto = orZero(datos.remuneracionReferencia()); estimadoSueldoBruto = orZero(datos.remuneracionReferencia());
if (datos.mesCorte() == 7 || datos.mesCorte() == 12) { if (datos.mesCorte() == 7 || datos.mesCorte() == 12) {
estimadoGratificacion = estimadoSueldoBruto.multiply(FACTOR_GRATIFICACION); estimadoGratificacion = gratificacion(estimadoSueldoBruto);
} }
} }
BigDecimal estimadoMesCorte = estimadoSueldoBruto.add(estimadoGratificacion); BigDecimal estimadoMesCorte = estimadoSueldoBruto.add(estimadoGratificacion);
// Meses después del mes de corte: el propio mes de corte ya está cubierto por // Meses después del mes de corte: el propio mes de corte ya está cubierto por
// planillaCargada (real) o por el estimado de arriba. // planillaCargada (real) o por el estimado de arriba.
BigDecimal proyeccionSueldoBruto = orZero(datos.remuneracionReferencia()) BigDecimal proyeccionSueldoBruto = proyeccionSueldoBruto(datos);
.multiply(BigDecimal.valueOf(12 - datos.mesCorte()));
boolean julioCubierto = datos.julioPercibida() || (datos.mesCorte() == 7 && !mesCorteReal); boolean julioCubierto = datos.julioPercibida() || (datos.mesCorte() == 7 && !mesCorteReal);
boolean diciembreCubierto = datos.diciembrePercibida() || (datos.mesCorte() == 12 && !mesCorteReal); boolean diciembreCubierto = datos.diciembrePercibida() || (datos.mesCorte() == 12 && !mesCorteReal);
BigDecimal proyeccionGratificaciones = gratificacionesPendientes(datos, julioCubierto, diciembreCubierto); BigDecimal proyeccionGratificaciones = gratificacionesPendientes(datos, julioCubierto, diciembreCubierto);
...@@ -134,10 +133,12 @@ public class RentaQuintaCalculadora { ...@@ -134,10 +133,12 @@ public class RentaQuintaCalculadora {
/** /**
* Gratificaciones de julio y/o diciembre que aún no se han percibido por ningún medio (ni * Gratificaciones de julio y/o diciembre que aún no se han percibido por ningún medio (ni
* boleta real ni el estimado del propio mes de corte cuando este es julio/diciembre). * 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.
*/ */
private BigDecimal gratificacionesPendientes(DatosCalculo datos, boolean julioCubierto, boolean diciembreCubierto) { private BigDecimal gratificacionesPendientes(DatosCalculo datos, boolean julioCubierto, boolean diciembreCubierto) {
BigDecimal gratificacion = orZero(datos.basicoReferencia().add(datos.asignacionFamiliarReferencia())) BigDecimal gratificacion = gratificacion(orZero(datos.basicoReferencia()));
.multiply(FACTOR_GRATIFICACION);
BigDecimal total = BigDecimal.ZERO; BigDecimal total = BigDecimal.ZERO;
if (!julioCubierto) { if (!julioCubierto) {
total = total.add(gratificacion); total = total.add(gratificacion);
...@@ -148,6 +149,31 @@ public class RentaQuintaCalculadora { ...@@ -148,6 +149,31 @@ public class RentaQuintaCalculadora {
return total; return total;
} }
/**
* CONDICIÓN MODALIDAD — sueldo bruto de los meses que faltan después del mes de corte:
* <ul>
* <li>ADM y DOC tipo_pago 2: remuneración del mes de referencia × meses restantes.</li>
* <li>DOC tipo_pago 1 (por hora): remun_bruta_futura del mes consultado en
* pl_proyeccion_renta_docentehora ({@code proyeccionDocenteHora}).</li>
* </ul>
*/
private BigDecimal proyeccionSueldoBruto(DatosCalculo datos) {
return switch (datos.modalidad()) {
case MONTO_FIJO -> orZero(datos.remuneracionReferencia())
.multiply(BigDecimal.valueOf(12 - datos.mesCorte()));
case DOCENTE_POR_HORA -> orZero(datos.proyeccionDocenteHora());
};
}
/**
* Monto de una gratificación (julio o diciembre) a partir de su base: base × 1.09
* (gratificación + bonificación extraordinaria). Se usa tanto para el estimado del mes de
* corte como para las gratificaciones pendientes. Igual para todas las modalidades.
*/
private BigDecimal gratificacion(BigDecimal base) {
return base.multiply(FACTOR_GRATIFICACION);
}
private BigDecimal impuestoSobre(BigDecimal rentaNeta, BigDecimal valorUit, List<TramoCalculado> tramos) { private BigDecimal impuestoSobre(BigDecimal rentaNeta, BigDecimal valorUit, List<TramoCalculado> tramos) {
BigDecimal impuesto = BigDecimal.ZERO; BigDecimal impuesto = BigDecimal.ZERO;
BigDecimal pisoTramo = BigDecimal.ZERO; BigDecimal pisoTramo = BigDecimal.ZERO;
...@@ -217,9 +243,15 @@ public class RentaQuintaCalculadora { ...@@ -217,9 +243,15 @@ public class RentaQuintaCalculadora {
* {@code mesCorte} cuando ese mes sí está cargado. {@code remuneracionReferencia}, * {@code mesCorte} cuando ese mes sí está cargado. {@code remuneracionReferencia},
* {@code basicoReferencia} y {@code asignacionFamiliarReferencia} vienen de ese mes, y son * {@code basicoReferencia} y {@code asignacionFamiliarReferencia} vienen de ese mes, y son
* la base de la proyección cuando el mes de corte todavía no tiene boleta. * la base de la proyección cuando el mes de corte todavía no tiene boleta.
*
* <p>{@code modalidad} decide cómo se proyecta el sueldo bruto (ver {@link ModalidadCalculo});
* {@code proyeccionDocenteHora} solo se usa en {@link ModalidadCalculo#DOCENTE_POR_HORA}. La
* UIT, las 7 UIT, los tramos y el divisor son de ley e iguales para todas las planillas.
*/ */
public record DatosCalculo( public record DatosCalculo(
Integer periodo, Integer periodo,
ModalidadCalculo modalidad,
BigDecimal proyeccionDocenteHora,
int mesCorte, int mesCorte,
int mesReferencia, int mesReferencia,
BigDecimal historialReal, BigDecimal historialReal,
......
package so.planilla_be.rentaquinta.calculo;
import java.util.Arrays;
/**
* {@code tipo_pago} de pl_planilla, solo para docentes ({@link TipoPlanilla#DOCENTE}). El cliente
* (C#) lo envía como sufijo del parámetro {@code tipoPlanilla}:
*
* <ul>
* <li>{@code ADM} → cálculo normal (administrativo)</li>
* <li>{@code DOC1} → docente costo por hora, proyección = remun_bruta_futura del mes consultado</li>
* <li>{@code DOC2} → docente monto fijo, mismo cálculo que ADM</li>
* </ul>
*
* {@code DOC} sin dígito no se acepta. Sin {@code tipoPlanilla} se toma el {@code tipo_pago} de la
* boleta.
*/
public enum TipoPago {
POR_HORA("1"),
MONTO_FIJO("2");
private final String codigo;
TipoPago(String codigo) {
this.codigo = codigo;
}
public String getCodigo() {
return codigo;
}
/**
* Tipo de pago contenido en el parámetro {@code tipoPlanilla} ({@code DOC1} / {@code DOC2}).
* {@code null} si no trae sufijo ({@code ADM}, {@code DOC}, vacío) o si el sufijo no es válido;
* la validación del parámetro la hace {@link TipoPlanilla#desdeParametro}.
*/
public static TipoPago desdeParametro(String tipoPlanilla) {
if (tipoPlanilla == null) {
return null;
}
String normalizado = tipoPlanilla.trim().toUpperCase();
String prefijo = TipoPlanilla.DOCENTE.getCodigo();
if (!normalizado.startsWith(prefijo) || normalizado.length() == prefijo.length()) {
return null;
}
String sufijo = normalizado.substring(prefijo.length());
return Arrays.stream(values())
.filter(t -> t.codigo.equals(sufijo))
.findFirst()
.orElse(null);
}
}
package so.planilla_be.rentaquinta.calculo;
import so.planilla_be.rentaquinta.entity.RentaQuinta;
import so.planilla_be.rentaquinta.exception.TipoPlanillaInvalidaException;
import java.util.Arrays;
import java.util.stream.Collectors;
import java.util.stream.Stream;
/**
* Tipo de planilla, identificado por {@code planilla.pl_planilla.boleta_tipo_personal}. Filtra la
* grilla y forma parte de la clave de lo grabado en pl_renta_quinta. Las reglas del cálculo no
* salen solo de él: junto con {@code tipo_pago} definen la {@link ModalidadCalculo}.
*
* <p>El cliente (C#) lo envía en el parámetro {@code tipoPlanilla}: {@code ADM}, {@code DOC1}
* (docente costo por hora) o {@code DOC2} (docente monto fijo); el dígito es el
* {@code tipo_pago} ({@link TipoPago}). Si no lo envía, se toma de la boleta del trabajador.
*/
public enum TipoPlanilla {
/** {@code ADM}: planilla mensual (monto fijo). */
MENSUAL_MONTO_FIJO("ADM", "Planilla mensual (monto fijo)"),
/**
* {@code DOC}: docentes. Con {@code tipo_pago = 2} calculan igual que {@link #MENSUAL_MONTO_FIJO};
* con {@code tipo_pago = 1} son docentes por hora (ver {@link ModalidadCalculo}).
*/
DOCENTE("DOC", "Docentes (monto fijo o por hora, según tipo_pago)");
private final String codigo;
private final String descripcion;
TipoPlanilla(String codigo, String descripcion) {
this.codigo = codigo;
this.descripcion = descripcion;
}
public String getCodigo() {
return codigo;
}
public String getDescripcion() {
return descripcion;
}
public boolean esMensualOMontoFijo() {
return this == MENSUAL_MONTO_FIJO;
}
public boolean esDocente() {
return this == DOCENTE;
}
/**
* Valores aceptados en el parámetro {@code tipoPlanilla}: {@code ADM}, {@code DOC1} (docente
* costo por hora) y {@code DOC2} (docente monto fijo). {@code DOC} sin dígito no se acepta. El
* sufijo de DOC lo resuelve {@link TipoPago#desdeParametro}.
*/
public static final String VALORES_PARAMETRO = Stream.concat(
Stream.of(MENSUAL_MONTO_FIJO.codigo),
Arrays.stream(TipoPago.values()).map(p -> DOCENTE.codigo + p.getCodigo()))
.collect(Collectors.joining(", "));
/**
* Parámetro {@code tipoPlanilla} recibido del cliente ({@link #VALORES_PARAMETRO}).
* {@code null} o vacío = no se envió; un código desconocido responde 400.
*/
public static TipoPlanilla desdeParametro(String codigo) {
if (codigo == null || codigo.isBlank()) {
return null;
}
String normalizado = codigo.trim().toUpperCase();
if (normalizado.equals(MENSUAL_MONTO_FIJO.codigo)) {
return MENSUAL_MONTO_FIJO;
}
if (TipoPago.desdeParametro(normalizado) != null) {
return DOCENTE;
}
throw new TipoPlanillaInvalidaException(
"tipoPlanilla '%s' no válido. Valores permitidos: %s".formatted(codigo, VALORES_PARAMETRO));
}
/**
* Parámetro {@code tipoPlanilla} obligatorio (grabado en pl_renta_quinta): el tipo lo decide
* quien envía, no se toma de la boleta. Vacío o desconocido responde 400.
*/
public static TipoPlanilla obligatorio(String codigo) {
TipoPlanilla tipo = desdeParametro(codigo);
if (tipo == null) {
throw new TipoPlanillaInvalidaException(
"tipoPlanilla es obligatorio. Valores permitidos: %s".formatted(VALORES_PARAMETRO));
}
return tipo;
}
/**
* Tipo de planilla de una boleta. {@code boleta_tipo_personal} es {@code char(3)}, por eso el
* trim. Un código vacío o desconocido se trata como {@link #MENSUAL_MONTO_FIJO}, que es el
* cálculo que se aplicaba antes de condicionar por tipo.
*/
public static TipoPlanilla deBoleta(RentaQuinta boleta) {
String codigo = boleta.getBoletaTipoPersonal() == null ? "" : boleta.getBoletaTipoPersonal().trim();
return DOCENTE.codigo.equals(codigo) ? DOCENTE : MENSUAL_MONTO_FIJO;
}
}
package so.planilla_be.rentaquinta.controller; package so.planilla_be.rentaquinta.controller;
import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.Parameter;
import io.swagger.v3.oas.annotations.tags.Tag; import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.validation.constraints.Max; import jakarta.validation.constraints.Max;
import jakarta.validation.constraints.Min; import jakarta.validation.constraints.Min;
import jakarta.validation.constraints.NotNull; import jakarta.validation.constraints.NotNull;
import org.springframework.data.domain.Page; import org.springframework.data.domain.Page;
import org.springframework.validation.annotation.Validated; import org.springframework.validation.annotation.Validated;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController; import org.springframework.web.bind.annotation.RestController;
import so.planilla_be.rentaquinta.calculo.TipoPago;
import so.planilla_be.rentaquinta.calculo.TipoPlanilla;
import so.planilla_be.rentaquinta.dto.RentaQuintaDetalleCompletoDTO; import so.planilla_be.rentaquinta.dto.RentaQuintaDetalleCompletoDTO;
import so.planilla_be.rentaquinta.dto.RentaQuintaDetalleDTO; import so.planilla_be.rentaquinta.dto.RentaQuintaDetalleDTO;
import so.planilla_be.rentaquinta.dto.RentaQuintaFiltroDTO; import so.planilla_be.rentaquinta.dto.RentaQuintaFiltroDTO;
...@@ -27,6 +32,16 @@ import java.util.List; ...@@ -27,6 +32,16 @@ import java.util.List;
@RequestMapping("/api/v1/renta-quinta") @RequestMapping("/api/v1/renta-quinta")
public class RentaQuintaController { public class RentaQuintaController {
/** Parámetro que envía el cliente (C#) para elegir las reglas del cálculo por tipo de planilla. */
private static final String TIPO_PLANILLA_DOC = "ADM = administrativo (cálculo normal); DOC1 = docente "
+ "costo por hora (proyección desde pl_proyeccion_renta_docentehora); DOC2 = docente monto fijo "
+ "(mismo cálculo que ADM). Filtra por boleta_tipo_personal y tipo_pago. Si no se envía, se "
+ "toma de la boleta del trabajador.";
private static final String TIPO_PLANILLA_GRABAR_DOC = "Obligatorio. ADM, DOC1 (costo por hora) o DOC2 "
+ "(monto fijo). En pl_renta_quinta se graba boleta_tipo_personal (ADM/DOC) y tipo_pago (1/2, null en ADM).";
private final RentaQuintaService service; private final RentaQuintaService service;
public RentaQuintaController(RentaQuintaService service) { public RentaQuintaController(RentaQuintaService service) {
...@@ -39,15 +54,16 @@ public class RentaQuintaController { ...@@ -39,15 +54,16 @@ public class RentaQuintaController {
@RequestParam @NotNull Integer anio, @RequestParam @NotNull Integer anio,
@RequestParam @NotNull @Min(1) @Max(12) Integer mes, @RequestParam @NotNull @Min(1) @Max(12) Integer mes,
@RequestParam(required = false) Integer sede, @RequestParam(required = false) Integer sede,
@RequestParam(required = false) Integer tipoPlanilla, @Parameter(description = TIPO_PLANILLA_DOC) @RequestParam(required = false) String tipoPlanilla,
@RequestParam(required = false) Integer areaGeneral, @RequestParam(required = false) Integer areaGeneral,
@RequestParam(required = false) Integer subArea, @RequestParam(required = false) Integer subArea,
@RequestParam(required = false) Integer cargo, @RequestParam(required = false) Integer cargo,
@RequestParam(required = false) String documentoOApellidos, @RequestParam(required = false) String documentoOApellidos,
@RequestParam(defaultValue = "0") Integer page, @RequestParam(defaultValue = "0") Integer page,
@RequestParam(defaultValue = "20") Integer size) { @RequestParam(defaultValue = "20") Integer size) {
TipoPlanilla tipo = TipoPlanilla.desdeParametro(tipoPlanilla);
return service.buscar(new RentaQuintaFiltroDTO( return service.buscar(new RentaQuintaFiltroDTO(
anio, mes, sede, tipoPlanilla, areaGeneral, subArea, cargo, anio, mes, sede, tipo, TipoPago.desdeParametro(tipoPlanilla), areaGeneral, subArea, cargo,
documentoOApellidos, page, size)); documentoOApellidos, page, size));
} }
...@@ -64,8 +80,10 @@ public class RentaQuintaController { ...@@ -64,8 +80,10 @@ public class RentaQuintaController {
public RentaQuintaDetalleCompletoDTO detallado( public RentaQuintaDetalleCompletoDTO detallado(
@RequestParam String numeroDocumento, @RequestParam String numeroDocumento,
@RequestParam Integer anio, @RequestParam Integer anio,
@RequestParam @Min(1) @Max(12) Integer mes) { @RequestParam @Min(1) @Max(12) Integer mes,
return service.obtenerDetalleCompleto(numeroDocumento, anio, mes); @Parameter(description = TIPO_PLANILLA_DOC) @RequestParam(required = false) String tipoPlanilla) {
TipoPlanilla tipo = TipoPlanilla.desdeParametro(tipoPlanilla);
return service.obtenerDetalleCompleto(numeroDocumento, anio, mes, tipo, TipoPago.desdeParametro(tipoPlanilla));
} }
@Operation(summary = "Detalle por codper + anio + mes") @Operation(summary = "Detalle por codper + anio + mes")
...@@ -84,4 +102,38 @@ public class RentaQuintaController { ...@@ -84,4 +102,38 @@ public class RentaQuintaController {
@PathVariable Integer anio) { @PathVariable Integer anio) {
return service.historialAnual(codper, anio); return service.historialAnual(codper, anio);
} }
@Operation(summary = "Graba (upsert) en pl_renta_quinta la retención del mes calculada para el "
+ "trabajador: documento + año + mes + retención")
@PostMapping("/grabar")
public ResponseEntity<Void> grabar(
@RequestParam String numeroDocumento,
@RequestParam @NotNull Integer anio,
@RequestParam @NotNull @Min(1) @Max(12) Integer mes,
@Parameter(description = TIPO_PLANILLA_GRABAR_DOC) @RequestParam String tipoPlanilla) {
TipoPlanilla tipo = TipoPlanilla.obligatorio(tipoPlanilla);
service.grabarRetencion(numeroDocumento, anio, mes, tipo, TipoPago.desdeParametro(tipoPlanilla));
return ResponseEntity.ok().build();
}
@Operation(summary = "Graba (upsert) en pl_renta_quinta la retención del mes de todos los "
+ "trabajadores que matchean los mismos filtros del listado (sin límite de página)")
@PostMapping("/grabar-masivo")
public GrabadoMasivoResultado grabarMasivo(
@RequestParam @NotNull Integer anio,
@RequestParam @NotNull @Min(1) @Max(12) Integer mes,
@RequestParam(required = false) Integer sede,
@Parameter(description = TIPO_PLANILLA_GRABAR_DOC) @RequestParam String tipoPlanilla,
@RequestParam(required = false) Integer areaGeneral,
@RequestParam(required = false) Integer subArea,
@RequestParam(required = false) Integer cargo,
@RequestParam(required = false) String documentoOApellidos) {
TipoPlanilla tipo = TipoPlanilla.obligatorio(tipoPlanilla);
int grabados = service.grabarMasivo(new RentaQuintaFiltroDTO(
anio, mes, sede, tipo, TipoPago.desdeParametro(tipoPlanilla), areaGeneral, subArea, cargo,
documentoOApellidos, null, null));
return new GrabadoMasivoResultado(grabados);
}
public record GrabadoMasivoResultado(int trabajadoresGrabados) {}
} }
package so.planilla_be.rentaquinta.dto; package so.planilla_be.rentaquinta.dto;
import so.planilla_be.rentaquinta.calculo.TipoPago;
import so.planilla_be.rentaquinta.calculo.TipoPlanilla;
/** /**
* Filtros de la grilla de retenciones mensuales. * Filtros de la grilla de retenciones mensuales.
* *
* <p>Todos los filtros son numéricos salvo {@code documentoOApellidos}, que es texto libre * <p>{@code documentoOApellidos} es texto libre (nro de documento o apellidos) y
* (nro de documento o apellidos). Los filtros de catálogo (sede, tipo de planilla, área, * {@code tipoPlanilla} es el código de {@code boleta_tipo_personal} (ADM / DOC), que además de
* subárea, cargo) llegan como Integer y se comparan contra columnas TEXT convirtiendo el * filtrar decide las reglas del cálculo; {@code tipoPago} (sufijo de DOC1 / DOC2: 1 = por hora,
* número a su representación en texto. * 2 = monto fijo) filtra por {@code tipo_pago} y decide la proyección. El resto de filtros de catálogo
* (sede, área, subárea, cargo) llegan como Integer y se comparan contra columnas TEXT convirtiendo
* el número a su
* representación en texto.
*/ */
public record RentaQuintaFiltroDTO( public record RentaQuintaFiltroDTO(
Integer periodo, Integer periodo,
Integer mesCorte, Integer mesCorte,
Integer sede, Integer sede,
Integer tipoPlanilla, TipoPlanilla tipoPlanilla,
TipoPago tipoPago,
Integer areaGeneral, Integer areaGeneral,
Integer subArea, Integer subArea,
Integer cargo, Integer cargo,
......
...@@ -21,5 +21,7 @@ public record RentaQuintaResumenDTO( ...@@ -21,5 +21,7 @@ public record RentaQuintaResumenDTO(
BigDecimal rentaBrutaAnual, BigDecimal rentaBrutaAnual,
BigDecimal impuestoAnual, BigDecimal impuestoAnual,
BigDecimal retenidoAnterior, BigDecimal retenidoAnterior,
BigDecimal retencionDelMes BigDecimal retencionDelMes,
BigDecimal retencionAnteriorGrabada, // <--- NUEVO
BigDecimal diferenciaRetencion // <--- NUEVO
) {} ) {}
...@@ -168,6 +168,15 @@ public class RentaQuinta { ...@@ -168,6 +168,15 @@ public class RentaQuinta {
@Transient @Transient
private BigDecimal utilidades; private BigDecimal utilidades;
/**
* Básico usado para calcular la gratificación pendiente (julio/diciembre): se toma de
* planilla.pl_renta_basico del mes anterior al de referencia; si no hay registro ahí, el
* básico + asignación familiar de pl_planilla del mes de referencia. Ver
* RentaQuintaServiceImpl#aplicarBasicoYUtilidades.
*/
@Transient
private BigDecimal basicoGratificacion;
/* VACACIONES Y LIQUIDACIÓN */ /* VACACIONES Y LIQUIDACIÓN */
@Column(name = "cts", precision = 15, scale = 2) @Column(name = "cts", precision = 15, scale = 2)
...@@ -378,6 +387,10 @@ public class RentaQuinta { ...@@ -378,6 +387,10 @@ public class RentaQuinta {
@Column(name = "tipo_labor") @Column(name = "tipo_labor")
private String tipoLabor; private String tipoLabor;
/** Solo en DOC: 1 = docente por hora, 2 = monto fijo (ver {@code ModalidadCalculo}). */
@Column(name = "tipo_pago", length = 1, columnDefinition = "bpchar")
private String tipoPago;
/** No existen en pl_planilla; se conservan para no romper el contrato del API. */ /** No existen en pl_planilla; se conservan para no romper el contrato del API. */
@Transient @Transient
private Integer campo1; private Integer campo1;
......
package so.planilla_be.rentaquinta.entity;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.IdClass;
import jakarta.persistence.Id;
import jakarta.persistence.Table;
import lombok.Getter;
import lombok.Setter;
import java.math.BigDecimal;
/**
* Retención de quinta categoría grabada para un trabajador en un periodo dado, en
* planilla.pl_renta_quinta. Solo guarda lo mínimo: documento, año, mes, tipo de planilla, tipo de
* pago y la retención calculada para ese mes de corte (no es una boleta completa, esa vive en pl_planilla).
*
* <p>La PK incluye {@code boleta_tipo_personal} ({@code ADM} / {@code DOC}, ver
* {@link so.planilla_be.rentaquinta.calculo.TipoPlanilla}): un mismo documento puede tener una
* retención grabada por cada tipo de planilla en el mismo periodo.
*/
@Getter
@Setter
@Entity
@Table(name = "pl_renta_quinta")
@IdClass(RentaQuintaGrabadaId.class)
public class RentaQuintaGrabada {
@Id
@Column(name = "numero_documento", length = 8)
private String numeroDocumento;
@Id
@Column(name = "anio")
private Integer anio;
@Id
@Column(name = "mes")
private Integer mes;
/** char(3): {@code bpchar} para que {@code ddl-auto: validate} no falle. */
@Id
@Column(name = "boleta_tipo_personal", length = 3, columnDefinition = "bpchar")
private String boletaTipoPersonal;
@Column(name = "retencion_mes_actual", nullable = false)
private BigDecimal retencionMesActual;
/**
* Parte de la PK, NOT NULL. Solo DOC: {@code 1} = docente costo por hora (DOC1), {@code 2} =
* monto fijo (DOC2); en ADM {@code ''} (vacío). char(1), ver
* {@link so.planilla_be.rentaquinta.calculo.TipoPago}.
*/
@Id
@Column(name = "tipo_pago", length = 1, columnDefinition = "bpchar")
private String tipoPago;
}
package so.planilla_be.rentaquinta.entity;
import java.io.Serializable;
import java.util.Objects;
/**
* PK de planilla.pl_renta_quinta: {@code (numero_documento, anio, mes, boleta_tipo_personal,
* tipo_pago)}. {@code tipo_pago} es {@code ''} en ADM y {@code 1}/{@code 2} en DOC.
*/
public class RentaQuintaGrabadaId implements Serializable {
private String numeroDocumento;
private Integer anio;
private Integer mes;
private String boletaTipoPersonal;
private String tipoPago;
public RentaQuintaGrabadaId() {
}
public RentaQuintaGrabadaId(String numeroDocumento, Integer anio, Integer mes, String boletaTipoPersonal,
String tipoPago) {
this.numeroDocumento = numeroDocumento;
this.anio = anio;
this.mes = mes;
this.boletaTipoPersonal = boletaTipoPersonal;
this.tipoPago = tipoPago;
}
// --- GETTERS Y SETTERS OBLIGATORIOS PARA JPA ---
public String getNumeroDocumento() {
return numeroDocumento;
}
public void setNumeroDocumento(String numeroDocumento) {
this.numeroDocumento = numeroDocumento;
}
public Integer getAnio() {
return anio;
}
public void setAnio(Integer anio) {
this.anio = anio;
}
public Integer getMes() {
return mes;
}
public void setMes(Integer mes) {
this.mes = mes;
}
public String getBoletaTipoPersonal() {
return boletaTipoPersonal;
}
public void setBoletaTipoPersonal(String boletaTipoPersonal) {
this.boletaTipoPersonal = boletaTipoPersonal;
}
public String getTipoPago() {
return tipoPago;
}
public void setTipoPago(String tipoPago) {
this.tipoPago = tipoPago;
}
@Override
public boolean equals(Object o) {
if (this == o) {
return true;
}
if (!(o instanceof RentaQuintaGrabadaId that)) {
return false;
}
return Objects.equals(numeroDocumento, that.numeroDocumento)
&& Objects.equals(anio, that.anio)
&& Objects.equals(mes, that.mes)
&& Objects.equals(boletaTipoPersonal, that.boletaTipoPersonal)
&& Objects.equals(tipoPago, that.tipoPago);
}
@Override
public int hashCode() {
return Objects.hash(numeroDocumento, anio, mes, boletaTipoPersonal, tipoPago);
}
}
...@@ -33,6 +33,12 @@ public class GlobalExceptionHandler { ...@@ -33,6 +33,12 @@ public class GlobalExceptionHandler {
return build(HttpStatus.CONFLICT, ex.getMessage()); return build(HttpStatus.CONFLICT, ex.getMessage());
} }
/** tipoPlanilla con un código distinto de ADM / DOC. */
@ExceptionHandler(TipoPlanillaInvalidaException.class)
public ResponseEntity<Map<String, Object>> handleTipoPlanilla(TipoPlanillaInvalidaException ex) {
return build(HttpStatus.BAD_REQUEST, ex.getMessage());
}
/** Filtros fuera de rango (p. ej. mesCorte=13). */ /** Filtros fuera de rango (p. ej. mesCorte=13). */
@ExceptionHandler(ConstraintViolationException.class) @ExceptionHandler(ConstraintViolationException.class)
public ResponseEntity<Map<String, Object>> handleValidation(ConstraintViolationException ex) { public ResponseEntity<Map<String, Object>> handleValidation(ConstraintViolationException ex) {
......
package so.planilla_be.rentaquinta.exception;
public class TipoPlanillaInvalidaException extends RuntimeException {
public TipoPlanillaInvalidaException(String message) {
super(message);
}
}
...@@ -24,7 +24,7 @@ public class RentaQuintaMapper { ...@@ -24,7 +24,7 @@ public class RentaQuintaMapper {
public RentaQuintaResumenDTO toResumen(RentaQuinta referencia, Integer codper, Integer periodo, Integer mesCorte, public RentaQuintaResumenDTO toResumen(RentaQuinta referencia, Integer codper, Integer periodo, Integer mesCorte,
BigDecimal historialReal, BigDecimal planillaCargada, BigDecimal historialReal, BigDecimal planillaCargada,
BigDecimal retenidoAnterior, CalculoRentaQuinta calculo) { BigDecimal retenidoAnterior, CalculoRentaQuinta calculo,BigDecimal retencionAnteriorGrabada, BigDecimal diferencia) {
return new RentaQuintaResumenDTO( return new RentaQuintaResumenDTO(
codper, codper,
periodo, periodo,
...@@ -33,11 +33,13 @@ public class RentaQuintaMapper { ...@@ -33,11 +33,13 @@ public class RentaQuintaMapper {
mesCorte, mesCorte,
orZero(historialReal), orZero(historialReal),
orZero(planillaCargada), orZero(planillaCargada),
calculo.proyeccionFutura(), calculo.proyeccionFutura().add(calculo.otrosEmpleadoresImporteProyeccion()),
calculo.rentaBrutaAnualSinOtrosEmpleadores(), calculo.rentaBrutaAnualSinOtrosEmpleadores(),
calculo.impuestoAnual(), calculo.impuestoAnual(),
orZero(retenidoAnterior), orZero(retenidoAnterior),
calculo.retencionDelMes()); calculo.retencionDelMes(),
orZero(retencionAnteriorGrabada),
diferencia);
} }
public RentaQuintaHistorialDTO toHistorial(RentaQuinta e) { public RentaQuintaHistorialDTO toHistorial(RentaQuinta e) {
......
package so.planilla_be.rentaquinta.repository;
import org.springframework.data.jpa.repository.JpaRepository;
import so.planilla_be.rentaquinta.entity.RentaQuintaGrabada;
import so.planilla_be.rentaquinta.entity.RentaQuintaGrabadaId;
import java.util.List;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
public interface RentaQuintaGrabadaRepository extends JpaRepository<RentaQuintaGrabada, RentaQuintaGrabadaId> {
@Query("select r from RentaQuintaGrabada r where r.anio = :anio and r.mes = :mes and r.numeroDocumento in :numerosDocumento")
List<RentaQuintaGrabada> findByAnioAndMesAndNumeroDocumentoIn(
@Param("anio") Integer anio,
@Param("mes") Integer mes,
@Param("numerosDocumento") List<String> numerosDocumento
);
}
...@@ -19,6 +19,7 @@ public interface RentaQuintaRepository extends JpaRepository<RentaQuinta, Long> ...@@ -19,6 +19,7 @@ public interface RentaQuintaRepository extends JpaRepository<RentaQuinta, Long>
String FILTROS = """ String FILTROS = """
and (:sede is null or trim(r.codlocal) = :sede) and (:sede is null or trim(r.codlocal) = :sede)
and (:tipoPlanilla is null or trim(r.boletaTipoPersonal) = :tipoPlanilla) and (:tipoPlanilla is null or trim(r.boletaTipoPersonal) = :tipoPlanilla)
and (:tipoPago is null or trim(r.tipoPago) = :tipoPago)
and (:areaGeneral is null or r.areaGeneral = :areaGeneral) and (:areaGeneral is null or r.areaGeneral = :areaGeneral)
and (:subArea is null or r.subArea = :subArea) and (:subArea is null or r.subArea = :subArea)
and (:cargo is null or r.cargo = :cargo) and (:cargo is null or r.cargo = :cargo)
...@@ -62,6 +63,7 @@ public interface RentaQuintaRepository extends JpaRepository<RentaQuinta, Long> ...@@ -62,6 +63,7 @@ public interface RentaQuintaRepository extends JpaRepository<RentaQuinta, Long>
@Param("mesCorte") Integer mesCorte, @Param("mesCorte") Integer mesCorte,
@Param("sede") String sede, @Param("sede") String sede,
@Param("tipoPlanilla") String tipoPlanilla, @Param("tipoPlanilla") String tipoPlanilla,
@Param("tipoPago") String tipoPago,
@Param("areaGeneral") String areaGeneral, @Param("areaGeneral") String areaGeneral,
@Param("subArea") String subArea, @Param("subArea") String subArea,
@Param("cargo") String cargo, @Param("cargo") String cargo,
......
package so.planilla_be.rentaquinta.service; package so.planilla_be.rentaquinta.service;
import org.springframework.data.domain.Page; import org.springframework.data.domain.Page;
import so.planilla_be.rentaquinta.calculo.TipoPago;
import so.planilla_be.rentaquinta.calculo.TipoPlanilla;
import so.planilla_be.rentaquinta.dto.RentaQuintaDetalleCompletoDTO; import so.planilla_be.rentaquinta.dto.RentaQuintaDetalleCompletoDTO;
import so.planilla_be.rentaquinta.dto.RentaQuintaDetalleDTO; import so.planilla_be.rentaquinta.dto.RentaQuintaDetalleDTO;
import so.planilla_be.rentaquinta.dto.RentaQuintaFiltroDTO; import so.planilla_be.rentaquinta.dto.RentaQuintaFiltroDTO;
...@@ -15,9 +17,15 @@ public interface RentaQuintaService { ...@@ -15,9 +17,15 @@ public interface RentaQuintaService {
RentaQuintaDetalleDTO obtenerPorId(Long id); RentaQuintaDetalleDTO obtenerPorId(Long id);
RentaQuintaDetalleCompletoDTO obtenerDetalleCompleto(String numeroDocumento, Integer anio, Integer mes); RentaQuintaDetalleCompletoDTO obtenerDetalleCompleto(String numeroDocumento, Integer anio, Integer mes,
TipoPlanilla tipoPlanilla, TipoPago tipoPago);
RentaQuintaDetalleDTO obtenerBoleta(Integer codper, Integer anio, Integer mes); RentaQuintaDetalleDTO obtenerBoleta(Integer codper, Integer anio, Integer mes);
List<RentaQuintaHistorialDTO> historialAnual(Integer codper, Integer anio); List<RentaQuintaHistorialDTO> historialAnual(Integer codper, Integer anio);
void grabarRetencion(String numeroDocumento, Integer anio, Integer mes, TipoPlanilla tipoPlanilla,
TipoPago tipoPago);
int grabarMasivo(RentaQuintaFiltroDTO filtro);
} }
...@@ -5,18 +5,26 @@ import org.springframework.data.domain.PageRequest; ...@@ -5,18 +5,26 @@ import org.springframework.data.domain.PageRequest;
import org.springframework.data.domain.Pageable; import org.springframework.data.domain.Pageable;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional; import org.springframework.transaction.annotation.Transactional;
import so.planilla_be.proyecciondocentehora.repository.ProyeccionDocenteHoraRepository;
import so.planilla_be.rentaquinta.calculo.CalculoRentaQuinta; import so.planilla_be.rentaquinta.calculo.CalculoRentaQuinta;
import so.planilla_be.rentaquinta.calculo.ModalidadCalculo;
import so.planilla_be.rentaquinta.calculo.RentaQuintaCalculadora; import so.planilla_be.rentaquinta.calculo.RentaQuintaCalculadora;
import so.planilla_be.rentaquinta.calculo.RentaQuintaCalculadora.DatosCalculo; import so.planilla_be.rentaquinta.calculo.RentaQuintaCalculadora.DatosCalculo;
import so.planilla_be.rentaquinta.calculo.TipoPago;
import so.planilla_be.rentaquinta.calculo.TipoPlanilla;
import so.planilla_be.rentaquinta.exception.TipoPlanillaInvalidaException;
import so.planilla_be.rentaquinta.dto.RentaQuintaDetalleCompletoDTO; import so.planilla_be.rentaquinta.dto.RentaQuintaDetalleCompletoDTO;
import so.planilla_be.rentaquinta.dto.RentaQuintaDetalleDTO; import so.planilla_be.rentaquinta.dto.RentaQuintaDetalleDTO;
import so.planilla_be.rentaquinta.dto.RentaQuintaFiltroDTO; import so.planilla_be.rentaquinta.dto.RentaQuintaFiltroDTO;
import so.planilla_be.rentaquinta.dto.RentaQuintaHistorialDTO; import so.planilla_be.rentaquinta.dto.RentaQuintaHistorialDTO;
import so.planilla_be.rentaquinta.dto.RentaQuintaResumenDTO; import so.planilla_be.rentaquinta.dto.RentaQuintaResumenDTO;
import so.planilla_be.rentaquinta.entity.RentaQuinta; import so.planilla_be.rentaquinta.entity.RentaQuinta;
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.exception.RentaQuintaNoEncontradaException;
import so.planilla_be.rentaquinta.mapper.RentaQuintaMapper; import so.planilla_be.rentaquinta.mapper.RentaQuintaMapper;
import so.planilla_be.rentaquinta.repository.DocumentoResumenProjection; import so.planilla_be.rentaquinta.repository.DocumentoResumenProjection;
import so.planilla_be.rentaquinta.repository.RentaQuintaGrabadaRepository;
import so.planilla_be.rentaquinta.repository.RentaQuintaRepository; import so.planilla_be.rentaquinta.repository.RentaQuintaRepository;
import so.planilla_be.rentaquinta.service.RentaQuintaService; import so.planilla_be.rentaquinta.service.RentaQuintaService;
import so.planilla_be.otrosempleadores.entity.RetencionOtrosEmpleadores; import so.planilla_be.otrosempleadores.entity.RetencionOtrosEmpleadores;
...@@ -27,6 +35,7 @@ import so.planilla_be.utilidades.entity.Utilidad; ...@@ -27,6 +35,7 @@ import so.planilla_be.utilidades.entity.Utilidad;
import so.planilla_be.utilidades.repository.UtilidadRepository; import so.planilla_be.utilidades.repository.UtilidadRepository;
import java.math.BigDecimal; import java.math.BigDecimal;
import java.util.ArrayList;
import java.util.Comparator; import java.util.Comparator;
import java.util.List; import java.util.List;
import java.util.Map; import java.util.Map;
...@@ -41,6 +50,12 @@ public class RentaQuintaServiceImpl implements RentaQuintaService { ...@@ -41,6 +50,12 @@ public class RentaQuintaServiceImpl implements RentaQuintaService {
private static final int SIZE_DEFAULT = 20; private static final int SIZE_DEFAULT = 20;
private static final int SIZE_MAX = 100; private static final int SIZE_MAX = 100;
private static final int GRABADO_MASIVO_BATCH = 200;
/**
* tipo_pago de pl_renta_quinta en ADM: la columna es NOT NULL (parte de la PK). Un espacio y no
* {@code ""}, porque char(1) se lee con padding (' ') y Hibernate lo tomaría como cambio de PK.
*/
private static final String TIPO_PAGO_VACIO = " ";
private final RentaQuintaRepository repository; private final RentaQuintaRepository repository;
private final RentaQuintaMapper mapper; private final RentaQuintaMapper mapper;
...@@ -48,27 +63,44 @@ public class RentaQuintaServiceImpl implements RentaQuintaService { ...@@ -48,27 +63,44 @@ public class RentaQuintaServiceImpl implements RentaQuintaService {
private final RetencionOtrosEmpleadoresRepository otrosEmpleadoresRepository; private final RetencionOtrosEmpleadoresRepository otrosEmpleadoresRepository;
private final RentaBasicoRepository rentaBasicoRepository; private final RentaBasicoRepository rentaBasicoRepository;
private final UtilidadRepository utilidadRepository; private final UtilidadRepository utilidadRepository;
private final RentaQuintaGrabadaRepository rentaQuintaGrabadaRepository;
private final ProyeccionDocenteHoraRepository proyeccionDocenteHoraRepository;
public RentaQuintaServiceImpl(RentaQuintaRepository repository, RentaQuintaMapper mapper, public RentaQuintaServiceImpl(RentaQuintaRepository repository, RentaQuintaMapper mapper,
RentaQuintaCalculadora calculadora, RentaQuintaCalculadora calculadora,
RetencionOtrosEmpleadoresRepository otrosEmpleadoresRepository, RetencionOtrosEmpleadoresRepository otrosEmpleadoresRepository,
RentaBasicoRepository rentaBasicoRepository, RentaBasicoRepository rentaBasicoRepository,
UtilidadRepository utilidadRepository) { UtilidadRepository utilidadRepository,
RentaQuintaGrabadaRepository rentaQuintaGrabadaRepository,
ProyeccionDocenteHoraRepository proyeccionDocenteHoraRepository) {
this.repository = repository; this.repository = repository;
this.mapper = mapper; this.mapper = mapper;
this.calculadora = calculadora; this.calculadora = calculadora;
this.otrosEmpleadoresRepository = otrosEmpleadoresRepository; this.otrosEmpleadoresRepository = otrosEmpleadoresRepository;
this.rentaBasicoRepository = rentaBasicoRepository; this.rentaBasicoRepository = rentaBasicoRepository;
this.utilidadRepository = utilidadRepository; this.utilidadRepository = utilidadRepository;
this.rentaQuintaGrabadaRepository = rentaQuintaGrabadaRepository;
this.proyeccionDocenteHoraRepository = proyeccionDocenteHoraRepository;
} }
/**
* TODO: provisorio mientras el frontend no tiene un botón de grabado masivo dedicado (ver
* docs/grabar-renta-quinta-frontend.md). Hasta entonces, cada consulta de la grilla graba de
* una vez en pl_renta_quinta la retención de TODOS los trabajadores que matchean el filtro
* (no solo los de la página actual) — igual que dispararía {@link #grabarMasivo}. Cuando el
* botón esté, sacar el {@code grabarSegunFiltro} de acá y dejar que solo lo dispare
* {@code grabarMasivo}.
*/
@Override @Override
@Transactional
public Page<RentaQuintaResumenDTO> buscar(RentaQuintaFiltroDTO filtro) { public Page<RentaQuintaResumenDTO> buscar(RentaQuintaFiltroDTO filtro) {
//grabarSegunFiltro(filtro);
Page<DocumentoResumenProjection> documentos = repository.buscarDocumentos( Page<DocumentoResumenProjection> documentos = repository.buscarDocumentos(
filtro.periodo(), filtro.periodo(),
filtro.mesCorte(), filtro.mesCorte(),
codigo(filtro.sede()), codigo(filtro.sede()),
codigo(filtro.tipoPlanilla()), codigo(filtro.tipoPlanilla()), // CONDICIÓN TIPO PLANILLA: filtra por boleta_tipo_personal
codigo(filtro.tipoPago()), // y por tipo_pago (solo DOC)
codigo(filtro.areaGeneral()), codigo(filtro.areaGeneral()),
codigo(filtro.subArea()), codigo(filtro.subArea()),
codigo(filtro.cargo()), codigo(filtro.cargo()),
...@@ -84,26 +116,36 @@ public class RentaQuintaServiceImpl implements RentaQuintaService { ...@@ -84,26 +116,36 @@ public class RentaQuintaServiceImpl implements RentaQuintaService {
Map<String, List<RentaQuinta>> porDocumento = historialCompleto.stream() Map<String, List<RentaQuinta>> porDocumento = historialCompleto.stream()
.collect(Collectors.groupingBy(RentaQuinta::getNumeroDocumento)); .collect(Collectors.groupingBy(RentaQuinta::getNumeroDocumento));
// 1. AQUÍ OBTIENES EL MAPA CON LAS RETENCIONES GRABADAS DEL MES ANTERIOR:
Map<String, BigDecimal> retencionesAnteriores = obtenerRetencionesMesAnterior(numeros, filtro.periodo(), filtro.mesCorte());
return documentos.map(d -> { return documentos.map(d -> {
List<RentaQuinta> historial = porDocumento.getOrDefault(d.getNumeroDocumento(), List.of()); List<RentaQuinta> historial = porDocumento.getOrDefault(d.getNumeroDocumento(), List.of());
Agregado a = agregar(historial, filtro.mesCorte()); Agregado a = agregar(historial, filtro.mesCorte());
CalculoRentaQuinta calculo = calcular(a, historial, filtro.periodo(), filtro.mesCorte()); TipoPlanilla tipoPlanilla = tipoPlanilla(filtro.tipoPlanilla(), a.referencia());
// Sin boleta real del mes de corte, planillaCargada muestra el mismo estimado que el CalculoRentaQuinta calculo = calcular(a, historial, filtro.periodo(), filtro.mesCorte(), tipoPlanilla,
// bloque "actual" del detalle completo (sueldo bruto del mes de referencia + grat. filtro.tipoPago());
// 109% si el mes de corte es julio/diciembre), no 0.
BigDecimal planillaCargada = a.mesCorteReal() BigDecimal planillaCargada = (a.mesCorteReal()
? a.planillaCargadaReal() ? a.planillaCargadaReal()
: calculo.estimadoMesCorteSueldoBruto().add(calculo.estimadoMesCorteGratificacion()); : calculo.estimadoMesCorteSueldoBruto().add(calculo.estimadoMesCorteGratificacion()))
.add(calculo.otrosEmpleadoresImporteMesActual());
Integer codper = historial.stream() Integer codper = historial.stream()
.map(RentaQuinta::getCodper) .map(RentaQuinta::getCodper)
.filter(Objects::nonNull) .filter(Objects::nonNull)
.max(Integer::compareTo) .max(Integer::compareTo)
.orElse(null); .orElse(null);
// Mismo total que el bloque "pasado real realizado" del detalle completo: planilla
// propia + lo declarado por otros empleadores en meses anteriores al de corte.
BigDecimal historialReal = a.historialReal().add(calculo.otrosEmpleadoresImporteAnterior()); BigDecimal historialReal = a.historialReal().add(calculo.otrosEmpleadoresImporteAnterior());
// Extraer la retención grabada en base de datos del mes anterior (206.24) y restar con la calculada del mes (247.97)
BigDecimal retencionAnteriorGrabada = retencionesAnteriores.getOrDefault(
claveGrabada(d.getNumeroDocumento(), tipoPlanilla.getCodigo()), BigDecimal.ZERO);
BigDecimal retencionActualCalculada = calculo.retencionDelMes();
BigDecimal diferencia = retencionActualCalculada.subtract(retencionAnteriorGrabada);
// 3. PASAS LOS VALORES A TU MAPPER:
return mapper.toResumen(a.referencia(), codper, filtro.periodo(), filtro.mesCorte(), return mapper.toResumen(a.referencia(), codper, filtro.periodo(), filtro.mesCorte(),
historialReal, planillaCargada, a.retenidoAnterior(), calculo); historialReal, planillaCargada, a.retenidoAnterior(), calculo,retencionAnteriorGrabada, diferencia);
}); });
} }
...@@ -112,6 +154,24 @@ public class RentaQuintaServiceImpl implements RentaQuintaService { ...@@ -112,6 +154,24 @@ public class RentaQuintaServiceImpl implements RentaQuintaService {
return valor == null ? null : valor.toString(); return valor == null ? null : valor.toString();
} }
/** boleta_tipo_personal es char(3): el repository lo compara con trim() contra este código. */
private String codigo(TipoPlanilla tipoPlanilla) {
return tipoPlanilla == null ? null : tipoPlanilla.getCodigo();
}
/** tipo_pago es char(1): el repository lo compara con trim() contra este código. */
private String codigo(TipoPago tipoPago) {
return tipoPago == null ? null : tipoPago.getCodigo();
}
/**
* Tipo de planilla con el que se calcula a un trabajador: el que envía el cliente (C#) en
* {@code tipoPlanilla}; si no lo envía, el de su boleta de referencia.
*/
private TipoPlanilla tipoPlanilla(TipoPlanilla solicitado, RentaQuinta referencia) {
return solicitado != null ? solicitado : TipoPlanilla.deBoleta(referencia);
}
private String patron(String texto) { private String patron(String texto) {
if (texto == null || texto.isBlank()) { if (texto == null || texto.isBlank()) {
return null; return null;
...@@ -143,8 +203,18 @@ public class RentaQuintaServiceImpl implements RentaQuintaService { ...@@ -143,8 +203,18 @@ public class RentaQuintaServiceImpl implements RentaQuintaService {
return mapper.toDetalle(boleta); return mapper.toDetalle(boleta);
} }
/**
* TODO: provisorio mientras el frontend no tiene el botón "Grabar" (ver
* docs/grabar-renta-quinta-frontend.md). Hasta que exista ese botón dedicado,
* cada consulta del detallado graba de una vez la retención en pl_renta_quinta, para no
* depender de un segundo request manual. Cuando el botón esté, sacar el {@code upsertRetencion}
* de acá y dejar que solo lo dispare {@link #grabarRetencion}.
*/
@Override @Override
public RentaQuintaDetalleCompletoDTO obtenerDetalleCompleto(String numeroDocumento, Integer anio, Integer mes) { @Transactional
public RentaQuintaDetalleCompletoDTO obtenerDetalleCompleto(String numeroDocumento, Integer anio, Integer mes,
TipoPlanilla tipoPlanillaSolicitado,
TipoPago tipoPago) {
List<RentaQuinta> historialAnio = repository.historialAnualPorDocumento(numeroDocumento, anio); List<RentaQuinta> historialAnio = repository.historialAnualPorDocumento(numeroDocumento, anio);
aplicarBasicoYUtilidades(historialAnio, anio); aplicarBasicoYUtilidades(historialAnio, anio);
int mesCorte = mes; int mesCorte = mes;
...@@ -154,11 +224,108 @@ public class RentaQuintaServiceImpl implements RentaQuintaService { ...@@ -154,11 +224,108 @@ public class RentaQuintaServiceImpl implements RentaQuintaService {
throw new RentaQuintaNoEncontradaException( throw new RentaQuintaNoEncontradaException(
"No existe boleta para documento %s, periodo %d-%02d".formatted(numeroDocumento, anio, mes)); "No existe boleta para documento %s, periodo %d-%02d".formatted(numeroDocumento, anio, mes));
} }
CalculoRentaQuinta calculo = calcular(a, historialAnio, anio, mesCorte); TipoPlanilla tipoPlanilla = tipoPlanilla(tipoPlanillaSolicitado, a.referencia());
CalculoRentaQuinta calculo = calcular(a, historialAnio, anio, mesCorte, tipoPlanilla, tipoPago);
return mapper.toDetalleCompleto(a.referencia(), historialAnio, mesCorte, calculo); return mapper.toDetalleCompleto(a.referencia(), historialAnio, mesCorte, calculo);
} }
/** /**
* Graba (upsert) en planilla.pl_renta_quinta la retención del mes calculada para el
* trabajador: documento + año + mes + tipo de planilla + retención, no una boleta completa
* (esa vive en pl_planilla). Reutiliza el mismo cálculo que {@link #obtenerDetalleCompleto}.
* El tipo grabado es siempre el enviado por el cliente: nunca se toma de la boleta.
*/
@Override
@Transactional
public void grabarRetencion(String numeroDocumento, Integer anio, Integer mes, TipoPlanilla tipoPlanilla,
TipoPago tipoPago) {
exigirTipoPlanilla(tipoPlanilla);
RentaQuintaDetalleCompletoDTO detalle = obtenerDetalleCompleto(numeroDocumento, anio, mes, tipoPlanilla,
tipoPago);
upsertRetencion(numeroDocumento, anio, mes, tipoPlanilla, tipoPago,
detalle.controlRetenciones().retencionMesActual());
}
/** El grabado en pl_renta_quinta requiere el tipo enviado (ADM / DOC); no hay valor por defecto. */
private void exigirTipoPlanilla(TipoPlanilla tipoPlanilla) {
if (tipoPlanilla == null) {
throw new TipoPlanillaInvalidaException("tipoPlanilla es obligatorio para grabar (ADM / DOC1 / DOC2)");
}
}
/**
* {@code tipo_pago} se graba tal como lo envió el cliente (DOC1 → 1, DOC2 → 2); en ADM va
* vacío: la columna es parte de la PK y NOT NULL, no admite null.
*/
private void upsertRetencion(String numeroDocumento, Integer anio, Integer mes, TipoPlanilla tipoPlanilla,
TipoPago tipoPago, BigDecimal retencionMesActual) {
String tipoPagoGrabado = tipoPlanilla.esDocente() && tipoPago != null ? tipoPago.getCodigo() : TIPO_PAGO_VACIO;
RentaQuintaGrabadaId id = new RentaQuintaGrabadaId(numeroDocumento, anio, mes, tipoPlanilla.getCodigo(),
tipoPagoGrabado);
RentaQuintaGrabada grabada = rentaQuintaGrabadaRepository.findById(id).orElseGet(RentaQuintaGrabada::new);
grabada.setNumeroDocumento(numeroDocumento);
grabada.setAnio(anio);
grabada.setMes(mes);
grabada.setBoletaTipoPersonal(tipoPlanilla.getCodigo());
grabada.setTipoPago(tipoPagoGrabado);
grabada.setRetencionMesActual(retencionMesActual);
rentaQuintaGrabadaRepository.save(grabada);
}
/**
* Graba (upsert) en pl_renta_quinta la retención del mes de todos los trabajadores que
* matchean los mismos filtros de la grilla ({@code buscar}), sin el tope de paginación de
* la grilla (recorre todas las páginas internamente). Usa el mismo cálculo que la grilla
* ({@code calculo.retencionDelMes()}), consistente con lo que muestra el resumen.
*/
@Override
@Transactional
public int grabarMasivo(RentaQuintaFiltroDTO filtro) {
exigirTipoPlanilla(filtro.tipoPlanilla());
return grabarSegunFiltro(filtro);
}
private int grabarSegunFiltro(RentaQuintaFiltroDTO filtro) {
List<String> numeros = new ArrayList<>();
int page = 0;
Page<DocumentoResumenProjection> pagina;
do {
pagina = repository.buscarDocumentos(
filtro.periodo(), filtro.mesCorte(),
codigo(filtro.sede()), codigo(filtro.tipoPlanilla()), codigo(filtro.tipoPago()),
codigo(filtro.areaGeneral()),
codigo(filtro.subArea()), codigo(filtro.cargo()), patron(filtro.documentoOApellidos()),
PageRequest.of(page, GRABADO_MASIVO_BATCH));
numeros.addAll(pagina.getContent().stream().map(DocumentoResumenProjection::getNumeroDocumento).toList());
page++;
} while (pagina.hasNext());
if (numeros.isEmpty()) {
return 0;
}
List<RentaQuinta> historialCompleto = repository.historialHastaMes(numeros, filtro.periodo(), filtro.mesCorte());
aplicarBasicoYUtilidades(historialCompleto, filtro.periodo());
Map<String, List<RentaQuinta>> porDocumento = historialCompleto.stream()
.collect(Collectors.groupingBy(RentaQuinta::getNumeroDocumento));
int grabados = 0;
for (String numeroDocumento : numeros) {
List<RentaQuinta> historial = porDocumento.getOrDefault(numeroDocumento, List.of());
Agregado a = agregar(historial, filtro.mesCorte());
if (a.referencia() == null) {
continue;
}
TipoPlanilla tipoPlanilla = tipoPlanilla(filtro.tipoPlanilla(), a.referencia());
CalculoRentaQuinta calculo = calcular(a, historial, filtro.periodo(), filtro.mesCorte(), tipoPlanilla,
filtro.tipoPago());
upsertRetencion(numeroDocumento, filtro.periodo(), filtro.mesCorte(), tipoPlanilla, filtro.tipoPago(),
calculo.retencionDelMes());
grabados++;
}
return grabados;
}
/**
* Agregado anual de un trabajador hasta el mes de corte, base de {@link DatosCalculo}: usado * 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. * tanto por la grilla como por el detalle completo, para que ambos calculen exactamente igual.
* *
...@@ -189,19 +356,28 @@ public class RentaQuintaServiceImpl implements RentaQuintaService { ...@@ -189,19 +356,28 @@ public class RentaQuintaServiceImpl implements RentaQuintaService {
retenidoAnterior, julioPercibida, diciembrePercibida); retenidoAnterior, julioPercibida, diciembrePercibida);
} }
private CalculoRentaQuinta calcular(Agregado a, List<RentaQuinta> historial, Integer periodo, int mesCorte) { private CalculoRentaQuinta calcular(Agregado a, List<RentaQuinta> historial, Integer periodo, int mesCorte,
TipoPlanilla tipoPlanilla, TipoPago tipoPago) {
int mesReferencia = a.referencia().getBoletaMes(); int mesReferencia = a.referencia().getBoletaMes();
// Todas las filas del documento en el mes de referencia (una persona puede tener varios // Todas las filas del documento en el mes de referencia (una persona puede tener varios
// codper/tipo de planilla). // codper/tipo de planilla).
BigDecimal sueldoBrutoReferencia = sumaMesCorte(historial, esMesCorte(mesReferencia), RentaQuinta::getSueldoBruto); BigDecimal sueldoBrutoReferencia = sumaMesCorte(historial, esMesCorte(mesReferencia), RentaQuinta::getSueldoBruto);
BigDecimal basicoReferencia = sumaMesCorte(historial, esMesCorte(mesReferencia), RentaQuinta::getBasico); BigDecimal basicoReferencia = sumaMesCorte(historial, esMesCorte(mesReferencia), RentaQuinta::getBasicoGratificacion);
BigDecimal asignacionFamiliarReferencia = BigDecimal asignacionFamiliarReferencia =
sumaMesCorte(historial, esMesCorte(mesReferencia), RentaQuinta::getAsignacionFamiliar); sumaMesCorte(historial, esMesCorte(mesReferencia), RentaQuinta::getAsignacionFamiliar);
OtrosEmpleadoresAgregado otrosEmpleadores = otrosEmpleadores(a.referencia().getNumeroDocumento(), periodo, mesCorte); String numeroDocumento = a.referencia().getNumeroDocumento();
OtrosEmpleadoresAgregado otrosEmpleadores = otrosEmpleadores(numeroDocumento, periodo, mesCorte);
ModalidadCalculo modalidad = modalidad(tipoPlanilla, tipoPago, historial, mesReferencia);
BigDecimal proyeccionDocenteHora = modalidad == ModalidadCalculo.DOCENTE_POR_HORA
? proyeccionDocenteHoraRepository.remunBrutaFutura(
numeroDocumento, tipoPlanilla.getCodigo(), periodo, mesCorte)
: BigDecimal.ZERO;
return calculadora.calcular(new DatosCalculo( return calculadora.calcular(new DatosCalculo(
periodo, mesCorte, mesReferencia, a.historialReal(), a.planillaCargadaReal(), periodo, modalidad, proyeccionDocenteHora, mesCorte, mesReferencia, a.historialReal(),
a.planillaCargadaReal(),
sueldoBrutoReferencia, basicoReferencia, asignacionFamiliarReferencia, a.retenidoAnterior(), sueldoBrutoReferencia, basicoReferencia, asignacionFamiliarReferencia, a.retenidoAnterior(),
a.julioPercibida(), a.diciembrePercibida(), a.julioPercibida(), a.diciembrePercibida(),
otrosEmpleadores.importeAnterior(), otrosEmpleadores.importeMesActual(), otrosEmpleadores.importeAnterior(), otrosEmpleadores.importeMesActual(),
...@@ -211,6 +387,27 @@ public class RentaQuintaServiceImpl implements RentaQuintaService { ...@@ -211,6 +387,27 @@ public class RentaQuintaServiceImpl implements RentaQuintaService {
} }
/** /**
* CONDICIÓN MODALIDAD — ADM siempre es monto fijo. En DOC decide el {@code tipoPago} enviado
* por el cliente; si no lo envió, el {@code tipo_pago} de las boletas DOC del mes de
* referencia (una persona puede tener filas ADM y DOC en el mismo mes): si alguna es 1 es
* docente por hora; si no, monto fijo.
*/
private ModalidadCalculo modalidad(TipoPlanilla tipoPlanilla, TipoPago solicitado, List<RentaQuinta> historial,
int mesReferencia) {
if (solicitado != null) {
return ModalidadCalculo.de(tipoPlanilla, solicitado);
}
boolean porHora = historial.stream()
.filter(esMesCorte(mesReferencia))
.filter(r -> TipoPlanilla.deBoleta(r) == TipoPlanilla.DOCENTE)
.map(RentaQuinta::getTipoPago)
.filter(Objects::nonNull)
.map(String::trim)
.anyMatch(TipoPago.POR_HORA.getCodigo()::equals);
return ModalidadCalculo.de(tipoPlanilla, porHora ? TipoPago.POR_HORA : null);
}
/**
* Importe y retención declarados por otros empleadores, separados en tres columnas: meses * Importe y retención declarados por otros empleadores, separados en tres columnas: meses
* anteriores al de corte, el propio mes de corte, y meses posteriores (proyección) — estos * anteriores al de corte, el propio mes de corte, y meses posteriores (proyección) — estos
* últimos ya pueden estar cargados de antemano por el trabajador/empleador y afectan tanto la * últimos ya pueden estar cargados de antemano por el trabajador/empleador y afectan tanto la
...@@ -245,10 +442,15 @@ public class RentaQuintaServiceImpl implements RentaQuintaService { ...@@ -245,10 +442,15 @@ public class RentaQuintaServiceImpl implements RentaQuintaService {
} }
/** /**
* planilla.pl_renta_basico reemplaza el básico de pl_planilla cuando existe un registro para * El básico que compone el sueldo bruto (historial de ingresos, boleta, etc.) es siempre el
* (numeroDocumento, anio, mes); si no, se conserva el de pl_planilla. planilla.pl_utilidades * de pl_planilla; pl_renta_basico no lo reemplaza. planilla.pl_utilidades pasa a ser el campo
* pasa a ser el campo "utilidades" que antes vivía en la propia renta_quinta (0 si no hay * "utilidades" que antes vivía en la propia renta_quinta (0 si no hay declaración para ese
* declaración para ese mes). * mes).
*
* <p>Además calcula {@code basicoGratificacion}: solo para la gratificación pendiente
* (julio/diciembre) se busca en pl_renta_basico si existe algún registro del trabajador (sin
* filtrar por mes); si existe, se usa solo ese básico; si no, básico + asignación familiar de
* pl_planilla de ese mes.
*/ */
private void aplicarBasicoYUtilidades(List<RentaQuinta> historial, Integer anio) { private void aplicarBasicoYUtilidades(List<RentaQuinta> historial, Integer anio) {
if (historial.isEmpty()) { if (historial.isEmpty()) {
...@@ -258,23 +460,52 @@ public class RentaQuintaServiceImpl implements RentaQuintaService { ...@@ -258,23 +460,52 @@ public class RentaQuintaServiceImpl implements RentaQuintaService {
.map(RentaQuinta::getNumeroDocumento) .map(RentaQuinta::getNumeroDocumento)
.distinct() .distinct()
.toList(); .toList();
Map<String, Map<Integer, BigDecimal>> basicoPorDocumento = rentaBasicoRepository Map<String, BigDecimal> basicoPorDocumento = rentaBasicoRepository
.findByAnioAndNumeroDocumentoIn(anio, documentos).stream() .findByAnioAndNumeroDocumentoIn(anio, documentos).stream()
.collect(Collectors.groupingBy(RentaBasico::getNumeroDocumento, .collect(Collectors.toMap(RentaBasico::getNumeroDocumento, RentaBasico::getMontoBasico, (a, b) -> a));
Collectors.toMap(RentaBasico::getMes, RentaBasico::getMontoBasico)));
Map<String, Map<Integer, BigDecimal>> utilidadPorDocumento = utilidadRepository Map<String, Map<Integer, BigDecimal>> utilidadPorDocumento = utilidadRepository
.findByAnioAndNumeroDocumentoIn(anio, documentos).stream() .findByAnioAndNumeroDocumentoIn(anio, documentos).stream()
.collect(Collectors.groupingBy(Utilidad::getNumeroDocumento, .collect(Collectors.groupingBy(Utilidad::getNumeroDocumento,
Collectors.toMap(Utilidad::getMes, Utilidad::getMontoUtilidad))); Collectors.toMap(Utilidad::getMes, Utilidad::getMontoUtilidad)));
for (RentaQuinta r : historial) { for (RentaQuinta r : historial) {
BigDecimal basico = valorDelMes(basicoPorDocumento, r.getNumeroDocumento(), r.getBoletaMes()); r.setBasicoGratificacion(baseGratificacion(r, basicoPorDocumento.get(r.getNumeroDocumento())));
if (basico != null) {
r.setBasico(basico);
}
r.setUtilidades(orZero(valorDelMes(utilidadPorDocumento, r.getNumeroDocumento(), r.getBoletaMes()))); r.setUtilidades(orZero(valorDelMes(utilidadPorDocumento, r.getNumeroDocumento(), r.getBoletaMes())));
} }
} }
/**
* Base de la gratificación pendiente (julio/diciembre) de una boleta, igual para ADM y DOC
* (monto fijo o por hora). {@code basicoRentaBasico} es el monto de pl_renta_basico del
* trabajador (null si no tiene registro).
*/
private BigDecimal baseGratificacion(RentaQuinta boleta, BigDecimal basicoRentaBasico) {
return basicoRentaBasico != null ? basicoRentaBasico
: orZero(boleta.getBasico()).add(orZero(boleta.getAsignacionFamiliar()));
}
/**
* Retenciones grabadas del mes anterior, con clave documento + tipo de planilla
* ({@link #claveGrabada}): la PK de pl_renta_quinta incluye boleta_tipo_personal, así que un
* documento puede tener una fila ADM y otra DOC en el mismo mes.
*/
private Map<String, BigDecimal> obtenerRetencionesMesAnterior(List<String> documentos, Integer anio, int mesCorte) {
if (mesCorte <= 1 || documentos.isEmpty()) {
return Map.of();
}
int mesAnterior = mesCorte - 1;
return rentaQuintaGrabadaRepository.findByAnioAndMesAndNumeroDocumentoIn(anio, mesAnterior, documentos)
.stream()
.collect(Collectors.toMap(
g -> claveGrabada(g.getNumeroDocumento(), g.getBoletaTipoPersonal()),
RentaQuintaGrabada::getRetencionMesActual,
(a, b) -> a
));
}
/** boleta_tipo_personal es char(3): trim por el padding de PostgreSQL. */
private String claveGrabada(String numeroDocumento, String boletaTipoPersonal) {
return numeroDocumento + "|" + (boletaTipoPersonal == null ? "" : boletaTipoPersonal.trim());
}
private BigDecimal valorDelMes(Map<String, Map<Integer, BigDecimal>> porDocumento, String numeroDocumento, Integer mes) { private BigDecimal valorDelMes(Map<String, Map<Integer, BigDecimal>> porDocumento, String numeroDocumento, Integer mes) {
Map<Integer, BigDecimal> porMes = porDocumento.get(numeroDocumento); Map<Integer, BigDecimal> porMes = porDocumento.get(numeroDocumento);
return porMes == null ? null : porMes.get(mes); return porMes == null ? null : porMes.get(mes);
...@@ -328,4 +559,5 @@ public class RentaQuintaServiceImpl implements RentaQuintaService { ...@@ -328,4 +559,5 @@ public class RentaQuintaServiceImpl implements RentaQuintaService {
BigDecimal retencionMesActual, BigDecimal retencionMesActual,
BigDecimal retencionProyeccion BigDecimal retencionProyeccion
) {} ) {}
} }
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