< 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
88%
Covered lines: 492
Uncovered lines: 66
Coverable lines: 558
Total lines: 900
Line coverage: 88.1%
Branch coverage
80%
Covered branches: 158
Total branches: 196
Branch coverage: 80.6%
Method coverage

Feature is only available for sponsors

Upgrade to PRO version

Metrics

File(s)

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

#LineLine coverage
 1using System.Runtime.CompilerServices;
 2using FAU.DataAccess;
 3using FAU.DataAccess.Repositories;
 4using FAU.Entidades;
 5using FAU.Logica.Calculadores;
 6using FAU.Logica.Calculators;
 7using FAU.Logica.DTOs;
 8using Microsoft.EntityFrameworkCore;
 9
 10[assembly: InternalsVisibleTo("FAU.Tests")]
 11
 12namespace FAU.Logica.Services;
 13
 14public class LiquidacionService : ILiquidacionService
 15{
 16    private const int CodigoIrpf = 517;
 17    private const int CodigoMontepioSubalterno = 550;
 18    private const int CodigoSanidad = 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
 4347    public LiquidacionService(
 4348        ApplicationDbContext context,
 4349        IAuditoriaService auditoriaService,
 4350        ICurrentUserContext currentUser,
 4351        IInsumosPeriodoLoader insumosLoader,
 4352        IProcesadorBeneficiosService procesadorBeneficios,
 4353        ICalculadorFactory calculadorFactory,
 4354        ICalcularDiferencia615Service diferencia615Service,
 4355        IDescuentoPersonalRepository descuentoPersonalRepository,
 4356        IDependienteRepository dependienteRepository,
 4357        IDeficitCalculator deficitCalculator,
 4358        IDescuentosLegalesCalculator descuentosLegalesCalculator,
 4359        IIrpfService irpfService)
 60    {
 4361        _context = context;
 4362        _auditoriaService = auditoriaService;
 4363        _currentUser = currentUser;
 4364        _insumosLoader = insumosLoader;
 4365        _procesadorBeneficios = procesadorBeneficios;
 4366        _calculadorFactory = calculadorFactory;
 4367        _diferencia615Service = diferencia615Service;
 4368        _descuentoPersonalRepository = descuentoPersonalRepository;
 4369        _dependienteRepository = dependienteRepository;
 4370        _deficitCalculator = deficitCalculator;
 4371        _descuentosLegalesCalculator = descuentosLegalesCalculator;
 4372        _irpfService = irpfService;
 4373    }
 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    private async Task<int> ObtenerVersionMaximaAsync(long periodoId)
 482    {
 1483        return await _context.LiquidacionItems
 1484            .AsNoTracking()
 1485            .Where(i => i.PeriodoId == periodoId)
 1486            .MaxAsync(i => (int?)i.Version) ?? 0;
 1487    }
 488
 489    public async Task<IEnumerable<CalculoEstadoDto>> ObtenerEstadosAsync(long periodoId, int? version = null)
 490    {
 2491        var v = version ?? await ObtenerVersionMaximaAsync(periodoId);
 2492        return await _context.LiquidacionEstadosPersonal
 2493            .AsNoTracking()
 2494            .Where(e => e.PeriodoId == periodoId && e.Version == v)
 2495            .Select(e => new CalculoEstadoDto
 2496            {
 2497                Cedula = e.Cedula,
 2498                Version = e.Version,
 2499                Estado = e.Estado,
 2500                MensajeError = e.MensajeError
 2501            })
 2502            .ToListAsync();
 2503    }
 504
 505    public async Task<IEnumerable<CalculoItemDto>> ObtenerItemsAsync(long periodoId, int? version = null)
 506    {
 0507        var v = version ?? await ObtenerVersionMaximaAsync(periodoId);
 0508        return await _context.LiquidacionItems
 0509            .AsNoTracking()
 0510            .Where(i => i.PeriodoId == periodoId && i.Version == v)
 0511            .Select(i => new CalculoItemDto
 0512            {
 0513                Cedula = i.Cedula,
 0514                Version = i.Version,
 0515                CodigoConcepto = i.CodigoConcepto,
 0516                NombreConcepto = i.NombreConcepto,
 0517                Importe = i.Importe,
 0518                RubroContable = i.RubroContable,
 0519                ObjetoGasto = i.ObjetoGasto
 0520            })
 0521            .ToListAsync();
 0522    }
 523
 524    public async Task<IEnumerable<LiquidacionResumenDto>> ObtenerResumenAsync(long periodoId, int? version = null)
 525    {
 0526        var v = version ?? await ObtenerVersionMaximaAsync(periodoId);
 0527        return await _context.LiquidacionResumenes
 0528            .AsNoTracking()
 0529            .Where(r => r.PeriodoId == periodoId && r.Version == v)
 0530            .Select(r => new LiquidacionResumenDto
 0531            {
 0532                Version = r.Version,
 0533                ProgramaId = r.ProgramaId,
 0534                RegimenId = r.RegimenId,
 0535                Rubro083 = r.Rubro083,
 0536                TotalImporte = r.TotalImporte,
 0537                Descripcion = r.Descripcion
 0538            })
 0539            .ToListAsync();
 0540    }
 541
 542    public async Task<IEnumerable<int>> ObtenerVersionesAsync(long periodoId)
 543    {
 2544        return await _context.LiquidacionItems
 2545            .AsNoTracking()
 2546            .Where(i => i.PeriodoId == periodoId)
 2547            .Select(i => i.Version)
 2548            .Distinct()
 2549            .OrderByDescending(v => v)
 2550            .ToListAsync();
 2551    }
 552
 553    // Civiles aportan FONASA por tasa única; los militares aportan Sanidad Militar por tramos.
 1554    private static readonly HashSet<string> CalculadoresCiviles =
 1555        new(StringComparer.OrdinalIgnoreCase) { "Sit70Calculator", "CivilCalculator" };
 556
 557    private async Task<AportesLegales> CalcularAportesLegalesAsync(
 558        long regimenId, decimal baseSdl, InsumosPeriodo insumos, bool esMilitar)
 559    {
 37560        var regimen = await _context.Regimenes.AsNoTracking().FirstOrDefaultAsync(r => r.Id == regimenId);
 37561        var tasaMontepio = regimen is not null && regimen.EsLeyVieja
 37562            ? insumos.ParametroPeriodo.TasaMontepioLeyVieja
 37563            : insumos.ParametroPeriodo.TasaMontepioLeyNueva;
 564
 37565        var montepio = _descuentosLegalesCalculator.CalcularMontepio(baseSdl, tasaMontepio);
 37566        var frl = _descuentosLegalesCalculator.CalcularFrl(baseSdl, insumos.ParametroPeriodo.TasaFrl);
 567
 568        // Sanidad Militar (Art. 94 Ley 18.834) reemplaza a FONASA para militares.
 569        // Base = misma que el montepío (baseSdl). Civiles mantienen FONASA por tasa única.
 37570        var tramo = esMilitar ? insumos.ObtenerTramoSanidad(baseSdl) : null;
 37571        if (tramo is not null)
 572        {
 0573            var sanidad = _descuentosLegalesCalculator.CalcularSanidad(baseSdl, tramo.Porcentaje);
 0574            return new AportesLegales(montepio, sanidad, frl, tramo.CodigoRetencion);
 575        }
 576
 37577        var fonasa = _descuentosLegalesCalculator.CalcularFonasa(baseSdl, insumos.ParametroPeriodo.TasaFonasa);
 37578        return new AportesLegales(montepio, fonasa, frl, SanidadCodigo: null);
 37579    }
 580
 581    private async Task<ResultadoIrpfLiquidacion> CalcularIrpfAsync(
 582        long periodoId,
 583        long personaId,
 584        string cedula,
 585        int version,
 586        decimal nominalBruto,
 587        AportesLegales aportes,
 588        InsumosPeriodo insumos,
 589        IReadOnlyDictionary<int, CatalogoItem> catalogoPorCodigo)
 590    {
 37591        var request = new CalcularIrpfRequest
 37592        {
 37593            IngresoGravados = nominalBruto,
 37594            Montepio = aportes.Montepio,
 37595            Fonasa = aportes.Fonasa,
 37596            Frl = aportes.Frl,
 37597            Deducciones = await CrearDeduccionesIrpfAsync(personaId),
 37598            Parametros = new ParametrosIrpfDto
 37599            {
 37600                BpcMensual = insumos.ParametroPeriodo.BpcAnual / 12m,
 37601                UmbralDeduccionBpc = insumos.ParametroPeriodo.UmbralDeduccionBpc,
 37602                TasaDeduccionAlta = insumos.ParametroPeriodo.TasaDeduccionAlta,
 37603                TasaDeduccionBaja = insumos.ParametroPeriodo.TasaDeduccionBaja
 37604            },
 37605            Franjas = insumos.FranjasIrpf
 75606                .Select(f => new FranjaIrpfCalculoDto
 75607                {
 75608                    FranjaId = f.Id,
 75609                    NumeroFranja = f.NumeroFranja,
 75610                    DesdeBpc = f.DesdeBpc,
 75611                    HastaBpc = f.HastaBpc,
 75612                    Tasa = f.Tasa
 75613                })
 37614                .ToList()
 37615        };
 616
 37617        var (exito, idIrpf, _) = await _irpfService.CalcularYPersistirAsync(periodoId, personaId, request);
 37618        if (!exito || !idIrpf.HasValue)
 33619            return new ResultadoIrpfLiquidacion(0m, null);
 620
 4621        var irpf = await _irpfService.ObtenerPorIdAsync(idIrpf.Value);
 4622        if (irpf is null)
 0623            return new ResultadoIrpfLiquidacion(0m, null);
 624
 4625        var item = catalogoPorCodigo.TryGetValue(CodigoIrpf, out var catalogoIrpf)
 4626            ? CrearItemLiquidacion(periodoId, cedula, version, catalogoIrpf, -irpf.TotalIrpf)
 4627            : null;
 628
 4629        return new ResultadoIrpfLiquidacion(irpf.TotalIrpf, item);
 37630    }
 631
 632    private async Task<List<DeduccionDto>> CrearDeduccionesIrpfAsync(long personaId)
 633    {
 37634        var dependientes = await _dependienteRepository.GetDependientesByPersonaAsync(personaId);
 635
 37636        return dependientes
 3637            .Where(d => d.Activo)
 37638            .SelectMany(d =>
 37639            {
 3640                if (string.Equals(d.Tipo, "conyuge_sin_ingresos", StringComparison.OrdinalIgnoreCase))
 1641                    return [new DeduccionDto { Tipo = "conyuge_sin_ingresos", Cantidad = 1 }];
 37642
 2643                if (string.Equals(d.Tipo, "hijo", StringComparison.OrdinalIgnoreCase))
 37644                {
 2645                    var tipoHijo = d.Discapacitado ? "hijo_discapacidad" : "hijo_menor";
 2646                    return [new DeduccionDto { Tipo = tipoHijo, Cantidad = 1 }];
 37647                }
 37648
 0649                return Array.Empty<DeduccionDto>();
 37650            })
 37651            .ToList();
 37652    }
 653
 654    private async Task<IReadOnlyList<LiquidacionItem>> CrearItemsDescuentosPersonalesAsync(
 655        long periodoId,
 656        long personaId,
 657        string cedula,
 658        int version,
 659        decimal nominalBruto,
 660        decimal descuentosLegales,
 661        DateTime fechaPeriodo)
 662    {
 37663        var descuentosConfirmados = (await _descuentoPersonalRepository
 37664                .GetConfirmadosPorPersonaAsync(periodoId, personaId))
 10665            .Where(d => d.CatalogoItem is not null)
 37666            // Vigencia (ej. CUOTA_ING): se aplica solo si no venció antes del período
 10667            .Where(d => d.FechaHasta is null || d.FechaHasta >= fechaPeriodo)
 37668            .ToList();
 669
 37670        if (descuentosConfirmados.Count == 0)
 28671            return Array.Empty<LiquidacionItem>();
 672
 673        // Filtrar descuentos sin acreedor asignado (datos previos a la migración de acreedor_id)
 9674        var descuentosConAcreedor = descuentosConfirmados
 10675            .Where(d => d.AcreedorId is not null && d.Acreedor is not null)
 9676            .ToList();
 677
 9678        if (descuentosConAcreedor.Count == 0)
 0679            return Array.Empty<LiquidacionItem>();
 680
 9681        var entradasDeficit = descuentosConAcreedor
 10682            .Select(d => CrearEntradaDeficit(d, personaId))
 9683            .ToList();
 684
 9685        var resultadosDeficit = (await _deficitCalculator.AplicarAsync(
 9686            nominalBruto,
 9687            descuentosLegales,
 9688            entradasDeficit)).ToList();
 689
 19690        var descuentosPorId = descuentosConAcreedor.ToDictionary(d => d.Id);
 9691        var items = new List<LiquidacionItem>();
 692
 38693        foreach (var resultadoDeficit in resultadosDeficit)
 694        {
 10695            if (resultadoDeficit.DescuentoPeriodoId is null)
 696                continue;
 697
 10698            if (!descuentosPorId.TryGetValue(resultadoDeficit.DescuentoPeriodoId.Value, out var descuento))
 699                continue;
 700
 10701            descuento.MontoAplicado = resultadoDeficit.MontoAplicado;
 10702            descuento.MontoNoAplicado = resultadoDeficit.MontoNoAplicado;
 10703            descuento.Estado = resultadoDeficit.Estado;
 704
 10705            if (!string.IsNullOrWhiteSpace(resultadoDeficit.MotivoNoAplicado) &&
 10706                string.IsNullOrWhiteSpace(descuento.Observaciones))
 707            {
 0708                descuento.Observaciones = resultadoDeficit.MotivoNoAplicado;
 709            }
 710
 10711            if (resultadoDeficit.MontoAplicado <= 0m)
 712                continue;
 713
 10714            items.Add(CrearItemLiquidacion(
 10715                periodoId,
 10716                cedula,
 10717                version,
 10718                descuento.CatalogoItem,
 10719                -resultadoDeficit.MontoAplicado));
 720        }
 721
 9722        return items;
 37723    }
 724
 725    private static DescuentoPersonalInput CrearEntradaDeficit(DescuentoPersonalPeriodo descuento, long personaId)
 726    {
 10727        if (descuento.AcreedorId is null)
 0728            throw new InvalidOperationException($"El descuento personal {descuento.Id} no tiene acreedor asignado.");
 729
 10730        if (descuento.Acreedor is null)
 0731            throw new InvalidOperationException($"El descuento personal {descuento.Id} no tiene acreedor cargado.");
 732
 10733        return new DescuentoPersonalInput(
 10734            descuento.Id,
 10735            personaId,
 10736            descuento.CatalogoItem.Codigo,
 10737            descuento.Importe,
 10738            descuento.AcreedorId.Value,
 10739            descuento.Acreedor.CategoriaDeficit,
 10740            descuento.Acreedor.OrdenLegalDeficit,
 10741            descuento.FechaComunicacion ?? descuento.FechaCreacion);
 742    }
 743
 744    private static IReadOnlyList<LiquidacionItem> CrearItemsAportesLegales(
 745        long periodoId,
 746        string cedula,
 747        int version,
 748        AportesLegales aportes,
 749        IReadOnlyDictionary<int, CatalogoItem> catalogoPorCodigo)
 750    {
 37751        var items = new List<LiquidacionItem>();
 752
 37753        AgregarItemAporteLegal(
 37754            items, periodoId, cedula, version, aportes.Montepio,
 37755            BuscarCatalogoItem(catalogoPorCodigo, CodigoMontepioSubalterno, 545, 555),
 37756            codigoFallback: CodigoMontepioSubalterno.ToString(),
 37757            nombreFallback: "MONTEPIO",
 37758            rubroFallback: "5.04");
 759
 760        // Salud: Sanidad Militar por tramos (código 5010/5011/5012) o FONASA por tasa única.
 37761        if (aportes.SanidadCodigo is int sanidadCodigo)
 762        {
 0763            AgregarItemAporteLegal(
 0764                items, periodoId, cedula, version, aportes.Fonasa,
 0765                BuscarCatalogoItem(catalogoPorCodigo, sanidadCodigo),
 0766                codigoFallback: sanidadCodigo.ToString(),
 0767                nombreFallback: "SANIDAD",
 0768                rubroFallback: $"5.{sanidadCodigo % 100:D2}");
 769        }
 770        else
 771        {
 37772            AgregarItemAporteLegal(
 37773                items, periodoId, cedula, version, aportes.Fonasa,
 37774                BuscarCatalogoItem(catalogoPorCodigo, CodigoSanidad, 521, 522, 523, 524, 525, 526, 527, 528),
 37775                codigoFallback: CodigoSanidad.ToString(),
 37776                nombreFallback: "FONASA",
 37777                rubroFallback: "5.010");
 778        }
 779
 37780        AgregarItemAporteLegal(
 37781            items, periodoId, cedula, version, aportes.Frl,
 37782            null,
 37783            codigoFallback: CodigoFrl,
 37784            nombreFallback: "FRL",
 37785            rubroFallback: "000.000");
 786
 37787        return items;
 788    }
 789
 790    private static void AgregarItemAporteLegal(
 791        ICollection<LiquidacionItem> items,
 792        long periodoId,
 793        string cedula,
 794        int version,
 795        decimal importe,
 796        CatalogoItem? catalogoItem,
 797        string codigoFallback,
 798        string nombreFallback,
 799        string rubroFallback)
 800    {
 111801        if (importe <= 0m)
 33802            return;
 803
 78804        if (catalogoItem is not null)
 805        {
 7806            items.Add(CrearItemLiquidacion(periodoId, cedula, version, catalogoItem, -importe));
 7807            return;
 808        }
 809
 71810        items.Add(CrearItemLiquidacion(periodoId, cedula, version, codigoFallback, nombreFallback, -importe, rubroFallba
 71811    }
 812
 813    private static CatalogoItem? BuscarCatalogoItem(
 814        IReadOnlyDictionary<int, CatalogoItem> catalogoPorCodigo,
 815        params int[] codigos)
 816    {
 973817        foreach (var codigo in codigos)
 818        {
 416819            if (catalogoPorCodigo.TryGetValue(codigo, out var catalogoItem))
 7820                return catalogoItem;
 821        }
 822
 67823        return null;
 824    }
 825
 826    private static LiquidacionItem CrearItemLiquidacion(
 827        long periodoId,
 828        string cedula,
 829        int version,
 830        CatalogoItem catalogoItem,
 831        decimal importe)
 832    {
 24833        return CrearItemLiquidacion(
 24834            periodoId, cedula, version,
 24835            catalogoItem.Codigo.ToString(),
 24836            catalogoItem.Nombre,
 24837            importe,
 24838            catalogoItem.ClasificacionSiif ?? "000.000");
 839    }
 840
 841    private static LiquidacionItem CrearItemLiquidacion(
 842        long periodoId,
 843        string cedula,
 844        int version,
 845        string codigoConcepto,
 846        string nombreConcepto,
 847        decimal importe,
 848        string rubroContable)
 849    {
 95850        return new LiquidacionItem
 95851        {
 95852            PeriodoId = periodoId,
 95853            Cedula = cedula,
 95854            Version = version,
 95855            CodigoConcepto = codigoConcepto,
 95856            NombreConcepto = nombreConcepto,
 95857            Importe = importe,
 95858            RubroContable = rubroContable,
 95859            Rubro083 = false,
 95860            FechaCreacion = DateTime.UtcNow
 95861        };
 862    }
 863
 864    /// <summary>
 865    /// Diferencia entre el redondeo al entero más cercano y <paramref name="totalSinAjuste"/>.
 866    /// Centavos &lt;= 0.50 redondean hacia abajo; centavos &gt; 0.50 redondean hacia arriba.
 867    /// La parte fraccionaria (<c>frac</c>) siempre queda en [0, 1) vía <see cref="Math.Floor(decimal)"/>,
 868    /// por lo que la regla es válida también para totales negativos.
 869    /// </summary>
 870    internal static decimal CalcularAjusteRedondeo(decimal totalSinAjuste)
 871    {
 43872        var piso = Math.Floor(totalSinAjuste);
 43873        var frac = totalSinAjuste - piso;
 43874        var redondeado = frac <= 0.50m ? piso : piso + 1m;
 43875        return redondeado - totalSinAjuste;
 876    }
 877
 878    private static decimal CalcularSueldoBaseParaTraslado(InsumosPeriodo insumos, long gradoId)
 879    {
 880        const int CodigoSueldoBase     = 11000;
 881        const int CodigoCompOficial    = 42012;
 882        const int CodigoCompSubalterno = 42022;
 883
 38884        var sueldoBase     = insumos.ObtenerRemuneracion(gradoId, CodigoSueldoBase)?.Monto ?? 0m;
 38885        var compOficial    = insumos.ObtenerRemuneracion(gradoId, CodigoCompOficial)?.Monto ?? 0m;
 38886        var compSubalterno = insumos.ObtenerRemuneracion(gradoId, CodigoCompSubalterno)?.Monto ?? 0m;
 887
 38888        return sueldoBase + compOficial + compSubalterno;
 889    }
 890
 891    // Fonasa carries el monto del aporte de salud: FONASA por tasa única (civiles) o
 892    // Sanidad Militar por tramos (militares). SanidadCodigo indica el código del tramo
 893    // aplicado (5010/5011/5012) cuando corresponde a Sanidad Militar; null para FONASA.
 407894    private sealed record AportesLegales(decimal Montepio, decimal Fonasa, decimal Frl, int? SanidadCodigo = null)
 895    {
 37896        public decimal Total => Montepio + Fonasa + Frl;
 897    }
 898
 222899    private sealed record ResultadoIrpfLiquidacion(decimal TotalIrpf, LiquidacionItem? Item);
 900}

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()
ObtenerVersionMaximaAsync()
ObtenerEstadosAsync()
ObtenerItemsAsync()
ObtenerResumenAsync()
ObtenerVersionesAsync()
.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()