< 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: 460
Uncovered lines: 75
Coverable lines: 535
Total lines: 865
Line coverage: 85.9%
Branch coverage
82%
Covered branches: 154
Total branches: 186
Branch coverage: 82.7%
Method coverage

Feature is only available for sponsors

Upgrade to PRO version

Metrics

File(s)

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

#LineLine coverage
 1using System.Runtime.CompilerServices;
 2using FAU.DataAccess;
 3using FAU.DataAccess.Repositories;
 4using FAU.Entidades;
 5using FAU.Logica.Calculadores;
 6using FAU.Logica.Calculators;
 7using FAU.Logica.DTOs;
 8using Microsoft.EntityFrameworkCore;
 9
 10[assembly: InternalsVisibleTo("FAU.Tests")]
 11
 12namespace FAU.Logica.Services;
 13
 14public class LiquidacionService : ILiquidacionService
 15{
 16    private const int CodigoIrpf = 517;
 17    private const int CodigoMontepioSubalterno = 550;
 18    private const int CodigoSanidad = 520;
 19    private const string CodigoFrl = "FRL";
 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 IDeficitCalculator _deficitCalculator;
 44    private readonly IDescuentosLegalesCalculator _descuentosLegalesCalculator;
 45    private readonly IIrpfService _irpfService;
 46
 3947    public LiquidacionService(
 3948        ApplicationDbContext context,
 3949        IAuditoriaService auditoriaService,
 3950        ICurrentUserContext currentUser,
 3951        IInsumosPeriodoLoader insumosLoader,
 3952        IProcesadorBeneficiosService procesadorBeneficios,
 3953        ICalculadorFactory calculadorFactory,
 3954        ICalcularDiferencia615Service diferencia615Service,
 3955        IDescuentoPersonalRepository descuentoPersonalRepository,
 3956        IDependienteRepository dependienteRepository,
 3957        IDeficitCalculator deficitCalculator,
 3958        IDescuentosLegalesCalculator descuentosLegalesCalculator,
 3959        IIrpfService irpfService)
 60    {
 3961        _context = context;
 3962        _auditoriaService = auditoriaService;
 3963        _currentUser = currentUser;
 3964        _insumosLoader = insumosLoader;
 3965        _procesadorBeneficios = procesadorBeneficios;
 3966        _calculadorFactory = calculadorFactory;
 3967        _diferencia615Service = diferencia615Service;
 3968        _descuentoPersonalRepository = descuentoPersonalRepository;
 3969        _dependienteRepository = dependienteRepository;
 3970        _deficitCalculator = deficitCalculator;
 3971        _descuentosLegalesCalculator = descuentosLegalesCalculator;
 3972        _irpfService = irpfService;
 3973    }
 74
 75    /// <summary>
 76    /// Genera la liquidación para el período. La versión se determina automáticamente:
 77    /// max_version_existente + 1, lo que permite recalcular después de una reapertura
 78    /// sin perder el historial de versiones anteriores.
 79    /// </summary>
 80    public async Task<(bool Exito, string? Error, int Version)> GenerarLiquidacionAsync(long periodoId)
 81    {
 4082        var periodo = await _context.PeriodosLiquidacion
 4083            .AsNoTracking()
 4084            .FirstOrDefaultAsync(p => p.Id == periodoId && p.Activo);
 85
 4086        if (periodo is null)
 187            return (false, "Período de liquidación no encontrado o no está activo", 0);
 88
 89        // Versión = max existente + 1 (1 si es la primera vez)
 3990        var maxVersion = await _context.LiquidacionItems
 3991            .Where(li => li.PeriodoId == periodoId)
 3992            .MaxAsync(li => (int?)li.Version) ?? 0;
 3993        var version = maxVersion + 1;
 94
 95        InsumosPeriodo insumos;
 96        try
 97        {
 3998            insumos = await _insumosLoader.CargarAsync(periodoId);
 3999        }
 0100        catch (InvalidOperationException ex) when (ex.Message.Contains("arámetro"))
 101        {
 0102            return (false, $"No se encontraron parámetros para el período {periodo.Anio}/{periodo.Mes}", 0);
 103        }
 104
 105        // Usamos el mismo patrón de Join que funciona con el HasConversion de Estado enum→string
 39106        var rlRaw = await _context.RelacionesLaborales
 39107            .AsNoTracking()
 39108            .Where(r => r.Estado == EstadoRelacion.Activo)
 39109            .Join(
 39110                _context.Personas.AsNoTracking(),
 39111                r => r.PersonaId,
 39112                p => p.Id,
 39113                (r, p) => new { RL = r, Persona = p })
 39114            .ToListAsync();
 115
 39116        if (!rlRaw.Any())
 2117            return (false, "No se encontraron relaciones laborales activas para generar la liquidación", version);
 118
 119        // Cargar navigation properties necesarias para el pipeline de calculadoras
 75120        var situacionIds = rlRaw.Select(x => x.RL.SituacionId).Distinct().ToHashSet();
 75121        var regimenIds   = rlRaw.Select(x => x.RL.RegimenId).Distinct().ToHashSet();
 122
 37123        var situacionesDict = await _context.Situaciones.AsNoTracking()
 37124            .Where(s => situacionIds.Contains(s.Id))
 54125            .ToDictionaryAsync(s => s.Id);
 126
 37127        var regimenesDict = await _context.Regimenes.AsNoTracking()
 37128            .Where(r => regimenIds.Contains(r.Id))
 55129            .ToDictionaryAsync(r => r.Id);
 130
 131        // Materializar RelacionLaboral con navigation properties resueltas en memoria
 37132        var relacionesLaborales = rlRaw.Select(x =>
 37133        {
 38134            x.RL.Persona = x.Persona;
 38135            x.RL.Situacion = situacionesDict.GetValueOrDefault(x.RL.SituacionId)!;
 38136            x.RL.Regimen   = regimenesDict.GetValueOrDefault(x.RL.RegimenId)!;
 38137            return x.RL;
 37138        }).ToList();
 139
 37140        var primerDiaPeriodo = new DateOnly(periodo.Anio, periodo.Mes, 1);
 37141        var itemsToSave = new List<LiquidacionItem>();
 37142        var incidenciasToSave = new List<IncidenciaCalculo>();
 37143        var estadosPersonal = new Dictionary<string, string>();
 144
 145        // Pre-load historico de primas únicas en una sola consulta
 75146        var cedulasPersonas = relacionesLaborales.Select(r => r.Persona.Cedula).ToHashSet();
 37147        var itemsHistoricosGlobal = await _context.LiquidacionItems
 37148            .AsNoTracking()
 37149            .Where(li => cedulasPersonas.Contains(li.Cedula) &&
 37150                         (li.CodigoConcepto == "340" || li.CodigoConcepto == "350" || li.CodigoConcepto == "370"))
 37151            .ToListAsync();
 152
 153        // Índice de catálogo por código (usado por helpers de aportes legales)
 37154        var catalogoPorCodigo = insumos.CatalogoItems
 236155            .GroupBy(i => i.Codigo)
 455156            .ToDictionary(g => g.Key, g => g.First());
 157
 150158        foreach (var rl in relacionesLaborales)
 159        {
 38160            var cedula = rl.Persona.Cedula;
 161            try
 162            {
 38163                var acumuladores = new Acumuladores();
 38164                var itemsHaberes = new List<ItemCalculado>();
 38165                var itemsBeneficios = new List<ItemBeneficioCalculado>();
 166
 167                // --- Paso 1-3: Calculador de haberes base ---
 38168                var calculador = rl.Situacion?.Calculador is not null
 38169                    ? _calculadorFactory.Resolver(rl.Situacion.Calculador)
 38170                    : null;
 171
 38172                if (calculador is not null)
 173                {
 174                    // Prorrateo por novedad 'dias_eximidos': factor = dias/30.
 175                    // Endurecido: dias_afectados debe estar en [1,30]. Fuera de rango (incl. 0)
 176                    // NO se prorratea (factor 1) y se registra incidencia, para no anular
 177                    // indebidamente los haberes ni romper el cálculo de aportes/IRPF.
 17178                    var novedadEximidos = insumos.ObtenerNovedadEximidos(rl.PersonaId);
 17179                    var factorProrrateo = 1m;
 17180                    if (novedadEximidos is not null)
 181                    {
 3182                        var diasAfectados = novedadEximidos.DiasAfectados;
 3183                        if (diasAfectados is < 1 or > 30)
 184                        {
 1185                            incidenciasToSave.Add(new IncidenciaCalculo
 1186                            {
 1187                                PeriodoId = periodoId, Version = version, PersonaId = rl.PersonaId,
 1188                                Tipo = TipoIncidenciaCalculo.CalculoSimplificado,
 1189                                Descripcion = $"Novedad 'dias_eximidos' con dias_afectados fuera de rango [1,30]: {diasA
 1190                                CreadoEn = DateTime.UtcNow
 1191                            });
 192                        }
 2193                        else if (diasAfectados < 30)
 194                        {
 2195                            factorProrrateo = diasAfectados / 30m;
 196                        }
 197                    }
 198
 17199                    var resultadoCalculo = await calculador.CalcularAsync(rl, insumos, acumuladores);
 200
 17201                    if (factorProrrateo < 1m)
 202                    {
 24203                        foreach (var item in resultadoCalculo.Items)
 10204                            item.Importe = Math.Round(item.Importe * factorProrrateo, 2);
 205
 2206                        acumuladores.Reiniciar();
 24207                        foreach (var item in resultadoCalculo.Items)
 208                        {
 58209                            var ci = insumos.CatalogoItems.FirstOrDefault(c => c.Codigo == item.CodigoConcepto);
 19210                            if (ci is not null) acumuladores.AgregarItem(item.Importe, ci);
 211                        }
 212                    }
 213
 17214                    itemsHaberes.AddRange(resultadoCalculo.Items);
 215
 34216                    foreach (var inc in resultadoCalculo.Incidencias)
 0217                        incidenciasToSave.Add(new IncidenciaCalculo
 0218                        {
 0219                            PeriodoId = periodoId, Version = version, PersonaId = rl.PersonaId,
 0220                            Tipo = TipoIncidenciaCalculo.CalculoSimplificado,
 0221                            Descripcion = inc, CreadoEn = DateTime.UtcNow
 0222                        });
 223                }
 224                else
 225                {
 21226                    incidenciasToSave.Add(new IncidenciaCalculo
 21227                    {
 21228                        PeriodoId = periodoId, Version = version, PersonaId = rl.PersonaId,
 21229                        Tipo = TipoIncidenciaCalculo.ErrorCalculo,
 21230                        Descripcion = $"Calculador no registrado: {rl.Situacion?.Calculador}",
 21231                        CreadoEn = DateTime.UtcNow
 21232                    });
 233                }
 234
 235                // --- Paso 4: Beneficios sociales ---
 38236                var beneficios = insumos.ObtenerBeneficios(rl.PersonaId);
 38237                var itemsHistoricoPersona = itemsHistoricosGlobal.Where(h => h.Cedula == cedula).ToList();
 38238                var sueldoBasePersona = CalcularSueldoBaseParaTraslado(insumos, rl.GradoId);
 38239                var resultadoBeneficios = _procesadorBeneficios.ProcesarBeneficios(
 38240                    rl.PersonaId, beneficios, insumos.ParametroPeriodo, insumos.CatalogoItems,
 38241                    itemsHistoricoPersona, primerDiaPeriodo,
 38242                    insumos.BeneficiariosPorBeneficio, insumos.InstructivoAsigFamiliar,
 38243                    insumos.InstructivoPsf, insumos.TablaPorHogar,
 38244                    sueldoBaseParaTraslado: sueldoBasePersona);
 37245                itemsBeneficios.AddRange(resultadoBeneficios.Items);
 246
 74247                foreach (var inc in resultadoBeneficios.Incidencias)
 0248                    incidenciasToSave.Add(new IncidenciaCalculo
 0249                    {
 0250                        PeriodoId = periodoId, Version = version, PersonaId = rl.PersonaId,
 0251                        Tipo = inc.Tipo, Descripcion = inc.Descripcion, CreadoEn = DateTime.UtcNow
 0252                    });
 253
 254                // --- Paso 5: Compensaciones por lote ---
 255                // El OG viene de ConfigCompensacion.CatalogoItem (no hardcodeado)
 37256                var compensaciones = insumos.ObtenerCompensaciones(rl.PersonaId);
 78257                foreach (var comp in compensaciones)
 258                {
 2259                    var ogCode = ObtenerOGCompensacion(comp);
 2260                    if (ogCode is null) continue;   // sin config de OG → saltear
 261
 2262                    var monto = comp.Monto ?? 0m;
 2263                    if (monto <= 0) continue;
 264
 18265                    var ci = insumos.CatalogoItems.FirstOrDefault(c => c.Codigo == ogCode.Value);
 2266                    itemsHaberes.Add(new ItemCalculado
 2267                    {
 2268                        CodigoConcepto = ogCode.Value,
 2269                        NombreConcepto = ci?.Nombre ?? comp.LoteCompensacion?.TipoCompensacion?.Nombre ?? "",
 2270                        Importe = monto
 2271                    });
 4272                    if (ci is not null) acumuladores.AgregarItem(monto, ci);
 273                }
 274
 275                // --- Paso 6: Diferencia por ascenso 042.615 ---
 37276                var diffAscenso = insumos.ObtenerDiferenciaAscenso(rl.PersonaId);
 37277                if (diffAscenso is not null && diffAscenso.Monto > 0)
 278                {
 279                    const int Cod615 = 42615;
 9280                    var ci615 = insumos.CatalogoItems.FirstOrDefault(c => c.Codigo == Cod615);
 1281                    itemsHaberes.Add(new ItemCalculado
 1282                    {
 1283                        CodigoConcepto = Cod615,
 1284                        NombreConcepto = ci615?.Nombre ?? "Diferencia por Ascenso (042.615)",
 1285                        Importe = diffAscenso.Monto
 1286                    });
 2287                    if (ci615 is not null) acumuladores.AgregarItem(diffAscenso.Monto, ci615);
 288                }
 289
 290                // --- Paso 7: Aportes legales (Montepío, FONASA, FRL) ---
 291                // baseSdlAportes = todos haberes positivos + beneficios gravados
 292                // nominalBruto   = todos haberes positivos + todos beneficios positivos
 293                // Se suma directamente desde itemsHaberes porque contribuye_w_nominal
 294                // puede no estar configurado en el catálogo (en ese caso WNominal=0).
 209295                var totalHaberesPositivos = itemsHaberes.Where(i => i.Importe > 0m).Sum(i => i.Importe);
 37296                var totalBeneficiosGravados = resultadoBeneficios.Items
 29297                    .Where(i => i.Importe > 0m
 29298                                && catalogoPorCodigo.TryGetValue(i.CodigoConcepto, out var ciBen)
 29299                                && (ciBen.EsGravado || ciBen.ContribuyeWNominal))
 58300                    .Sum(i => i.Importe);
 37301                var baseSdlAportes = totalHaberesPositivos + totalBeneficiosGravados;
 37302                var nominalBruto = totalHaberesPositivos
 90303                    + resultadoBeneficios.Items.Where(i => i.Importe > 0m).Sum(i => i.Importe);
 304
 37305                var esMilitar = !CalculadoresCiviles.Contains(rl.Situacion?.Calculador ?? string.Empty);
 37306                var aportes = await CalcularAportesLegalesAsync(rl.RegimenId, baseSdlAportes, insumos, esMilitar);
 37307                var itemsAportesLegales = CrearItemsAportesLegales(periodoId, cedula, version, aportes, catalogoPorCodig
 37308                itemsToSave.AddRange(itemsAportesLegales);
 309
 310                // --- Paso 8: IRPF ---
 311                // Eliminar registro anterior para que CalcularYPersistirAsync siempre inserte limpio
 37312                var irpfAnterior = await _context.Set<IrpfMensual>()
 37313                    .FirstOrDefaultAsync(i => i.LiquidacionId == periodoId && i.PersonaId == rl.PersonaId);
 37314                if (irpfAnterior is not null)
 315                {
 0316                    var detallesAnteriores = await _context.Set<IrpfDetalleFranjas>()
 0317                        .Where(d => d.IrpfMensualId == irpfAnterior.Id)
 0318                        .ToListAsync();
 0319                    _context.Set<IrpfDetalleFranjas>().RemoveRange(detallesAnteriores);
 0320                    _context.Set<IrpfMensual>().Remove(irpfAnterior);
 0321                    await _context.SaveChangesAsync();
 322                }
 323
 37324                var resultadoIrpf = await CalcularIrpfAsync(
 37325                    periodoId, rl.PersonaId, cedula, version,
 37326                    nominalBruto, aportes, insumos, catalogoPorCodigo);
 327
 328                // Descartar entidades IrpfMensual trackeadas si la persistencia falló,
 329                // para que no se reintentan en el SaveChangesAsync final.
 74330                foreach (var entry in _context.ChangeTracker.Entries<IrpfMensual>()
 0331                             .Where(e => e.State == Microsoft.EntityFrameworkCore.EntityState.Added)
 37332                             .ToList())
 0333                    entry.State = Microsoft.EntityFrameworkCore.EntityState.Detached;
 334
 37335                if (resultadoIrpf.Item is not null && resultadoIrpf.TotalIrpf != 0)
 3336                    itemsToSave.Add(resultadoIrpf.Item);
 337                else
 338                {
 339                    // Diagnóstico: registrar siempre el resultado del cálculo IRPF para debugging
 34340                    var tieneCatalogo = catalogoPorCodigo.ContainsKey(CodigoIrpf);
 34341                    incidenciasToSave.Add(new IncidenciaCalculo
 34342                    {
 34343                        PeriodoId = periodoId, Version = version, PersonaId = rl.PersonaId,
 34344                        Tipo = TipoIncidenciaCalculo.CalculoSimplificado,
 34345                        Descripcion = $"IRPF diag: TotalIrpf={resultadoIrpf.TotalIrpf:F2} ItemNull={resultadoIrpf.Item i
 34346                        CreadoEn = DateTime.UtcNow
 34347                    });
 348                }
 349
 350                // --- Paso 9: Descuentos personales con déficit ---
 351                // Excluir códigos ya emitidos como aportes para no contarlos dos veces en el déficit
 37352                var codigosAportesEmitidos = itemsAportesLegales
 78353                    .Where(i => !string.IsNullOrWhiteSpace(i.CodigoConcepto))
 78354                    .Select(i => i.CodigoConcepto)
 37355                    .ToHashSet(StringComparer.OrdinalIgnoreCase);
 37356                var descuentosLegalesEnBeneficios = resultadoBeneficios.Items
 29357                    .Where(i => i.Importe < 0m
 29358                                && catalogoPorCodigo.TryGetValue(i.CodigoConcepto, out var ci)
 29359                                && string.Equals(ci.Tipo, "descuento_legal", StringComparison.OrdinalIgnoreCase)
 29360                                && !codigosAportesEmitidos.Contains(i.CodigoConcepto.ToString()))
 41361                    .Sum(i => Math.Abs(i.Importe));
 37362                var totalDescuentosLegales = aportes.Total + resultadoIrpf.TotalIrpf + descuentosLegalesEnBeneficios;
 37363                var fechaPeriodo = new DateTime(insumos.ParametroPeriodo.Anio, insumos.ParametroPeriodo.Mes, 1);
 37364                var itemsDescuentosPersonales = await CrearItemsDescuentosPersonalesAsync(
 37365                    periodoId, rl.PersonaId, cedula, version,
 37366                    nominalBruto, totalDescuentosLegales, fechaPeriodo);
 37367                itemsToSave.AddRange(itemsDescuentosPersonales);
 368
 369                // --- Paso 9.5: Ajuste de redondeo ---
 370                // El liquido real (lo que el funcionario ve en el recibo) es la suma de
 371                // todos los items que se van a persistir, no `nominalBruto` (que filtra
 372                // solo positivos y se usa como base de aportes/IRPF).
 123373                var totalSinAjuste = itemsHaberes.Sum(i => i.Importe)
 29374                    + itemsBeneficios.Sum(i => i.Importe)
 78375                    + itemsAportesLegales.Sum(i => i.Importe)
 37376                    + (resultadoIrpf.Item?.Importe ?? 0m)
 47377                    + itemsDescuentosPersonales.Sum(i => i.Importe);
 378
 37379                var ajusteRedondeo = CalcularAjusteRedondeo(totalSinAjuste);
 37380                if (ajusteRedondeo != 0m && catalogoPorCodigo.TryGetValue(CodigoAjusteRedondeo, out var ciAjuste))
 4381                    itemsToSave.Add(CrearItemLiquidacion(periodoId, cedula, version, ciAjuste, ajusteRedondeo));
 382
 383                // --- Paso 10: Estado ---
 37384                var tieneError = incidenciasToSave.Any(i =>
 76385                    i.PersonaId == rl.PersonaId && i.Tipo == TipoIncidenciaCalculo.ErrorCalculo);
 37386                var liquido = nominalBruto - totalDescuentosLegales
 47387                    - itemsDescuentosPersonales.Sum(i => Math.Abs(i.Importe));
 37388                var estado = tieneError ? "ERROR" : (liquido < 0 ? "REVISAR" : "CALCULADO");
 37389                estadosPersonal[cedula] = estado;
 390
 391                // --- Paso 11: Persistir ítems de haberes y beneficios ---
 246392                foreach (var item in itemsHaberes)
 393                {
 86394                    itemsToSave.Add(new LiquidacionItem
 86395                    {
 86396                        PeriodoId = periodoId,
 86397                        Cedula = cedula,
 86398                        Version = version,
 86399                        CodigoConcepto = item.CodigoConcepto.ToString(),
 86400                        NombreConcepto = item.NombreConcepto,
 86401                        Importe = item.Importe,
 86402                        RubroContable = string.Empty,
 86403                        Rubro083 = false,
 86404                        FechaCreacion = DateTime.UtcNow
 86405                    });
 406                }
 132407                foreach (var item in itemsBeneficios)
 408                {
 29409                    itemsToSave.Add(new LiquidacionItem
 29410                    {
 29411                        PeriodoId = periodoId,
 29412                        Cedula = cedula,
 29413                        Version = version,
 29414                        CodigoConcepto = item.CodigoConcepto.ToString(),
 29415                        NombreConcepto = item.NombreConcepto,
 29416                        Importe = item.Importe,
 29417                        RubroContable = item.RubroContable,
 29418                        Rubro083 = false,
 29419                        ClaveEvento = item.ClaveEvento,
 29420                        FechaCreacion = DateTime.UtcNow
 29421                    });
 422                }
 37423            }
 1424            catch (Exception ex)
 425            {
 1426                estadosPersonal[cedula] = "ERROR";
 1427                incidenciasToSave.Add(new IncidenciaCalculo
 1428                {
 1429                    PeriodoId = periodoId,
 1430                    Version = version,
 1431                    PersonaId = rl.PersonaId,
 1432                    Tipo = TipoIncidenciaCalculo.ErrorCalculo,
 1433                    Descripcion = $"Error en pipeline de liquidación: {ex.Message}",
 1434                    CreadoEn = DateTime.UtcNow
 1435                });
 1436            }
 38437        }
 438
 75439        var estados = relacionesLaborales.Select(rl => new LiquidacionEstadoPersonal
 75440        {
 75441            PeriodoId = periodoId,
 75442            Cedula = rl.Persona.Cedula,
 75443            Version = version,
 75444            Estado = estadosPersonal.TryGetValue(rl.Persona.Cedula, out var e) ? e : "PENDIENTE",
 75445            FechaActualizacion = DateTime.UtcNow
 75446        }).ToList();
 447
 37448        await _context.LiquidacionEstadosPersonal.AddRangeAsync(estados);
 449
 37450        if (itemsToSave.Count > 0)
 36451            await _context.LiquidacionItems.AddRangeAsync(itemsToSave);
 452
 37453        if (incidenciasToSave.Count > 0)
 37454            await _context.IncidenciasCalculo.AddRangeAsync(incidenciasToSave);
 455
 247456        var totalImporte = itemsToSave.Sum(i => i.Importe);
 37457        var resumen = new LiquidacionResumen
 37458        {
 37459            PeriodoId = periodoId,
 37460            Version = version,
 37461            TotalImporte = totalImporte,
 37462            Rubro083 = false,
 37463            Descripcion = "Liquidación generada"
 37464        };
 37465        await _context.LiquidacionResumenes.AddAsync(resumen);
 466
 37467        await _context.SaveChangesAsync();
 468
 37469        await _auditoriaService.LogAuditoriaAsync(
 37470            _currentUser.UserId ?? 0,
 37471            AccionEnum.GenerarLiquidacion,
 37472            ContextoEnum.Liquidaciones,
 37473            _currentUser.Host,
 37474            entidad: nameof(LiquidacionEstadoPersonal),
 37475            entidadId: periodoId,
 37476            detalle: new { periodoId, version, registros = estados.Count, items = itemsToSave.Count, incidencias = incid
 477
 37478        return (true, null, version);
 40479    }
 480
 481    public async Task<IEnumerable<CalculoEstadoDto>> ObtenerEstadosAsync(long periodoId, int version)
 482    {
 0483        return await _context.LiquidacionEstadosPersonal
 0484            .AsNoTracking()
 0485            .Where(e => e.PeriodoId == periodoId && e.Version == version)
 0486            .Select(e => new CalculoEstadoDto
 0487            {
 0488                Cedula = e.Cedula,
 0489                Version = e.Version,
 0490                Estado = e.Estado,
 0491                MensajeError = e.MensajeError
 0492            })
 0493            .ToListAsync();
 0494    }
 495
 496    public async Task<IEnumerable<CalculoItemDto>> ObtenerItemsAsync(long periodoId, int version)
 497    {
 0498        return await _context.LiquidacionItems
 0499            .AsNoTracking()
 0500            .Where(i => i.PeriodoId == periodoId && i.Version == version)
 0501            .Select(i => new CalculoItemDto
 0502            {
 0503                Cedula = i.Cedula,
 0504                Version = i.Version,
 0505                CodigoConcepto = i.CodigoConcepto,
 0506                NombreConcepto = i.NombreConcepto,
 0507                Importe = i.Importe,
 0508                RubroContable = i.RubroContable,
 0509                ObjetoGasto = i.ObjetoGasto
 0510            })
 0511            .ToListAsync();
 0512    }
 513
 514    public async Task<IEnumerable<LiquidacionResumenDto>> ObtenerResumenAsync(long periodoId, int version)
 515    {
 0516        return await _context.LiquidacionResumenes
 0517            .AsNoTracking()
 0518            .Where(r => r.PeriodoId == periodoId && r.Version == version)
 0519            .Select(r => new LiquidacionResumenDto
 0520            {
 0521                Version = r.Version,
 0522                ProgramaId = r.ProgramaId,
 0523                RegimenId = r.RegimenId,
 0524                Rubro083 = r.Rubro083,
 0525                TotalImporte = r.TotalImporte,
 0526                Descripcion = r.Descripcion
 0527            })
 0528            .ToListAsync();
 0529    }
 530
 531    // Civiles aportan FONASA por tasa única; los militares aportan Sanidad Militar por tramos.
 1532    private static readonly HashSet<string> CalculadoresCiviles =
 1533        new(StringComparer.OrdinalIgnoreCase) { "Sit70Calculator", "CivilCalculator" };
 534
 535    private async Task<AportesLegales> CalcularAportesLegalesAsync(
 536        long regimenId, decimal baseSdl, InsumosPeriodo insumos, bool esMilitar)
 537    {
 37538        var regimen = await _context.Regimenes.AsNoTracking().FirstOrDefaultAsync(r => r.Id == regimenId);
 37539        var tasaMontepio = regimen is not null && regimen.EsLeyVieja
 37540            ? insumos.ParametroPeriodo.TasaMontepioLeyVieja
 37541            : insumos.ParametroPeriodo.TasaMontepioLeyNueva;
 542
 37543        var montepio = _descuentosLegalesCalculator.CalcularMontepio(baseSdl, tasaMontepio);
 37544        var frl = _descuentosLegalesCalculator.CalcularFrl(baseSdl, insumos.ParametroPeriodo.TasaFrl);
 545
 546        // Sanidad Militar (Art. 94 Ley 18.834) reemplaza a FONASA para militares.
 547        // Base = misma que el montepío (baseSdl). Civiles mantienen FONASA por tasa única.
 37548        var tramo = esMilitar ? insumos.ObtenerTramoSanidad(baseSdl) : null;
 37549        if (tramo is not null)
 550        {
 0551            var sanidad = _descuentosLegalesCalculator.CalcularSanidad(baseSdl, tramo.Porcentaje);
 0552            return new AportesLegales(montepio, sanidad, frl, tramo.CodigoRetencion);
 553        }
 554
 37555        var fonasa = _descuentosLegalesCalculator.CalcularFonasa(baseSdl, insumos.ParametroPeriodo.TasaFonasa);
 37556        return new AportesLegales(montepio, fonasa, frl, SanidadCodigo: null);
 37557    }
 558
 559    private async Task<ResultadoIrpfLiquidacion> CalcularIrpfAsync(
 560        long periodoId,
 561        long personaId,
 562        string cedula,
 563        int version,
 564        decimal nominalBruto,
 565        AportesLegales aportes,
 566        InsumosPeriodo insumos,
 567        IReadOnlyDictionary<int, CatalogoItem> catalogoPorCodigo)
 568    {
 37569        var request = new CalcularIrpfRequest
 37570        {
 37571            IngresoGravados = nominalBruto,
 37572            Montepio = aportes.Montepio,
 37573            Fonasa = aportes.Fonasa,
 37574            Frl = aportes.Frl,
 37575            Deducciones = await CrearDeduccionesIrpfAsync(personaId),
 37576            Parametros = new ParametrosIrpfDto
 37577            {
 37578                BpcMensual = insumos.ParametroPeriodo.BpcAnual / 12m,
 37579                UmbralDeduccionBpc = insumos.ParametroPeriodo.UmbralDeduccionBpc,
 37580                TasaDeduccionAlta = insumos.ParametroPeriodo.TasaDeduccionAlta,
 37581                TasaDeduccionBaja = insumos.ParametroPeriodo.TasaDeduccionBaja
 37582            },
 37583            Franjas = insumos.FranjasIrpf
 75584                .Select(f => new FranjaIrpfCalculoDto
 75585                {
 75586                    FranjaId = f.Id,
 75587                    NumeroFranja = f.NumeroFranja,
 75588                    DesdeBpc = f.DesdeBpc,
 75589                    HastaBpc = f.HastaBpc,
 75590                    Tasa = f.Tasa
 75591                })
 37592                .ToList()
 37593        };
 594
 37595        var (exito, idIrpf, _) = await _irpfService.CalcularYPersistirAsync(periodoId, personaId, request);
 37596        if (!exito || !idIrpf.HasValue)
 33597            return new ResultadoIrpfLiquidacion(0m, null);
 598
 4599        var irpf = await _irpfService.ObtenerPorIdAsync(idIrpf.Value);
 4600        if (irpf is null)
 0601            return new ResultadoIrpfLiquidacion(0m, null);
 602
 4603        var item = catalogoPorCodigo.TryGetValue(CodigoIrpf, out var catalogoIrpf)
 4604            ? CrearItemLiquidacion(periodoId, cedula, version, catalogoIrpf, -irpf.TotalIrpf)
 4605            : null;
 606
 4607        return new ResultadoIrpfLiquidacion(irpf.TotalIrpf, item);
 37608    }
 609
 610    private async Task<List<DeduccionDto>> CrearDeduccionesIrpfAsync(long personaId)
 611    {
 37612        var dependientes = await _dependienteRepository.GetDependientesByPersonaAsync(personaId);
 613
 37614        return dependientes
 3615            .Where(d => d.Activo)
 37616            .SelectMany(d =>
 37617            {
 3618                if (string.Equals(d.Tipo, "conyuge_sin_ingresos", StringComparison.OrdinalIgnoreCase))
 1619                    return [new DeduccionDto { Tipo = "conyuge_sin_ingresos", Cantidad = 1 }];
 37620
 2621                if (string.Equals(d.Tipo, "hijo", StringComparison.OrdinalIgnoreCase))
 37622                {
 2623                    var tipoHijo = d.Discapacitado ? "hijo_discapacidad" : "hijo_menor";
 2624                    return [new DeduccionDto { Tipo = tipoHijo, Cantidad = 1 }];
 37625                }
 37626
 0627                return Array.Empty<DeduccionDto>();
 37628            })
 37629            .ToList();
 37630    }
 631
 632    private async Task<IReadOnlyList<LiquidacionItem>> CrearItemsDescuentosPersonalesAsync(
 633        long periodoId,
 634        long personaId,
 635        string cedula,
 636        int version,
 637        decimal nominalBruto,
 638        decimal descuentosLegales,
 639        DateTime fechaPeriodo)
 640    {
 37641        var descuentosConfirmados = (await _descuentoPersonalRepository
 37642                .GetConfirmadosPorPersonaAsync(periodoId, personaId))
 10643            .Where(d => d.CatalogoItem is not null)
 37644            // Vigencia (ej. CUOTA_ING): se aplica solo si no venció antes del período
 10645            .Where(d => d.FechaHasta is null || d.FechaHasta >= fechaPeriodo)
 37646            .ToList();
 647
 37648        if (descuentosConfirmados.Count == 0)
 28649            return Array.Empty<LiquidacionItem>();
 650
 651        // Filtrar descuentos sin acreedor asignado (datos previos a la migración de acreedor_id)
 9652        var descuentosConAcreedor = descuentosConfirmados
 10653            .Where(d => d.AcreedorId is not null && d.Acreedor is not null)
 9654            .ToList();
 655
 9656        if (descuentosConAcreedor.Count == 0)
 0657            return Array.Empty<LiquidacionItem>();
 658
 9659        var entradasDeficit = descuentosConAcreedor
 10660            .Select(d => CrearEntradaDeficit(d, personaId))
 9661            .ToList();
 662
 9663        var resultadosDeficit = await _deficitCalculator.AplicarAsync(
 9664            nominalBruto,
 9665            descuentosLegales,
 9666            entradasDeficit);
 667
 19668        var descuentosPorId = descuentosConAcreedor.ToDictionary(d => d.Id);
 9669        var items = new List<LiquidacionItem>();
 670
 38671        foreach (var resultadoDeficit in resultadosDeficit)
 672        {
 10673            if (resultadoDeficit.DescuentoPeriodoId is null || resultadoDeficit.MontoAplicado <= 0m)
 674                continue;
 675
 10676            if (!descuentosPorId.TryGetValue(resultadoDeficit.DescuentoPeriodoId.Value, out var descuento))
 677                continue;
 678
 10679            items.Add(CrearItemLiquidacion(
 10680                periodoId,
 10681                cedula,
 10682                version,
 10683                descuento.CatalogoItem,
 10684                -resultadoDeficit.MontoAplicado));
 685        }
 686
 9687        return items;
 37688    }
 689
 690    private static DescuentoPersonalInput CrearEntradaDeficit(DescuentoPersonalPeriodo descuento, long personaId)
 691    {
 10692        if (descuento.AcreedorId is null)
 0693            throw new InvalidOperationException($"El descuento personal {descuento.Id} no tiene acreedor asignado.");
 694
 10695        if (descuento.Acreedor is null)
 0696            throw new InvalidOperationException($"El descuento personal {descuento.Id} no tiene acreedor cargado.");
 697
 10698        return new DescuentoPersonalInput(
 10699            descuento.Id,
 10700            personaId,
 10701            descuento.CatalogoItem.Codigo,
 10702            descuento.Importe,
 10703            descuento.AcreedorId.Value,
 10704            descuento.Acreedor.CategoriaDeficit,
 10705            descuento.Acreedor.OrdenLegalDeficit,
 10706            descuento.FechaComunicacion ?? descuento.FechaCreacion);
 707    }
 708
 709    private static IReadOnlyList<LiquidacionItem> CrearItemsAportesLegales(
 710        long periodoId,
 711        string cedula,
 712        int version,
 713        AportesLegales aportes,
 714        IReadOnlyDictionary<int, CatalogoItem> catalogoPorCodigo)
 715    {
 37716        var items = new List<LiquidacionItem>();
 717
 37718        AgregarItemAporteLegal(
 37719            items, periodoId, cedula, version, aportes.Montepio,
 37720            BuscarCatalogoItem(catalogoPorCodigo, CodigoMontepioSubalterno, 545, 555),
 37721            codigoFallback: CodigoMontepioSubalterno.ToString(),
 37722            nombreFallback: "MONTEPIO",
 37723            rubroFallback: "5.04");
 724
 725        // Salud: Sanidad Militar por tramos (código 5010/5011/5012) o FONASA por tasa única.
 37726        if (aportes.SanidadCodigo is int sanidadCodigo)
 727        {
 0728            AgregarItemAporteLegal(
 0729                items, periodoId, cedula, version, aportes.Fonasa,
 0730                BuscarCatalogoItem(catalogoPorCodigo, sanidadCodigo),
 0731                codigoFallback: sanidadCodigo.ToString(),
 0732                nombreFallback: "SANIDAD",
 0733                rubroFallback: $"5.{sanidadCodigo % 100:D2}");
 734        }
 735        else
 736        {
 37737            AgregarItemAporteLegal(
 37738                items, periodoId, cedula, version, aportes.Fonasa,
 37739                BuscarCatalogoItem(catalogoPorCodigo, CodigoSanidad, 521, 522, 523, 524, 525, 526, 527, 528),
 37740                codigoFallback: CodigoSanidad.ToString(),
 37741                nombreFallback: "FONASA",
 37742                rubroFallback: "5.010");
 743        }
 744
 37745        AgregarItemAporteLegal(
 37746            items, periodoId, cedula, version, aportes.Frl,
 37747            null,
 37748            codigoFallback: CodigoFrl,
 37749            nombreFallback: "FRL",
 37750            rubroFallback: "000.000");
 751
 37752        return items;
 753    }
 754
 755    private static void AgregarItemAporteLegal(
 756        ICollection<LiquidacionItem> items,
 757        long periodoId,
 758        string cedula,
 759        int version,
 760        decimal importe,
 761        CatalogoItem? catalogoItem,
 762        string codigoFallback,
 763        string nombreFallback,
 764        string rubroFallback)
 765    {
 111766        if (importe <= 0m)
 33767            return;
 768
 78769        if (catalogoItem is not null)
 770        {
 7771            items.Add(CrearItemLiquidacion(periodoId, cedula, version, catalogoItem, -importe));
 7772            return;
 773        }
 774
 71775        items.Add(CrearItemLiquidacion(periodoId, cedula, version, codigoFallback, nombreFallback, -importe, rubroFallba
 71776    }
 777
 778    private static CatalogoItem? BuscarCatalogoItem(
 779        IReadOnlyDictionary<int, CatalogoItem> catalogoPorCodigo,
 780        params int[] codigos)
 781    {
 973782        foreach (var codigo in codigos)
 783        {
 416784            if (catalogoPorCodigo.TryGetValue(codigo, out var catalogoItem))
 7785                return catalogoItem;
 786        }
 787
 67788        return null;
 789    }
 790
 791    private static LiquidacionItem CrearItemLiquidacion(
 792        long periodoId,
 793        string cedula,
 794        int version,
 795        CatalogoItem catalogoItem,
 796        decimal importe)
 797    {
 24798        return CrearItemLiquidacion(
 24799            periodoId, cedula, version,
 24800            catalogoItem.Codigo.ToString(),
 24801            catalogoItem.Nombre,
 24802            importe,
 24803            catalogoItem.ClasificacionSiif ?? "000.000");
 804    }
 805
 806    private static LiquidacionItem CrearItemLiquidacion(
 807        long periodoId,
 808        string cedula,
 809        int version,
 810        string codigoConcepto,
 811        string nombreConcepto,
 812        decimal importe,
 813        string rubroContable)
 814    {
 95815        return new LiquidacionItem
 95816        {
 95817            PeriodoId = periodoId,
 95818            Cedula = cedula,
 95819            Version = version,
 95820            CodigoConcepto = codigoConcepto,
 95821            NombreConcepto = nombreConcepto,
 95822            Importe = importe,
 95823            RubroContable = rubroContable,
 95824            Rubro083 = false,
 95825            FechaCreacion = DateTime.UtcNow
 95826        };
 827    }
 828
 829    /// <summary>
 830    /// Diferencia entre el redondeo al entero más cercano y <paramref name="totalSinAjuste"/>.
 831    /// Centavos &lt;= 0.50 redondean hacia abajo; centavos &gt; 0.50 redondean hacia arriba.
 832    /// La parte fraccionaria (<c>frac</c>) siempre queda en [0, 1) vía <see cref="Math.Floor(decimal)"/>,
 833    /// por lo que la regla es válida también para totales negativos.
 834    /// </summary>
 835    internal static decimal CalcularAjusteRedondeo(decimal totalSinAjuste)
 836    {
 43837        var piso = Math.Floor(totalSinAjuste);
 43838        var frac = totalSinAjuste - piso;
 43839        var redondeado = frac <= 0.50m ? piso : piso + 1m;
 43840        return redondeado - totalSinAjuste;
 841    }
 842
 843    private static decimal CalcularSueldoBaseParaTraslado(InsumosPeriodo insumos, long gradoId)
 844    {
 845        const int CodigoSueldoBase     = 11000;
 846        const int CodigoCompOficial    = 42012;
 847        const int CodigoCompSubalterno = 42022;
 848
 38849        var sueldoBase     = insumos.ObtenerRemuneracion(gradoId, CodigoSueldoBase)?.Monto ?? 0m;
 38850        var compOficial    = insumos.ObtenerRemuneracion(gradoId, CodigoCompOficial)?.Monto ?? 0m;
 38851        var compSubalterno = insumos.ObtenerRemuneracion(gradoId, CodigoCompSubalterno)?.Monto ?? 0m;
 852
 38853        return sueldoBase + compOficial + compSubalterno;
 854    }
 855
 856    // Fonasa carries el monto del aporte de salud: FONASA por tasa única (civiles) o
 857    // Sanidad Militar por tramos (militares). SanidadCodigo indica el código del tramo
 858    // aplicado (5010/5011/5012) cuando corresponde a Sanidad Militar; null para FONASA.
 407859    private sealed record AportesLegales(decimal Montepio, decimal Fonasa, decimal Frl, int? SanidadCodigo = null)
 860    {
 37861        public decimal Total => Montepio + Fonasa + Frl;
 862    }
 863
 222864    private sealed record ResultadoIrpfLiquidacion(decimal TotalIrpf, LiquidacionItem? Item);
 865}

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.Logica.Calculators.IDeficitCalculator,FAU.Logica.Services.IDescuentosLegalesCalculator,FAU.Logica.Services.IIrpfService)
GenerarLiquidacionAsync()
ObtenerEstadosAsync()
ObtenerItemsAsync()
ObtenerResumenAsync()
.cctor()
CalcularAportesLegalesAsync()
CalcularIrpfAsync()
CrearDeduccionesIrpfAsync()
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)
get_Montepio()
get_Total()
get_TotalIrpf()