< 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
86%
Covered lines: 665
Uncovered lines: 107
Coverable lines: 772
Total lines: 1246
Line coverage: 86.1%
Branch coverage
80%
Covered branches: 260
Total branches: 322
Branch coverage: 80.7%
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 = 521;
 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
 5348    public LiquidacionService(
 5349        ApplicationDbContext context,
 5350        IAuditoriaService auditoriaService,
 5351        ICurrentUserContext currentUser,
 5352        IInsumosPeriodoLoader insumosLoader,
 5353        IProcesadorBeneficiosService procesadorBeneficios,
 5354        ICalculadorFactory calculadorFactory,
 5355        ICalcularDiferencia615Service diferencia615Service,
 5356        IDescuentoPersonalRepository descuentoPersonalRepository,
 5357        IDependienteRepository dependienteRepository,
 5358        IForm3100Repository form3100Repository,
 5359        IDeficitCalculator deficitCalculator,
 5360        IDescuentosLegalesCalculator descuentosLegalesCalculator,
 5361        IIrpfService irpfService)
 62    {
 5363        _context = context;
 5364        _auditoriaService = auditoriaService;
 5365        _currentUser = currentUser;
 5366        _insumosLoader = insumosLoader;
 5367        _procesadorBeneficios = procesadorBeneficios;
 5368        _calculadorFactory = calculadorFactory;
 5369        _diferencia615Service = diferencia615Service;
 5370        _descuentoPersonalRepository = descuentoPersonalRepository;
 5371        _dependienteRepository = dependienteRepository;
 5372        _form3100Repository = form3100Repository;
 5373        _deficitCalculator = deficitCalculator;
 5374        _descuentosLegalesCalculator = descuentosLegalesCalculator;
 5375        _irpfService = irpfService;
 5376    }
 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, long? usuarioId 
 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            usuarioId ?? _currentUser.UserId ?? 0,
 42561            AccionEnum.GenerarLiquidacion,
 42562            ContextoEnum.Liquidaciones,
 42563            host ?? _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<PagedResult<CalculoEstadoDto>> ObtenerEstadosAsync(long periodoId, int? version = null, int? page 
 580    {
 7581        var v = version ?? await ObtenerVersionMaximaAsync(periodoId);
 582
 7583        if (page.HasValue && page < 1) page = 1;
 8584        if (pageSize.HasValue) pageSize = Math.Clamp(pageSize.Value, 1, 100);
 585
 7586        var query = _context.LiquidacionEstadosPersonal
 7587            .AsNoTracking()
 7588            .Where(e => e.PeriodoId == periodoId && e.Version == v
 7589                && (cedula == null || e.Cedula.Contains(cedula))
 7590                && (estado == null || e.Estado == estado));
 591
 7592        var totalCount = await query.CountAsync();
 593
 7594        if (page.HasValue && pageSize.HasValue)
 1595            query = query.Skip((page.Value - 1) * pageSize.Value).Take(pageSize.Value);
 596
 7597        var estados = await query
 7598            .Select(e => new CalculoEstadoDto
 7599            {
 7600                Cedula       = e.Cedula,
 7601                Version      = e.Version,
 7602                Estado       = e.Estado,
 7603                MensajeError = e.MensajeError
 7604            })
 7605            .ToListAsync();
 606
 15607        var cedulas = estados.Select(e => e.Cedula).ToHashSet();
 608
 7609        var totalesPorCedula = await _context.LiquidacionItems
 7610            .AsNoTracking()
 7611            .Where(i => i.PeriodoId == periodoId && i.Version == v && cedulas.Contains(i.Cedula))
 7612            .GroupBy(i => i.Cedula)
 7613            .Select(g => new
 7614            {
 7615                Cedula     = g.Key,
 7616                Nominal    = g.Where(i => i.Importe > 0).Sum(i => i.Importe),
 7617                Descuentos = g.Where(i => i.Importe < 0).Sum(i => i.Importe),
 7618            })
 8619            .ToDictionaryAsync(x => x.Cedula);
 620
 30621        foreach (var dto in estados)
 8622            if (totalesPorCedula.TryGetValue(dto.Cedula, out var tot))
 623            {
 1624                dto.Nominal    = tot.Nominal;
 1625                dto.Descuentos = tot.Descuentos;
 1626                dto.Liquido    = tot.Nominal + tot.Descuentos;
 627            }
 628
 7629        return new PagedResult<CalculoEstadoDto>
 7630        {
 7631            Items      = estados,
 7632            Page       = page ?? 1,
 7633            PageSize   = pageSize ?? 20,
 7634            TotalCount = totalCount
 7635        };
 7636    }
 637
 638    public async Task<EstadosResumenDto> ObtenerEstadosResumenAsync(long periodoId, int? version = null)
 639    {
 0640        var v = version ?? await ObtenerVersionMaximaAsync(periodoId);
 641
 0642        var totales = await _context.LiquidacionItems
 0643            .AsNoTracking()
 0644            .Where(i => i.PeriodoId == periodoId && i.Version == v)
 0645            .GroupBy(i => 1)
 0646            .Select(g => new
 0647            {
 0648                TotalNominal    = g.Where(i => i.Importe > 0).Sum(i => i.Importe),
 0649                TotalDescuentos = g.Where(i => i.Importe < 0).Sum(i => i.Importe),
 0650            })
 0651            .FirstOrDefaultAsync();
 652
 0653        var conteosPorEstado = await _context.LiquidacionEstadosPersonal
 0654            .AsNoTracking()
 0655            .Where(e => e.PeriodoId == periodoId && e.Version == v)
 0656            .GroupBy(e => e.Estado)
 0657            .Select(g => new { Estado = g.Key, Count = g.Count() })
 0658            .ToListAsync();
 659
 0660        int Get(string estado) => conteosPorEstado.FirstOrDefault(c => c.Estado == estado)?.Count ?? 0;
 661
 0662        var nominal    = totales?.TotalNominal    ?? 0m;
 0663        var descuentos = totales?.TotalDescuentos ?? 0m;
 664
 0665        return new EstadosResumenDto
 0666        {
 0667            TotalNominal    = nominal,
 0668            TotalDescuentos = descuentos,
 0669            TotalLiquido    = nominal + descuentos,
 0670            Conteos = new ConteosEstadoDto
 0671            {
 0672                Total     = conteosPorEstado.Sum(c => c.Count),
 0673                Calculado = Get("CALCULADO"),
 0674                Revisar   = Get("REVISAR"),
 0675                Error     = Get("ERROR"),
 0676                Pendiente = Get("PENDIENTE")
 0677            }
 0678        };
 0679    }
 680
 681    public async Task<IEnumerable<CalculoItemDto>> ObtenerItemsAsync(long periodoId, int? version = null, string? cedula
 682    {
 0683        var v = version ?? await ObtenerVersionMaximaAsync(periodoId);
 0684        var query = _context.LiquidacionItems
 0685            .AsNoTracking()
 0686            .Where(i => i.PeriodoId == periodoId && i.Version == v);
 687
 0688        if (!string.IsNullOrEmpty(cedula))
 0689            query = query.Where(i => i.Cedula == cedula);
 690
 0691        return await query
 0692            .Select(i => new CalculoItemDto
 0693            {
 0694                Cedula         = i.Cedula,
 0695                Version        = i.Version,
 0696                CodigoConcepto = i.CodigoConcepto,
 0697                NombreConcepto = i.NombreConcepto,
 0698                Importe        = i.Importe,
 0699                RubroContable  = i.RubroContable,
 0700                ObjetoGasto    = i.ObjetoGasto
 0701            })
 0702            .ToListAsync();
 0703    }
 704
 705    public async Task<IEnumerable<LiquidacionResumenDto>> ObtenerResumenAsync(long periodoId, int? version = null)
 706    {
 0707        var v = version ?? await ObtenerVersionMaximaAsync(periodoId);
 0708        return await _context.LiquidacionResumenes
 0709            .AsNoTracking()
 0710            .Where(r => r.PeriodoId == periodoId && r.Version == v)
 0711            .Select(r => new LiquidacionResumenDto
 0712            {
 0713                Version = r.Version,
 0714                ProgramaId = r.ProgramaId,
 0715                RegimenId = r.RegimenId,
 0716                Rubro083 = r.Rubro083,
 0717                TotalImporte = r.TotalImporte,
 0718                Descripcion = r.Descripcion
 0719            })
 0720            .ToListAsync();
 0721    }
 722
 723    public async Task<IEnumerable<int>> ObtenerVersionesAsync(long periodoId)
 724    {
 2725        return await _context.LiquidacionItems
 2726            .AsNoTracking()
 2727            .Where(i => i.PeriodoId == periodoId)
 2728            .Select(i => i.Version)
 2729            .Distinct()
 2730            .OrderByDescending(v => v)
 2731            .ToListAsync();
 2732    }
 733
 734    // Civiles aportan FONASA por tasa única; los militares aportan Sanidad Militar por tramos.
 1735    private static readonly HashSet<string> CalculadoresCiviles =
 1736        new(StringComparer.OrdinalIgnoreCase) { "Sit70Calculator", "CivilCalculator" };
 737
 738    private async Task<AportesLegales> CalcularAportesLegalesAsync(
 739        long regimenId, decimal baseSdl, InsumosPeriodo insumos, bool esMilitar)
 740    {
 42741        var regimen = await _context.Regimenes.AsNoTracking().FirstOrDefaultAsync(r => r.Id == regimenId);
 42742        var tasaMontepio = regimen is not null && regimen.EsLeyVieja
 42743            ? insumos.ParametroPeriodo.TasaMontepioLeyVieja
 42744            : insumos.ParametroPeriodo.TasaMontepioLeyNueva;
 745
 42746        var montepio = _descuentosLegalesCalculator.CalcularMontepio(baseSdl, tasaMontepio);
 42747        var tutelaSocial = _descuentosLegalesCalculator.CalcularTutelaSocial(baseSdl, insumos.ParametroPeriodo.TasaTutel
 748
 749        // Sanidad Militar (Art. 94 Ley 18.834) reemplaza a FONASA para militares.
 750        // Base = misma que el montepío (baseSdl). Civiles mantienen FONASA por tasa única.
 42751        var tramo = esMilitar ? insumos.ObtenerTramoSanidad(baseSdl) : null;
 42752        if (tramo is not null)
 753        {
 1754            var sanidad = _descuentosLegalesCalculator.CalcularSanidad(baseSdl, tramo.Porcentaje);
 1755            return new AportesLegales(montepio, sanidad, tutelaSocial, tramo.CodigoRetencion);
 756        }
 757
 41758        var fonasa = _descuentosLegalesCalculator.CalcularFonasa(baseSdl, insumos.ParametroPeriodo.TasaFonasa);
 41759        return new AportesLegales(montepio, fonasa, tutelaSocial, SanidadCodigo: null);
 42760    }
 761
 762    private async Task<ResultadoIrpfLiquidacion> CalcularIrpfAsync(
 763        long periodoId,
 764        long personaId,
 765        string cedula,
 766        int version,
 767        decimal nominalBruto,
 768        decimal baseSdlAportes,
 769        AportesLegales aportes,
 770        InsumosPeriodo insumos,
 771        IReadOnlyDictionary<int, CatalogoItem> catalogoPorCodigo,
 772        decimal adicional6 = 0m)
 773    {
 42774        var request = new CalcularIrpfRequest
 42775        {
 42776            IngresoGravados = nominalBruto,
 42777            Montepio = aportes.Montepio,
 42778            Fonasa = aportes.Fonasa,
 42779            Frl = 0m,
 42780            Adicional6 = adicional6,
 42781            Deducciones = await CrearDeduccionesIrpfAsync(
 42782                personaId,
 42783                insumos.ParametroPeriodo.Anio,
 42784                insumos.ParametroPeriodo.Mes,
 42785                insumos.ParametroPeriodo.BpcAnual),
 42786            Parametros = new ParametrosIrpfDto
 42787            {
 42788                BpcMensual = insumos.ParametroPeriodo.BpcAnual / 12m,
 42789                UmbralDeduccionBpc = insumos.ParametroPeriodo.UmbralDeduccionBpc,
 42790                TasaDeduccionAlta = insumos.ParametroPeriodo.TasaDeduccionAlta,
 42791                TasaDeduccionBaja = insumos.ParametroPeriodo.TasaDeduccionBaja
 42792            },
 42793            Franjas = insumos.FranjasIrpf
 87794                .Select(f => new FranjaIrpfCalculoDto
 87795                {
 87796                    FranjaId = f.Id,
 87797                    NumeroFranja = f.NumeroFranja,
 87798                    DesdeBpc = f.DesdeBpc,
 87799                    HastaBpc = f.HastaBpc,
 87800                    Tasa = f.Tasa
 87801                })
 42802                .ToList()
 42803        };
 804
 42805        var (exito, idIrpf, _) = await _irpfService.CalcularYPersistirAsync(periodoId, personaId, request);
 42806        if (!exito || !idIrpf.HasValue)
 38807            return new ResultadoIrpfLiquidacion(0m, null);
 808
 4809        var irpf = await _irpfService.ObtenerPorIdAsync(idIrpf.Value);
 4810        if (irpf is null)
 0811            return new ResultadoIrpfLiquidacion(0m, null);
 812
 4813        var item = catalogoPorCodigo.TryGetValue(CodigoIrpf, out var catalogoIrpf)
 4814            ? CrearItemLiquidacion(periodoId, cedula, version, catalogoIrpf, -irpf.TotalIrpf)
 4815            : null;
 816
 4817        return new ResultadoIrpfLiquidacion(irpf.TotalIrpf, item);
 42818    }
 819
 820    private async Task<List<DeduccionDto>> CrearDeduccionesIrpfAsync(
 821        long personaId, int anio, int mes, decimal bpcAnual)
 822    {
 42823        var dependientes = await _dependienteRepository.GetDependientesByPersonaAsync(personaId);
 824
 42825        var result = dependientes
 3826            .Where(d => d.Activo)
 42827            .SelectMany(d =>
 42828            {
 3829                if (string.Equals(d.Tipo, "conyuge_sin_ingresos", StringComparison.OrdinalIgnoreCase))
 1830                    return [new DeduccionDto { Tipo = "conyuge_sin_ingresos", Cantidad = 1 }];
 42831
 2832                if (string.Equals(d.Tipo, "hijo", StringComparison.OrdinalIgnoreCase))
 42833                {
 2834                    var tipoHijo = d.Discapacitado ? "hijo_discapacidad" : "hijo_menor";
 2835                    return [new DeduccionDto { Tipo = tipoHijo, Cantidad = 1 }];
 42836                }
 42837
 0838                return Array.Empty<DeduccionDto>();
 42839            })
 42840            .ToList();
 841
 42842        var form = await _form3100Repository.GetVigenteParaPeriodoAsync(personaId, anio, mes);
 42843        if (form is not null)
 844        {
 1845            AgregarDeduccionesPersonasACargoForm3100(result, form, anio, mes, bpcAnual);
 846
 1847            var deduccionProfesional = form.CappuImporte + form.ReintegroAporte
 1848                + CalcularFondoSolidaridadMensual(form, bpcAnual);
 849
 1850            if (deduccionProfesional > 0m)
 0851                result.Add(new DeduccionDto { Tipo = "profesional", Monto = deduccionProfesional });
 852        }
 853
 42854        return result;
 42855    }
 856
 857    private static void AgregarDeduccionesPersonasACargoForm3100(
 858        ICollection<DeduccionDto> resultado,
 859        Form3100 form,
 860        int anio,
 861        int mes,
 862        decimal bpcAnual)
 863    {
 1864        var bpcMensual = bpcAnual / 12m;
 1865        var fechaReferencia = new DateTime(anio, mes, DateTime.DaysInMonth(anio, mes));
 866
 8867        foreach (var personaCargo in form.PersonasACargo)
 868        {
 3869            if (personaCargo.PorcentajeAtribucion <= 0)
 870                continue;
 871
 3872            if (!personaCargo.Relacion.StartsWith("hij", StringComparison.OrdinalIgnoreCase))
 873                continue;
 874
 2875            var esDiscapacidad = personaCargo.Discapacidad;
 2876            if (!esDiscapacidad && CalcularEdad(personaCargo.FechaNacimiento, fechaReferencia) >= 18)
 877                continue;
 878
 2879            var factorAtribucion = Math.Min(personaCargo.PorcentajeAtribucion, (short)100) / 100m;
 2880            var bpcAnuales = esDiscapacidad ? 40m : 20m;
 2881            var tipo = esDiscapacidad ? "hijo_discapacidad" : "hijo_menor";
 2882            var monto = bpcAnuales * bpcMensual / 12m * factorAtribucion;
 883
 2884            if (monto > 0m)
 2885                resultado.Add(new DeduccionDto { Tipo = tipo, Monto = monto });
 886        }
 1887    }
 888
 889    private static int CalcularEdad(DateTime fechaNacimiento, DateTime fechaReferencia)
 890    {
 1891        var edad = fechaReferencia.Year - fechaNacimiento.Year;
 1892        if (fechaNacimiento.Date > fechaReferencia.Date.AddYears(-edad))
 1893            edad--;
 1894        return edad;
 895    }
 896
 897    private static decimal CalcularFondoSolidaridadMensual(Form3100 form, decimal bpcAnual)
 898    {
 1899        var bpcFactor = form.FondoSolidaridad switch
 1900        {
 0901            "0.5_BPC" => 0.5m,
 0902            "1_BPC"   => 1m,
 0903            "2_BPC"   => 2m,
 1904            _         => 0m
 1905        };
 1906        var bpcMensual = bpcAnual / 12m;
 1907        var montoBase = bpcFactor * bpcMensual / 12m;
 908
 1909        if (!form.AdicionalFondoSolidaridad)
 1910            return montoBase;
 911
 912        // Adicional Fondo de Solidaridad: monto propio (5/3 BPC mensual / 12 = 953,33 con BPC 2026)
 0913        var montoAdicional = 5m / 3m * bpcMensual / 12m;
 0914        return montoBase + montoAdicional;
 915    }
 916
 917    private async Task<IReadOnlyList<DescuentoPersonalPeriodo>> ObtenerDescuentosConfirmadosAplicablesAsync(
 918        long periodoId,
 919        long personaId,
 920        DateTime fechaPeriodo)
 921    {
 42922        return (await _descuentoPersonalRepository.GetConfirmadosPorPersonaAsync(periodoId, personaId))
 12923            .Where(d => d.CatalogoItem is not null)
 42924            // Vigencia (ej. CUOTA_ING): se aplica solo si no vencio antes del periodo.
 12925            .Where(d => d.FechaHasta is null || d.FechaHasta >= fechaPeriodo)
 42926            .ToList();
 42927    }
 928
 929    internal static IReadOnlyList<FictoIrpfCalculado> CalcularFictosIrpf(
 930        long periodoId,
 931        string cedula,
 932        int version,
 933        IReadOnlyList<DescuentoPersonalPeriodo> descuentosConfirmados,
 934        IReadOnlyDictionary<int, CatalogoItem> catalogoPorCodigo)
 935    {
 42936        if (descuentosConfirmados.Count == 0)
 31937            return Array.Empty<FictoIrpfCalculado>();
 938
 11939        var catalogoPorId = catalogoPorCodigo.Values
 47940            .GroupBy(c => c.Id)
 105941            .ToDictionary(g => g.Key, g => g.First());
 942
 11943        var fictos = new List<FictoIrpfCalculado>();
 944
 46945        foreach (var descuento in descuentosConfirmados)
 946        {
 12947            var catalogoDescuento = descuento.CatalogoItem;
 12948            if (catalogoDescuento.ItemParFictoId is not long itemParFictoId)
 949                continue;
 950
 2951            if (!catalogoPorId.TryGetValue(itemParFictoId, out var catalogoHaber))
 952                continue;
 953
 2954            if (!EsCatalogoFicto(catalogoHaber) || catalogoHaber.Codigo == catalogoDescuento.Codigo)
 955                continue;
 956
 2957            var importe = Math.Round(descuento.Importe, 2);
 2958            if (importe <= 0m)
 959                continue;
 960
 2961            var haber = new ItemCalculado
 2962            {
 2963                CodigoConcepto = catalogoHaber.Codigo,
 2964                NombreConcepto = catalogoHaber.Nombre,
 2965                Importe = importe
 2966            };
 2967            var itemDescuento = CrearItemLiquidacion(periodoId, cedula, version, catalogoDescuento, -importe);
 968
 2969            descuento.MontoAplicado = importe;
 2970            descuento.MontoNoAplicado = 0m;
 2971            descuento.Estado = "aplicado";
 972
 2973            fictos.Add(new FictoIrpfCalculado(haber, itemDescuento, descuento.Id, importe));
 974        }
 975
 11976        return fictos;
 977    }
 978
 979    private static bool EsCatalogoFicto(CatalogoItem? catalogoItem) =>
 116980        catalogoItem is not null && catalogoItem.Tipo.StartsWith("ficto_", StringComparison.OrdinalIgnoreCase);
 981
 982    private static bool EsCatalogoBeneficioSocial(CatalogoItem? catalogoItem) =>
 25983        catalogoItem is not null && catalogoItem.Tipo.StartsWith("beneficio_social", StringComparison.OrdinalIgnoreCase)
 984
 985    private static bool EsCatalogoHaberAportable(CatalogoItem? catalogoItem) =>
 30986        catalogoItem is not null
 30987        && catalogoItem.EsGravado
 30988        && catalogoItem.Tipo.StartsWith("haber_", StringComparison.OrdinalIgnoreCase)
 30989        && !EsCatalogoBeneficioSocial(catalogoItem)
 30990        && !EsCatalogoFicto(catalogoItem);
 991
 992    private async Task<IReadOnlyList<LiquidacionItem>> CrearItemsDescuentosPersonalesAsync(
 993        long periodoId,
 994        long personaId,
 995        string cedula,
 996        int version,
 997        decimal nominalBruto,
 998        decimal descuentosLegales,
 999        DateTime fechaPeriodo,
 1000        IReadOnlySet<long> idsDescuentosExcluidos)
 1001    {
 421002        var descuentosConfirmados = (await _descuentoPersonalRepository
 421003                .GetConfirmadosPorPersonaAsync(periodoId, personaId))
 121004            .Where(d => d.CatalogoItem is not null)
 421005            // Vigencia (ej. CUOTA_ING): se aplica solo si no venció antes del período
 121006            .Where(d => d.FechaHasta is null || d.FechaHasta >= fechaPeriodo)
 421007            .ToList();
 1008
 421009        if (descuentosConfirmados.Count == 0)
 311010            return Array.Empty<LiquidacionItem>();
 1011
 1012        // Filtrar descuentos sin acreedor asignado (datos previos a la migración de acreedor_id)
 111013        var descuentosConAcreedor = descuentosConfirmados
 121014            .Where(d => !idsDescuentosExcluidos.Contains(d.Id))
 101015            .Where(d => d.AcreedorId is not null && d.Acreedor is not null)
 111016            .ToList();
 1017
 111018        if (descuentosConAcreedor.Count == 0)
 21019            return Array.Empty<LiquidacionItem>();
 1020
 91021        var entradasDeficit = descuentosConAcreedor
 101022            .Select(d => CrearEntradaDeficit(d, personaId))
 91023            .ToList();
 1024
 91025        var resultadosDeficit = (await _deficitCalculator.AplicarAsync(
 91026            nominalBruto,
 91027            descuentosLegales,
 91028            entradasDeficit)).ToList();
 1029
 191030        var descuentosPorId = descuentosConAcreedor.ToDictionary(d => d.Id);
 91031        var items = new List<LiquidacionItem>();
 1032
 381033        foreach (var resultadoDeficit in resultadosDeficit)
 1034        {
 101035            if (resultadoDeficit.DescuentoPeriodoId is null)
 1036                continue;
 1037
 101038            if (!descuentosPorId.TryGetValue(resultadoDeficit.DescuentoPeriodoId.Value, out var descuento))
 1039                continue;
 1040
 101041            descuento.MontoAplicado = resultadoDeficit.MontoAplicado;
 101042            descuento.MontoNoAplicado = resultadoDeficit.MontoNoAplicado;
 101043            descuento.Estado = resultadoDeficit.Estado;
 1044
 101045            if (!string.IsNullOrWhiteSpace(resultadoDeficit.MotivoNoAplicado) &&
 101046                string.IsNullOrWhiteSpace(descuento.Observaciones))
 1047            {
 01048                descuento.Observaciones = resultadoDeficit.MotivoNoAplicado;
 1049            }
 1050
 101051            if (resultadoDeficit.MontoAplicado <= 0m)
 1052                continue;
 1053
 101054            items.Add(CrearItemLiquidacion(
 101055                periodoId,
 101056                cedula,
 101057                version,
 101058                descuento.CatalogoItem,
 101059                -resultadoDeficit.MontoAplicado));
 1060        }
 1061
 91062        return items;
 421063    }
 1064
 1065    private static DescuentoPersonalInput CrearEntradaDeficit(DescuentoPersonalPeriodo descuento, long personaId)
 1066    {
 101067        if (descuento.AcreedorId is null)
 01068            throw new InvalidOperationException($"El descuento personal {descuento.Id} no tiene acreedor asignado.");
 1069
 101070        if (descuento.Acreedor is null)
 01071            throw new InvalidOperationException($"El descuento personal {descuento.Id} no tiene acreedor cargado.");
 1072
 101073        return new DescuentoPersonalInput(
 101074            descuento.Id,
 101075            personaId,
 101076            descuento.CatalogoItem.Codigo,
 101077            descuento.Importe,
 101078            descuento.AcreedorId.Value,
 101079            descuento.Acreedor.CategoriaDeficit,
 101080            descuento.Acreedor.OrdenLegalDeficit,
 101081            descuento.FechaComunicacion ?? descuento.FechaCreacion);
 1082    }
 1083
 1084    private static IReadOnlyList<LiquidacionItem> CrearItemsAportesLegales(
 1085        long periodoId,
 1086        string cedula,
 1087        int version,
 1088        AportesLegales aportes,
 1089        IReadOnlyDictionary<int, CatalogoItem> catalogoPorCodigo)
 1090    {
 421091        var items = new List<LiquidacionItem>();
 1092
 421093        AgregarItemAporteLegal(
 421094            items, periodoId, cedula, version, aportes.Montepio,
 421095            BuscarCatalogoItem(catalogoPorCodigo, CodigoMontepioSubalterno, 545, 555),
 421096            codigoFallback: CodigoMontepioSubalterno.ToString(),
 421097            nombreFallback: "MONTEPIO",
 421098            rubroFallback: "5.04");
 1099
 1100        // Salud: Sanidad Militar por tramos (código 5010/5011/5012) o FONASA por tasa única.
 421101        if (aportes.SanidadCodigo is int sanidadCodigo)
 1102        {
 11103            AgregarItemAporteLegal(
 11104                items, periodoId, cedula, version, aportes.Fonasa,
 11105                BuscarCatalogoItem(catalogoPorCodigo, sanidadCodigo),
 11106                codigoFallback: sanidadCodigo.ToString(),
 11107                nombreFallback: "SANIDAD",
 11108                rubroFallback: $"5.{sanidadCodigo % 100:D2}");
 1109        }
 1110        else
 1111        {
 411112            AgregarItemAporteLegal(
 411113                items, periodoId, cedula, version, aportes.Fonasa,
 411114                BuscarCatalogoItem(catalogoPorCodigo, CodigoSanidad, 523, 524, 525, 526, 527, 528),
 411115                codigoFallback: CodigoSanidad.ToString(),
 411116                nombreFallback: "FONASA",
 411117                rubroFallback: "5.010");
 1118        }
 1119
 421120        AgregarItemAporteLegal(
 421121            items, periodoId, cedula, version, aportes.TutelaSocial,
 421122            BuscarCatalogoItem(catalogoPorCodigo, CodigoTutelaSocial),
 421123            codigoFallback: CodigoTutelaSocial.ToString(),
 421124            nombreFallback: "SERV. ESP. TUT. SOC.",
 421125            rubroFallback: "5.08");
 1126
 421127        return items;
 1128    }
 1129
 1130    private static void AgregarItemAporteLegal(
 1131        ICollection<LiquidacionItem> items,
 1132        long periodoId,
 1133        string cedula,
 1134        int version,
 1135        decimal importe,
 1136        CatalogoItem? catalogoItem,
 1137        string codigoFallback,
 1138        string nombreFallback,
 1139        string rubroFallback)
 1140    {
 1261141        if (importe <= 0m)
 381142            return;
 1143
 881144        if (catalogoItem is not null)
 1145        {
 121146            items.Add(CrearItemLiquidacion(periodoId, cedula, version, catalogoItem, -importe));
 121147            return;
 1148        }
 1149
 761150        items.Add(CrearItemLiquidacion(periodoId, cedula, version, codigoFallback, nombreFallback, -importe, rubroFallba
 761151    }
 1152
 1153    private static CatalogoItem? BuscarCatalogoItem(
 1154        IReadOnlyDictionary<int, CatalogoItem> catalogoPorCodigo,
 1155        params int[] codigos)
 1156    {
 11001157        foreach (var codigo in codigos)
 1158        {
 4301159            if (catalogoPorCodigo.TryGetValue(codigo, out var catalogoItem))
 121160                return catalogoItem;
 1161        }
 1162
 1141163        return null;
 1164    }
 1165
 1166    private static LiquidacionItem CrearItemLiquidacion(
 1167        long periodoId,
 1168        string cedula,
 1169        int version,
 1170        CatalogoItem catalogoItem,
 1171        decimal importe)
 1172    {
 311173        return CrearItemLiquidacion(
 311174            periodoId, cedula, version,
 311175            catalogoItem.Codigo.ToString(),
 311176            catalogoItem.Nombre,
 311177            importe,
 311178            catalogoItem.ClasificacionSiif ?? "000.000");
 1179    }
 1180
 1181    private static LiquidacionItem CrearItemLiquidacion(
 1182        long periodoId,
 1183        string cedula,
 1184        int version,
 1185        string codigoConcepto,
 1186        string nombreConcepto,
 1187        decimal importe,
 1188        string rubroContable)
 1189    {
 1071190        return new LiquidacionItem
 1071191        {
 1071192            PeriodoId = periodoId,
 1071193            Cedula = cedula,
 1071194            Version = version,
 1071195            CodigoConcepto = codigoConcepto,
 1071196            NombreConcepto = nombreConcepto,
 1071197            Importe = importe,
 1071198            RubroContable = rubroContable,
 1071199            Rubro083 = false,
 1071200            FechaCreacion = DateTime.Now
 1071201        };
 1202    }
 1203
 1204    /// <summary>
 1205    /// Diferencia entre el redondeo al entero más cercano y <paramref name="totalSinAjuste"/>.
 1206    /// Centavos &lt;= 0.50 redondean hacia abajo; centavos &gt; 0.50 redondean hacia arriba.
 1207    /// La parte fraccionaria (<c>frac</c>) siempre queda en [0, 1) vía <see cref="Math.Floor(decimal)"/>,
 1208    /// por lo que la regla es válida también para totales negativos.
 1209    /// </summary>
 1210    internal static decimal CalcularAjusteRedondeo(decimal totalSinAjuste)
 1211    {
 481212        var piso = Math.Floor(totalSinAjuste);
 481213        var frac = totalSinAjuste - piso;
 481214        var redondeado = frac <= 0.50m ? piso : piso + 1m;
 481215        return redondeado - totalSinAjuste;
 1216    }
 1217
 1218    private static decimal CalcularSueldoBaseParaTraslado(InsumosPeriodo insumos, long gradoId)
 1219    {
 1220        const int CodigoSueldoBase     = 11000;
 1221        const int CodigoCompOficial    = 42012;
 1222        const int CodigoCompSubalterno = 42022;
 1223
 431224        var sueldoBase     = insumos.ObtenerRemuneracion(gradoId, CodigoSueldoBase)?.Monto ?? 0m;
 431225        var compOficial    = insumos.ObtenerRemuneracion(gradoId, CodigoCompOficial)?.Monto ?? 0m;
 431226        var compSubalterno = insumos.ObtenerRemuneracion(gradoId, CodigoCompSubalterno)?.Monto ?? 0m;
 1227
 431228        return sueldoBase + compOficial + compSubalterno;
 1229    }
 1230
 1231    // Fonasa carries el monto del aporte de salud: FONASA por tasa única (civiles) o
 1232    // Sanidad Militar por tramos (militares). SanidadCodigo indica el código del tramo
 1233    // aplicado (5010/5011/5012, catálogo SANIDAD X BPC) cuando corresponde a Sanidad Militar; null para FONASA.
 21234    internal sealed record FictoIrpfCalculado(
 21235        ItemCalculado Haber,
 21236        LiquidacionItem Descuento,
 21237        long DescuentoPersonalPeriodoId,
 41238        decimal Importe);
 1239
 4201240    private sealed record AportesLegales(decimal Montepio, decimal Fonasa, decimal TutelaSocial = 0m, int? SanidadCodigo
 1241    {
 421242        public decimal Total => Montepio + Fonasa + TutelaSocial;
 1243    }
 1244
 2521245    private sealed record ResultadoIrpfLiquidacion(decimal TotalIrpf, LiquidacionItem? Item);
 1246}

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()
ObtenerEstadosResumenAsync()
Get()
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()