< 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: 655
Uncovered lines: 109
Coverable lines: 764
Total lines: 1230
Line coverage: 85.7%
Branch coverage
81%
Covered branches: 258
Total branches: 318
Branch coverage: 81.1%
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
 229159            .GroupBy(i => i.Codigo)
 496160            .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 fictosPersona = tieneSubsidio
 42324                    ? Array.Empty<FictoPersona>()
 42325                    : insumos.ObtenerFictos(rl.PersonaId);
 326
 327                // --- Paso 6.5: Fictos IRPF ---
 328                // Estos importes integran la base IRPF, pero no aportes ni déficit,
 329                // y se emite el mismo importe como descuento para netearlos.
 42330                var fictosIrpf = tieneSubsidio
 42331                    ? Array.Empty<FictoIrpfCalculado>()
 42332                    : CalcularFictosIrpf(periodoId, cedula, version, fictosPersona, catalogoPorCodigo);
 88333                foreach (var ficto in fictosIrpf)
 2334                    itemsHaberes.Add(ficto.Haber);
 335
 42336                var itemsDescuentosFictos = fictosIrpf
 2337                    .Select(f => f.Descuento)
 42338                    .ToList();
 339
 340                // --- Paso 7: Aportes legales (Montepío, FONASA, FRL) ---
 341                // baseSdlAportes = haberes positivos aportables. Beneficios sociales
 342                // y fictos no integran Montepio, Sanidad/FONASA ni Tutela Social.
 343                // nominalBruto   = todos haberes positivos + todos beneficios positivos
 344                // Se suma directamente desde itemsHaberes porque contribuye_w_nominal
 345                // puede no estar configurado en el catálogo (en ese caso WNominal=0).
 220346                var totalHaberesPositivos = itemsHaberes.Where(i => i.Importe > 0m).Sum(i => i.Importe);
 42347                var totalHaberesAportables = itemsHaberes
 89348                    .Where(i => i.Importe > 0m && !EsCatalogoFicto(catalogoPorCodigo.GetValueOrDefault(i.CodigoConcepto)
 129349                    .Sum(i => i.Importe);
 42350                var totalHaberesAportablesProcesador = resultadoBeneficios.Items
 35351                    .Where(i => i.Importe > 0m
 35352                                && catalogoPorCodigo.TryGetValue(i.CodigoConcepto, out var catalogoItem)
 35353                                && EsCatalogoHaberAportable(catalogoItem))
 67354                    .Sum(i => i.Importe);
 42355                var baseSdlAportes = totalHaberesAportables + totalHaberesAportablesProcesador;
 42356                var nominalBruto = totalHaberesPositivos
 107357                    + resultadoBeneficios.Items.Where(i => i.Importe > 0m).Sum(i => i.Importe);
 42358                var nominalBrutoParaDeficit = nominalBruto
 44359                    - fictosIrpf.Sum(f => f.Importe);
 360
 361                // --- Paso 7.5: Subsidio por enfermedad ---
 362                // Si existe novedad confirmada, el nominal va íntegro al fondo de subsidio.
 363                // No se calculan aportes legales, IRPF ni descuentos personales — replica el
 364                // comportamiento del legacy donde DESCUENTOS = NOMINAL y A COBRAR = 0.
 42365                if (tieneSubsidio)
 366                {
 0367                    var factor = Math.Min((int)novedadSubsidio!.DiasAfectados, 30) / 30m
 0368                                 * (novedadSubsidio.PorcentajeSubsidio ?? 100m) / 100m;
 0369                    var montoSubsidio = Math.Round(nominalBruto * factor, 2);
 0370                    itemsToSave.Add(CrearItemLiquidacion(periodoId, cedula, version,
 0371                        "SUBSIDIO", "Subsidio por Enfermedad", -montoSubsidio, "SUBSIDIO"));
 0372                    estadosPersonal[cedula] = "CALCULADO";
 373                }
 374
 42375                if (!tieneSubsidio)
 376                {
 42377                var esMilitar = !CalculadoresCiviles.Contains(rl.Situacion?.Calculador ?? string.Empty);
 378                // UsaFonasa=true anula Sanidad Militar aunque sea militar (SS_FF_AA=0 en legacy)
 42379                var usaSanidadMilitar = esMilitar && !rl.UsaFonasa;
 42380                var aportes = await CalcularAportesLegalesAsync(rl.RegimenId, baseSdlAportes, insumos, usaSanidadMilitar
 42381                var itemsAportesLegales = CrearItemsAportesLegales(periodoId, cedula, version, aportes, catalogoPorCodig
 42382                itemsToSave.AddRange(itemsAportesLegales);
 42383                itemsToSave.AddRange(itemsDescuentosFictos);
 384
 385                // --- Paso 8: IRPF ---
 386                // Eliminar registro anterior para que CalcularYPersistirAsync siempre inserte limpio
 42387                var irpfAnterior = await _context.Set<IrpfMensual>()
 42388                    .FirstOrDefaultAsync(i => i.LiquidacionId == periodoId && i.PersonaId == rl.PersonaId);
 42389                if (irpfAnterior is not null)
 390                {
 0391                    var detallesAnteriores = await _context.Set<IrpfDetalleFranjas>()
 0392                        .Where(d => d.IrpfMensualId == irpfAnterior.Id)
 0393                        .ToListAsync();
 0394                    _context.Set<IrpfDetalleFranjas>().RemoveRange(detallesAnteriores);
 0395                    _context.Set<IrpfMensual>().Remove(irpfAnterior);
 0396                    await _context.SaveChangesAsync();
 397                }
 398
 399                // Adicional 6%: la base IRPF es (ingresos sujetos a montepio) × 1,06.
 400                // Se pasa como Adicional6 = baseSdlAportes × 6% al request; el calculator
 401                // aplica las franjas sobre IngresoGravados + Adicional6.
 402                // Solo aplica cuando baseSdlAportes > 10 BPC (condición del legacy).
 42403                var bpcMensualIrpf = insumos.ParametroPeriodo.BpcAnual / 12m;
 42404                var adicional6 = baseSdlAportes > 10m * bpcMensualIrpf
 42405                    ? Math.Round(baseSdlAportes * 0.06m, 2)
 42406                    : 0m;
 407
 42408                var resultadoIrpf = await CalcularIrpfAsync(
 42409                    periodoId, rl.PersonaId, cedula, version,
 42410                    nominalBruto, baseSdlAportes, aportes, insumos, catalogoPorCodigo, adicional6);
 411
 412                // Descartar entidades IrpfMensual trackeadas si la persistencia falló,
 413                // para que no se reintentan en el SaveChangesAsync final.
 84414                foreach (var entry in _context.ChangeTracker.Entries<IrpfMensual>()
 0415                             .Where(e => e.State == Microsoft.EntityFrameworkCore.EntityState.Added)
 42416                             .ToList())
 0417                    entry.State = Microsoft.EntityFrameworkCore.EntityState.Detached;
 418
 42419                if (resultadoIrpf.Item is not null && resultadoIrpf.TotalIrpf != 0)
 3420                    itemsToSave.Add(resultadoIrpf.Item);
 421                else
 422                {
 423                    // Diagnóstico: registrar siempre el resultado del cálculo IRPF para debugging
 39424                    var tieneCatalogo = catalogoPorCodigo.ContainsKey(CodigoIrpf);
 39425                    incidenciasToSave.Add(new IncidenciaCalculo
 39426                    {
 39427                        PeriodoId = periodoId, Version = version, PersonaId = rl.PersonaId,
 39428                        Tipo = TipoIncidenciaCalculo.CalculoSimplificado,
 39429                        Descripcion = $"IRPF diag: TotalIrpf={resultadoIrpf.TotalIrpf:F2} ItemNull={resultadoIrpf.Item i
 39430                        CreadoEn = DateTime.Now
 39431                    });
 432                }
 433
 434                // --- Paso 9: Descuentos personales con déficit ---
 435                // Excluir códigos ya emitidos como aportes para no contarlos dos veces en el déficit
 42436                var codigosAportesEmitidos = itemsAportesLegales
 88437                    .Where(i => !string.IsNullOrWhiteSpace(i.CodigoConcepto))
 88438                    .Select(i => i.CodigoConcepto)
 42439                    .ToHashSet(StringComparer.OrdinalIgnoreCase);
 42440                var descuentosLegalesEnBeneficios = resultadoBeneficios.Items
 35441                    .Where(i => i.Importe < 0m
 35442                                && catalogoPorCodigo.TryGetValue(i.CodigoConcepto, out var ci)
 35443                                && string.Equals(ci.Tipo, "descuento_legal", StringComparison.OrdinalIgnoreCase)
 35444                                && !codigosAportesEmitidos.Contains(i.CodigoConcepto.ToString()))
 46445                    .Sum(i => Math.Abs(i.Importe));
 44446                var totalDescuentosFictos = fictosIrpf.Sum(f => f.Importe);
 42447                var totalDescuentosLegales = aportes.Total + resultadoIrpf.TotalIrpf + descuentosLegalesEnBeneficios + t
 42448                var totalDescuentosLegalesParaDeficit =
 42449                    aportes.Total + resultadoIrpf.TotalIrpf + descuentosLegalesEnBeneficios;
 42450                var itemsDescuentosPersonales = await CrearItemsDescuentosPersonalesAsync(
 42451                    periodoId, rl.PersonaId, cedula, version,
 42452                    nominalBrutoParaDeficit, totalDescuentosLegalesParaDeficit, fechaPeriodo);
 42453                itemsToSave.AddRange(itemsDescuentosPersonales);
 454
 455                // --- Paso 9.5: Ajuste de redondeo ---
 456                // El liquido real (lo que el funcionario ve en el recibo) es la suma de
 457                // todos los items que se van a persistir, no `nominalBruto` (que filtra
 458                // solo positivos y se usa como base de aportes/IRPF).
 131459                var totalSinAjuste = itemsHaberes.Sum(i => i.Importe)
 35460                    + itemsBeneficios.Sum(i => i.Importe)
 88461                    + itemsAportesLegales.Sum(i => i.Importe)
 2462                    + itemsDescuentosFictos.Sum(i => i.Importe)
 42463                    + (resultadoIrpf.Item?.Importe ?? 0m)
 53464                    + itemsDescuentosPersonales.Sum(i => i.Importe);
 465
 42466                var ajusteRedondeo = CalcularAjusteRedondeo(totalSinAjuste);
 42467                if (ajusteRedondeo != 0m && catalogoPorCodigo.TryGetValue(CodigoAjusteRedondeo, out var ciAjuste))
 4468                    itemsToSave.Add(CrearItemLiquidacion(periodoId, cedula, version, ciAjuste, ajusteRedondeo));
 469
 470                // --- Paso 10: Estado ---
 42471                var tieneError = incidenciasToSave.Any(i =>
 86472                    i.PersonaId == rl.PersonaId && i.Tipo == TipoIncidenciaCalculo.ErrorCalculo);
 42473                var liquido = nominalBruto - totalDescuentosLegales
 53474                    - itemsDescuentosPersonales.Sum(i => Math.Abs(i.Importe));
 42475                var estado = tieneError ? "ERROR" : (liquido < 0 ? "REVISAR" : "CALCULADO");
 42476                estadosPersonal[cedula] = estado;
 42477                } // end if (!tieneSubsidio)
 478
 479                // --- Paso 11: Persistir ítems de haberes y beneficios ---
 262480                foreach (var item in itemsHaberes)
 481                {
 89482                    catalogoPorCodigo.TryGetValue(item.CodigoConcepto, out var catalogoItem);
 89483                    itemsToSave.Add(new LiquidacionItem
 89484                    {
 89485                        PeriodoId = periodoId,
 89486                        Cedula = cedula,
 89487                        Version = version,
 89488                        CodigoConcepto = item.CodigoConcepto.ToString(),
 89489                        NombreConcepto = item.NombreConcepto,
 89490                        Importe = item.Importe,
 89491                        RubroContable = catalogoItem?.ClasificacionSiif ?? string.Empty,
 89492                        Rubro083 = false,
 89493                        FechaCreacion = DateTime.Now
 89494                    });
 495                }
 154496                foreach (var item in itemsBeneficios)
 497                {
 35498                    itemsToSave.Add(new LiquidacionItem
 35499                    {
 35500                        PeriodoId = periodoId,
 35501                        Cedula = cedula,
 35502                        Version = version,
 35503                        CodigoConcepto = item.CodigoConcepto.ToString(),
 35504                        NombreConcepto = item.NombreConcepto,
 35505                        Importe = item.Importe,
 35506                        RubroContable = item.RubroContable,
 35507                        Rubro083 = false,
 35508                        ClaveEvento = item.ClaveEvento,
 35509                        FechaCreacion = DateTime.Now
 35510                    });
 511                }
 42512            }
 1513            catch (Exception ex)
 514            {
 1515                estadosPersonal[cedula] = "ERROR";
 1516                erroresPersonal[cedula] = ex.Message;
 1517                incidenciasToSave.Add(new IncidenciaCalculo
 1518                {
 1519                    PeriodoId = periodoId,
 1520                    Version = version,
 1521                    PersonaId = rl.PersonaId,
 1522                    Tipo = TipoIncidenciaCalculo.ErrorCalculo,
 1523                    Descripcion = $"Error en pipeline de liquidación: {ex.Message}",
 1524                    CreadoEn = DateTime.Now
 1525                });
 1526            }
 43527        }
 528
 85529        var estados = relacionesLaborales.Select(rl => new LiquidacionEstadoPersonal
 85530        {
 85531            PeriodoId = periodoId,
 85532            Cedula = rl.Persona.Cedula,
 85533            Version = version,
 85534            Estado = estadosPersonal.TryGetValue(rl.Persona.Cedula, out var e) ? e : "PENDIENTE",
 85535            MensajeError = erroresPersonal.TryGetValue(rl.Persona.Cedula, out var err) ? err : null,
 85536            FechaActualizacion = DateTime.Now
 85537        }).ToList();
 538
 42539        await _context.LiquidacionEstadosPersonal.AddRangeAsync(estados);
 540
 42541        if (itemsToSave.Count > 0)
 41542            await _context.LiquidacionItems.AddRangeAsync(itemsToSave);
 543
 42544        if (incidenciasToSave.Count > 0)
 42545            await _context.IncidenciasCalculo.AddRangeAsync(incidenciasToSave);
 546
 274547        var totalImporte = itemsToSave.Sum(i => i.Importe);
 42548        var resumen = new LiquidacionResumen
 42549        {
 42550            PeriodoId = periodoId,
 42551            Version = version,
 42552            TotalImporte = totalImporte,
 42553            Rubro083 = false,
 42554            Descripcion = "Liquidación generada"
 42555        };
 42556        await _context.LiquidacionResumenes.AddAsync(resumen);
 557
 42558        await _context.SaveChangesAsync();
 559
 42560        await _auditoriaService.LogAuditoriaAsync(
 42561            usuarioId ?? _currentUser.UserId ?? 0,
 42562            AccionEnum.GenerarLiquidacion,
 42563            ContextoEnum.Liquidaciones,
 42564            host ?? _currentUser.Host,
 42565            entidad: nameof(LiquidacionEstadoPersonal),
 42566            entidadId: periodoId,
 42567            detalle: new { periodoId, version, registros = estados.Count, items = itemsToSave.Count, incidencias = incid
 568
 42569        return (true, null, version);
 45570    }
 571
 572    private async Task<int> ObtenerVersionMaximaAsync(long periodoId)
 573    {
 1574        return await _context.LiquidacionItems
 1575            .AsNoTracking()
 1576            .Where(i => i.PeriodoId == periodoId)
 1577            .MaxAsync(i => (int?)i.Version) ?? 0;
 1578    }
 579
 580    public async Task<PagedResult<CalculoEstadoDto>> ObtenerEstadosAsync(long periodoId, int? version = null, int? page 
 581    {
 7582        var v = version ?? await ObtenerVersionMaximaAsync(periodoId);
 583
 7584        if (page.HasValue && page < 1) page = 1;
 8585        if (pageSize.HasValue) pageSize = Math.Clamp(pageSize.Value, 1, 100);
 586
 7587        var query = _context.LiquidacionEstadosPersonal
 7588            .AsNoTracking()
 7589            .Where(e => e.PeriodoId == periodoId && e.Version == v
 7590                && (cedula == null || e.Cedula.Contains(cedula))
 7591                && (estado == null || e.Estado == estado));
 592
 7593        var totalCount = await query.CountAsync();
 594
 7595        if (page.HasValue && pageSize.HasValue)
 1596            query = query.Skip((page.Value - 1) * pageSize.Value).Take(pageSize.Value);
 597
 7598        var estados = await query
 7599            .Select(e => new CalculoEstadoDto
 7600            {
 7601                Cedula       = e.Cedula,
 7602                Version      = e.Version,
 7603                Estado       = e.Estado,
 7604                MensajeError = e.MensajeError
 7605            })
 7606            .ToListAsync();
 607
 15608        var cedulas = estados.Select(e => e.Cedula).ToHashSet();
 609
 7610        var totalesPorCedula = await _context.LiquidacionItems
 7611            .AsNoTracking()
 7612            .Where(i => i.PeriodoId == periodoId && i.Version == v && cedulas.Contains(i.Cedula))
 7613            .GroupBy(i => i.Cedula)
 7614            .Select(g => new
 7615            {
 7616                Cedula     = g.Key,
 7617                Nominal    = g.Where(i => i.Importe > 0).Sum(i => i.Importe),
 7618                Descuentos = g.Where(i => i.Importe < 0).Sum(i => i.Importe),
 7619            })
 8620            .ToDictionaryAsync(x => x.Cedula);
 621
 30622        foreach (var dto in estados)
 8623            if (totalesPorCedula.TryGetValue(dto.Cedula, out var tot))
 624            {
 1625                dto.Nominal    = tot.Nominal;
 1626                dto.Descuentos = tot.Descuentos;
 1627                dto.Liquido    = tot.Nominal + tot.Descuentos;
 628            }
 629
 7630        return new PagedResult<CalculoEstadoDto>
 7631        {
 7632            Items      = estados,
 7633            Page       = page ?? 1,
 7634            PageSize   = pageSize ?? 20,
 7635            TotalCount = totalCount
 7636        };
 7637    }
 638
 639    public async Task<EstadosResumenDto> ObtenerEstadosResumenAsync(long periodoId, int? version = null)
 640    {
 0641        var v = version ?? await ObtenerVersionMaximaAsync(periodoId);
 642
 0643        var totales = await _context.LiquidacionItems
 0644            .AsNoTracking()
 0645            .Where(i => i.PeriodoId == periodoId && i.Version == v)
 0646            .GroupBy(i => 1)
 0647            .Select(g => new
 0648            {
 0649                TotalNominal    = g.Where(i => i.Importe > 0).Sum(i => i.Importe),
 0650                TotalDescuentos = g.Where(i => i.Importe < 0).Sum(i => i.Importe),
 0651            })
 0652            .FirstOrDefaultAsync();
 653
 0654        var conteosPorEstado = await _context.LiquidacionEstadosPersonal
 0655            .AsNoTracking()
 0656            .Where(e => e.PeriodoId == periodoId && e.Version == v)
 0657            .GroupBy(e => e.Estado)
 0658            .Select(g => new { Estado = g.Key, Count = g.Count() })
 0659            .ToListAsync();
 660
 0661        int Get(string estado) => conteosPorEstado.FirstOrDefault(c => c.Estado == estado)?.Count ?? 0;
 662
 0663        var nominal    = totales?.TotalNominal    ?? 0m;
 0664        var descuentos = totales?.TotalDescuentos ?? 0m;
 665
 0666        return new EstadosResumenDto
 0667        {
 0668            TotalNominal    = nominal,
 0669            TotalDescuentos = descuentos,
 0670            TotalLiquido    = nominal + descuentos,
 0671            Conteos = new ConteosEstadoDto
 0672            {
 0673                Total     = conteosPorEstado.Sum(c => c.Count),
 0674                Calculado = Get("CALCULADO"),
 0675                Revisar   = Get("REVISAR"),
 0676                Error     = Get("ERROR"),
 0677                Pendiente = Get("PENDIENTE")
 0678            }
 0679        };
 0680    }
 681
 682    public async Task<IEnumerable<CalculoItemDto>> ObtenerItemsAsync(long periodoId, int? version = null, string? cedula
 683    {
 0684        var v = version ?? await ObtenerVersionMaximaAsync(periodoId);
 0685        var query = _context.LiquidacionItems
 0686            .AsNoTracking()
 0687            .Where(i => i.PeriodoId == periodoId && i.Version == v);
 688
 0689        if (!string.IsNullOrEmpty(cedula))
 0690            query = query.Where(i => i.Cedula == cedula);
 691
 0692        return await query
 0693            .Select(i => new CalculoItemDto
 0694            {
 0695                Cedula         = i.Cedula,
 0696                Version        = i.Version,
 0697                CodigoConcepto = i.CodigoConcepto,
 0698                NombreConcepto = i.NombreConcepto,
 0699                Importe        = i.Importe,
 0700                RubroContable  = i.RubroContable,
 0701                ObjetoGasto    = i.ObjetoGasto
 0702            })
 0703            .ToListAsync();
 0704    }
 705
 706    public async Task<IEnumerable<LiquidacionResumenDto>> ObtenerResumenAsync(long periodoId, int? version = null)
 707    {
 0708        var v = version ?? await ObtenerVersionMaximaAsync(periodoId);
 0709        return await _context.LiquidacionResumenes
 0710            .AsNoTracking()
 0711            .Where(r => r.PeriodoId == periodoId && r.Version == v)
 0712            .Select(r => new LiquidacionResumenDto
 0713            {
 0714                Version = r.Version,
 0715                ProgramaId = r.ProgramaId,
 0716                RegimenId = r.RegimenId,
 0717                Rubro083 = r.Rubro083,
 0718                TotalImporte = r.TotalImporte,
 0719                Descripcion = r.Descripcion
 0720            })
 0721            .ToListAsync();
 0722    }
 723
 724    public async Task<IEnumerable<int>> ObtenerVersionesAsync(long periodoId)
 725    {
 2726        return await _context.LiquidacionItems
 2727            .AsNoTracking()
 2728            .Where(i => i.PeriodoId == periodoId)
 2729            .Select(i => i.Version)
 2730            .Distinct()
 2731            .OrderByDescending(v => v)
 2732            .ToListAsync();
 2733    }
 734
 735    // Civiles aportan FONASA por tasa única; los militares aportan Sanidad Militar por tramos.
 1736    private static readonly HashSet<string> CalculadoresCiviles =
 1737        new(StringComparer.OrdinalIgnoreCase) { "Sit70Calculator", "CivilCalculator" };
 738
 739    private async Task<AportesLegales> CalcularAportesLegalesAsync(
 740        long regimenId, decimal baseSdl, InsumosPeriodo insumos, bool esMilitar)
 741    {
 42742        var regimen = await _context.Regimenes.AsNoTracking().FirstOrDefaultAsync(r => r.Id == regimenId);
 42743        var tasaMontepio = regimen is not null && regimen.EsLeyVieja
 42744            ? insumos.ParametroPeriodo.TasaMontepioLeyVieja
 42745            : insumos.ParametroPeriodo.TasaMontepioLeyNueva;
 746
 42747        var montepio = _descuentosLegalesCalculator.CalcularMontepio(baseSdl, tasaMontepio);
 42748        var tutelaSocial = _descuentosLegalesCalculator.CalcularTutelaSocial(baseSdl, insumos.ParametroPeriodo.TasaTutel
 749
 750        // Sanidad Militar (Art. 94 Ley 18.834) reemplaza a FONASA para militares.
 751        // Base = misma que el montepío (baseSdl). Civiles mantienen FONASA por tasa única.
 42752        var tramo = esMilitar ? insumos.ObtenerTramoSanidad(baseSdl) : null;
 42753        if (tramo is not null)
 754        {
 1755            var sanidad = _descuentosLegalesCalculator.CalcularSanidad(baseSdl, tramo.Porcentaje);
 1756            return new AportesLegales(montepio, sanidad, tutelaSocial, tramo.CodigoRetencion);
 757        }
 758
 41759        var fonasa = _descuentosLegalesCalculator.CalcularFonasa(baseSdl, insumos.ParametroPeriodo.TasaFonasa);
 41760        return new AportesLegales(montepio, fonasa, tutelaSocial, SanidadCodigo: null);
 42761    }
 762
 763    private async Task<ResultadoIrpfLiquidacion> CalcularIrpfAsync(
 764        long periodoId,
 765        long personaId,
 766        string cedula,
 767        int version,
 768        decimal nominalBruto,
 769        decimal baseSdlAportes,
 770        AportesLegales aportes,
 771        InsumosPeriodo insumos,
 772        IReadOnlyDictionary<int, CatalogoItem> catalogoPorCodigo,
 773        decimal adicional6 = 0m)
 774    {
 42775        var request = new CalcularIrpfRequest
 42776        {
 42777            IngresoGravados = nominalBruto,
 42778            Montepio = aportes.Montepio,
 42779            Fonasa = aportes.Fonasa,
 42780            Frl = 0m,
 42781            Adicional6 = adicional6,
 42782            Deducciones = await CrearDeduccionesIrpfAsync(
 42783                personaId,
 42784                insumos.ParametroPeriodo.Anio,
 42785                insumos.ParametroPeriodo.Mes,
 42786                insumos.ParametroPeriodo.BpcAnual),
 42787            Parametros = new ParametrosIrpfDto
 42788            {
 42789                BpcMensual = insumos.ParametroPeriodo.BpcAnual / 12m,
 42790                UmbralDeduccionBpc = insumos.ParametroPeriodo.UmbralDeduccionBpc,
 42791                TasaDeduccionAlta = insumos.ParametroPeriodo.TasaDeduccionAlta,
 42792                TasaDeduccionBaja = insumos.ParametroPeriodo.TasaDeduccionBaja
 42793            },
 42794            Franjas = insumos.FranjasIrpf
 87795                .Select(f => new FranjaIrpfCalculoDto
 87796                {
 87797                    FranjaId = f.Id,
 87798                    NumeroFranja = f.NumeroFranja,
 87799                    DesdeBpc = f.DesdeBpc,
 87800                    HastaBpc = f.HastaBpc,
 87801                    Tasa = f.Tasa
 87802                })
 42803                .ToList()
 42804        };
 805
 42806        var (exito, idIrpf, _) = await _irpfService.CalcularYPersistirAsync(periodoId, personaId, request);
 42807        if (!exito || !idIrpf.HasValue)
 38808            return new ResultadoIrpfLiquidacion(0m, null);
 809
 4810        var irpf = await _irpfService.ObtenerPorIdAsync(idIrpf.Value);
 4811        if (irpf is null)
 0812            return new ResultadoIrpfLiquidacion(0m, null);
 813
 4814        var item = catalogoPorCodigo.TryGetValue(CodigoIrpf, out var catalogoIrpf)
 4815            ? CrearItemLiquidacion(periodoId, cedula, version, catalogoIrpf, -irpf.TotalIrpf)
 4816            : null;
 817
 4818        return new ResultadoIrpfLiquidacion(irpf.TotalIrpf, item);
 42819    }
 820
 821    private async Task<List<DeduccionDto>> CrearDeduccionesIrpfAsync(
 822        long personaId, int anio, int mes, decimal bpcAnual)
 823    {
 42824        var dependientes = await _dependienteRepository.GetDependientesByPersonaAsync(personaId);
 825
 42826        var result = dependientes
 3827            .Where(d => d.Activo)
 42828            .SelectMany(d =>
 42829            {
 3830                if (string.Equals(d.Tipo, "conyuge_sin_ingresos", StringComparison.OrdinalIgnoreCase))
 1831                    return [new DeduccionDto { Tipo = "conyuge_sin_ingresos", Cantidad = 1 }];
 42832
 2833                if (string.Equals(d.Tipo, "hijo", StringComparison.OrdinalIgnoreCase))
 42834                {
 2835                    var tipoHijo = d.Discapacitado ? "hijo_discapacidad" : "hijo_menor";
 2836                    return [new DeduccionDto { Tipo = tipoHijo, Cantidad = 1 }];
 42837                }
 42838
 0839                return Array.Empty<DeduccionDto>();
 42840            })
 42841            .ToList();
 842
 42843        var form = await _form3100Repository.GetVigenteParaPeriodoAsync(personaId, anio, mes);
 42844        if (form is not null)
 845        {
 1846            AgregarDeduccionesPersonasACargoForm3100(result, form, anio, mes, bpcAnual);
 847
 1848            var deduccionProfesional = form.CappuImporte + form.ReintegroAporte
 1849                + CalcularFondoSolidaridadMensual(form, bpcAnual);
 850
 1851            if (deduccionProfesional > 0m)
 0852                result.Add(new DeduccionDto { Tipo = "profesional", Monto = deduccionProfesional });
 853        }
 854
 42855        return result;
 42856    }
 857
 858    private static void AgregarDeduccionesPersonasACargoForm3100(
 859        ICollection<DeduccionDto> resultado,
 860        Form3100 form,
 861        int anio,
 862        int mes,
 863        decimal bpcAnual)
 864    {
 1865        var bpcMensual = bpcAnual / 12m;
 1866        var fechaReferencia = new DateTime(anio, mes, DateTime.DaysInMonth(anio, mes));
 867
 8868        foreach (var personaCargo in form.PersonasACargo)
 869        {
 3870            if (personaCargo.PorcentajeAtribucion <= 0)
 871                continue;
 872
 3873            if (!personaCargo.Relacion.StartsWith("hij", StringComparison.OrdinalIgnoreCase))
 874                continue;
 875
 2876            var esDiscapacidad = personaCargo.Discapacidad;
 2877            if (!esDiscapacidad && CalcularEdad(personaCargo.FechaNacimiento, fechaReferencia) >= 18)
 878                continue;
 879
 2880            var factorAtribucion = Math.Min(personaCargo.PorcentajeAtribucion, (short)100) / 100m;
 2881            var bpcAnuales = esDiscapacidad ? 40m : 20m;
 2882            var tipo = esDiscapacidad ? "hijo_discapacidad" : "hijo_menor";
 2883            var monto = bpcAnuales * bpcMensual / 12m * factorAtribucion;
 884
 2885            if (monto > 0m)
 2886                resultado.Add(new DeduccionDto { Tipo = tipo, Monto = monto });
 887        }
 1888    }
 889
 890    private static int CalcularEdad(DateTime fechaNacimiento, DateTime fechaReferencia)
 891    {
 1892        var edad = fechaReferencia.Year - fechaNacimiento.Year;
 1893        if (fechaNacimiento.Date > fechaReferencia.Date.AddYears(-edad))
 1894            edad--;
 1895        return edad;
 896    }
 897
 898    private static decimal CalcularFondoSolidaridadMensual(Form3100 form, decimal bpcAnual)
 899    {
 1900        var bpcFactor = form.FondoSolidaridad switch
 1901        {
 0902            "0.5_BPC" => 0.5m,
 0903            "1_BPC"   => 1m,
 0904            "2_BPC"   => 2m,
 1905            _         => 0m
 1906        };
 1907        var bpcMensual = bpcAnual / 12m;
 1908        var montoBase = bpcFactor * bpcMensual / 12m;
 909
 1910        if (!form.AdicionalFondoSolidaridad)
 1911            return montoBase;
 912
 913        // Adicional Fondo de Solidaridad: monto propio (5/3 BPC mensual / 12 = 953,33 con BPC 2026)
 0914        var montoAdicional = 5m / 3m * bpcMensual / 12m;
 0915        return montoBase + montoAdicional;
 916    }
 917
 918    internal static IReadOnlyList<FictoIrpfCalculado> CalcularFictosIrpf(
 919        long periodoId,
 920        string cedula,
 921        int version,
 922        IReadOnlyList<FictoPersona> fictosActivos,
 923        IReadOnlyDictionary<int, CatalogoItem> catalogoPorCodigo)
 924    {
 43925        if (fictosActivos.Count == 0)
 40926            return Array.Empty<FictoIrpfCalculado>();
 927
 3928        var catalogoPorId = catalogoPorCodigo.Values
 17929            .GroupBy(c => c.Id)
 37930            .ToDictionary(g => g.Key, g => g.First());
 931
 3932        var fictos = new List<FictoIrpfCalculado>();
 933
 16934        foreach (var ficto in fictosActivos)
 935        {
 5936            var catalogoDescuento = ficto.CatalogoItem;
 5937            if (catalogoDescuento.ItemParFictoId is not long itemParFictoId)
 938                continue;
 939
 5940            if (!catalogoPorId.TryGetValue(itemParFictoId, out var catalogoHaber))
 941                continue;
 942
 5943            if (!EsCatalogoFicto(catalogoHaber) || catalogoHaber.Codigo == catalogoDescuento.Codigo)
 944                continue;
 945
 5946            var importe = Math.Round(ficto.Importe, 2);
 5947            if (importe <= 0m)
 948                continue;
 949
 5950            var haber = new ItemCalculado
 5951            {
 5952                CodigoConcepto = catalogoHaber.Codigo,
 5953                NombreConcepto = catalogoHaber.Nombre,
 5954                Importe = importe
 5955            };
 5956            var itemDescuento = CrearItemLiquidacion(periodoId, cedula, version, catalogoDescuento, -importe);
 957
 5958            fictos.Add(new FictoIrpfCalculado(haber, itemDescuento, ficto.Id, importe));
 959        }
 960
 3961        return fictos;
 962    }
 963
 964    private static bool EsCatalogoFicto(CatalogoItem? catalogoItem) =>
 119965        catalogoItem is not null && catalogoItem.Tipo.StartsWith("ficto_", StringComparison.OrdinalIgnoreCase);
 966
 967    private static bool EsCatalogoBeneficioSocial(CatalogoItem? catalogoItem) =>
 25968        catalogoItem is not null && catalogoItem.Tipo.StartsWith("beneficio_social", StringComparison.OrdinalIgnoreCase)
 969
 970    private static bool EsCatalogoHaberAportable(CatalogoItem? catalogoItem) =>
 30971        catalogoItem is not null
 30972        && catalogoItem.EsGravado
 30973        && catalogoItem.Tipo.StartsWith("haber_", StringComparison.OrdinalIgnoreCase)
 30974        && !EsCatalogoBeneficioSocial(catalogoItem)
 30975        && !EsCatalogoFicto(catalogoItem);
 976
 977    private async Task<IReadOnlyList<LiquidacionItem>> CrearItemsDescuentosPersonalesAsync(
 978        long periodoId,
 979        long personaId,
 980        string cedula,
 981        int version,
 982        decimal nominalBruto,
 983        decimal descuentosLegales,
 984        DateTime fechaPeriodo)
 985    {
 42986        var descuentosConfirmados = (await _descuentoPersonalRepository
 42987                .GetConfirmadosPorPersonaAsync(periodoId, personaId))
 11988            .Where(d => d.CatalogoItem is not null)
 42989            // Vigencia (ej. CUOTA_ING): se aplica solo si no venció antes del período
 11990            .Where(d => d.FechaHasta is null || d.FechaHasta >= fechaPeriodo)
 42991            .ToList();
 992
 42993        if (descuentosConfirmados.Count == 0)
 32994            return Array.Empty<LiquidacionItem>();
 995
 996        // Filtrar descuentos sin acreedor asignado (datos previos a la migración de acreedor_id)
 10997        var descuentosConAcreedor = descuentosConfirmados
 11998            .Where(d => d.CatalogoItem.ItemParFictoId is null)
 11999            .Where(d => d.AcreedorId is not null && d.Acreedor is not null)
 101000            .ToList();
 1001
 101002        if (descuentosConAcreedor.Count == 0)
 01003            return Array.Empty<LiquidacionItem>();
 1004
 101005        var entradasDeficit = descuentosConAcreedor
 111006            .Select(d => CrearEntradaDeficit(d, personaId))
 101007            .ToList();
 1008
 101009        var resultadosDeficit = (await _deficitCalculator.AplicarAsync(
 101010            nominalBruto,
 101011            descuentosLegales,
 101012            entradasDeficit)).ToList();
 1013
 211014        var descuentosPorId = descuentosConAcreedor.ToDictionary(d => d.Id);
 101015        var items = new List<LiquidacionItem>();
 1016
 421017        foreach (var resultadoDeficit in resultadosDeficit)
 1018        {
 111019            if (resultadoDeficit.DescuentoPeriodoId is null)
 1020                continue;
 1021
 111022            if (!descuentosPorId.TryGetValue(resultadoDeficit.DescuentoPeriodoId.Value, out var descuento))
 1023                continue;
 1024
 111025            descuento.MontoAplicado = resultadoDeficit.MontoAplicado;
 111026            descuento.MontoNoAplicado = resultadoDeficit.MontoNoAplicado;
 111027            descuento.Estado = resultadoDeficit.Estado;
 1028
 111029            if (!string.IsNullOrWhiteSpace(resultadoDeficit.MotivoNoAplicado) &&
 111030                string.IsNullOrWhiteSpace(descuento.Observaciones))
 1031            {
 01032                descuento.Observaciones = resultadoDeficit.MotivoNoAplicado;
 1033            }
 1034
 111035            if (resultadoDeficit.MontoAplicado <= 0m)
 1036                continue;
 1037
 111038            items.Add(CrearItemLiquidacion(
 111039                periodoId,
 111040                cedula,
 111041                version,
 111042                descuento.CatalogoItem,
 111043                -resultadoDeficit.MontoAplicado));
 1044        }
 1045
 101046        return items;
 421047    }
 1048
 1049    private static DescuentoPersonalInput CrearEntradaDeficit(DescuentoPersonalPeriodo descuento, long personaId)
 1050    {
 111051        if (descuento.AcreedorId is null)
 01052            throw new InvalidOperationException($"El descuento personal {descuento.Id} no tiene acreedor asignado.");
 1053
 111054        if (descuento.Acreedor is null)
 01055            throw new InvalidOperationException($"El descuento personal {descuento.Id} no tiene acreedor cargado.");
 1056
 111057        return new DescuentoPersonalInput(
 111058            descuento.Id,
 111059            personaId,
 111060            descuento.CatalogoItem.Codigo,
 111061            descuento.Importe,
 111062            descuento.AcreedorId.Value,
 111063            descuento.Acreedor.CategoriaDeficit,
 111064            descuento.Acreedor.OrdenLegalDeficit,
 111065            descuento.FechaComunicacion ?? descuento.FechaCreacion);
 1066    }
 1067
 1068    private static IReadOnlyList<LiquidacionItem> CrearItemsAportesLegales(
 1069        long periodoId,
 1070        string cedula,
 1071        int version,
 1072        AportesLegales aportes,
 1073        IReadOnlyDictionary<int, CatalogoItem> catalogoPorCodigo)
 1074    {
 421075        var items = new List<LiquidacionItem>();
 1076
 421077        AgregarItemAporteLegal(
 421078            items, periodoId, cedula, version, aportes.Montepio,
 421079            BuscarCatalogoItem(catalogoPorCodigo, CodigoMontepioSubalterno, 545, 555),
 421080            codigoFallback: CodigoMontepioSubalterno.ToString(),
 421081            nombreFallback: "MONTEPIO",
 421082            rubroFallback: "5.04");
 1083
 1084        // Salud: Sanidad Militar por tramos (código 5010/5011/5012) o FONASA por tasa única.
 421085        if (aportes.SanidadCodigo is int sanidadCodigo)
 1086        {
 11087            AgregarItemAporteLegal(
 11088                items, periodoId, cedula, version, aportes.Fonasa,
 11089                BuscarCatalogoItem(catalogoPorCodigo, sanidadCodigo),
 11090                codigoFallback: sanidadCodigo.ToString(),
 11091                nombreFallback: "SANIDAD",
 11092                rubroFallback: $"5.{sanidadCodigo % 100:D2}");
 1093        }
 1094        else
 1095        {
 411096            AgregarItemAporteLegal(
 411097                items, periodoId, cedula, version, aportes.Fonasa,
 411098                BuscarCatalogoItem(catalogoPorCodigo, CodigoSanidad, 523, 524, 525, 526, 527, 528),
 411099                codigoFallback: CodigoSanidad.ToString(),
 411100                nombreFallback: "FONASA",
 411101                rubroFallback: "5.010");
 1102        }
 1103
 421104        AgregarItemAporteLegal(
 421105            items, periodoId, cedula, version, aportes.TutelaSocial,
 421106            BuscarCatalogoItem(catalogoPorCodigo, CodigoTutelaSocial),
 421107            codigoFallback: CodigoTutelaSocial.ToString(),
 421108            nombreFallback: "SERV. ESP. TUT. SOC.",
 421109            rubroFallback: "5.08");
 1110
 421111        return items;
 1112    }
 1113
 1114    private static void AgregarItemAporteLegal(
 1115        ICollection<LiquidacionItem> items,
 1116        long periodoId,
 1117        string cedula,
 1118        int version,
 1119        decimal importe,
 1120        CatalogoItem? catalogoItem,
 1121        string codigoFallback,
 1122        string nombreFallback,
 1123        string rubroFallback)
 1124    {
 1261125        if (importe <= 0m)
 381126            return;
 1127
 881128        if (catalogoItem is not null)
 1129        {
 121130            items.Add(CrearItemLiquidacion(periodoId, cedula, version, catalogoItem, -importe));
 121131            return;
 1132        }
 1133
 761134        items.Add(CrearItemLiquidacion(periodoId, cedula, version, codigoFallback, nombreFallback, -importe, rubroFallba
 761135    }
 1136
 1137    private static CatalogoItem? BuscarCatalogoItem(
 1138        IReadOnlyDictionary<int, CatalogoItem> catalogoPorCodigo,
 1139        params int[] codigos)
 1140    {
 11001141        foreach (var codigo in codigos)
 1142        {
 4301143            if (catalogoPorCodigo.TryGetValue(codigo, out var catalogoItem))
 121144                return catalogoItem;
 1145        }
 1146
 1141147        return null;
 1148    }
 1149
 1150    private static LiquidacionItem CrearItemLiquidacion(
 1151        long periodoId,
 1152        string cedula,
 1153        int version,
 1154        CatalogoItem catalogoItem,
 1155        decimal importe)
 1156    {
 351157        return CrearItemLiquidacion(
 351158            periodoId, cedula, version,
 351159            catalogoItem.Codigo.ToString(),
 351160            catalogoItem.Nombre,
 351161            importe,
 351162            catalogoItem.ClasificacionSiif ?? "000.000");
 1163    }
 1164
 1165    private static LiquidacionItem CrearItemLiquidacion(
 1166        long periodoId,
 1167        string cedula,
 1168        int version,
 1169        string codigoConcepto,
 1170        string nombreConcepto,
 1171        decimal importe,
 1172        string rubroContable)
 1173    {
 1111174        return new LiquidacionItem
 1111175        {
 1111176            PeriodoId = periodoId,
 1111177            Cedula = cedula,
 1111178            Version = version,
 1111179            CodigoConcepto = codigoConcepto,
 1111180            NombreConcepto = nombreConcepto,
 1111181            Importe = importe,
 1111182            RubroContable = rubroContable,
 1111183            Rubro083 = false,
 1111184            FechaCreacion = DateTime.Now
 1111185        };
 1186    }
 1187
 1188    /// <summary>
 1189    /// Diferencia entre el redondeo al entero más cercano y <paramref name="totalSinAjuste"/>.
 1190    /// Centavos &lt;= 0.50 redondean hacia abajo; centavos &gt; 0.50 redondean hacia arriba.
 1191    /// La parte fraccionaria (<c>frac</c>) siempre queda en [0, 1) vía <see cref="Math.Floor(decimal)"/>,
 1192    /// por lo que la regla es válida también para totales negativos.
 1193    /// </summary>
 1194    internal static decimal CalcularAjusteRedondeo(decimal totalSinAjuste)
 1195    {
 481196        var piso = Math.Floor(totalSinAjuste);
 481197        var frac = totalSinAjuste - piso;
 481198        var redondeado = frac <= 0.50m ? piso : piso + 1m;
 481199        return redondeado - totalSinAjuste;
 1200    }
 1201
 1202    private static decimal CalcularSueldoBaseParaTraslado(InsumosPeriodo insumos, long gradoId)
 1203    {
 1204        const int CodigoSueldoBase     = 11000;
 1205        const int CodigoCompOficial    = 42012;
 1206        const int CodigoCompSubalterno = 42022;
 1207
 431208        var sueldoBase     = insumos.ObtenerRemuneracion(gradoId, CodigoSueldoBase)?.Monto ?? 0m;
 431209        var compOficial    = insumos.ObtenerRemuneracion(gradoId, CodigoCompOficial)?.Monto ?? 0m;
 431210        var compSubalterno = insumos.ObtenerRemuneracion(gradoId, CodigoCompSubalterno)?.Monto ?? 0m;
 1211
 431212        return sueldoBase + compOficial + compSubalterno;
 1213    }
 1214
 1215    // Fonasa carries el monto del aporte de salud: FONASA por tasa única (civiles) o
 1216    // Sanidad Militar por tramos (militares). SanidadCodigo indica el código del tramo
 1217    // aplicado (5010/5011/5012, catálogo SANIDAD X BPC) cuando corresponde a Sanidad Militar; null para FONASA.
 51218    internal sealed record FictoIrpfCalculado(
 81219        ItemCalculado Haber,
 51220        LiquidacionItem Descuento,
 01221        long FictoPersonaId,
 91222        decimal Importe);
 1223
 5461224    private sealed record AportesLegales(decimal Montepio, decimal Fonasa, decimal TutelaSocial = 0m, int? SanidadCodigo
 1225    {
 841226        public decimal Total => Montepio + Fonasa + TutelaSocial;
 1227    }
 1228
 2941229    private sealed record ResultadoIrpfLiquidacion(decimal TotalIrpf, LiquidacionItem? Item);
 1230}

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