< 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
85%
Covered lines: 664
Uncovered lines: 110
Coverable lines: 774
Total lines: 1253
Line coverage: 85.7%
Branch coverage
80%
Covered branches: 263
Total branches: 326
Branch coverage: 80.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 = 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
 5448    public LiquidacionService(
 5449        ApplicationDbContext context,
 5450        IAuditoriaService auditoriaService,
 5451        ICurrentUserContext currentUser,
 5452        IInsumosPeriodoLoader insumosLoader,
 5453        IProcesadorBeneficiosService procesadorBeneficios,
 5454        ICalculadorFactory calculadorFactory,
 5455        ICalcularDiferencia615Service diferencia615Service,
 5456        IDescuentoPersonalRepository descuentoPersonalRepository,
 5457        IDependienteRepository dependienteRepository,
 5458        IForm3100Repository form3100Repository,
 5459        IDeficitCalculator deficitCalculator,
 5460        IDescuentosLegalesCalculator descuentosLegalesCalculator,
 5461        IIrpfService irpfService)
 62    {
 5463        _context = context;
 5464        _auditoriaService = auditoriaService;
 5465        _currentUser = currentUser;
 5466        _insumosLoader = insumosLoader;
 5467        _procesadorBeneficios = procesadorBeneficios;
 5468        _calculadorFactory = calculadorFactory;
 5469        _diferencia615Service = diferencia615Service;
 5470        _descuentoPersonalRepository = descuentoPersonalRepository;
 5471        _dependienteRepository = dependienteRepository;
 5472        _form3100Repository = form3100Repository;
 5473        _deficitCalculator = deficitCalculator;
 5474        _descuentosLegalesCalculator = descuentosLegalesCalculator;
 5475        _irpfService = irpfService;
 5476    }
 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    {
 4685        var periodo = await _context.PeriodosLiquidacion
 4686            .AsNoTracking()
 4687            .FirstOrDefaultAsync(p => p.Id == periodoId && p.Activo);
 88
 4689        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)
 4593        var maxVersion = await _context.LiquidacionItems
 4594            .Where(li => li.PeriodoId == periodoId)
 4595            .MaxAsync(li => (int?)li.Version) ?? 0;
 4596        var version = maxVersion + 1;
 97
 98        InsumosPeriodo insumos;
 99        try
 100        {
 45101            insumos = await _insumosLoader.CargarAsync(periodoId);
 45102        }
 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
 45109        var rlRaw = await _context.RelacionesLaborales
 45110            .AsNoTracking()
 45111            .Where(r => r.Estado == EstadoRelacion.Activo)
 45112            .Join(
 45113                _context.Personas.AsNoTracking(),
 45114                r => r.PersonaId,
 45115                p => p.Id,
 45116                (r, p) => new { RL = r, Persona = p })
 45117            .ToListAsync();
 118
 45119        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
 87123        var situacionIds = rlRaw.Select(x => x.RL.SituacionId).Distinct().ToHashSet();
 87124        var regimenIds   = rlRaw.Select(x => x.RL.RegimenId).Distinct().ToHashSet();
 125
 43126        var situacionesDict = await _context.Situaciones.AsNoTracking()
 43127            .Where(s => situacionIds.Contains(s.Id))
 61128            .ToDictionaryAsync(s => s.Id);
 129
 43130        var regimenesDict = await _context.Regimenes.AsNoTracking()
 43131            .Where(r => regimenIds.Contains(r.Id))
 61132            .ToDictionaryAsync(r => r.Id);
 133
 134        // Materializar RelacionLaboral con navigation properties resueltas en memoria
 43135        var relacionesLaborales = rlRaw.Select(x =>
 43136        {
 44137            x.RL.Persona = x.Persona;
 44138            x.RL.Situacion = situacionesDict.GetValueOrDefault(x.RL.SituacionId)!;
 44139            x.RL.Regimen   = regimenesDict.GetValueOrDefault(x.RL.RegimenId)!;
 44140            return x.RL;
 43141        }).ToList();
 142
 43143        var primerDiaPeriodo = new DateOnly(periodo.Anio, periodo.Mes, 1);
 43144        var itemsToSave = new List<LiquidacionItem>();
 43145        var incidenciasToSave = new List<IncidenciaCalculo>();
 43146        var estadosPersonal = new Dictionary<string, string>();
 43147        var erroresPersonal = new Dictionary<string, string>();
 148
 149        // Pre-load historico de primas únicas en una sola consulta
 87150        var cedulasPersonas = relacionesLaborales.Select(r => r.Persona.Cedula).ToHashSet();
 43151        var itemsHistoricosGlobal = await _context.LiquidacionItems
 43152            .AsNoTracking()
 43153            .Where(li => cedulasPersonas.Contains(li.Cedula) &&
 43154                         (li.CodigoConcepto == "340" || li.CodigoConcepto == "350" || li.CodigoConcepto == "370"))
 43155            .ToListAsync();
 156
 157        // Índice de catálogo por código (usado por helpers de aportes legales)
 43158        var catalogoPorCodigo = insumos.CatalogoItems
 233159            .GroupBy(i => i.Codigo)
 505160            .ToDictionary(g => g.Key, g => g.First());
 161        // Índice por id (usado para resolver el par ficto de un descuento, ver EsDescuentoLegalOFicto)
 43162        var catalogoPorId = catalogoPorCodigo.Values
 231163            .GroupBy(c => c.Id)
 505164            .ToDictionary(g => g.Key, g => g.First());
 165
 174166        foreach (var rl in relacionesLaborales)
 167        {
 44168            var cedula = rl.Persona.Cedula;
 169            try
 170            {
 44171                var acumuladores = new Acumuladores();
 44172                var itemsHaberes = new List<ItemCalculado>();
 44173                var itemsBeneficios = new List<ItemBeneficioCalculado>();
 174
 175                // --- Paso 1-3: Calculador de haberes base ---
 44176                var calculador = rl.Situacion?.Calculador is not null
 44177                    ? _calculadorFactory.Resolver(rl.Situacion.Calculador)
 44178                    : null;
 179
 44180                if (calculador is not null)
 181                {
 182                    // Prorrateo por novedad 'dias_eximidos': factor = dias/30.
 183                    // Endurecido: dias_afectados debe estar en [1,30]. Fuera de rango (incl. 0)
 184                    // NO se prorratea (factor 1) y se registra incidencia, para no anular
 185                    // indebidamente los haberes ni romper el cálculo de aportes/IRPF.
 18186                    var novedadEximidos = insumos.ObtenerNovedadEximidos(rl.PersonaId);
 18187                    var factorProrrateo = 1m;
 18188                    if (novedadEximidos is not null)
 189                    {
 3190                        var diasAfectados = novedadEximidos.DiasAfectados;
 3191                        if (diasAfectados is < 1 or > 30)
 192                        {
 1193                            incidenciasToSave.Add(new IncidenciaCalculo
 1194                            {
 1195                                PeriodoId = periodoId, Version = version, PersonaId = rl.PersonaId,
 1196                                Tipo = TipoIncidenciaCalculo.CalculoSimplificado,
 1197                                Descripcion = $"Novedad 'dias_eximidos' con dias_afectados fuera de rango [1,30]: {diasA
 1198                                CreadoEn = DateTime.Now
 1199                            });
 200                        }
 2201                        else if (diasAfectados < 30)
 202                        {
 2203                            factorProrrateo = diasAfectados / 30m;
 204                        }
 205                    }
 206
 18207                    var resultadoCalculo = await calculador.CalcularAsync(rl, insumos, acumuladores);
 208
 18209                    if (factorProrrateo < 1m)
 210                    {
 20211                        foreach (var item in resultadoCalculo.Items)
 8212                            item.Importe = Math.Round(item.Importe * factorProrrateo, 2);
 213
 2214                        acumuladores.Reiniciar();
 20215                        foreach (var item in resultadoCalculo.Items)
 216                        {
 36217                            var ci = insumos.CatalogoItems.FirstOrDefault(c => c.Codigo == item.CodigoConcepto);
 16218                            if (ci is not null) acumuladores.AgregarItem(item.Importe, ci);
 219                        }
 220                    }
 221
 18222                    itemsHaberes.AddRange(resultadoCalculo.Items);
 223
 36224                    foreach (var inc in resultadoCalculo.Incidencias)
 0225                        incidenciasToSave.Add(new IncidenciaCalculo
 0226                        {
 0227                            PeriodoId = periodoId, Version = version, PersonaId = rl.PersonaId,
 0228                            Tipo = TipoIncidenciaCalculo.CalculoSimplificado,
 0229                            Descripcion = inc, CreadoEn = DateTime.Now
 0230                        });
 231                }
 232                else
 233                {
 26234                    incidenciasToSave.Add(new IncidenciaCalculo
 26235                    {
 26236                        PeriodoId = periodoId, Version = version, PersonaId = rl.PersonaId,
 26237                        Tipo = TipoIncidenciaCalculo.ErrorCalculo,
 26238                        Descripcion = $"Calculador no registrado: {rl.Situacion?.Calculador}",
 26239                        CreadoEn = DateTime.Now
 26240                    });
 241                }
 242
 243                // --- Paso 4: Beneficios sociales ---
 44244                var beneficios = insumos.ObtenerBeneficios(rl.PersonaId);
 44245                var itemsHistoricoPersona = itemsHistoricosGlobal.Where(h => h.Cedula == cedula).ToList();
 44246                var sueldoBasePersona = CalcularSueldoBaseParaTraslado(insumos, rl.GradoId);
 44247                var resultadoBeneficios = _procesadorBeneficios.ProcesarBeneficios(
 44248                    rl.PersonaId, beneficios, insumos.ParametroPeriodo, insumos.CatalogoItems,
 44249                    itemsHistoricoPersona, primerDiaPeriodo,
 44250                    insumos.BeneficiariosPorBeneficio, insumos.InstructivoAsigFamiliar,
 44251                    insumos.InstructivoPsf, insumos.TablaPorHogar,
 44252                    sueldoBaseParaTraslado: sueldoBasePersona);
 43253                itemsBeneficios.AddRange(resultadoBeneficios.Items);
 254
 86255                foreach (var inc in resultadoBeneficios.Incidencias)
 0256                    incidenciasToSave.Add(new IncidenciaCalculo
 0257                    {
 0258                        PeriodoId = periodoId, Version = version, PersonaId = rl.PersonaId,
 0259                        Tipo = inc.Tipo, Descripcion = inc.Descripcion, CreadoEn = DateTime.Now
 0260                    });
 261
 262                // --- Paso 5: Compensaciones por lote ---
 263                // El OG viene de ConfigCompensacion.CatalogoItem (no hardcodeado)
 43264                var compensaciones = insumos.ObtenerCompensaciones(rl.PersonaId);
 90265                foreach (var comp in compensaciones)
 266                {
 2267                    var ogCode = ObtenerOGCompensacion(comp);
 2268                    if (ogCode is null) continue;   // sin config de OG → saltear
 269
 2270                    var monto = comp.Monto ?? 0m;
 2271                    if (monto <= 0) continue;
 272
 18273                    var ci = insumos.CatalogoItems.FirstOrDefault(c => c.Codigo == ogCode.Value);
 2274                    itemsHaberes.Add(new ItemCalculado
 2275                    {
 2276                        CodigoConcepto = ogCode.Value,
 2277                        NombreConcepto = ci?.Nombre ?? comp.LoteCompensacion?.TipoCompensacion?.Nombre ?? "",
 2278                        Importe = monto
 2279                    });
 4280                    if (ci is not null) acumuladores.AgregarItem(monto, ci);
 281                }
 282
 283                // --- Paso 5.5: Mod. Aportes Ley 19.695 (042.413) ---
 284                // Se calcula AQUÍ, después de compensaciones (paso 5), para que ASIDUIDAD
 285                // y otras compensaciones entren en WNominal antes del cálculo.
 286                // Replica el orden del legacy: SUM33315.PRG corre después de SUM33304.PRG.
 287                // Fórmula: (WNominal - 048.042) × 2.35% — el legacy no deduce 042.403
 43288                if (rl.Regimen is not null && !rl.Regimen.EsLeyVieja)
 289                {
 80290                    var monto048042 = itemsHaberes.Where(i => i.CodigoConcepto == 48042).Sum(i => i.Importe);
 15291                    var base413 = acumuladores.WNominal - monto048042;
 15292                    if (base413 > 0)
 293                    {
 15294                        var monto413 = Math.Round(base413 * 0.0235m, 2);
 295                        const int Cod413 = 42413;
 15296                        var ci413 = catalogoPorCodigo.TryGetValue(Cod413, out var c413) ? c413 : null;
 15297                        itemsHaberes.Add(new ItemCalculado
 15298                        {
 15299                            CodigoConcepto = Cod413,
 15300                            NombreConcepto = ci413?.Nombre ?? "MOD. APORTES LEY 19.695 MDN (042.413)",
 15301                            Importe = monto413
 15302                        });
 25303                        if (ci413 is not null) acumuladores.AgregarItem(monto413, ci413);
 304                    }
 305                }
 306
 307                // --- Paso 6: Diferencia por ascenso 042.615 ---
 43308                var diffAscenso = insumos.ObtenerDiferenciaAscenso(rl.PersonaId);
 43309                if (diffAscenso is not null && diffAscenso.Monto > 0)
 310                {
 311                    const int Cod615 = 42615;
 9312                    var ci615 = insumos.CatalogoItems.FirstOrDefault(c => c.Codigo == Cod615);
 1313                    itemsHaberes.Add(new ItemCalculado
 1314                    {
 1315                        CodigoConcepto = Cod615,
 1316                        NombreConcepto = ci615?.Nombre ?? "Diferencia por Ascenso (042.615)",
 1317                        Importe = diffAscenso.Monto
 1318                    });
 2319                    if (ci615 is not null) acumuladores.AgregarItem(diffAscenso.Monto, ci615);
 320                }
 321
 43322                var fechaPeriodo = new DateTime(insumos.ParametroPeriodo.Anio, insumos.ParametroPeriodo.Mes, 1);
 43323                var novedadSubsidio = insumos.ObtenerNovedades(rl.PersonaId)
 46324                    .FirstOrDefault(n => n.Tipo == "subsidio_enfermedad" && n.Estado == "confirmada");
 43325                var tieneSubsidio = novedadSubsidio is not null;
 326
 43327                var fictosPersona = tieneSubsidio
 43328                    ? Array.Empty<FictoPersona>()
 43329                    : insumos.ObtenerFictos(rl.PersonaId);
 330
 331                // --- Paso 6.5: Fictos IRPF ---
 332                // Estos importes integran la base IRPF, pero no aportes ni déficit,
 333                // y se emite el mismo importe como descuento para netearlos.
 43334                var fictosIrpf = tieneSubsidio
 43335                    ? Array.Empty<FictoIrpfCalculado>()
 43336                    : CalcularFictosIrpf(periodoId, cedula, version, fictosPersona, catalogoPorCodigo);
 90337                foreach (var ficto in fictosIrpf)
 2338                    itemsHaberes.Add(ficto.Haber);
 339
 43340                var itemsDescuentosFictos = fictosIrpf
 2341                    .Select(f => f.Descuento)
 43342                    .ToList();
 343
 344                // --- Paso 7: Aportes legales (Montepío, FONASA, FRL) ---
 345                // baseSdlAportes = haberes positivos aportables. Beneficios sociales
 346                // y fictos no integran Montepio, Sanidad/FONASA ni Tutela Social.
 347                // nominalBruto   = todos haberes positivos + todos beneficios positivos
 348                // Se suma directamente desde itemsHaberes porque contribuye_w_nominal
 349                // puede no estar configurado en el catálogo (en ese caso WNominal=0).
 221350                var totalHaberesPositivos = itemsHaberes.Where(i => i.Importe > 0m).Sum(i => i.Importe);
 43351                var totalHaberesAportables = itemsHaberes
 89352                    .Where(i => i.Importe > 0m && !EsCatalogoFicto(catalogoPorCodigo.GetValueOrDefault(i.CodigoConcepto)
 130353                    .Sum(i => i.Importe);
 43354                var totalHaberesAportablesProcesador = resultadoBeneficios.Items
 38355                    .Where(i => i.Importe > 0m
 38356                                && catalogoPorCodigo.TryGetValue(i.CodigoConcepto, out var catalogoItem)
 38357                                && EsCatalogoHaberAportable(catalogoItem))
 69358                    .Sum(i => i.Importe);
 43359                var baseSdlAportes = totalHaberesAportables + totalHaberesAportablesProcesador;
 43360                var nominalBruto = totalHaberesPositivos
 113361                    + resultadoBeneficios.Items.Where(i => i.Importe > 0m).Sum(i => i.Importe);
 43362                var nominalBrutoParaDeficit = nominalBruto
 45363                    - fictosIrpf.Sum(f => f.Importe);
 364
 365                // --- Paso 7.5: Subsidio por enfermedad ---
 366                // Si existe novedad confirmada, el nominal va íntegro al fondo de subsidio.
 367                // No se calculan aportes legales, IRPF ni descuentos personales — replica el
 368                // comportamiento del legacy donde DESCUENTOS = NOMINAL y A COBRAR = 0.
 43369                if (tieneSubsidio)
 370                {
 0371                    var factor = Math.Min((int)novedadSubsidio!.DiasAfectados, 30) / 30m
 0372                                 * (novedadSubsidio.PorcentajeSubsidio ?? 100m) / 100m;
 0373                    var montoSubsidio = Math.Round(nominalBruto * factor, 2);
 0374                    itemsToSave.Add(CrearItemLiquidacion(periodoId, cedula, version,
 0375                        "SUBSIDIO", "Subsidio por Enfermedad", -montoSubsidio, "SUBSIDIO"));
 0376                    estadosPersonal[cedula] = "CALCULADO";
 377                }
 378
 43379                if (!tieneSubsidio)
 380                {
 43381                var esMilitar = !CalculadoresCiviles.Contains(rl.Situacion?.Calculador ?? string.Empty);
 382                // UsaFonasa=true anula Sanidad Militar aunque sea militar (SS_FF_AA=0 en legacy)
 43383                var usaSanidadMilitar = esMilitar && !rl.UsaFonasa;
 43384                var aportes = await CalcularAportesLegalesAsync(rl.RegimenId, baseSdlAportes, insumos, usaSanidadMilitar
 43385                var itemsAportesLegales = CrearItemsAportesLegales(periodoId, cedula, version, aportes, catalogoPorCodig
 43386                itemsToSave.AddRange(itemsAportesLegales);
 43387                itemsToSave.AddRange(itemsDescuentosFictos);
 388
 389                // --- Paso 8: IRPF ---
 390                // Eliminar registro anterior para que CalcularYPersistirAsync siempre inserte limpio
 43391                var irpfAnterior = await _context.Set<IrpfMensual>()
 43392                    .FirstOrDefaultAsync(i => i.LiquidacionId == periodoId && i.PersonaId == rl.PersonaId);
 43393                if (irpfAnterior is not null)
 394                {
 0395                    var detallesAnteriores = await _context.Set<IrpfDetalleFranjas>()
 0396                        .Where(d => d.IrpfMensualId == irpfAnterior.Id)
 0397                        .ToListAsync();
 0398                    _context.Set<IrpfDetalleFranjas>().RemoveRange(detallesAnteriores);
 0399                    _context.Set<IrpfMensual>().Remove(irpfAnterior);
 0400                    await _context.SaveChangesAsync();
 401                }
 402
 403                // Adicional 6%: la base IRPF es (ingresos sujetos a montepio) × 1,06.
 404                // Se pasa como Adicional6 = baseSdlAportes × 6% al request; el calculator
 405                // aplica las franjas sobre IngresoGravados + Adicional6.
 406                // Solo aplica cuando baseSdlAportes > 10 BPC (condición del legacy).
 43407                var bpcMensualIrpf = insumos.ParametroPeriodo.BpcAnual / 12m;
 43408                var adicional6 = baseSdlAportes > 10m * bpcMensualIrpf
 43409                    ? Math.Round(baseSdlAportes * 0.06m, 2)
 43410                    : 0m;
 411
 43412                var resultadoIrpf = await CalcularIrpfAsync(
 43413                    periodoId, rl.PersonaId, cedula, version,
 43414                    nominalBruto, baseSdlAportes, aportes, insumos, catalogoPorCodigo, adicional6);
 415
 416                // Descartar entidades IrpfMensual trackeadas si la persistencia falló,
 417                // para que no se reintentan en el SaveChangesAsync final.
 86418                foreach (var entry in _context.ChangeTracker.Entries<IrpfMensual>()
 0419                             .Where(e => e.State == Microsoft.EntityFrameworkCore.EntityState.Added)
 43420                             .ToList())
 0421                    entry.State = Microsoft.EntityFrameworkCore.EntityState.Detached;
 422
 43423                if (resultadoIrpf.Item is not null && resultadoIrpf.TotalIrpf != 0)
 3424                    itemsToSave.Add(resultadoIrpf.Item);
 425                else
 426                {
 427                    // Diagnóstico: registrar siempre el resultado del cálculo IRPF para debugging
 40428                    var tieneCatalogo = catalogoPorCodigo.ContainsKey(CodigoIrpf);
 40429                    incidenciasToSave.Add(new IncidenciaCalculo
 40430                    {
 40431                        PeriodoId = periodoId, Version = version, PersonaId = rl.PersonaId,
 40432                        Tipo = TipoIncidenciaCalculo.CalculoSimplificado,
 40433                        Descripcion = $"IRPF diag: TotalIrpf={resultadoIrpf.TotalIrpf:F2} ItemNull={resultadoIrpf.Item i
 40434                        CreadoEn = DateTime.Now
 40435                    });
 436                }
 437
 438                // --- Paso 9: Descuentos personales con déficit ---
 439                // Excluir códigos ya emitidos como aportes para no contarlos dos veces en el déficit
 43440                var codigosAportesEmitidos = itemsAportesLegales
 91441                    .Where(i => !string.IsNullOrWhiteSpace(i.CodigoConcepto))
 91442                    .Select(i => i.CodigoConcepto)
 43443                    .ToHashSet(StringComparer.OrdinalIgnoreCase);
 43444                var descuentosLegalesEnBeneficios = resultadoBeneficios.Items
 38445                    .Where(i => i.Importe < 0m
 38446                                && catalogoPorCodigo.TryGetValue(i.CodigoConcepto, out var ci)
 38447                                && EsDescuentoLegalOFicto(ci, catalogoPorId)
 38448                                && !codigosAportesEmitidos.Contains(i.CodigoConcepto.ToString()))
 48449                    .Sum(i => Math.Abs(i.Importe));
 45450                var totalDescuentosFictos = fictosIrpf.Sum(f => f.Importe);
 43451                var totalDescuentosLegales = aportes.Total + resultadoIrpf.TotalIrpf + descuentosLegalesEnBeneficios + t
 43452                var totalDescuentosLegalesParaDeficit =
 43453                    aportes.Total + resultadoIrpf.TotalIrpf + descuentosLegalesEnBeneficios;
 43454                var itemsDescuentosPersonales = await CrearItemsDescuentosPersonalesAsync(
 43455                    periodoId, rl.PersonaId, cedula, version,
 43456                    nominalBrutoParaDeficit, totalDescuentosLegalesParaDeficit, fechaPeriodo);
 43457                itemsToSave.AddRange(itemsDescuentosPersonales);
 458
 459                // --- Paso 9.5: Ajuste de redondeo ---
 460                // El liquido real (lo que el funcionario ve en el recibo) es la suma de
 461                // todos los items que se van a persistir, no `nominalBruto` (que filtra
 462                // solo positivos y se usa como base de aportes/IRPF).
 132463                var totalSinAjuste = itemsHaberes.Sum(i => i.Importe)
 38464                    + itemsBeneficios.Sum(i => i.Importe)
 91465                    + itemsAportesLegales.Sum(i => i.Importe)
 2466                    + itemsDescuentosFictos.Sum(i => i.Importe)
 43467                    + (resultadoIrpf.Item?.Importe ?? 0m)
 55468                    + itemsDescuentosPersonales.Sum(i => i.Importe);
 469
 43470                var ajusteRedondeo = CalcularAjusteRedondeo(totalSinAjuste);
 43471                if (ajusteRedondeo != 0m && catalogoPorCodigo.TryGetValue(CodigoAjusteRedondeo, out var ciAjuste))
 4472                    itemsToSave.Add(CrearItemLiquidacion(periodoId, cedula, version, ciAjuste, ajusteRedondeo));
 473
 474                // --- Paso 10: Estado ---
 43475                var tieneError = incidenciasToSave.Any(i =>
 88476                    i.PersonaId == rl.PersonaId && i.Tipo == TipoIncidenciaCalculo.ErrorCalculo);
 43477                var liquido = nominalBruto - totalDescuentosLegales
 55478                    - itemsDescuentosPersonales.Sum(i => Math.Abs(i.Importe));
 43479                var estado = tieneError ? "ERROR" : (liquido < 0 ? "REVISAR" : "CALCULADO");
 43480                estadosPersonal[cedula] = estado;
 43481                } // end if (!tieneSubsidio)
 482
 483                // --- Paso 11: Persistir ítems de haberes y beneficios ---
 264484                foreach (var item in itemsHaberes)
 485                {
 89486                    catalogoPorCodigo.TryGetValue(item.CodigoConcepto, out var catalogoItem);
 89487                    itemsToSave.Add(new LiquidacionItem
 89488                    {
 89489                        PeriodoId = periodoId,
 89490                        Cedula = cedula,
 89491                        Version = version,
 89492                        CodigoConcepto = item.CodigoConcepto.ToString(),
 89493                        NombreConcepto = item.NombreConcepto,
 89494                        Importe = item.Importe,
 89495                        RubroContable = catalogoItem?.ClasificacionSiif ?? string.Empty,
 89496                        Rubro083 = false,
 89497                        FechaCreacion = DateTime.Now
 89498                    });
 499                }
 162500                foreach (var item in itemsBeneficios)
 501                {
 38502                    itemsToSave.Add(new LiquidacionItem
 38503                    {
 38504                        PeriodoId = periodoId,
 38505                        Cedula = cedula,
 38506                        Version = version,
 38507                        CodigoConcepto = item.CodigoConcepto.ToString(),
 38508                        NombreConcepto = item.NombreConcepto,
 38509                        Importe = item.Importe,
 38510                        RubroContable = item.RubroContable,
 38511                        Rubro083 = false,
 38512                        ClaveEvento = item.ClaveEvento,
 38513                        FechaCreacion = DateTime.Now
 38514                    });
 515                }
 43516            }
 1517            catch (Exception ex)
 518            {
 1519                estadosPersonal[cedula] = "ERROR";
 1520                erroresPersonal[cedula] = ex.Message;
 1521                incidenciasToSave.Add(new IncidenciaCalculo
 1522                {
 1523                    PeriodoId = periodoId,
 1524                    Version = version,
 1525                    PersonaId = rl.PersonaId,
 1526                    Tipo = TipoIncidenciaCalculo.ErrorCalculo,
 1527                    Descripcion = $"Error en pipeline de liquidación: {ex.Message}",
 1528                    CreadoEn = DateTime.Now
 1529                });
 1530            }
 44531        }
 532
 87533        var estados = relacionesLaborales.Select(rl => new LiquidacionEstadoPersonal
 87534        {
 87535            PeriodoId = periodoId,
 87536            Cedula = rl.Persona.Cedula,
 87537            Version = version,
 87538            Estado = estadosPersonal.TryGetValue(rl.Persona.Cedula, out var e) ? e : "PENDIENTE",
 87539            MensajeError = erroresPersonal.TryGetValue(rl.Persona.Cedula, out var err) ? err : null,
 87540            FechaActualizacion = DateTime.Now
 87541        }).ToList();
 542
 43543        await _context.LiquidacionEstadosPersonal.AddRangeAsync(estados);
 544
 43545        if (itemsToSave.Count > 0)
 42546            await _context.LiquidacionItems.AddRangeAsync(itemsToSave);
 547
 43548        if (incidenciasToSave.Count > 0)
 43549            await _context.IncidenciasCalculo.AddRangeAsync(incidenciasToSave);
 550
 282551        var totalImporte = itemsToSave.Sum(i => i.Importe);
 43552        var resumen = new LiquidacionResumen
 43553        {
 43554            PeriodoId = periodoId,
 43555            Version = version,
 43556            TotalImporte = totalImporte,
 43557            Rubro083 = false,
 43558            Descripcion = "Liquidación generada"
 43559        };
 43560        await _context.LiquidacionResumenes.AddAsync(resumen);
 561
 43562        await _context.SaveChangesAsync();
 563
 43564        await _auditoriaService.LogAuditoriaAsync(
 43565            usuarioId ?? _currentUser.UserId ?? 0,
 43566            AccionEnum.GenerarLiquidacion,
 43567            ContextoEnum.Liquidaciones,
 43568            host ?? _currentUser.Host,
 43569            entidad: nameof(LiquidacionEstadoPersonal),
 43570            entidadId: periodoId,
 43571            detalle: new { periodoId, version, registros = estados.Count, items = itemsToSave.Count, incidencias = incid
 572
 43573        return (true, null, version);
 46574    }
 575
 576    private async Task<int> ObtenerVersionMaximaAsync(long periodoId)
 577    {
 1578        return await _context.LiquidacionItems
 1579            .AsNoTracking()
 1580            .Where(i => i.PeriodoId == periodoId)
 1581            .MaxAsync(i => (int?)i.Version) ?? 0;
 1582    }
 583
 584    public async Task<PagedResult<CalculoEstadoDto>> ObtenerEstadosAsync(long periodoId, int? version = null, int? page 
 585    {
 7586        var v = version ?? await ObtenerVersionMaximaAsync(periodoId);
 587
 7588        if (page.HasValue && page < 1) page = 1;
 8589        if (pageSize.HasValue) pageSize = Math.Clamp(pageSize.Value, 1, 100);
 590
 7591        var query = _context.LiquidacionEstadosPersonal
 7592            .AsNoTracking()
 7593            .Where(e => e.PeriodoId == periodoId && e.Version == v
 7594                && (cedula == null || e.Cedula.Contains(cedula))
 7595                && (estado == null || e.Estado == estado));
 596
 7597        var totalCount = await query.CountAsync();
 598
 7599        if (page.HasValue && pageSize.HasValue)
 1600            query = query.Skip((page.Value - 1) * pageSize.Value).Take(pageSize.Value);
 601
 7602        var estados = await query
 7603            .Select(e => new CalculoEstadoDto
 7604            {
 7605                Cedula       = e.Cedula,
 7606                Version      = e.Version,
 7607                Estado       = e.Estado,
 7608                MensajeError = e.MensajeError
 7609            })
 7610            .ToListAsync();
 611
 15612        var cedulas = estados.Select(e => e.Cedula).ToHashSet();
 613
 7614        var totalesPorCedula = await _context.LiquidacionItems
 7615            .AsNoTracking()
 7616            .Where(i => i.PeriodoId == periodoId && i.Version == v && cedulas.Contains(i.Cedula))
 7617            .GroupBy(i => i.Cedula)
 7618            .Select(g => new
 7619            {
 7620                Cedula     = g.Key,
 7621                Nominal    = g.Where(i => i.Importe > 0).Sum(i => i.Importe),
 7622                Descuentos = g.Where(i => i.Importe < 0).Sum(i => i.Importe),
 7623            })
 8624            .ToDictionaryAsync(x => x.Cedula);
 625
 30626        foreach (var dto in estados)
 8627            if (totalesPorCedula.TryGetValue(dto.Cedula, out var tot))
 628            {
 1629                dto.Nominal    = tot.Nominal;
 1630                dto.Descuentos = tot.Descuentos;
 1631                dto.Liquido    = tot.Nominal + tot.Descuentos;
 632            }
 633
 7634        return new PagedResult<CalculoEstadoDto>
 7635        {
 7636            Items      = estados,
 7637            Page       = page ?? 1,
 7638            PageSize   = pageSize ?? 20,
 7639            TotalCount = totalCount
 7640        };
 7641    }
 642
 643    public async Task<EstadosResumenDto> ObtenerEstadosResumenAsync(long periodoId, int? version = null)
 644    {
 0645        var v = version ?? await ObtenerVersionMaximaAsync(periodoId);
 646
 0647        var totales = await _context.LiquidacionItems
 0648            .AsNoTracking()
 0649            .Where(i => i.PeriodoId == periodoId && i.Version == v)
 0650            .GroupBy(i => 1)
 0651            .Select(g => new
 0652            {
 0653                TotalNominal    = g.Where(i => i.Importe > 0).Sum(i => i.Importe),
 0654                TotalDescuentos = g.Where(i => i.Importe < 0).Sum(i => i.Importe),
 0655            })
 0656            .FirstOrDefaultAsync();
 657
 0658        var conteosPorEstado = await _context.LiquidacionEstadosPersonal
 0659            .AsNoTracking()
 0660            .Where(e => e.PeriodoId == periodoId && e.Version == v)
 0661            .GroupBy(e => e.Estado)
 0662            .Select(g => new { Estado = g.Key, Count = g.Count() })
 0663            .ToListAsync();
 664
 0665        int Get(string estado) => conteosPorEstado.FirstOrDefault(c => c.Estado == estado)?.Count ?? 0;
 666
 0667        var nominal    = totales?.TotalNominal    ?? 0m;
 0668        var descuentos = totales?.TotalDescuentos ?? 0m;
 669
 0670        return new EstadosResumenDto
 0671        {
 0672            TotalNominal    = nominal,
 0673            TotalDescuentos = descuentos,
 0674            TotalLiquido    = nominal + descuentos,
 0675            Conteos = new ConteosEstadoDto
 0676            {
 0677                Total     = conteosPorEstado.Sum(c => c.Count),
 0678                Calculado = Get("CALCULADO"),
 0679                Revisar   = Get("REVISAR"),
 0680                Error     = Get("ERROR"),
 0681                Pendiente = Get("PENDIENTE")
 0682            }
 0683        };
 0684    }
 685
 686    public async Task<IEnumerable<CalculoItemDto>> ObtenerItemsAsync(long periodoId, int? version = null, string? cedula
 687    {
 0688        var v = version ?? await ObtenerVersionMaximaAsync(periodoId);
 0689        var query = _context.LiquidacionItems
 0690            .AsNoTracking()
 0691            .Where(i => i.PeriodoId == periodoId && i.Version == v);
 692
 0693        if (!string.IsNullOrEmpty(cedula))
 0694            query = query.Where(i => i.Cedula == cedula);
 695
 0696        return await query
 0697            .Select(i => new CalculoItemDto
 0698            {
 0699                Cedula         = i.Cedula,
 0700                Version        = i.Version,
 0701                CodigoConcepto = i.CodigoConcepto,
 0702                NombreConcepto = i.NombreConcepto,
 0703                Importe        = i.Importe,
 0704                RubroContable  = i.RubroContable,
 0705                ObjetoGasto    = i.ObjetoGasto
 0706            })
 0707            .ToListAsync();
 0708    }
 709
 710    public async Task<IEnumerable<LiquidacionResumenDto>> ObtenerResumenAsync(long periodoId, int? version = null)
 711    {
 0712        var v = version ?? await ObtenerVersionMaximaAsync(periodoId);
 0713        return await _context.LiquidacionResumenes
 0714            .AsNoTracking()
 0715            .Where(r => r.PeriodoId == periodoId && r.Version == v)
 0716            .Select(r => new LiquidacionResumenDto
 0717            {
 0718                Version = r.Version,
 0719                ProgramaId = r.ProgramaId,
 0720                RegimenId = r.RegimenId,
 0721                Rubro083 = r.Rubro083,
 0722                TotalImporte = r.TotalImporte,
 0723                Descripcion = r.Descripcion
 0724            })
 0725            .ToListAsync();
 0726    }
 727
 728    public async Task<IEnumerable<int>> ObtenerVersionesAsync(long periodoId)
 729    {
 2730        return await _context.LiquidacionItems
 2731            .AsNoTracking()
 2732            .Where(i => i.PeriodoId == periodoId)
 2733            .Select(i => i.Version)
 2734            .Distinct()
 2735            .OrderByDescending(v => v)
 2736            .ToListAsync();
 2737    }
 738
 739    // Civiles aportan FONASA por tasa única; los militares aportan Sanidad Militar por tramos.
 1740    private static readonly HashSet<string> CalculadoresCiviles =
 1741        new(StringComparer.OrdinalIgnoreCase) { "Sit70Calculator", "CivilCalculator" };
 742
 743    private async Task<AportesLegales> CalcularAportesLegalesAsync(
 744        long regimenId, decimal baseSdl, InsumosPeriodo insumos, bool esMilitar)
 745    {
 43746        var regimen = await _context.Regimenes.AsNoTracking().FirstOrDefaultAsync(r => r.Id == regimenId);
 43747        var tasaMontepio = regimen is not null && regimen.EsLeyVieja
 43748            ? insumos.ParametroPeriodo.TasaMontepioLeyVieja
 43749            : insumos.ParametroPeriodo.TasaMontepioLeyNueva;
 750
 43751        var montepio = _descuentosLegalesCalculator.CalcularMontepio(baseSdl, tasaMontepio);
 43752        var tutelaSocial = _descuentosLegalesCalculator.CalcularTutelaSocial(baseSdl, insumos.ParametroPeriodo.TasaTutel
 753
 754        // Sanidad Militar (Art. 94 Ley 18.834) reemplaza a FONASA para militares.
 755        // Base = misma que el montepío (baseSdl). Civiles mantienen FONASA por tasa única.
 43756        var tramo = esMilitar ? insumos.ObtenerTramoSanidad(baseSdl) : null;
 43757        if (tramo is not null)
 758        {
 1759            var sanidad = _descuentosLegalesCalculator.CalcularSanidad(baseSdl, tramo.Porcentaje);
 1760            return new AportesLegales(montepio, sanidad, tutelaSocial, tramo.CodigoRetencion);
 761        }
 762
 42763        var fonasa = _descuentosLegalesCalculator.CalcularFonasa(baseSdl, insumos.ParametroPeriodo.TasaFonasa);
 42764        return new AportesLegales(montepio, fonasa, tutelaSocial, SanidadCodigo: null);
 43765    }
 766
 767    private async Task<ResultadoIrpfLiquidacion> CalcularIrpfAsync(
 768        long periodoId,
 769        long personaId,
 770        string cedula,
 771        int version,
 772        decimal nominalBruto,
 773        decimal baseSdlAportes,
 774        AportesLegales aportes,
 775        InsumosPeriodo insumos,
 776        IReadOnlyDictionary<int, CatalogoItem> catalogoPorCodigo,
 777        decimal adicional6 = 0m)
 778    {
 43779        var request = new CalcularIrpfRequest
 43780        {
 43781            IngresoGravados = nominalBruto,
 43782            Montepio = aportes.Montepio,
 43783            Fonasa = aportes.Fonasa,
 43784            Frl = 0m,
 43785            Adicional6 = adicional6,
 43786            Deducciones = await CrearDeduccionesIrpfAsync(
 43787                personaId,
 43788                insumos.ParametroPeriodo.Anio,
 43789                insumos.ParametroPeriodo.Mes,
 43790                insumos.ParametroPeriodo.BpcAnual),
 43791            Parametros = new ParametrosIrpfDto
 43792            {
 43793                BpcMensual = insumos.ParametroPeriodo.BpcAnual / 12m,
 43794                UmbralDeduccionBpc = insumos.ParametroPeriodo.UmbralDeduccionBpc,
 43795                TasaDeduccionAlta = insumos.ParametroPeriodo.TasaDeduccionAlta,
 43796                TasaDeduccionBaja = insumos.ParametroPeriodo.TasaDeduccionBaja
 43797            },
 43798            Franjas = insumos.FranjasIrpf
 93799                .Select(f => new FranjaIrpfCalculoDto
 93800                {
 93801                    FranjaId = f.Id,
 93802                    NumeroFranja = f.NumeroFranja,
 93803                    DesdeBpc = f.DesdeBpc,
 93804                    HastaBpc = f.HastaBpc,
 93805                    Tasa = f.Tasa
 93806                })
 43807                .ToList()
 43808        };
 809
 43810        var (exito, idIrpf, _) = await _irpfService.CalcularYPersistirAsync(periodoId, personaId, request);
 43811        if (!exito || !idIrpf.HasValue)
 39812            return new ResultadoIrpfLiquidacion(0m, null);
 813
 4814        var irpf = await _irpfService.ObtenerPorIdAsync(idIrpf.Value);
 4815        if (irpf is null)
 0816            return new ResultadoIrpfLiquidacion(0m, null);
 817
 4818        var item = catalogoPorCodigo.TryGetValue(CodigoIrpf, out var catalogoIrpf)
 4819            ? CrearItemLiquidacion(periodoId, cedula, version, catalogoIrpf, -irpf.TotalIrpf)
 4820            : null;
 821
 4822        return new ResultadoIrpfLiquidacion(irpf.TotalIrpf, item);
 43823    }
 824
 825    private async Task<List<DeduccionDto>> CrearDeduccionesIrpfAsync(
 826        long personaId, int anio, int mes, decimal bpcAnual)
 827    {
 43828        var dependientes = await _dependienteRepository.GetDependientesByPersonaAsync(personaId);
 829
 43830        var result = dependientes
 3831            .Where(d => d.Activo)
 43832            .SelectMany(d =>
 43833            {
 3834                if (string.Equals(d.Tipo, "conyuge_sin_ingresos", StringComparison.OrdinalIgnoreCase))
 1835                    return [new DeduccionDto { Tipo = "conyuge_sin_ingresos", Cantidad = 1 }];
 43836
 2837                if (string.Equals(d.Tipo, "hijo", StringComparison.OrdinalIgnoreCase))
 43838                {
 2839                    var tipoHijo = d.Discapacitado ? "hijo_discapacidad" : "hijo_menor";
 2840                    return [new DeduccionDto { Tipo = tipoHijo, Cantidad = 1 }];
 43841                }
 43842
 0843                return Array.Empty<DeduccionDto>();
 43844            })
 43845            .ToList();
 846
 43847        var form = await _form3100Repository.GetVigenteParaPeriodoAsync(personaId, anio, mes);
 43848        if (form is not null)
 849        {
 1850            AgregarDeduccionesPersonasACargoForm3100(result, form, anio, mes, bpcAnual);
 851
 1852            var deduccionProfesional = form.CappuImporte + form.ReintegroAporte
 1853                + CalcularFondoSolidaridadMensual(form, bpcAnual);
 854
 1855            if (deduccionProfesional > 0m)
 0856                result.Add(new DeduccionDto { Tipo = "profesional", Monto = deduccionProfesional });
 857        }
 858
 43859        return result;
 43860    }
 861
 862    private static void AgregarDeduccionesPersonasACargoForm3100(
 863        ICollection<DeduccionDto> resultado,
 864        Form3100 form,
 865        int anio,
 866        int mes,
 867        decimal bpcAnual)
 868    {
 1869        var bpcMensual = bpcAnual / 12m;
 1870        var fechaReferencia = new DateTime(anio, mes, DateTime.DaysInMonth(anio, mes));
 871
 8872        foreach (var personaCargo in form.PersonasACargo)
 873        {
 3874            if (personaCargo.PorcentajeAtribucion <= 0)
 875                continue;
 876
 3877            if (!personaCargo.Relacion.StartsWith("hij", StringComparison.OrdinalIgnoreCase))
 878                continue;
 879
 2880            var esDiscapacidad = personaCargo.Discapacidad;
 2881            if (!esDiscapacidad && CalcularEdad(personaCargo.FechaNacimiento, fechaReferencia) >= 18)
 882                continue;
 883
 2884            var factorAtribucion = Math.Min(personaCargo.PorcentajeAtribucion, (short)100) / 100m;
 2885            var bpcAnuales = esDiscapacidad ? 40m : 20m;
 2886            var tipo = esDiscapacidad ? "hijo_discapacidad" : "hijo_menor";
 2887            var monto = bpcAnuales * bpcMensual / 12m * factorAtribucion;
 888
 2889            if (monto > 0m)
 2890                resultado.Add(new DeduccionDto { Tipo = tipo, Monto = monto });
 891        }
 1892    }
 893
 894    private static int CalcularEdad(DateTime fechaNacimiento, DateTime fechaReferencia)
 895    {
 1896        var edad = fechaReferencia.Year - fechaNacimiento.Year;
 1897        if (fechaNacimiento.Date > fechaReferencia.Date.AddYears(-edad))
 1898            edad--;
 1899        return edad;
 900    }
 901
 902    private static decimal CalcularFondoSolidaridadMensual(Form3100 form, decimal bpcAnual)
 903    {
 1904        var bpcFactor = form.FondoSolidaridad switch
 1905        {
 0906            "0.5_BPC" => 0.5m,
 0907            "1_BPC"   => 1m,
 0908            "2_BPC"   => 2m,
 1909            _         => 0m
 1910        };
 1911        var bpcMensual = bpcAnual / 12m;
 1912        var montoBase = bpcFactor * bpcMensual / 12m;
 913
 1914        if (!form.AdicionalFondoSolidaridad)
 1915            return montoBase;
 916
 917        // Adicional Fondo de Solidaridad: monto propio (5/3 BPC mensual / 12 = 953,33 con BPC 2026)
 0918        var montoAdicional = 5m / 3m * bpcMensual / 12m;
 0919        return montoBase + montoAdicional;
 920    }
 921
 922    internal static IReadOnlyList<FictoIrpfCalculado> CalcularFictosIrpf(
 923        long periodoId,
 924        string cedula,
 925        int version,
 926        IReadOnlyList<FictoPersona> fictosActivos,
 927        IReadOnlyDictionary<int, CatalogoItem> catalogoPorCodigo)
 928    {
 44929        if (fictosActivos.Count == 0)
 41930            return Array.Empty<FictoIrpfCalculado>();
 931
 3932        var catalogoPorId = catalogoPorCodigo.Values
 17933            .GroupBy(c => c.Id)
 37934            .ToDictionary(g => g.Key, g => g.First());
 935
 3936        var fictos = new List<FictoIrpfCalculado>();
 937
 16938        foreach (var ficto in fictosActivos)
 939        {
 5940            var catalogoDescuento = ficto.CatalogoItem;
 5941            if (catalogoDescuento.ItemParFictoId is not long itemParFictoId)
 942                continue;
 943
 5944            if (!catalogoPorId.TryGetValue(itemParFictoId, out var catalogoHaber))
 945                continue;
 946
 5947            if (!EsCatalogoFicto(catalogoHaber) || catalogoHaber.Codigo == catalogoDescuento.Codigo)
 948                continue;
 949
 5950            var importe = Math.Round(ficto.Importe, 2);
 5951            if (importe <= 0m)
 952                continue;
 953
 5954            var haber = new ItemCalculado
 5955            {
 5956                CodigoConcepto = catalogoHaber.Codigo,
 5957                NombreConcepto = catalogoHaber.Nombre,
 5958                Importe = importe
 5959            };
 5960            var itemDescuento = CrearItemLiquidacion(periodoId, cedula, version, catalogoDescuento, -importe);
 961
 5962            fictos.Add(new FictoIrpfCalculado(haber, itemDescuento, ficto.Id, importe));
 963        }
 964
 3965        return fictos;
 966    }
 967
 968    private static bool EsCatalogoFicto(CatalogoItem? catalogoItem) =>
 121969        catalogoItem is not null && catalogoItem.Tipo.StartsWith("ficto_", StringComparison.OrdinalIgnoreCase);
 970
 971    // Espeja ReciboRepository.EsDescuentoLegalParaRecibo: un descuento pareado a un ficto
 972    // (ej. Serv. Comedor Alimento, pareado al haber ficto Serv. Comedor IRPF) es neutro en
 973    // efectivo real y debe protegerse igual que un descuento legal, no competir por el
 974    // disponible del DeficitCalculator ni quedar fuera del piso del 35%.
 975    private static bool EsDescuentoLegalOFicto(
 976        CatalogoItem? catalogoItem,
 977        IReadOnlyDictionary<long, CatalogoItem> catalogoPorId)
 978    {
 6979        if (catalogoItem is null)
 0980            return false;
 981
 6982        if (string.Equals(catalogoItem.Tipo, "descuento_legal", StringComparison.OrdinalIgnoreCase))
 5983            return true;
 984
 1985        return catalogoItem.ItemParFictoId is long itemParFictoId
 1986            && catalogoPorId.TryGetValue(itemParFictoId, out var catalogoPar)
 1987            && EsCatalogoFicto(catalogoPar);
 988    }
 989
 990    private static bool EsCatalogoBeneficioSocial(CatalogoItem? catalogoItem) =>
 26991        catalogoItem is not null && catalogoItem.Tipo.StartsWith("beneficio_social", StringComparison.OrdinalIgnoreCase)
 992
 993    private static bool EsCatalogoHaberAportable(CatalogoItem? catalogoItem) =>
 32994        catalogoItem is not null
 32995        && catalogoItem.EsGravado
 32996        && catalogoItem.Tipo.StartsWith("haber_", StringComparison.OrdinalIgnoreCase)
 32997        && !EsCatalogoBeneficioSocial(catalogoItem)
 32998        && !EsCatalogoFicto(catalogoItem);
 999
 1000    private async Task<IReadOnlyList<LiquidacionItem>> CrearItemsDescuentosPersonalesAsync(
 1001        long periodoId,
 1002        long personaId,
 1003        string cedula,
 1004        int version,
 1005        decimal nominalBruto,
 1006        decimal descuentosLegales,
 1007        DateTime fechaPeriodo)
 1008    {
 431009        var descuentosConfirmados = (await _descuentoPersonalRepository
 431010                .GetConfirmadosPorPersonaAsync(periodoId, personaId))
 121011            .Where(d => d.CatalogoItem is not null)
 431012            // Vigencia (ej. CUOTA_ING): se aplica solo si no venció antes del período
 121013            .Where(d => d.FechaHasta is null || d.FechaHasta >= fechaPeriodo)
 431014            .ToList();
 1015
 431016        if (descuentosConfirmados.Count == 0)
 321017            return Array.Empty<LiquidacionItem>();
 1018
 1019        // Filtrar descuentos sin acreedor asignado (datos previos a la migración de acreedor_id)
 111020        var descuentosConAcreedor = descuentosConfirmados
 121021            .Where(d => d.CatalogoItem.ItemParFictoId is null)
 121022            .Where(d => d.AcreedorId is not null && d.Acreedor is not null)
 111023            .ToList();
 1024
 111025        if (descuentosConAcreedor.Count == 0)
 01026            return Array.Empty<LiquidacionItem>();
 1027
 111028        var entradasDeficit = descuentosConAcreedor
 121029            .Select(d => CrearEntradaDeficit(d, personaId))
 111030            .ToList();
 1031
 111032        var resultadosDeficit = (await _deficitCalculator.AplicarAsync(
 111033            nominalBruto,
 111034            descuentosLegales,
 111035            entradasDeficit)).ToList();
 1036
 231037        var descuentosPorId = descuentosConAcreedor.ToDictionary(d => d.Id);
 111038        var items = new List<LiquidacionItem>();
 1039
 461040        foreach (var resultadoDeficit in resultadosDeficit)
 1041        {
 121042            if (resultadoDeficit.DescuentoPeriodoId is null)
 1043                continue;
 1044
 121045            if (!descuentosPorId.TryGetValue(resultadoDeficit.DescuentoPeriodoId.Value, out var descuento))
 1046                continue;
 1047
 121048            descuento.MontoAplicado = resultadoDeficit.MontoAplicado;
 121049            descuento.MontoNoAplicado = resultadoDeficit.MontoNoAplicado;
 121050            descuento.Estado = resultadoDeficit.Estado;
 1051
 121052            if (!string.IsNullOrWhiteSpace(resultadoDeficit.MotivoNoAplicado) &&
 121053                string.IsNullOrWhiteSpace(descuento.Observaciones))
 1054            {
 01055                descuento.Observaciones = resultadoDeficit.MotivoNoAplicado;
 1056            }
 1057
 121058            if (resultadoDeficit.MontoAplicado <= 0m)
 1059                continue;
 1060
 121061            items.Add(CrearItemLiquidacion(
 121062                periodoId,
 121063                cedula,
 121064                version,
 121065                descuento.CatalogoItem,
 121066                -resultadoDeficit.MontoAplicado));
 1067        }
 1068
 111069        return items;
 431070    }
 1071
 1072    private static DescuentoPersonalInput CrearEntradaDeficit(DescuentoPersonalPeriodo descuento, long personaId)
 1073    {
 121074        if (descuento.AcreedorId is null)
 01075            throw new InvalidOperationException($"El descuento personal {descuento.Id} no tiene acreedor asignado.");
 1076
 121077        if (descuento.Acreedor is null)
 01078            throw new InvalidOperationException($"El descuento personal {descuento.Id} no tiene acreedor cargado.");
 1079
 121080        return new DescuentoPersonalInput(
 121081            descuento.Id,
 121082            personaId,
 121083            descuento.CatalogoItem.Codigo,
 121084            descuento.Importe,
 121085            descuento.AcreedorId.Value,
 121086            descuento.Acreedor.CategoriaDeficit,
 121087            descuento.Acreedor.OrdenLegalDeficit,
 121088            descuento.FechaComunicacion ?? descuento.FechaCreacion);
 1089    }
 1090
 1091    private static IReadOnlyList<LiquidacionItem> CrearItemsAportesLegales(
 1092        long periodoId,
 1093        string cedula,
 1094        int version,
 1095        AportesLegales aportes,
 1096        IReadOnlyDictionary<int, CatalogoItem> catalogoPorCodigo)
 1097    {
 431098        var items = new List<LiquidacionItem>();
 1099
 431100        AgregarItemAporteLegal(
 431101            items, periodoId, cedula, version, aportes.Montepio,
 431102            BuscarCatalogoItem(catalogoPorCodigo, CodigoMontepioSubalterno, 545, 555),
 431103            codigoFallback: CodigoMontepioSubalterno.ToString(),
 431104            nombreFallback: "MONTEPIO",
 431105            rubroFallback: "5.04");
 1106
 1107        // Salud: Sanidad Militar por tramos (código 5010/5011/5012) o FONASA por tasa única.
 431108        if (aportes.SanidadCodigo is int sanidadCodigo)
 1109        {
 11110            AgregarItemAporteLegal(
 11111                items, periodoId, cedula, version, aportes.Fonasa,
 11112                BuscarCatalogoItem(catalogoPorCodigo, sanidadCodigo),
 11113                codigoFallback: sanidadCodigo.ToString(),
 11114                nombreFallback: "SANIDAD",
 11115                rubroFallback: $"5.{sanidadCodigo % 100:D2}");
 1116        }
 1117        else
 1118        {
 421119            AgregarItemAporteLegal(
 421120                items, periodoId, cedula, version, aportes.Fonasa,
 421121                BuscarCatalogoItem(catalogoPorCodigo, CodigoSanidad, 523, 524, 525, 526, 527, 528),
 421122                codigoFallback: CodigoSanidad.ToString(),
 421123                nombreFallback: "FONASA",
 421124                rubroFallback: "5.010");
 1125        }
 1126
 431127        AgregarItemAporteLegal(
 431128            items, periodoId, cedula, version, aportes.TutelaSocial,
 431129            BuscarCatalogoItem(catalogoPorCodigo, CodigoTutelaSocial),
 431130            codigoFallback: CodigoTutelaSocial.ToString(),
 431131            nombreFallback: "SERV. ESP. TUT. SOC.",
 431132            rubroFallback: "5.08");
 1133
 431134        return items;
 1135    }
 1136
 1137    private static void AgregarItemAporteLegal(
 1138        ICollection<LiquidacionItem> items,
 1139        long periodoId,
 1140        string cedula,
 1141        int version,
 1142        decimal importe,
 1143        CatalogoItem? catalogoItem,
 1144        string codigoFallback,
 1145        string nombreFallback,
 1146        string rubroFallback)
 1147    {
 1291148        if (importe <= 0m)
 381149            return;
 1150
 911151        if (catalogoItem is not null)
 1152        {
 121153            items.Add(CrearItemLiquidacion(periodoId, cedula, version, catalogoItem, -importe));
 121154            return;
 1155        }
 1156
 791157        items.Add(CrearItemLiquidacion(periodoId, cedula, version, codigoFallback, nombreFallback, -importe, rubroFallba
 791158    }
 1159
 1160    private static CatalogoItem? BuscarCatalogoItem(
 1161        IReadOnlyDictionary<int, CatalogoItem> catalogoPorCodigo,
 1162        params int[] codigos)
 1163    {
 11281164        foreach (var codigo in codigos)
 1165        {
 4411166            if (catalogoPorCodigo.TryGetValue(codigo, out var catalogoItem))
 121167                return catalogoItem;
 1168        }
 1169
 1171170        return null;
 1171    }
 1172
 1173    private static LiquidacionItem CrearItemLiquidacion(
 1174        long periodoId,
 1175        string cedula,
 1176        int version,
 1177        CatalogoItem catalogoItem,
 1178        decimal importe)
 1179    {
 361180        return CrearItemLiquidacion(
 361181            periodoId, cedula, version,
 361182            catalogoItem.Codigo.ToString(),
 361183            catalogoItem.Nombre,
 361184            importe,
 361185            catalogoItem.ClasificacionSiif ?? "000.000");
 1186    }
 1187
 1188    private static LiquidacionItem CrearItemLiquidacion(
 1189        long periodoId,
 1190        string cedula,
 1191        int version,
 1192        string codigoConcepto,
 1193        string nombreConcepto,
 1194        decimal importe,
 1195        string rubroContable)
 1196    {
 1151197        return new LiquidacionItem
 1151198        {
 1151199            PeriodoId = periodoId,
 1151200            Cedula = cedula,
 1151201            Version = version,
 1151202            CodigoConcepto = codigoConcepto,
 1151203            NombreConcepto = nombreConcepto,
 1151204            Importe = importe,
 1151205            RubroContable = rubroContable,
 1151206            Rubro083 = false,
 1151207            FechaCreacion = DateTime.Now
 1151208        };
 1209    }
 1210
 1211    /// <summary>
 1212    /// Diferencia entre el redondeo al entero más cercano y <paramref name="totalSinAjuste"/>.
 1213    /// Centavos &lt;= 0.50 redondean hacia abajo; centavos &gt; 0.50 redondean hacia arriba.
 1214    /// La parte fraccionaria (<c>frac</c>) siempre queda en [0, 1) vía <see cref="Math.Floor(decimal)"/>,
 1215    /// por lo que la regla es válida también para totales negativos.
 1216    /// </summary>
 1217    internal static decimal CalcularAjusteRedondeo(decimal totalSinAjuste)
 1218    {
 491219        var piso = Math.Floor(totalSinAjuste);
 491220        var frac = totalSinAjuste - piso;
 491221        var redondeado = frac <= 0.50m ? piso : piso + 1m;
 491222        return redondeado - totalSinAjuste;
 1223    }
 1224
 1225    private static decimal CalcularSueldoBaseParaTraslado(InsumosPeriodo insumos, long gradoId)
 1226    {
 1227        const int CodigoSueldoBase     = 11000;
 1228        const int CodigoCompOficial    = 42012;
 1229        const int CodigoCompSubalterno = 42022;
 1230
 441231        var sueldoBase     = insumos.ObtenerRemuneracion(gradoId, CodigoSueldoBase)?.Monto ?? 0m;
 441232        var compOficial    = insumos.ObtenerRemuneracion(gradoId, CodigoCompOficial)?.Monto ?? 0m;
 441233        var compSubalterno = insumos.ObtenerRemuneracion(gradoId, CodigoCompSubalterno)?.Monto ?? 0m;
 1234
 441235        return sueldoBase + compOficial + compSubalterno;
 1236    }
 1237
 1238    // Fonasa carries el monto del aporte de salud: FONASA por tasa única (civiles) o
 1239    // Sanidad Militar por tramos (militares). SanidadCodigo indica el código del tramo
 1240    // aplicado (5010/5011/5012, catálogo SANIDAD X BPC) cuando corresponde a Sanidad Militar; null para FONASA.
 51241    internal sealed record FictoIrpfCalculado(
 81242        ItemCalculado Haber,
 51243        LiquidacionItem Descuento,
 01244        long FictoPersonaId,
 91245        decimal Importe);
 1246
 5591247    private sealed record AportesLegales(decimal Montepio, decimal Fonasa, decimal TutelaSocial = 0m, int? SanidadCodigo
 1248    {
 861249        public decimal Total => Montepio + Fonasa + TutelaSocial;
 1250    }
 1251
 3011252    private sealed record ResultadoIrpfLiquidacion(decimal TotalIrpf, LiquidacionItem? Item);
 1253}

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)
CalcularFictosIrpf(System.Int64,System.String,System.Int32,System.Collections.Generic.IReadOnlyList`1<FAU.Entidades.FictoPersona>,System.Collections.Generic.IReadOnlyDictionary`2<System.Int32,FAU.Entidades.CatalogoItem>)
EsCatalogoFicto(FAU.Entidades.CatalogoItem)
EsDescuentoLegalOFicto(FAU.Entidades.CatalogoItem,System.Collections.Generic.IReadOnlyDictionary`2<System.Int64,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_FictoPersonaId()
get_Importe()
get_Montepio()
get_Total()
get_TotalIrpf()