< Summary

Information
Class: FAU.Logica.Services.LiquidacionService
Assembly: FAU.Logica
File(s): /home/runner/work/Sistema.Liquidacion.BE/Sistema.Liquidacion.BE/FAU.Logica/Services/LiquidacionService.cs
Line coverage
90%
Covered lines: 649
Uncovered lines: 71
Coverable lines: 720
Total lines: 1180
Line coverage: 90.1%
Branch coverage
82%
Covered branches: 248
Total branches: 300
Branch coverage: 82.6%
Method coverage

Feature is only available for sponsors

Upgrade to PRO version

Metrics

File(s)

/home/runner/work/Sistema.Liquidacion.BE/Sistema.Liquidacion.BE/FAU.Logica/Services/LiquidacionService.cs

#LineLine coverage
 1using System.Runtime.CompilerServices;
 2using FAU.DataAccess;
 3using FAU.DataAccess.Repositories;
 4using FAU.Entidades;
 5using FAU.Logica.Calculadores;
 6using FAU.Logica.Calculators;
 7using FAU.Logica.DTOs;
 8using Microsoft.EntityFrameworkCore;
 9
 10[assembly: InternalsVisibleTo("FAU.Tests")]
 11
 12namespace FAU.Logica.Services;
 13
 14public class LiquidacionService : ILiquidacionService
 15{
 16    private const int CodigoIrpf = 517;
 17    private const int CodigoMontepioSubalterno = 550;
 18    private const int CodigoSanidad = 520;
 19    private const int CodigoTutelaSocial = 540;
 20    private const int CodigoAjusteRedondeo = 90000;
 21
 22    // El OG de cada compensación viene de ConfigCompensacion.CatalogoItem
 23    // cargado via InsumosPeriodoLoader (TipoCompensacion → Configuraciones → CatalogoItem)
 24    private static int? ObtenerOGCompensacion(ItemLoteCompensacion comp)
 25    {
 226        var codigo = comp.LoteCompensacion?.TipoCompensacion?.Configuraciones
 227            .FirstOrDefault(c => c.CatalogoItem is not null)
 228            ?.CatalogoItem?.Codigo;
 229        return codigo;
 30    }
 31
 32    private readonly ApplicationDbContext _context;
 33    private readonly IAuditoriaService _auditoriaService;
 34    private readonly ICurrentUserContext _currentUser;
 35    private readonly IInsumosPeriodoLoader _insumosLoader;
 36    private readonly IProcesadorBeneficiosService _procesadorBeneficios;
 37    // Calculadoras de haberes base
 38    private readonly ICalculadorFactory _calculadorFactory;
 39    private readonly ICalcularDiferencia615Service _diferencia615Service;
 40    // Descuentos legales, IRPF y déficit
 41    private readonly IDescuentoPersonalRepository _descuentoPersonalRepository;
 42    private readonly IDependienteRepository _dependienteRepository;
 43    private readonly IForm3100Repository _form3100Repository;
 44    private readonly IDeficitCalculator _deficitCalculator;
 45    private readonly IDescuentosLegalesCalculator _descuentosLegalesCalculator;
 46    private readonly IIrpfService _irpfService;
 47
 4848    public LiquidacionService(
 4849        ApplicationDbContext context,
 4850        IAuditoriaService auditoriaService,
 4851        ICurrentUserContext currentUser,
 4852        IInsumosPeriodoLoader insumosLoader,
 4853        IProcesadorBeneficiosService procesadorBeneficios,
 4854        ICalculadorFactory calculadorFactory,
 4855        ICalcularDiferencia615Service diferencia615Service,
 4856        IDescuentoPersonalRepository descuentoPersonalRepository,
 4857        IDependienteRepository dependienteRepository,
 4858        IForm3100Repository form3100Repository,
 4859        IDeficitCalculator deficitCalculator,
 4860        IDescuentosLegalesCalculator descuentosLegalesCalculator,
 4861        IIrpfService irpfService)
 62    {
 4863        _context = context;
 4864        _auditoriaService = auditoriaService;
 4865        _currentUser = currentUser;
 4866        _insumosLoader = insumosLoader;
 4867        _procesadorBeneficios = procesadorBeneficios;
 4868        _calculadorFactory = calculadorFactory;
 4869        _diferencia615Service = diferencia615Service;
 4870        _descuentoPersonalRepository = descuentoPersonalRepository;
 4871        _dependienteRepository = dependienteRepository;
 4872        _form3100Repository = form3100Repository;
 4873        _deficitCalculator = deficitCalculator;
 4874        _descuentosLegalesCalculator = descuentosLegalesCalculator;
 4875        _irpfService = irpfService;
 4876    }
 77
 78    /// <summary>
 79    /// Genera la liquidación para el período. La versión se determina automáticamente:
 80    /// max_version_existente + 1, lo que permite recalcular después de una reapertura
 81    /// sin perder el historial de versiones anteriores.
 82    /// </summary>
 83    public async Task<(bool Exito, string? Error, int Version)> GenerarLiquidacionAsync(long periodoId)
 84    {
 4585        var periodo = await _context.PeriodosLiquidacion
 4586            .AsNoTracking()
 4587            .FirstOrDefaultAsync(p => p.Id == periodoId && p.Activo);
 88
 4589        if (periodo is null)
 190            return (false, "Período de liquidación no encontrado o no está activo", 0);
 91
 92        // Versión = max existente + 1 (1 si es la primera vez)
 4493        var maxVersion = await _context.LiquidacionItems
 4494            .Where(li => li.PeriodoId == periodoId)
 4495            .MaxAsync(li => (int?)li.Version) ?? 0;
 4496        var version = maxVersion + 1;
 97
 98        InsumosPeriodo insumos;
 99        try
 100        {
 44101            insumos = await _insumosLoader.CargarAsync(periodoId);
 44102        }
 0103        catch (InvalidOperationException ex) when (ex.Message.Contains("arámetro"))
 104        {
 0105            return (false, $"No se encontraron parámetros para el período {periodo.Anio}/{periodo.Mes}", 0);
 106        }
 107
 108        // Usamos el mismo patrón de Join que funciona con el HasConversion de Estado enum→string
 44109        var rlRaw = await _context.RelacionesLaborales
 44110            .AsNoTracking()
 44111            .Where(r => r.Estado == EstadoRelacion.Activo)
 44112            .Join(
 44113                _context.Personas.AsNoTracking(),
 44114                r => r.PersonaId,
 44115                p => p.Id,
 44116                (r, p) => new { RL = r, Persona = p })
 44117            .ToListAsync();
 118
 44119        if (!rlRaw.Any())
 2120            return (false, "No se encontraron relaciones laborales activas para generar la liquidación", version);
 121
 122        // Cargar navigation properties necesarias para el pipeline de calculadoras
 85123        var situacionIds = rlRaw.Select(x => x.RL.SituacionId).Distinct().ToHashSet();
 85124        var regimenIds   = rlRaw.Select(x => x.RL.RegimenId).Distinct().ToHashSet();
 125
 42126        var situacionesDict = await _context.Situaciones.AsNoTracking()
 42127            .Where(s => situacionIds.Contains(s.Id))
 60128            .ToDictionaryAsync(s => s.Id);
 129
 42130        var regimenesDict = await _context.Regimenes.AsNoTracking()
 42131            .Where(r => regimenIds.Contains(r.Id))
 60132            .ToDictionaryAsync(r => r.Id);
 133
 134        // Materializar RelacionLaboral con navigation properties resueltas en memoria
 42135        var relacionesLaborales = rlRaw.Select(x =>
 42136        {
 43137            x.RL.Persona = x.Persona;
 43138            x.RL.Situacion = situacionesDict.GetValueOrDefault(x.RL.SituacionId)!;
 43139            x.RL.Regimen   = regimenesDict.GetValueOrDefault(x.RL.RegimenId)!;
 43140            return x.RL;
 42141        }).ToList();
 142
 42143        var primerDiaPeriodo = new DateOnly(periodo.Anio, periodo.Mes, 1);
 42144        var itemsToSave = new List<LiquidacionItem>();
 42145        var incidenciasToSave = new List<IncidenciaCalculo>();
 42146        var estadosPersonal = new Dictionary<string, string>();
 42147        var erroresPersonal = new Dictionary<string, string>();
 148
 149        // Pre-load historico de primas únicas en una sola consulta
 85150        var cedulasPersonas = relacionesLaborales.Select(r => r.Persona.Cedula).ToHashSet();
 42151        var itemsHistoricosGlobal = await _context.LiquidacionItems
 42152            .AsNoTracking()
 42153            .Where(li => cedulasPersonas.Contains(li.Cedula) &&
 42154                         (li.CodigoConcepto == "340" || li.CodigoConcepto == "350" || li.CodigoConcepto == "370"))
 42155            .ToListAsync();
 156
 157        // Índice de catálogo por código (usado por helpers de aportes legales)
 42158        var catalogoPorCodigo = insumos.CatalogoItems
 228159            .GroupBy(i => i.Codigo)
 494160            .ToDictionary(g => g.Key, g => g.First());
 161
 170162        foreach (var rl in relacionesLaborales)
 163        {
 43164            var cedula = rl.Persona.Cedula;
 165            try
 166            {
 43167                var acumuladores = new Acumuladores();
 43168                var itemsHaberes = new List<ItemCalculado>();
 43169                var itemsBeneficios = new List<ItemBeneficioCalculado>();
 170
 171                // --- Paso 1-3: Calculador de haberes base ---
 43172                var calculador = rl.Situacion?.Calculador is not null
 43173                    ? _calculadorFactory.Resolver(rl.Situacion.Calculador)
 43174                    : null;
 175
 43176                if (calculador is not null)
 177                {
 178                    // Prorrateo por novedad 'dias_eximidos': factor = dias/30.
 179                    // Endurecido: dias_afectados debe estar en [1,30]. Fuera de rango (incl. 0)
 180                    // NO se prorratea (factor 1) y se registra incidencia, para no anular
 181                    // indebidamente los haberes ni romper el cálculo de aportes/IRPF.
 18182                    var novedadEximidos = insumos.ObtenerNovedadEximidos(rl.PersonaId);
 18183                    var factorProrrateo = 1m;
 18184                    if (novedadEximidos is not null)
 185                    {
 3186                        var diasAfectados = novedadEximidos.DiasAfectados;
 3187                        if (diasAfectados is < 1 or > 30)
 188                        {
 1189                            incidenciasToSave.Add(new IncidenciaCalculo
 1190                            {
 1191                                PeriodoId = periodoId, Version = version, PersonaId = rl.PersonaId,
 1192                                Tipo = TipoIncidenciaCalculo.CalculoSimplificado,
 1193                                Descripcion = $"Novedad 'dias_eximidos' con dias_afectados fuera de rango [1,30]: {diasA
 1194                                CreadoEn = DateTime.Now
 1195                            });
 196                        }
 2197                        else if (diasAfectados < 30)
 198                        {
 2199                            factorProrrateo = diasAfectados / 30m;
 200                        }
 201                    }
 202
 18203                    var resultadoCalculo = await calculador.CalcularAsync(rl, insumos, acumuladores);
 204
 18205                    if (factorProrrateo < 1m)
 206                    {
 20207                        foreach (var item in resultadoCalculo.Items)
 8208                            item.Importe = Math.Round(item.Importe * factorProrrateo, 2);
 209
 2210                        acumuladores.Reiniciar();
 20211                        foreach (var item in resultadoCalculo.Items)
 212                        {
 36213                            var ci = insumos.CatalogoItems.FirstOrDefault(c => c.Codigo == item.CodigoConcepto);
 16214                            if (ci is not null) acumuladores.AgregarItem(item.Importe, ci);
 215                        }
 216                    }
 217
 18218                    itemsHaberes.AddRange(resultadoCalculo.Items);
 219
 36220                    foreach (var inc in resultadoCalculo.Incidencias)
 0221                        incidenciasToSave.Add(new IncidenciaCalculo
 0222                        {
 0223                            PeriodoId = periodoId, Version = version, PersonaId = rl.PersonaId,
 0224                            Tipo = TipoIncidenciaCalculo.CalculoSimplificado,
 0225                            Descripcion = inc, CreadoEn = DateTime.Now
 0226                        });
 227                }
 228                else
 229                {
 25230                    incidenciasToSave.Add(new IncidenciaCalculo
 25231                    {
 25232                        PeriodoId = periodoId, Version = version, PersonaId = rl.PersonaId,
 25233                        Tipo = TipoIncidenciaCalculo.ErrorCalculo,
 25234                        Descripcion = $"Calculador no registrado: {rl.Situacion?.Calculador}",
 25235                        CreadoEn = DateTime.Now
 25236                    });
 237                }
 238
 239                // --- Paso 4: Beneficios sociales ---
 43240                var beneficios = insumos.ObtenerBeneficios(rl.PersonaId);
 43241                var itemsHistoricoPersona = itemsHistoricosGlobal.Where(h => h.Cedula == cedula).ToList();
 43242                var sueldoBasePersona = CalcularSueldoBaseParaTraslado(insumos, rl.GradoId);
 43243                var resultadoBeneficios = _procesadorBeneficios.ProcesarBeneficios(
 43244                    rl.PersonaId, beneficios, insumos.ParametroPeriodo, insumos.CatalogoItems,
 43245                    itemsHistoricoPersona, primerDiaPeriodo,
 43246                    insumos.BeneficiariosPorBeneficio, insumos.InstructivoAsigFamiliar,
 43247                    insumos.InstructivoPsf, insumos.TablaPorHogar,
 43248                    sueldoBaseParaTraslado: sueldoBasePersona);
 42249                itemsBeneficios.AddRange(resultadoBeneficios.Items);
 250
 84251                foreach (var inc in resultadoBeneficios.Incidencias)
 0252                    incidenciasToSave.Add(new IncidenciaCalculo
 0253                    {
 0254                        PeriodoId = periodoId, Version = version, PersonaId = rl.PersonaId,
 0255                        Tipo = inc.Tipo, Descripcion = inc.Descripcion, CreadoEn = DateTime.Now
 0256                    });
 257
 258                // --- Paso 5: Compensaciones por lote ---
 259                // El OG viene de ConfigCompensacion.CatalogoItem (no hardcodeado)
 42260                var compensaciones = insumos.ObtenerCompensaciones(rl.PersonaId);
 88261                foreach (var comp in compensaciones)
 262                {
 2263                    var ogCode = ObtenerOGCompensacion(comp);
 2264                    if (ogCode is null) continue;   // sin config de OG → saltear
 265
 2266                    var monto = comp.Monto ?? 0m;
 2267                    if (monto <= 0) continue;
 268
 18269                    var ci = insumos.CatalogoItems.FirstOrDefault(c => c.Codigo == ogCode.Value);
 2270                    itemsHaberes.Add(new ItemCalculado
 2271                    {
 2272                        CodigoConcepto = ogCode.Value,
 2273                        NombreConcepto = ci?.Nombre ?? comp.LoteCompensacion?.TipoCompensacion?.Nombre ?? "",
 2274                        Importe = monto
 2275                    });
 4276                    if (ci is not null) acumuladores.AgregarItem(monto, ci);
 277                }
 278
 279                // --- Paso 5.5: Mod. Aportes Ley 19.695 (042.413) ---
 280                // Se calcula AQUÍ, después de compensaciones (paso 5), para que ASIDUIDAD
 281                // y otras compensaciones entren en WNominal antes del cálculo.
 282                // Replica el orden del legacy: SUM33315.PRG corre después de SUM33304.PRG.
 283                // Fórmula: (WNominal - 048.042) × 2.35% — el legacy no deduce 042.403
 42284                if (rl.Regimen is not null && !rl.Regimen.EsLeyVieja)
 285                {
 80286                    var monto048042 = itemsHaberes.Where(i => i.CodigoConcepto == 48042).Sum(i => i.Importe);
 15287                    var base413 = acumuladores.WNominal - monto048042;
 15288                    if (base413 > 0)
 289                    {
 15290                        var monto413 = Math.Round(base413 * 0.0235m, 2);
 291                        const int Cod413 = 42413;
 15292                        var ci413 = catalogoPorCodigo.TryGetValue(Cod413, out var c413) ? c413 : null;
 15293                        itemsHaberes.Add(new ItemCalculado
 15294                        {
 15295                            CodigoConcepto = Cod413,
 15296                            NombreConcepto = ci413?.Nombre ?? "MOD. APORTES LEY 19.695 MDN (042.413)",
 15297                            Importe = monto413
 15298                        });
 25299                        if (ci413 is not null) acumuladores.AgregarItem(monto413, ci413);
 300                    }
 301                }
 302
 303                // --- Paso 6: Diferencia por ascenso 042.615 ---
 42304                var diffAscenso = insumos.ObtenerDiferenciaAscenso(rl.PersonaId);
 42305                if (diffAscenso is not null && diffAscenso.Monto > 0)
 306                {
 307                    const int Cod615 = 42615;
 9308                    var ci615 = insumos.CatalogoItems.FirstOrDefault(c => c.Codigo == Cod615);
 1309                    itemsHaberes.Add(new ItemCalculado
 1310                    {
 1311                        CodigoConcepto = Cod615,
 1312                        NombreConcepto = ci615?.Nombre ?? "Diferencia por Ascenso (042.615)",
 1313                        Importe = diffAscenso.Monto
 1314                    });
 2315                    if (ci615 is not null) acumuladores.AgregarItem(diffAscenso.Monto, ci615);
 316                }
 317
 42318                var fechaPeriodo = new DateTime(insumos.ParametroPeriodo.Anio, insumos.ParametroPeriodo.Mes, 1);
 42319                var novedadSubsidio = insumos.ObtenerNovedades(rl.PersonaId)
 45320                    .FirstOrDefault(n => n.Tipo == "subsidio_enfermedad" && n.Estado == "confirmada");
 42321                var tieneSubsidio = novedadSubsidio is not null;
 322
 42323                var descuentosConfirmadosPersona = tieneSubsidio
 42324                    ? Array.Empty<DescuentoPersonalPeriodo>()
 42325                    : await ObtenerDescuentosConfirmadosAplicablesAsync(periodoId, rl.PersonaId, fechaPeriodo);
 326
 327                // --- Paso 6.5: Fictos IRPF ---
 328                // El legado agrega estos importes como haberes para IRPF y deficit,
 329                // y luego emite el mismo importe como descuento legal para netearlo.
 42330                var fictosIrpf = tieneSubsidio
 42331                    ? Array.Empty<FictoIrpfCalculado>()
 42332                    : CalcularFictosIrpf(periodoId, cedula, version, descuentosConfirmadosPersona, catalogoPorCodigo);
 88333                foreach (var ficto in fictosIrpf)
 2334                    itemsHaberes.Add(ficto.Haber);
 335
 42336                var idsDescuentosFictos = fictosIrpf
 2337                    .Select(f => f.DescuentoPersonalPeriodoId)
 42338                    .ToHashSet();
 42339                var itemsDescuentosFictos = fictosIrpf
 2340                    .Select(f => f.Descuento)
 42341                    .ToList();
 342
 343                // --- Paso 7: Aportes legales (Montepío, FONASA, FRL) ---
 344                // baseSdlAportes = haberes positivos aportables. Beneficios sociales
 345                // y fictos no integran Montepio, Sanidad/FONASA ni Tutela Social.
 346                // nominalBruto   = todos haberes positivos + todos beneficios positivos
 347                // Se suma directamente desde itemsHaberes porque contribuye_w_nominal
 348                // puede no estar configurado en el catálogo (en ese caso WNominal=0).
 220349                var totalHaberesPositivos = itemsHaberes.Where(i => i.Importe > 0m).Sum(i => i.Importe);
 42350                var totalHaberesAportables = itemsHaberes
 89351                    .Where(i => i.Importe > 0m && !EsCatalogoFicto(catalogoPorCodigo.GetValueOrDefault(i.CodigoConcepto)
 129352                    .Sum(i => i.Importe);
 42353                var totalHaberesAportablesProcesador = resultadoBeneficios.Items
 35354                    .Where(i => i.Importe > 0m
 35355                                && catalogoPorCodigo.TryGetValue(i.CodigoConcepto, out var catalogoItem)
 35356                                && EsCatalogoHaberAportable(catalogoItem))
 67357                    .Sum(i => i.Importe);
 42358                var baseSdlAportes = totalHaberesAportables + totalHaberesAportablesProcesador;
 42359                var nominalBruto = totalHaberesPositivos
 107360                    + resultadoBeneficios.Items.Where(i => i.Importe > 0m).Sum(i => i.Importe);
 361
 362                // --- Paso 7.5: Subsidio por enfermedad ---
 363                // Si existe novedad confirmada, el nominal va íntegro al fondo de subsidio.
 364                // No se calculan aportes legales, IRPF ni descuentos personales — replica el
 365                // comportamiento del legacy donde DESCUENTOS = NOMINAL y A COBRAR = 0.
 42366                if (tieneSubsidio)
 367                {
 0368                    var factor = Math.Min((int)novedadSubsidio!.DiasAfectados, 30) / 30m
 0369                                 * (novedadSubsidio.PorcentajeSubsidio ?? 100m) / 100m;
 0370                    var montoSubsidio = Math.Round(nominalBruto * factor, 2);
 0371                    itemsToSave.Add(CrearItemLiquidacion(periodoId, cedula, version,
 0372                        "SUBSIDIO", "Subsidio por Enfermedad", -montoSubsidio, "SUBSIDIO"));
 0373                    estadosPersonal[cedula] = "CALCULADO";
 374                }
 375
 42376                if (!tieneSubsidio)
 377                {
 42378                var esMilitar = !CalculadoresCiviles.Contains(rl.Situacion?.Calculador ?? string.Empty);
 379                // UsaFonasa=true anula Sanidad Militar aunque sea militar (SS_FF_AA=0 en legacy)
 42380                var usaSanidadMilitar = esMilitar && !rl.UsaFonasa;
 42381                var aportes = await CalcularAportesLegalesAsync(rl.RegimenId, baseSdlAportes, insumos, usaSanidadMilitar
 42382                var itemsAportesLegales = CrearItemsAportesLegales(periodoId, cedula, version, aportes, catalogoPorCodig
 42383                itemsToSave.AddRange(itemsAportesLegales);
 42384                itemsToSave.AddRange(itemsDescuentosFictos);
 385
 386                // --- Paso 8: IRPF ---
 387                // Eliminar registro anterior para que CalcularYPersistirAsync siempre inserte limpio
 42388                var irpfAnterior = await _context.Set<IrpfMensual>()
 42389                    .FirstOrDefaultAsync(i => i.LiquidacionId == periodoId && i.PersonaId == rl.PersonaId);
 42390                if (irpfAnterior is not null)
 391                {
 0392                    var detallesAnteriores = await _context.Set<IrpfDetalleFranjas>()
 0393                        .Where(d => d.IrpfMensualId == irpfAnterior.Id)
 0394                        .ToListAsync();
 0395                    _context.Set<IrpfDetalleFranjas>().RemoveRange(detallesAnteriores);
 0396                    _context.Set<IrpfMensual>().Remove(irpfAnterior);
 0397                    await _context.SaveChangesAsync();
 398                }
 399
 400                // Adicional 6%: la base IRPF es (ingresos sujetos a montepio) × 1,06.
 401                // Se pasa como Adicional6 = baseSdlAportes × 6% al request; el calculator
 402                // aplica las franjas sobre IngresoGravados + Adicional6.
 403                // Solo aplica cuando baseSdlAportes > 10 BPC (condición del legacy).
 42404                var bpcMensualIrpf = insumos.ParametroPeriodo.BpcAnual / 12m;
 42405                var adicional6 = baseSdlAportes > 10m * bpcMensualIrpf
 42406                    ? Math.Round(baseSdlAportes * 0.06m, 2)
 42407                    : 0m;
 408
 42409                var resultadoIrpf = await CalcularIrpfAsync(
 42410                    periodoId, rl.PersonaId, cedula, version,
 42411                    nominalBruto, baseSdlAportes, aportes, insumos, catalogoPorCodigo, adicional6);
 412
 413                // Descartar entidades IrpfMensual trackeadas si la persistencia falló,
 414                // para que no se reintentan en el SaveChangesAsync final.
 84415                foreach (var entry in _context.ChangeTracker.Entries<IrpfMensual>()
 0416                             .Where(e => e.State == Microsoft.EntityFrameworkCore.EntityState.Added)
 42417                             .ToList())
 0418                    entry.State = Microsoft.EntityFrameworkCore.EntityState.Detached;
 419
 42420                if (resultadoIrpf.Item is not null && resultadoIrpf.TotalIrpf != 0)
 3421                    itemsToSave.Add(resultadoIrpf.Item);
 422                else
 423                {
 424                    // Diagnóstico: registrar siempre el resultado del cálculo IRPF para debugging
 39425                    var tieneCatalogo = catalogoPorCodigo.ContainsKey(CodigoIrpf);
 39426                    incidenciasToSave.Add(new IncidenciaCalculo
 39427                    {
 39428                        PeriodoId = periodoId, Version = version, PersonaId = rl.PersonaId,
 39429                        Tipo = TipoIncidenciaCalculo.CalculoSimplificado,
 39430                        Descripcion = $"IRPF diag: TotalIrpf={resultadoIrpf.TotalIrpf:F2} ItemNull={resultadoIrpf.Item i
 39431                        CreadoEn = DateTime.Now
 39432                    });
 433                }
 434
 435                // --- Paso 9: Descuentos personales con déficit ---
 436                // Excluir códigos ya emitidos como aportes para no contarlos dos veces en el déficit
 42437                var codigosAportesEmitidos = itemsAportesLegales
 88438                    .Where(i => !string.IsNullOrWhiteSpace(i.CodigoConcepto))
 88439                    .Select(i => i.CodigoConcepto)
 42440                    .ToHashSet(StringComparer.OrdinalIgnoreCase);
 42441                var descuentosLegalesEnBeneficios = resultadoBeneficios.Items
 35442                    .Where(i => i.Importe < 0m
 35443                                && catalogoPorCodigo.TryGetValue(i.CodigoConcepto, out var ci)
 35444                                && string.Equals(ci.Tipo, "descuento_legal", StringComparison.OrdinalIgnoreCase)
 35445                                && !codigosAportesEmitidos.Contains(i.CodigoConcepto.ToString()))
 46446                    .Sum(i => Math.Abs(i.Importe));
 44447                var totalDescuentosFictos = fictosIrpf.Sum(f => f.Importe);
 42448                var totalDescuentosLegales = aportes.Total + resultadoIrpf.TotalIrpf + descuentosLegalesEnBeneficios + t
 42449                var itemsDescuentosPersonales = await CrearItemsDescuentosPersonalesAsync(
 42450                    periodoId, rl.PersonaId, cedula, version,
 42451                    nominalBruto, totalDescuentosLegales, fechaPeriodo, idsDescuentosFictos);
 42452                itemsToSave.AddRange(itemsDescuentosPersonales);
 453
 454                // --- Paso 9.5: Ajuste de redondeo ---
 455                // El liquido real (lo que el funcionario ve en el recibo) es la suma de
 456                // todos los items que se van a persistir, no `nominalBruto` (que filtra
 457                // solo positivos y se usa como base de aportes/IRPF).
 131458                var totalSinAjuste = itemsHaberes.Sum(i => i.Importe)
 35459                    + itemsBeneficios.Sum(i => i.Importe)
 88460                    + itemsAportesLegales.Sum(i => i.Importe)
 2461                    + itemsDescuentosFictos.Sum(i => i.Importe)
 42462                    + (resultadoIrpf.Item?.Importe ?? 0m)
 52463                    + itemsDescuentosPersonales.Sum(i => i.Importe);
 464
 42465                var ajusteRedondeo = CalcularAjusteRedondeo(totalSinAjuste);
 42466                if (ajusteRedondeo != 0m && catalogoPorCodigo.TryGetValue(CodigoAjusteRedondeo, out var ciAjuste))
 4467                    itemsToSave.Add(CrearItemLiquidacion(periodoId, cedula, version, ciAjuste, ajusteRedondeo));
 468
 469                // --- Paso 10: Estado ---
 42470                var tieneError = incidenciasToSave.Any(i =>
 86471                    i.PersonaId == rl.PersonaId && i.Tipo == TipoIncidenciaCalculo.ErrorCalculo);
 42472                var liquido = nominalBruto - totalDescuentosLegales
 52473                    - itemsDescuentosPersonales.Sum(i => Math.Abs(i.Importe));
 42474                var estado = tieneError ? "ERROR" : (liquido < 0 ? "REVISAR" : "CALCULADO");
 42475                estadosPersonal[cedula] = estado;
 42476                } // end if (!tieneSubsidio)
 477
 478                // --- Paso 11: Persistir ítems de haberes y beneficios ---
 262479                foreach (var item in itemsHaberes)
 480                {
 89481                    catalogoPorCodigo.TryGetValue(item.CodigoConcepto, out var catalogoItem);
 89482                    itemsToSave.Add(new LiquidacionItem
 89483                    {
 89484                        PeriodoId = periodoId,
 89485                        Cedula = cedula,
 89486                        Version = version,
 89487                        CodigoConcepto = item.CodigoConcepto.ToString(),
 89488                        NombreConcepto = item.NombreConcepto,
 89489                        Importe = item.Importe,
 89490                        RubroContable = catalogoItem?.ClasificacionSiif ?? string.Empty,
 89491                        Rubro083 = false,
 89492                        FechaCreacion = DateTime.Now
 89493                    });
 494                }
 154495                foreach (var item in itemsBeneficios)
 496                {
 35497                    itemsToSave.Add(new LiquidacionItem
 35498                    {
 35499                        PeriodoId = periodoId,
 35500                        Cedula = cedula,
 35501                        Version = version,
 35502                        CodigoConcepto = item.CodigoConcepto.ToString(),
 35503                        NombreConcepto = item.NombreConcepto,
 35504                        Importe = item.Importe,
 35505                        RubroContable = item.RubroContable,
 35506                        Rubro083 = false,
 35507                        ClaveEvento = item.ClaveEvento,
 35508                        FechaCreacion = DateTime.Now
 35509                    });
 510                }
 42511            }
 1512            catch (Exception ex)
 513            {
 1514                estadosPersonal[cedula] = "ERROR";
 1515                erroresPersonal[cedula] = ex.Message;
 1516                incidenciasToSave.Add(new IncidenciaCalculo
 1517                {
 1518                    PeriodoId = periodoId,
 1519                    Version = version,
 1520                    PersonaId = rl.PersonaId,
 1521                    Tipo = TipoIncidenciaCalculo.ErrorCalculo,
 1522                    Descripcion = $"Error en pipeline de liquidación: {ex.Message}",
 1523                    CreadoEn = DateTime.Now
 1524                });
 1525            }
 43526        }
 527
 85528        var estados = relacionesLaborales.Select(rl => new LiquidacionEstadoPersonal
 85529        {
 85530            PeriodoId = periodoId,
 85531            Cedula = rl.Persona.Cedula,
 85532            Version = version,
 85533            Estado = estadosPersonal.TryGetValue(rl.Persona.Cedula, out var e) ? e : "PENDIENTE",
 85534            MensajeError = erroresPersonal.TryGetValue(rl.Persona.Cedula, out var err) ? err : null,
 85535            FechaActualizacion = DateTime.Now
 85536        }).ToList();
 537
 42538        await _context.LiquidacionEstadosPersonal.AddRangeAsync(estados);
 539
 42540        if (itemsToSave.Count > 0)
 41541            await _context.LiquidacionItems.AddRangeAsync(itemsToSave);
 542
 42543        if (incidenciasToSave.Count > 0)
 42544            await _context.IncidenciasCalculo.AddRangeAsync(incidenciasToSave);
 545
 273546        var totalImporte = itemsToSave.Sum(i => i.Importe);
 42547        var resumen = new LiquidacionResumen
 42548        {
 42549            PeriodoId = periodoId,
 42550            Version = version,
 42551            TotalImporte = totalImporte,
 42552            Rubro083 = false,
 42553            Descripcion = "Liquidación generada"
 42554        };
 42555        await _context.LiquidacionResumenes.AddAsync(resumen);
 556
 42557        await _context.SaveChangesAsync();
 558
 42559        await _auditoriaService.LogAuditoriaAsync(
 42560            _currentUser.UserId ?? 0,
 42561            AccionEnum.GenerarLiquidacion,
 42562            ContextoEnum.Liquidaciones,
 42563            _currentUser.Host,
 42564            entidad: nameof(LiquidacionEstadoPersonal),
 42565            entidadId: periodoId,
 42566            detalle: new { periodoId, version, registros = estados.Count, items = itemsToSave.Count, incidencias = incid
 567
 42568        return (true, null, version);
 45569    }
 570
 571    private async Task<int> ObtenerVersionMaximaAsync(long periodoId)
 572    {
 1573        return await _context.LiquidacionItems
 1574            .AsNoTracking()
 1575            .Where(i => i.PeriodoId == periodoId)
 1576            .MaxAsync(i => (int?)i.Version) ?? 0;
 1577    }
 578
 579    public async Task<IEnumerable<CalculoEstadoDto>> ObtenerEstadosAsync(long periodoId, int? version = null)
 580    {
 2581        var v = version ?? await ObtenerVersionMaximaAsync(periodoId);
 582
 2583        var totalesPorCedula = await _context.LiquidacionItems
 2584            .AsNoTracking()
 2585            .Where(i => i.PeriodoId == periodoId && i.Version == v)
 2586            .GroupBy(i => i.Cedula)
 2587            .Select(g => new
 2588            {
 2589                Cedula = g.Key,
 2590                Nominal    = g.Where(i => i.Importe > 0).Sum(i => i.Importe),
 2591                Descuentos = g.Where(i => i.Importe < 0).Sum(i => i.Importe),
 2592            })
 3593            .ToDictionaryAsync(x => x.Cedula);
 594
 2595        var estados = await _context.LiquidacionEstadosPersonal
 2596            .AsNoTracking()
 2597            .Where(e => e.PeriodoId == periodoId && e.Version == v)
 2598            .Select(e => new CalculoEstadoDto
 2599            {
 2600                Cedula       = e.Cedula,
 2601                Version      = e.Version,
 2602                Estado       = e.Estado,
 2603                MensajeError = e.MensajeError
 2604            })
 2605            .ToListAsync();
 606
 8607        foreach (var dto in estados)
 2608            if (totalesPorCedula.TryGetValue(dto.Cedula, out var tot))
 609            {
 1610                dto.Nominal    = tot.Nominal;
 1611                dto.Descuentos = tot.Descuentos;
 1612                dto.Liquido    = tot.Nominal + tot.Descuentos;
 613            }
 614
 2615        return estados;
 2616    }
 617
 618    public async Task<IEnumerable<CalculoItemDto>> ObtenerItemsAsync(long periodoId, int? version = null, string? cedula
 619    {
 0620        var v = version ?? await ObtenerVersionMaximaAsync(periodoId);
 0621        var query = _context.LiquidacionItems
 0622            .AsNoTracking()
 0623            .Where(i => i.PeriodoId == periodoId && i.Version == v);
 624
 0625        if (!string.IsNullOrEmpty(cedula))
 0626            query = query.Where(i => i.Cedula == cedula);
 627
 0628        return await query
 0629            .Select(i => new CalculoItemDto
 0630            {
 0631                Cedula         = i.Cedula,
 0632                Version        = i.Version,
 0633                CodigoConcepto = i.CodigoConcepto,
 0634                NombreConcepto = i.NombreConcepto,
 0635                Importe        = i.Importe,
 0636                RubroContable  = i.RubroContable,
 0637                ObjetoGasto    = i.ObjetoGasto
 0638            })
 0639            .ToListAsync();
 0640    }
 641
 642    public async Task<IEnumerable<LiquidacionResumenDto>> ObtenerResumenAsync(long periodoId, int? version = null)
 643    {
 0644        var v = version ?? await ObtenerVersionMaximaAsync(periodoId);
 0645        return await _context.LiquidacionResumenes
 0646            .AsNoTracking()
 0647            .Where(r => r.PeriodoId == periodoId && r.Version == v)
 0648            .Select(r => new LiquidacionResumenDto
 0649            {
 0650                Version = r.Version,
 0651                ProgramaId = r.ProgramaId,
 0652                RegimenId = r.RegimenId,
 0653                Rubro083 = r.Rubro083,
 0654                TotalImporte = r.TotalImporte,
 0655                Descripcion = r.Descripcion
 0656            })
 0657            .ToListAsync();
 0658    }
 659
 660    public async Task<IEnumerable<int>> ObtenerVersionesAsync(long periodoId)
 661    {
 2662        return await _context.LiquidacionItems
 2663            .AsNoTracking()
 2664            .Where(i => i.PeriodoId == periodoId)
 2665            .Select(i => i.Version)
 2666            .Distinct()
 2667            .OrderByDescending(v => v)
 2668            .ToListAsync();
 2669    }
 670
 671    // Civiles aportan FONASA por tasa única; los militares aportan Sanidad Militar por tramos.
 1672    private static readonly HashSet<string> CalculadoresCiviles =
 1673        new(StringComparer.OrdinalIgnoreCase) { "Sit70Calculator", "CivilCalculator" };
 674
 675    private async Task<AportesLegales> CalcularAportesLegalesAsync(
 676        long regimenId, decimal baseSdl, InsumosPeriodo insumos, bool esMilitar)
 677    {
 42678        var regimen = await _context.Regimenes.AsNoTracking().FirstOrDefaultAsync(r => r.Id == regimenId);
 42679        var tasaMontepio = regimen is not null && regimen.EsLeyVieja
 42680            ? insumos.ParametroPeriodo.TasaMontepioLeyVieja
 42681            : insumos.ParametroPeriodo.TasaMontepioLeyNueva;
 682
 42683        var montepio = _descuentosLegalesCalculator.CalcularMontepio(baseSdl, tasaMontepio);
 42684        var tutelaSocial = _descuentosLegalesCalculator.CalcularTutelaSocial(baseSdl, insumos.ParametroPeriodo.TasaTutel
 685
 686        // Sanidad Militar (Art. 94 Ley 18.834) reemplaza a FONASA para militares.
 687        // Base = misma que el montepío (baseSdl). Civiles mantienen FONASA por tasa única.
 42688        var tramo = esMilitar ? insumos.ObtenerTramoSanidad(baseSdl) : null;
 42689        if (tramo is not null)
 690        {
 1691            var sanidad = _descuentosLegalesCalculator.CalcularSanidad(baseSdl, tramo.Porcentaje);
 1692            return new AportesLegales(montepio, sanidad, tutelaSocial, tramo.CodigoRetencion);
 693        }
 694
 41695        var fonasa = _descuentosLegalesCalculator.CalcularFonasa(baseSdl, insumos.ParametroPeriodo.TasaFonasa);
 41696        return new AportesLegales(montepio, fonasa, tutelaSocial, SanidadCodigo: null);
 42697    }
 698
 699    private async Task<ResultadoIrpfLiquidacion> CalcularIrpfAsync(
 700        long periodoId,
 701        long personaId,
 702        string cedula,
 703        int version,
 704        decimal nominalBruto,
 705        decimal baseSdlAportes,
 706        AportesLegales aportes,
 707        InsumosPeriodo insumos,
 708        IReadOnlyDictionary<int, CatalogoItem> catalogoPorCodigo,
 709        decimal adicional6 = 0m)
 710    {
 42711        var request = new CalcularIrpfRequest
 42712        {
 42713            IngresoGravados = nominalBruto,
 42714            Montepio = aportes.Montepio,
 42715            Fonasa = aportes.Fonasa,
 42716            Frl = 0m,
 42717            Adicional6 = adicional6,
 42718            Deducciones = await CrearDeduccionesIrpfAsync(
 42719                personaId,
 42720                insumos.ParametroPeriodo.Anio,
 42721                insumos.ParametroPeriodo.Mes,
 42722                insumos.ParametroPeriodo.BpcAnual),
 42723            Parametros = new ParametrosIrpfDto
 42724            {
 42725                BpcMensual = insumos.ParametroPeriodo.BpcAnual / 12m,
 42726                UmbralDeduccionBpc = insumos.ParametroPeriodo.UmbralDeduccionBpc,
 42727                TasaDeduccionAlta = insumos.ParametroPeriodo.TasaDeduccionAlta,
 42728                TasaDeduccionBaja = insumos.ParametroPeriodo.TasaDeduccionBaja
 42729            },
 42730            Franjas = insumos.FranjasIrpf
 87731                .Select(f => new FranjaIrpfCalculoDto
 87732                {
 87733                    FranjaId = f.Id,
 87734                    NumeroFranja = f.NumeroFranja,
 87735                    DesdeBpc = f.DesdeBpc,
 87736                    HastaBpc = f.HastaBpc,
 87737                    Tasa = f.Tasa
 87738                })
 42739                .ToList()
 42740        };
 741
 42742        var (exito, idIrpf, _) = await _irpfService.CalcularYPersistirAsync(periodoId, personaId, request);
 42743        if (!exito || !idIrpf.HasValue)
 38744            return new ResultadoIrpfLiquidacion(0m, null);
 745
 4746        var irpf = await _irpfService.ObtenerPorIdAsync(idIrpf.Value);
 4747        if (irpf is null)
 0748            return new ResultadoIrpfLiquidacion(0m, null);
 749
 4750        var item = catalogoPorCodigo.TryGetValue(CodigoIrpf, out var catalogoIrpf)
 4751            ? CrearItemLiquidacion(periodoId, cedula, version, catalogoIrpf, -irpf.TotalIrpf)
 4752            : null;
 753
 4754        return new ResultadoIrpfLiquidacion(irpf.TotalIrpf, item);
 42755    }
 756
 757    private async Task<List<DeduccionDto>> CrearDeduccionesIrpfAsync(
 758        long personaId, int anio, int mes, decimal bpcAnual)
 759    {
 42760        var dependientes = await _dependienteRepository.GetDependientesByPersonaAsync(personaId);
 761
 42762        var result = dependientes
 3763            .Where(d => d.Activo)
 42764            .SelectMany(d =>
 42765            {
 3766                if (string.Equals(d.Tipo, "conyuge_sin_ingresos", StringComparison.OrdinalIgnoreCase))
 1767                    return [new DeduccionDto { Tipo = "conyuge_sin_ingresos", Cantidad = 1 }];
 42768
 2769                if (string.Equals(d.Tipo, "hijo", StringComparison.OrdinalIgnoreCase))
 42770                {
 2771                    var tipoHijo = d.Discapacitado ? "hijo_discapacidad" : "hijo_menor";
 2772                    return [new DeduccionDto { Tipo = tipoHijo, Cantidad = 1 }];
 42773                }
 42774
 0775                return Array.Empty<DeduccionDto>();
 42776            })
 42777            .ToList();
 778
 42779        var form = await _form3100Repository.GetVigenteParaPeriodoAsync(personaId, anio, mes);
 42780        if (form is not null)
 781        {
 1782            AgregarDeduccionesPersonasACargoForm3100(result, form, anio, mes, bpcAnual);
 783
 1784            var deduccionProfesional = form.CappuImporte + form.ReintegroAporte
 1785                + CalcularFondoSolidaridadMensual(form, bpcAnual);
 786
 1787            if (deduccionProfesional > 0m)
 0788                result.Add(new DeduccionDto { Tipo = "profesional", Monto = deduccionProfesional });
 789        }
 790
 42791        return result;
 42792    }
 793
 794    private static void AgregarDeduccionesPersonasACargoForm3100(
 795        ICollection<DeduccionDto> resultado,
 796        Form3100 form,
 797        int anio,
 798        int mes,
 799        decimal bpcAnual)
 800    {
 1801        var bpcMensual = bpcAnual / 12m;
 1802        var fechaReferencia = new DateTime(anio, mes, DateTime.DaysInMonth(anio, mes));
 803
 8804        foreach (var personaCargo in form.PersonasACargo)
 805        {
 3806            if (personaCargo.PorcentajeAtribucion <= 0)
 807                continue;
 808
 3809            if (!personaCargo.Relacion.StartsWith("hij", StringComparison.OrdinalIgnoreCase))
 810                continue;
 811
 2812            var esDiscapacidad = personaCargo.Discapacidad;
 2813            if (!esDiscapacidad && CalcularEdad(personaCargo.FechaNacimiento, fechaReferencia) >= 18)
 814                continue;
 815
 2816            var factorAtribucion = Math.Min(personaCargo.PorcentajeAtribucion, (short)100) / 100m;
 2817            var bpcAnuales = esDiscapacidad ? 40m : 20m;
 2818            var tipo = esDiscapacidad ? "hijo_discapacidad" : "hijo_menor";
 2819            var monto = bpcAnuales * bpcMensual / 12m * factorAtribucion;
 820
 2821            if (monto > 0m)
 2822                resultado.Add(new DeduccionDto { Tipo = tipo, Monto = monto });
 823        }
 1824    }
 825
 826    private static int CalcularEdad(DateTime fechaNacimiento, DateTime fechaReferencia)
 827    {
 1828        var edad = fechaReferencia.Year - fechaNacimiento.Year;
 1829        if (fechaNacimiento.Date > fechaReferencia.Date.AddYears(-edad))
 1830            edad--;
 1831        return edad;
 832    }
 833
 834    private static decimal CalcularFondoSolidaridadMensual(Form3100 form, decimal bpcAnual)
 835    {
 1836        var bpcFactor = form.FondoSolidaridad switch
 1837        {
 0838            "0.5_BPC" => 0.5m,
 0839            "1_BPC"   => 1m,
 0840            "2_BPC"   => 2m,
 1841            _         => 0m
 1842        };
 1843        if (form.AdicionalFondoSolidaridad)
 0844            bpcFactor += 0.5m;
 845
 846        // N_BPC = N BPC por año; BpcAnual = 12 × BpcMensual → monthly = N × BpcMensual / 12
 1847        var bpcMensual = bpcAnual / 12m;
 1848        return bpcFactor * bpcMensual / 12m;
 849    }
 850
 851    private async Task<IReadOnlyList<DescuentoPersonalPeriodo>> ObtenerDescuentosConfirmadosAplicablesAsync(
 852        long periodoId,
 853        long personaId,
 854        DateTime fechaPeriodo)
 855    {
 42856        return (await _descuentoPersonalRepository.GetConfirmadosPorPersonaAsync(periodoId, personaId))
 12857            .Where(d => d.CatalogoItem is not null)
 42858            // Vigencia (ej. CUOTA_ING): se aplica solo si no vencio antes del periodo.
 12859            .Where(d => d.FechaHasta is null || d.FechaHasta >= fechaPeriodo)
 42860            .ToList();
 42861    }
 862
 863    internal static IReadOnlyList<FictoIrpfCalculado> CalcularFictosIrpf(
 864        long periodoId,
 865        string cedula,
 866        int version,
 867        IReadOnlyList<DescuentoPersonalPeriodo> descuentosConfirmados,
 868        IReadOnlyDictionary<int, CatalogoItem> catalogoPorCodigo)
 869    {
 42870        if (descuentosConfirmados.Count == 0)
 31871            return Array.Empty<FictoIrpfCalculado>();
 872
 11873        var catalogoPorId = catalogoPorCodigo.Values
 47874            .GroupBy(c => c.Id)
 105875            .ToDictionary(g => g.Key, g => g.First());
 876
 11877        var fictos = new List<FictoIrpfCalculado>();
 878
 46879        foreach (var descuento in descuentosConfirmados)
 880        {
 12881            var catalogoDescuento = descuento.CatalogoItem;
 12882            if (catalogoDescuento.ItemParFictoId is not long itemParFictoId)
 883                continue;
 884
 2885            if (!catalogoPorId.TryGetValue(itemParFictoId, out var catalogoHaber))
 886                continue;
 887
 2888            if (!EsCatalogoFicto(catalogoHaber) || catalogoHaber.Codigo == catalogoDescuento.Codigo)
 889                continue;
 890
 2891            var importe = Math.Round(descuento.Importe, 2);
 2892            if (importe <= 0m)
 893                continue;
 894
 2895            var haber = new ItemCalculado
 2896            {
 2897                CodigoConcepto = catalogoHaber.Codigo,
 2898                NombreConcepto = catalogoHaber.Nombre,
 2899                Importe = importe
 2900            };
 2901            var itemDescuento = CrearItemLiquidacion(periodoId, cedula, version, catalogoDescuento, -importe);
 902
 2903            descuento.MontoAplicado = importe;
 2904            descuento.MontoNoAplicado = 0m;
 2905            descuento.Estado = "aplicado";
 906
 2907            fictos.Add(new FictoIrpfCalculado(haber, itemDescuento, descuento.Id, importe));
 908        }
 909
 11910        return fictos;
 911    }
 912
 913    private static bool EsCatalogoFicto(CatalogoItem? catalogoItem) =>
 116914        catalogoItem is not null && catalogoItem.Tipo.StartsWith("ficto_", StringComparison.OrdinalIgnoreCase);
 915
 916    private static bool EsCatalogoBeneficioSocial(CatalogoItem? catalogoItem) =>
 25917        catalogoItem is not null && catalogoItem.Tipo.StartsWith("beneficio_social", StringComparison.OrdinalIgnoreCase)
 918
 919    private static bool EsCatalogoHaberAportable(CatalogoItem? catalogoItem) =>
 30920        catalogoItem is not null
 30921        && catalogoItem.EsGravado
 30922        && catalogoItem.Tipo.StartsWith("haber_", StringComparison.OrdinalIgnoreCase)
 30923        && !EsCatalogoBeneficioSocial(catalogoItem)
 30924        && !EsCatalogoFicto(catalogoItem);
 925
 926    private async Task<IReadOnlyList<LiquidacionItem>> CrearItemsDescuentosPersonalesAsync(
 927        long periodoId,
 928        long personaId,
 929        string cedula,
 930        int version,
 931        decimal nominalBruto,
 932        decimal descuentosLegales,
 933        DateTime fechaPeriodo,
 934        IReadOnlySet<long> idsDescuentosExcluidos)
 935    {
 42936        var descuentosConfirmados = (await _descuentoPersonalRepository
 42937                .GetConfirmadosPorPersonaAsync(periodoId, personaId))
 12938            .Where(d => d.CatalogoItem is not null)
 42939            // Vigencia (ej. CUOTA_ING): se aplica solo si no venció antes del período
 12940            .Where(d => d.FechaHasta is null || d.FechaHasta >= fechaPeriodo)
 42941            .ToList();
 942
 42943        if (descuentosConfirmados.Count == 0)
 31944            return Array.Empty<LiquidacionItem>();
 945
 946        // Filtrar descuentos sin acreedor asignado (datos previos a la migración de acreedor_id)
 11947        var descuentosConAcreedor = descuentosConfirmados
 12948            .Where(d => !idsDescuentosExcluidos.Contains(d.Id))
 10949            .Where(d => d.AcreedorId is not null && d.Acreedor is not null)
 11950            .ToList();
 951
 11952        if (descuentosConAcreedor.Count == 0)
 2953            return Array.Empty<LiquidacionItem>();
 954
 9955        var entradasDeficit = descuentosConAcreedor
 10956            .Select(d => CrearEntradaDeficit(d, personaId))
 9957            .ToList();
 958
 9959        var resultadosDeficit = (await _deficitCalculator.AplicarAsync(
 9960            nominalBruto,
 9961            descuentosLegales,
 9962            entradasDeficit)).ToList();
 963
 19964        var descuentosPorId = descuentosConAcreedor.ToDictionary(d => d.Id);
 9965        var items = new List<LiquidacionItem>();
 966
 38967        foreach (var resultadoDeficit in resultadosDeficit)
 968        {
 10969            if (resultadoDeficit.DescuentoPeriodoId is null)
 970                continue;
 971
 10972            if (!descuentosPorId.TryGetValue(resultadoDeficit.DescuentoPeriodoId.Value, out var descuento))
 973                continue;
 974
 10975            descuento.MontoAplicado = resultadoDeficit.MontoAplicado;
 10976            descuento.MontoNoAplicado = resultadoDeficit.MontoNoAplicado;
 10977            descuento.Estado = resultadoDeficit.Estado;
 978
 10979            if (!string.IsNullOrWhiteSpace(resultadoDeficit.MotivoNoAplicado) &&
 10980                string.IsNullOrWhiteSpace(descuento.Observaciones))
 981            {
 0982                descuento.Observaciones = resultadoDeficit.MotivoNoAplicado;
 983            }
 984
 10985            if (resultadoDeficit.MontoAplicado <= 0m)
 986                continue;
 987
 10988            items.Add(CrearItemLiquidacion(
 10989                periodoId,
 10990                cedula,
 10991                version,
 10992                descuento.CatalogoItem,
 10993                -resultadoDeficit.MontoAplicado));
 994        }
 995
 9996        return items;
 42997    }
 998
 999    private static DescuentoPersonalInput CrearEntradaDeficit(DescuentoPersonalPeriodo descuento, long personaId)
 1000    {
 101001        if (descuento.AcreedorId is null)
 01002            throw new InvalidOperationException($"El descuento personal {descuento.Id} no tiene acreedor asignado.");
 1003
 101004        if (descuento.Acreedor is null)
 01005            throw new InvalidOperationException($"El descuento personal {descuento.Id} no tiene acreedor cargado.");
 1006
 101007        return new DescuentoPersonalInput(
 101008            descuento.Id,
 101009            personaId,
 101010            descuento.CatalogoItem.Codigo,
 101011            descuento.Importe,
 101012            descuento.AcreedorId.Value,
 101013            descuento.Acreedor.CategoriaDeficit,
 101014            descuento.Acreedor.OrdenLegalDeficit,
 101015            descuento.FechaComunicacion ?? descuento.FechaCreacion);
 1016    }
 1017
 1018    private static IReadOnlyList<LiquidacionItem> CrearItemsAportesLegales(
 1019        long periodoId,
 1020        string cedula,
 1021        int version,
 1022        AportesLegales aportes,
 1023        IReadOnlyDictionary<int, CatalogoItem> catalogoPorCodigo)
 1024    {
 421025        var items = new List<LiquidacionItem>();
 1026
 421027        AgregarItemAporteLegal(
 421028            items, periodoId, cedula, version, aportes.Montepio,
 421029            BuscarCatalogoItem(catalogoPorCodigo, CodigoMontepioSubalterno, 545, 555),
 421030            codigoFallback: CodigoMontepioSubalterno.ToString(),
 421031            nombreFallback: "MONTEPIO",
 421032            rubroFallback: "5.04");
 1033
 1034        // Salud: Sanidad Militar por tramos (código 5010/5011/5012) o FONASA por tasa única.
 421035        if (aportes.SanidadCodigo is int sanidadCodigo)
 1036        {
 11037            AgregarItemAporteLegal(
 11038                items, periodoId, cedula, version, aportes.Fonasa,
 11039                BuscarCatalogoItem(catalogoPorCodigo, sanidadCodigo),
 11040                codigoFallback: sanidadCodigo.ToString(),
 11041                nombreFallback: "SANIDAD",
 11042                rubroFallback: $"5.{sanidadCodigo % 100:D2}");
 1043        }
 1044        else
 1045        {
 411046            AgregarItemAporteLegal(
 411047                items, periodoId, cedula, version, aportes.Fonasa,
 411048                BuscarCatalogoItem(catalogoPorCodigo, CodigoSanidad, 521, 522, 523, 524, 525, 526, 527, 528),
 411049                codigoFallback: CodigoSanidad.ToString(),
 411050                nombreFallback: "FONASA",
 411051                rubroFallback: "5.010");
 1052        }
 1053
 421054        AgregarItemAporteLegal(
 421055            items, periodoId, cedula, version, aportes.TutelaSocial,
 421056            BuscarCatalogoItem(catalogoPorCodigo, CodigoTutelaSocial),
 421057            codigoFallback: CodigoTutelaSocial.ToString(),
 421058            nombreFallback: "SERV. ESP. TUT. SOC.",
 421059            rubroFallback: "5.08");
 1060
 421061        return items;
 1062    }
 1063
 1064    private static void AgregarItemAporteLegal(
 1065        ICollection<LiquidacionItem> items,
 1066        long periodoId,
 1067        string cedula,
 1068        int version,
 1069        decimal importe,
 1070        CatalogoItem? catalogoItem,
 1071        string codigoFallback,
 1072        string nombreFallback,
 1073        string rubroFallback)
 1074    {
 1261075        if (importe <= 0m)
 381076            return;
 1077
 881078        if (catalogoItem is not null)
 1079        {
 121080            items.Add(CrearItemLiquidacion(periodoId, cedula, version, catalogoItem, -importe));
 121081            return;
 1082        }
 1083
 761084        items.Add(CrearItemLiquidacion(periodoId, cedula, version, codigoFallback, nombreFallback, -importe, rubroFallba
 761085    }
 1086
 1087    private static CatalogoItem? BuscarCatalogoItem(
 1088        IReadOnlyDictionary<int, CatalogoItem> catalogoPorCodigo,
 1089        params int[] codigos)
 1090    {
 12561091        foreach (var codigo in codigos)
 1092        {
 5081093            if (catalogoPorCodigo.TryGetValue(codigo, out var catalogoItem))
 121094                return catalogoItem;
 1095        }
 1096
 1141097        return null;
 1098    }
 1099
 1100    private static LiquidacionItem CrearItemLiquidacion(
 1101        long periodoId,
 1102        string cedula,
 1103        int version,
 1104        CatalogoItem catalogoItem,
 1105        decimal importe)
 1106    {
 311107        return CrearItemLiquidacion(
 311108            periodoId, cedula, version,
 311109            catalogoItem.Codigo.ToString(),
 311110            catalogoItem.Nombre,
 311111            importe,
 311112            catalogoItem.ClasificacionSiif ?? "000.000");
 1113    }
 1114
 1115    private static LiquidacionItem CrearItemLiquidacion(
 1116        long periodoId,
 1117        string cedula,
 1118        int version,
 1119        string codigoConcepto,
 1120        string nombreConcepto,
 1121        decimal importe,
 1122        string rubroContable)
 1123    {
 1071124        return new LiquidacionItem
 1071125        {
 1071126            PeriodoId = periodoId,
 1071127            Cedula = cedula,
 1071128            Version = version,
 1071129            CodigoConcepto = codigoConcepto,
 1071130            NombreConcepto = nombreConcepto,
 1071131            Importe = importe,
 1071132            RubroContable = rubroContable,
 1071133            Rubro083 = false,
 1071134            FechaCreacion = DateTime.Now
 1071135        };
 1136    }
 1137
 1138    /// <summary>
 1139    /// Diferencia entre el redondeo al entero más cercano y <paramref name="totalSinAjuste"/>.
 1140    /// Centavos &lt;= 0.50 redondean hacia abajo; centavos &gt; 0.50 redondean hacia arriba.
 1141    /// La parte fraccionaria (<c>frac</c>) siempre queda en [0, 1) vía <see cref="Math.Floor(decimal)"/>,
 1142    /// por lo que la regla es válida también para totales negativos.
 1143    /// </summary>
 1144    internal static decimal CalcularAjusteRedondeo(decimal totalSinAjuste)
 1145    {
 481146        var piso = Math.Floor(totalSinAjuste);
 481147        var frac = totalSinAjuste - piso;
 481148        var redondeado = frac <= 0.50m ? piso : piso + 1m;
 481149        return redondeado - totalSinAjuste;
 1150    }
 1151
 1152    private static decimal CalcularSueldoBaseParaTraslado(InsumosPeriodo insumos, long gradoId)
 1153    {
 1154        const int CodigoSueldoBase     = 11000;
 1155        const int CodigoCompOficial    = 42012;
 1156        const int CodigoCompSubalterno = 42022;
 1157
 431158        var sueldoBase     = insumos.ObtenerRemuneracion(gradoId, CodigoSueldoBase)?.Monto ?? 0m;
 431159        var compOficial    = insumos.ObtenerRemuneracion(gradoId, CodigoCompOficial)?.Monto ?? 0m;
 431160        var compSubalterno = insumos.ObtenerRemuneracion(gradoId, CodigoCompSubalterno)?.Monto ?? 0m;
 1161
 431162        return sueldoBase + compOficial + compSubalterno;
 1163    }
 1164
 1165    // Fonasa carries el monto del aporte de salud: FONASA por tasa única (civiles) o
 1166    // Sanidad Militar por tramos (militares). SanidadCodigo indica el código del tramo
 1167    // aplicado (5010/5011/5012) cuando corresponde a Sanidad Militar; null para FONASA.
 21168    internal sealed record FictoIrpfCalculado(
 21169        ItemCalculado Haber,
 21170        LiquidacionItem Descuento,
 21171        long DescuentoPersonalPeriodoId,
 41172        decimal Importe);
 1173
 4201174    private sealed record AportesLegales(decimal Montepio, decimal Fonasa, decimal TutelaSocial = 0m, int? SanidadCodigo
 1175    {
 421176        public decimal Total => Montepio + Fonasa + TutelaSocial;
 1177    }
 1178
 2521179    private sealed record ResultadoIrpfLiquidacion(decimal TotalIrpf, LiquidacionItem? Item);
 1180}

Methods/Properties

ObtenerOGCompensacion(FAU.Entidades.ItemLoteCompensacion)
.ctor(FAU.DataAccess.ApplicationDbContext,FAU.Logica.Services.IAuditoriaService,FAU.Logica.Services.ICurrentUserContext,FAU.Logica.Services.IInsumosPeriodoLoader,FAU.Logica.Services.IProcesadorBeneficiosService,FAU.Logica.Calculadores.ICalculadorFactory,FAU.Logica.Services.ICalcularDiferencia615Service,FAU.DataAccess.Repositories.IDescuentoPersonalRepository,FAU.DataAccess.Repositories.IDependienteRepository,FAU.DataAccess.Repositories.IForm3100Repository,FAU.Logica.Calculators.IDeficitCalculator,FAU.Logica.Services.IDescuentosLegalesCalculator,FAU.Logica.Services.IIrpfService)
GenerarLiquidacionAsync()
ObtenerVersionMaximaAsync()
ObtenerEstadosAsync()
ObtenerItemsAsync()
ObtenerResumenAsync()
ObtenerVersionesAsync()
.cctor()
CalcularAportesLegalesAsync()
CalcularIrpfAsync()
CrearDeduccionesIrpfAsync()
AgregarDeduccionesPersonasACargoForm3100(System.Collections.Generic.ICollection`1<FAU.Logica.DTOs.DeduccionDto>,FAU.Entidades.Form3100,System.Int32,System.Int32,System.Decimal)
CalcularEdad(System.DateTime,System.DateTime)
CalcularFondoSolidaridadMensual(FAU.Entidades.Form3100,System.Decimal)
ObtenerDescuentosConfirmadosAplicablesAsync()
CalcularFictosIrpf(System.Int64,System.String,System.Int32,System.Collections.Generic.IReadOnlyList`1<FAU.Entidades.DescuentoPersonalPeriodo>,System.Collections.Generic.IReadOnlyDictionary`2<System.Int32,FAU.Entidades.CatalogoItem>)
EsCatalogoFicto(FAU.Entidades.CatalogoItem)
EsCatalogoBeneficioSocial(FAU.Entidades.CatalogoItem)
EsCatalogoHaberAportable(FAU.Entidades.CatalogoItem)
CrearItemsDescuentosPersonalesAsync()
CrearEntradaDeficit(FAU.Entidades.DescuentoPersonalPeriodo,System.Int64)
CrearItemsAportesLegales(System.Int64,System.String,System.Int32,FAU.Logica.Services.LiquidacionService/AportesLegales,System.Collections.Generic.IReadOnlyDictionary`2<System.Int32,FAU.Entidades.CatalogoItem>)
AgregarItemAporteLegal(System.Collections.Generic.ICollection`1<FAU.Entidades.LiquidacionItem>,System.Int64,System.String,System.Int32,System.Decimal,FAU.Entidades.CatalogoItem,System.String,System.String,System.String)
BuscarCatalogoItem(System.Collections.Generic.IReadOnlyDictionary`2<System.Int32,FAU.Entidades.CatalogoItem>,System.Int32[])
CrearItemLiquidacion(System.Int64,System.String,System.Int32,FAU.Entidades.CatalogoItem,System.Decimal)
CrearItemLiquidacion(System.Int64,System.String,System.Int32,System.String,System.String,System.Decimal,System.String)
CalcularAjusteRedondeo(System.Decimal)
CalcularSueldoBaseParaTraslado(FAU.Logica.DTOs.InsumosPeriodo,System.Int64)
.ctor(FAU.Logica.Calculadores.ItemCalculado,FAU.Entidades.LiquidacionItem,System.Int64,System.Decimal)
get_Haber()
get_Descuento()
get_DescuentoPersonalPeriodoId()
get_Importe()
get_Montepio()
get_Total()
get_TotalIrpf()