< 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: 506
Uncovered lines: 68
Coverable lines: 574
Total lines: 918
Line coverage: 88.1%
Branch coverage
81%
Covered branches: 162
Total branches: 200
Branch coverage: 81%
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>();
 37144        var erroresPersonal = new Dictionary<string, string>();
 145
 146        // Pre-load historico de primas únicas en una sola consulta
 75147        var cedulasPersonas = relacionesLaborales.Select(r => r.Persona.Cedula).ToHashSet();
 37148        var itemsHistoricosGlobal = await _context.LiquidacionItems
 37149            .AsNoTracking()
 37150            .Where(li => cedulasPersonas.Contains(li.Cedula) &&
 37151                         (li.CodigoConcepto == "340" || li.CodigoConcepto == "350" || li.CodigoConcepto == "370"))
 37152            .ToListAsync();
 153
 154        // Índice de catálogo por código (usado por helpers de aportes legales)
 37155        var catalogoPorCodigo = insumos.CatalogoItems
 236156            .GroupBy(i => i.Codigo)
 455157            .ToDictionary(g => g.Key, g => g.First());
 158
 150159        foreach (var rl in relacionesLaborales)
 160        {
 38161            var cedula = rl.Persona.Cedula;
 162            try
 163            {
 38164                var acumuladores = new Acumuladores();
 38165                var itemsHaberes = new List<ItemCalculado>();
 38166                var itemsBeneficios = new List<ItemBeneficioCalculado>();
 167
 168                // --- Paso 1-3: Calculador de haberes base ---
 38169                var calculador = rl.Situacion?.Calculador is not null
 38170                    ? _calculadorFactory.Resolver(rl.Situacion.Calculador)
 38171                    : null;
 172
 38173                if (calculador is not null)
 174                {
 175                    // Prorrateo por novedad 'dias_eximidos': factor = dias/30.
 176                    // Endurecido: dias_afectados debe estar en [1,30]. Fuera de rango (incl. 0)
 177                    // NO se prorratea (factor 1) y se registra incidencia, para no anular
 178                    // indebidamente los haberes ni romper el cálculo de aportes/IRPF.
 17179                    var novedadEximidos = insumos.ObtenerNovedadEximidos(rl.PersonaId);
 17180                    var factorProrrateo = 1m;
 17181                    if (novedadEximidos is not null)
 182                    {
 3183                        var diasAfectados = novedadEximidos.DiasAfectados;
 3184                        if (diasAfectados is < 1 or > 30)
 185                        {
 1186                            incidenciasToSave.Add(new IncidenciaCalculo
 1187                            {
 1188                                PeriodoId = periodoId, Version = version, PersonaId = rl.PersonaId,
 1189                                Tipo = TipoIncidenciaCalculo.CalculoSimplificado,
 1190                                Descripcion = $"Novedad 'dias_eximidos' con dias_afectados fuera de rango [1,30]: {diasA
 1191                                CreadoEn = DateTime.UtcNow
 1192                            });
 193                        }
 2194                        else if (diasAfectados < 30)
 195                        {
 2196                            factorProrrateo = diasAfectados / 30m;
 197                        }
 198                    }
 199
 17200                    var resultadoCalculo = await calculador.CalcularAsync(rl, insumos, acumuladores);
 201
 17202                    if (factorProrrateo < 1m)
 203                    {
 24204                        foreach (var item in resultadoCalculo.Items)
 10205                            item.Importe = Math.Round(item.Importe * factorProrrateo, 2);
 206
 2207                        acumuladores.Reiniciar();
 24208                        foreach (var item in resultadoCalculo.Items)
 209                        {
 58210                            var ci = insumos.CatalogoItems.FirstOrDefault(c => c.Codigo == item.CodigoConcepto);
 19211                            if (ci is not null) acumuladores.AgregarItem(item.Importe, ci);
 212                        }
 213                    }
 214
 17215                    itemsHaberes.AddRange(resultadoCalculo.Items);
 216
 34217                    foreach (var inc in resultadoCalculo.Incidencias)
 0218                        incidenciasToSave.Add(new IncidenciaCalculo
 0219                        {
 0220                            PeriodoId = periodoId, Version = version, PersonaId = rl.PersonaId,
 0221                            Tipo = TipoIncidenciaCalculo.CalculoSimplificado,
 0222                            Descripcion = inc, CreadoEn = DateTime.UtcNow
 0223                        });
 224                }
 225                else
 226                {
 21227                    incidenciasToSave.Add(new IncidenciaCalculo
 21228                    {
 21229                        PeriodoId = periodoId, Version = version, PersonaId = rl.PersonaId,
 21230                        Tipo = TipoIncidenciaCalculo.ErrorCalculo,
 21231                        Descripcion = $"Calculador no registrado: {rl.Situacion?.Calculador}",
 21232                        CreadoEn = DateTime.UtcNow
 21233                    });
 234                }
 235
 236                // --- Paso 4: Beneficios sociales ---
 38237                var beneficios = insumos.ObtenerBeneficios(rl.PersonaId);
 38238                var itemsHistoricoPersona = itemsHistoricosGlobal.Where(h => h.Cedula == cedula).ToList();
 38239                var sueldoBasePersona = CalcularSueldoBaseParaTraslado(insumos, rl.GradoId);
 38240                var resultadoBeneficios = _procesadorBeneficios.ProcesarBeneficios(
 38241                    rl.PersonaId, beneficios, insumos.ParametroPeriodo, insumos.CatalogoItems,
 38242                    itemsHistoricoPersona, primerDiaPeriodo,
 38243                    insumos.BeneficiariosPorBeneficio, insumos.InstructivoAsigFamiliar,
 38244                    insumos.InstructivoPsf, insumos.TablaPorHogar,
 38245                    sueldoBaseParaTraslado: sueldoBasePersona);
 37246                itemsBeneficios.AddRange(resultadoBeneficios.Items);
 247
 74248                foreach (var inc in resultadoBeneficios.Incidencias)
 0249                    incidenciasToSave.Add(new IncidenciaCalculo
 0250                    {
 0251                        PeriodoId = periodoId, Version = version, PersonaId = rl.PersonaId,
 0252                        Tipo = inc.Tipo, Descripcion = inc.Descripcion, CreadoEn = DateTime.UtcNow
 0253                    });
 254
 255                // --- Paso 5: Compensaciones por lote ---
 256                // El OG viene de ConfigCompensacion.CatalogoItem (no hardcodeado)
 37257                var compensaciones = insumos.ObtenerCompensaciones(rl.PersonaId);
 78258                foreach (var comp in compensaciones)
 259                {
 2260                    var ogCode = ObtenerOGCompensacion(comp);
 2261                    if (ogCode is null) continue;   // sin config de OG → saltear
 262
 2263                    var monto = comp.Monto ?? 0m;
 2264                    if (monto <= 0) continue;
 265
 18266                    var ci = insumos.CatalogoItems.FirstOrDefault(c => c.Codigo == ogCode.Value);
 2267                    itemsHaberes.Add(new ItemCalculado
 2268                    {
 2269                        CodigoConcepto = ogCode.Value,
 2270                        NombreConcepto = ci?.Nombre ?? comp.LoteCompensacion?.TipoCompensacion?.Nombre ?? "",
 2271                        Importe = monto
 2272                    });
 4273                    if (ci is not null) acumuladores.AgregarItem(monto, ci);
 274                }
 275
 276                // --- Paso 6: Diferencia por ascenso 042.615 ---
 37277                var diffAscenso = insumos.ObtenerDiferenciaAscenso(rl.PersonaId);
 37278                if (diffAscenso is not null && diffAscenso.Monto > 0)
 279                {
 280                    const int Cod615 = 42615;
 9281                    var ci615 = insumos.CatalogoItems.FirstOrDefault(c => c.Codigo == Cod615);
 1282                    itemsHaberes.Add(new ItemCalculado
 1283                    {
 1284                        CodigoConcepto = Cod615,
 1285                        NombreConcepto = ci615?.Nombre ?? "Diferencia por Ascenso (042.615)",
 1286                        Importe = diffAscenso.Monto
 1287                    });
 2288                    if (ci615 is not null) acumuladores.AgregarItem(diffAscenso.Monto, ci615);
 289                }
 290
 291                // --- Paso 7: Aportes legales (Montepío, FONASA, FRL) ---
 292                // baseSdlAportes = todos haberes positivos + beneficios gravados
 293                // nominalBruto   = todos haberes positivos + todos beneficios positivos
 294                // Se suma directamente desde itemsHaberes porque contribuye_w_nominal
 295                // puede no estar configurado en el catálogo (en ese caso WNominal=0).
 209296                var totalHaberesPositivos = itemsHaberes.Where(i => i.Importe > 0m).Sum(i => i.Importe);
 37297                var totalBeneficiosGravados = resultadoBeneficios.Items
 29298                    .Where(i => i.Importe > 0m
 29299                                && catalogoPorCodigo.TryGetValue(i.CodigoConcepto, out var ciBen)
 29300                                && (ciBen.EsGravado || ciBen.ContribuyeWNominal))
 58301                    .Sum(i => i.Importe);
 37302                var baseSdlAportes = totalHaberesPositivos + totalBeneficiosGravados;
 37303                var nominalBruto = totalHaberesPositivos
 90304                    + resultadoBeneficios.Items.Where(i => i.Importe > 0m).Sum(i => i.Importe);
 305
 37306                var esMilitar = !CalculadoresCiviles.Contains(rl.Situacion?.Calculador ?? string.Empty);
 37307                var aportes = await CalcularAportesLegalesAsync(rl.RegimenId, baseSdlAportes, insumos, esMilitar);
 37308                var itemsAportesLegales = CrearItemsAportesLegales(periodoId, cedula, version, aportes, catalogoPorCodig
 37309                itemsToSave.AddRange(itemsAportesLegales);
 310
 311                // --- Paso 8: IRPF ---
 312                // Eliminar registro anterior para que CalcularYPersistirAsync siempre inserte limpio
 37313                var irpfAnterior = await _context.Set<IrpfMensual>()
 37314                    .FirstOrDefaultAsync(i => i.LiquidacionId == periodoId && i.PersonaId == rl.PersonaId);
 37315                if (irpfAnterior is not null)
 316                {
 0317                    var detallesAnteriores = await _context.Set<IrpfDetalleFranjas>()
 0318                        .Where(d => d.IrpfMensualId == irpfAnterior.Id)
 0319                        .ToListAsync();
 0320                    _context.Set<IrpfDetalleFranjas>().RemoveRange(detallesAnteriores);
 0321                    _context.Set<IrpfMensual>().Remove(irpfAnterior);
 0322                    await _context.SaveChangesAsync();
 323                }
 324
 37325                var resultadoIrpf = await CalcularIrpfAsync(
 37326                    periodoId, rl.PersonaId, cedula, version,
 37327                    nominalBruto, aportes, insumos, catalogoPorCodigo);
 328
 329                // Descartar entidades IrpfMensual trackeadas si la persistencia falló,
 330                // para que no se reintentan en el SaveChangesAsync final.
 74331                foreach (var entry in _context.ChangeTracker.Entries<IrpfMensual>()
 0332                             .Where(e => e.State == Microsoft.EntityFrameworkCore.EntityState.Added)
 37333                             .ToList())
 0334                    entry.State = Microsoft.EntityFrameworkCore.EntityState.Detached;
 335
 37336                if (resultadoIrpf.Item is not null && resultadoIrpf.TotalIrpf != 0)
 3337                    itemsToSave.Add(resultadoIrpf.Item);
 338                else
 339                {
 340                    // Diagnóstico: registrar siempre el resultado del cálculo IRPF para debugging
 34341                    var tieneCatalogo = catalogoPorCodigo.ContainsKey(CodigoIrpf);
 34342                    incidenciasToSave.Add(new IncidenciaCalculo
 34343                    {
 34344                        PeriodoId = periodoId, Version = version, PersonaId = rl.PersonaId,
 34345                        Tipo = TipoIncidenciaCalculo.CalculoSimplificado,
 34346                        Descripcion = $"IRPF diag: TotalIrpf={resultadoIrpf.TotalIrpf:F2} ItemNull={resultadoIrpf.Item i
 34347                        CreadoEn = DateTime.UtcNow
 34348                    });
 349                }
 350
 351                // --- Paso 9: Descuentos personales con déficit ---
 352                // Excluir códigos ya emitidos como aportes para no contarlos dos veces en el déficit
 37353                var codigosAportesEmitidos = itemsAportesLegales
 78354                    .Where(i => !string.IsNullOrWhiteSpace(i.CodigoConcepto))
 78355                    .Select(i => i.CodigoConcepto)
 37356                    .ToHashSet(StringComparer.OrdinalIgnoreCase);
 37357                var descuentosLegalesEnBeneficios = resultadoBeneficios.Items
 29358                    .Where(i => i.Importe < 0m
 29359                                && catalogoPorCodigo.TryGetValue(i.CodigoConcepto, out var ci)
 29360                                && string.Equals(ci.Tipo, "descuento_legal", StringComparison.OrdinalIgnoreCase)
 29361                                && !codigosAportesEmitidos.Contains(i.CodigoConcepto.ToString()))
 41362                    .Sum(i => Math.Abs(i.Importe));
 37363                var totalDescuentosLegales = aportes.Total + resultadoIrpf.TotalIrpf + descuentosLegalesEnBeneficios;
 37364                var fechaPeriodo = new DateTime(insumos.ParametroPeriodo.Anio, insumos.ParametroPeriodo.Mes, 1);
 37365                var itemsDescuentosPersonales = await CrearItemsDescuentosPersonalesAsync(
 37366                    periodoId, rl.PersonaId, cedula, version,
 37367                    nominalBruto, totalDescuentosLegales, fechaPeriodo);
 37368                itemsToSave.AddRange(itemsDescuentosPersonales);
 369
 370                // --- Paso 9.5: Ajuste de redondeo ---
 371                // El liquido real (lo que el funcionario ve en el recibo) es la suma de
 372                // todos los items que se van a persistir, no `nominalBruto` (que filtra
 373                // solo positivos y se usa como base de aportes/IRPF).
 123374                var totalSinAjuste = itemsHaberes.Sum(i => i.Importe)
 29375                    + itemsBeneficios.Sum(i => i.Importe)
 78376                    + itemsAportesLegales.Sum(i => i.Importe)
 37377                    + (resultadoIrpf.Item?.Importe ?? 0m)
 47378                    + itemsDescuentosPersonales.Sum(i => i.Importe);
 379
 37380                var ajusteRedondeo = CalcularAjusteRedondeo(totalSinAjuste);
 37381                if (ajusteRedondeo != 0m && catalogoPorCodigo.TryGetValue(CodigoAjusteRedondeo, out var ciAjuste))
 4382                    itemsToSave.Add(CrearItemLiquidacion(periodoId, cedula, version, ciAjuste, ajusteRedondeo));
 383
 384                // --- Paso 10: Estado ---
 37385                var tieneError = incidenciasToSave.Any(i =>
 76386                    i.PersonaId == rl.PersonaId && i.Tipo == TipoIncidenciaCalculo.ErrorCalculo);
 37387                var liquido = nominalBruto - totalDescuentosLegales
 47388                    - itemsDescuentosPersonales.Sum(i => Math.Abs(i.Importe));
 37389                var estado = tieneError ? "ERROR" : (liquido < 0 ? "REVISAR" : "CALCULADO");
 37390                estadosPersonal[cedula] = estado;
 391
 392                // --- Paso 11: Persistir ítems de haberes y beneficios ---
 246393                foreach (var item in itemsHaberes)
 394                {
 86395                    itemsToSave.Add(new LiquidacionItem
 86396                    {
 86397                        PeriodoId = periodoId,
 86398                        Cedula = cedula,
 86399                        Version = version,
 86400                        CodigoConcepto = item.CodigoConcepto.ToString(),
 86401                        NombreConcepto = item.NombreConcepto,
 86402                        Importe = item.Importe,
 86403                        RubroContable = string.Empty,
 86404                        Rubro083 = false,
 86405                        FechaCreacion = DateTime.UtcNow
 86406                    });
 407                }
 132408                foreach (var item in itemsBeneficios)
 409                {
 29410                    itemsToSave.Add(new LiquidacionItem
 29411                    {
 29412                        PeriodoId = periodoId,
 29413                        Cedula = cedula,
 29414                        Version = version,
 29415                        CodigoConcepto = item.CodigoConcepto.ToString(),
 29416                        NombreConcepto = item.NombreConcepto,
 29417                        Importe = item.Importe,
 29418                        RubroContable = item.RubroContable,
 29419                        Rubro083 = false,
 29420                        ClaveEvento = item.ClaveEvento,
 29421                        FechaCreacion = DateTime.UtcNow
 29422                    });
 423                }
 37424            }
 1425            catch (Exception ex)
 426            {
 1427                estadosPersonal[cedula] = "ERROR";
 1428                erroresPersonal[cedula] = ex.Message;
 1429                incidenciasToSave.Add(new IncidenciaCalculo
 1430                {
 1431                    PeriodoId = periodoId,
 1432                    Version = version,
 1433                    PersonaId = rl.PersonaId,
 1434                    Tipo = TipoIncidenciaCalculo.ErrorCalculo,
 1435                    Descripcion = $"Error en pipeline de liquidación: {ex.Message}",
 1436                    CreadoEn = DateTime.UtcNow
 1437                });
 1438            }
 38439        }
 440
 75441        var estados = relacionesLaborales.Select(rl => new LiquidacionEstadoPersonal
 75442        {
 75443            PeriodoId = periodoId,
 75444            Cedula = rl.Persona.Cedula,
 75445            Version = version,
 75446            Estado = estadosPersonal.TryGetValue(rl.Persona.Cedula, out var e) ? e : "PENDIENTE",
 75447            MensajeError = erroresPersonal.TryGetValue(rl.Persona.Cedula, out var err) ? err : null,
 75448            FechaActualizacion = DateTime.UtcNow
 75449        }).ToList();
 450
 37451        await _context.LiquidacionEstadosPersonal.AddRangeAsync(estados);
 452
 37453        if (itemsToSave.Count > 0)
 36454            await _context.LiquidacionItems.AddRangeAsync(itemsToSave);
 455
 37456        if (incidenciasToSave.Count > 0)
 37457            await _context.IncidenciasCalculo.AddRangeAsync(incidenciasToSave);
 458
 247459        var totalImporte = itemsToSave.Sum(i => i.Importe);
 37460        var resumen = new LiquidacionResumen
 37461        {
 37462            PeriodoId = periodoId,
 37463            Version = version,
 37464            TotalImporte = totalImporte,
 37465            Rubro083 = false,
 37466            Descripcion = "Liquidación generada"
 37467        };
 37468        await _context.LiquidacionResumenes.AddAsync(resumen);
 469
 37470        await _context.SaveChangesAsync();
 471
 37472        await _auditoriaService.LogAuditoriaAsync(
 37473            _currentUser.UserId ?? 0,
 37474            AccionEnum.GenerarLiquidacion,
 37475            ContextoEnum.Liquidaciones,
 37476            _currentUser.Host,
 37477            entidad: nameof(LiquidacionEstadoPersonal),
 37478            entidadId: periodoId,
 37479            detalle: new { periodoId, version, registros = estados.Count, items = itemsToSave.Count, incidencias = incid
 480
 37481        return (true, null, version);
 40482    }
 483
 484    private async Task<int> ObtenerVersionMaximaAsync(long periodoId)
 485    {
 1486        return await _context.LiquidacionItems
 1487            .AsNoTracking()
 1488            .Where(i => i.PeriodoId == periodoId)
 1489            .MaxAsync(i => (int?)i.Version) ?? 0;
 1490    }
 491
 492    public async Task<IEnumerable<CalculoEstadoDto>> ObtenerEstadosAsync(long periodoId, int? version = null)
 493    {
 2494        var v = version ?? await ObtenerVersionMaximaAsync(periodoId);
 495
 2496        var totalesPorCedula = await _context.LiquidacionItems
 2497            .AsNoTracking()
 2498            .Where(i => i.PeriodoId == periodoId && i.Version == v)
 2499            .GroupBy(i => i.Cedula)
 2500            .Select(g => new
 2501            {
 2502                Cedula = g.Key,
 2503                Nominal    = g.Where(i => i.Importe > 0).Sum(i => i.Importe),
 2504                Descuentos = g.Where(i => i.Importe < 0).Sum(i => i.Importe),
 2505            })
 3506            .ToDictionaryAsync(x => x.Cedula);
 507
 2508        var estados = await _context.LiquidacionEstadosPersonal
 2509            .AsNoTracking()
 2510            .Where(e => e.PeriodoId == periodoId && e.Version == v)
 2511            .Select(e => new CalculoEstadoDto
 2512            {
 2513                Cedula       = e.Cedula,
 2514                Version      = e.Version,
 2515                Estado       = e.Estado,
 2516                MensajeError = e.MensajeError
 2517            })
 2518            .ToListAsync();
 519
 8520        foreach (var dto in estados)
 2521            if (totalesPorCedula.TryGetValue(dto.Cedula, out var tot))
 522            {
 1523                dto.Nominal    = tot.Nominal;
 1524                dto.Descuentos = tot.Descuentos;
 1525                dto.Liquido    = tot.Nominal + tot.Descuentos;
 526            }
 527
 2528        return estados;
 2529    }
 530
 531    public async Task<IEnumerable<CalculoItemDto>> ObtenerItemsAsync(long periodoId, int? version = null, string? cedula
 532    {
 0533        var v = version ?? await ObtenerVersionMaximaAsync(periodoId);
 0534        var query = _context.LiquidacionItems
 0535            .AsNoTracking()
 0536            .Where(i => i.PeriodoId == periodoId && i.Version == v);
 537
 0538        if (!string.IsNullOrEmpty(cedula))
 0539            query = query.Where(i => i.Cedula == cedula);
 540
 0541        return await query
 0542            .Select(i => new CalculoItemDto
 0543            {
 0544                Cedula         = i.Cedula,
 0545                Version        = i.Version,
 0546                CodigoConcepto = i.CodigoConcepto,
 0547                NombreConcepto = i.NombreConcepto,
 0548                Importe        = i.Importe,
 0549                RubroContable  = i.RubroContable,
 0550                ObjetoGasto    = i.ObjetoGasto
 0551            })
 0552            .ToListAsync();
 0553    }
 554
 555    public async Task<IEnumerable<LiquidacionResumenDto>> ObtenerResumenAsync(long periodoId, int? version = null)
 556    {
 0557        var v = version ?? await ObtenerVersionMaximaAsync(periodoId);
 0558        return await _context.LiquidacionResumenes
 0559            .AsNoTracking()
 0560            .Where(r => r.PeriodoId == periodoId && r.Version == v)
 0561            .Select(r => new LiquidacionResumenDto
 0562            {
 0563                Version = r.Version,
 0564                ProgramaId = r.ProgramaId,
 0565                RegimenId = r.RegimenId,
 0566                Rubro083 = r.Rubro083,
 0567                TotalImporte = r.TotalImporte,
 0568                Descripcion = r.Descripcion
 0569            })
 0570            .ToListAsync();
 0571    }
 572
 573    public async Task<IEnumerable<int>> ObtenerVersionesAsync(long periodoId)
 574    {
 2575        return await _context.LiquidacionItems
 2576            .AsNoTracking()
 2577            .Where(i => i.PeriodoId == periodoId)
 2578            .Select(i => i.Version)
 2579            .Distinct()
 2580            .OrderByDescending(v => v)
 2581            .ToListAsync();
 2582    }
 583
 584    // Civiles aportan FONASA por tasa única; los militares aportan Sanidad Militar por tramos.
 1585    private static readonly HashSet<string> CalculadoresCiviles =
 1586        new(StringComparer.OrdinalIgnoreCase) { "Sit70Calculator", "CivilCalculator" };
 587
 588    private async Task<AportesLegales> CalcularAportesLegalesAsync(
 589        long regimenId, decimal baseSdl, InsumosPeriodo insumos, bool esMilitar)
 590    {
 37591        var regimen = await _context.Regimenes.AsNoTracking().FirstOrDefaultAsync(r => r.Id == regimenId);
 37592        var tasaMontepio = regimen is not null && regimen.EsLeyVieja
 37593            ? insumos.ParametroPeriodo.TasaMontepioLeyVieja
 37594            : insumos.ParametroPeriodo.TasaMontepioLeyNueva;
 595
 37596        var montepio = _descuentosLegalesCalculator.CalcularMontepio(baseSdl, tasaMontepio);
 37597        var frl = _descuentosLegalesCalculator.CalcularFrl(baseSdl, insumos.ParametroPeriodo.TasaFrl);
 598
 599        // Sanidad Militar (Art. 94 Ley 18.834) reemplaza a FONASA para militares.
 600        // Base = misma que el montepío (baseSdl). Civiles mantienen FONASA por tasa única.
 37601        var tramo = esMilitar ? insumos.ObtenerTramoSanidad(baseSdl) : null;
 37602        if (tramo is not null)
 603        {
 0604            var sanidad = _descuentosLegalesCalculator.CalcularSanidad(baseSdl, tramo.Porcentaje);
 0605            return new AportesLegales(montepio, sanidad, frl, tramo.CodigoRetencion);
 606        }
 607
 37608        var fonasa = _descuentosLegalesCalculator.CalcularFonasa(baseSdl, insumos.ParametroPeriodo.TasaFonasa);
 37609        return new AportesLegales(montepio, fonasa, frl, SanidadCodigo: null);
 37610    }
 611
 612    private async Task<ResultadoIrpfLiquidacion> CalcularIrpfAsync(
 613        long periodoId,
 614        long personaId,
 615        string cedula,
 616        int version,
 617        decimal nominalBruto,
 618        AportesLegales aportes,
 619        InsumosPeriodo insumos,
 620        IReadOnlyDictionary<int, CatalogoItem> catalogoPorCodigo)
 621    {
 37622        var request = new CalcularIrpfRequest
 37623        {
 37624            IngresoGravados = nominalBruto,
 37625            Montepio = aportes.Montepio,
 37626            Fonasa = aportes.Fonasa,
 37627            Frl = aportes.Frl,
 37628            Deducciones = await CrearDeduccionesIrpfAsync(personaId),
 37629            Parametros = new ParametrosIrpfDto
 37630            {
 37631                BpcMensual = insumos.ParametroPeriodo.BpcAnual / 12m,
 37632                UmbralDeduccionBpc = insumos.ParametroPeriodo.UmbralDeduccionBpc,
 37633                TasaDeduccionAlta = insumos.ParametroPeriodo.TasaDeduccionAlta,
 37634                TasaDeduccionBaja = insumos.ParametroPeriodo.TasaDeduccionBaja
 37635            },
 37636            Franjas = insumos.FranjasIrpf
 75637                .Select(f => new FranjaIrpfCalculoDto
 75638                {
 75639                    FranjaId = f.Id,
 75640                    NumeroFranja = f.NumeroFranja,
 75641                    DesdeBpc = f.DesdeBpc,
 75642                    HastaBpc = f.HastaBpc,
 75643                    Tasa = f.Tasa
 75644                })
 37645                .ToList()
 37646        };
 647
 37648        var (exito, idIrpf, _) = await _irpfService.CalcularYPersistirAsync(periodoId, personaId, request);
 37649        if (!exito || !idIrpf.HasValue)
 33650            return new ResultadoIrpfLiquidacion(0m, null);
 651
 4652        var irpf = await _irpfService.ObtenerPorIdAsync(idIrpf.Value);
 4653        if (irpf is null)
 0654            return new ResultadoIrpfLiquidacion(0m, null);
 655
 4656        var item = catalogoPorCodigo.TryGetValue(CodigoIrpf, out var catalogoIrpf)
 4657            ? CrearItemLiquidacion(periodoId, cedula, version, catalogoIrpf, -irpf.TotalIrpf)
 4658            : null;
 659
 4660        return new ResultadoIrpfLiquidacion(irpf.TotalIrpf, item);
 37661    }
 662
 663    private async Task<List<DeduccionDto>> CrearDeduccionesIrpfAsync(long personaId)
 664    {
 37665        var dependientes = await _dependienteRepository.GetDependientesByPersonaAsync(personaId);
 666
 37667        return dependientes
 3668            .Where(d => d.Activo)
 37669            .SelectMany(d =>
 37670            {
 3671                if (string.Equals(d.Tipo, "conyuge_sin_ingresos", StringComparison.OrdinalIgnoreCase))
 1672                    return [new DeduccionDto { Tipo = "conyuge_sin_ingresos", Cantidad = 1 }];
 37673
 2674                if (string.Equals(d.Tipo, "hijo", StringComparison.OrdinalIgnoreCase))
 37675                {
 2676                    var tipoHijo = d.Discapacitado ? "hijo_discapacidad" : "hijo_menor";
 2677                    return [new DeduccionDto { Tipo = tipoHijo, Cantidad = 1 }];
 37678                }
 37679
 0680                return Array.Empty<DeduccionDto>();
 37681            })
 37682            .ToList();
 37683    }
 684
 685    private async Task<IReadOnlyList<LiquidacionItem>> CrearItemsDescuentosPersonalesAsync(
 686        long periodoId,
 687        long personaId,
 688        string cedula,
 689        int version,
 690        decimal nominalBruto,
 691        decimal descuentosLegales,
 692        DateTime fechaPeriodo)
 693    {
 37694        var descuentosConfirmados = (await _descuentoPersonalRepository
 37695                .GetConfirmadosPorPersonaAsync(periodoId, personaId))
 10696            .Where(d => d.CatalogoItem is not null)
 37697            // Vigencia (ej. CUOTA_ING): se aplica solo si no venció antes del período
 10698            .Where(d => d.FechaHasta is null || d.FechaHasta >= fechaPeriodo)
 37699            .ToList();
 700
 37701        if (descuentosConfirmados.Count == 0)
 28702            return Array.Empty<LiquidacionItem>();
 703
 704        // Filtrar descuentos sin acreedor asignado (datos previos a la migración de acreedor_id)
 9705        var descuentosConAcreedor = descuentosConfirmados
 10706            .Where(d => d.AcreedorId is not null && d.Acreedor is not null)
 9707            .ToList();
 708
 9709        if (descuentosConAcreedor.Count == 0)
 0710            return Array.Empty<LiquidacionItem>();
 711
 9712        var entradasDeficit = descuentosConAcreedor
 10713            .Select(d => CrearEntradaDeficit(d, personaId))
 9714            .ToList();
 715
 9716        var resultadosDeficit = await _deficitCalculator.AplicarAsync(
 9717            nominalBruto,
 9718            descuentosLegales,
 9719            entradasDeficit);
 720
 19721        var descuentosPorId = descuentosConAcreedor.ToDictionary(d => d.Id);
 9722        var items = new List<LiquidacionItem>();
 723
 38724        foreach (var resultadoDeficit in resultadosDeficit)
 725        {
 10726            if (resultadoDeficit.DescuentoPeriodoId is null || resultadoDeficit.MontoAplicado <= 0m)
 727                continue;
 728
 10729            if (!descuentosPorId.TryGetValue(resultadoDeficit.DescuentoPeriodoId.Value, out var descuento))
 730                continue;
 731
 10732            items.Add(CrearItemLiquidacion(
 10733                periodoId,
 10734                cedula,
 10735                version,
 10736                descuento.CatalogoItem,
 10737                -resultadoDeficit.MontoAplicado));
 738        }
 739
 9740        return items;
 37741    }
 742
 743    private static DescuentoPersonalInput CrearEntradaDeficit(DescuentoPersonalPeriodo descuento, long personaId)
 744    {
 10745        if (descuento.AcreedorId is null)
 0746            throw new InvalidOperationException($"El descuento personal {descuento.Id} no tiene acreedor asignado.");
 747
 10748        if (descuento.Acreedor is null)
 0749            throw new InvalidOperationException($"El descuento personal {descuento.Id} no tiene acreedor cargado.");
 750
 10751        return new DescuentoPersonalInput(
 10752            descuento.Id,
 10753            personaId,
 10754            descuento.CatalogoItem.Codigo,
 10755            descuento.Importe,
 10756            descuento.AcreedorId.Value,
 10757            descuento.Acreedor.CategoriaDeficit,
 10758            descuento.Acreedor.OrdenLegalDeficit,
 10759            descuento.FechaComunicacion ?? descuento.FechaCreacion);
 760    }
 761
 762    private static IReadOnlyList<LiquidacionItem> CrearItemsAportesLegales(
 763        long periodoId,
 764        string cedula,
 765        int version,
 766        AportesLegales aportes,
 767        IReadOnlyDictionary<int, CatalogoItem> catalogoPorCodigo)
 768    {
 37769        var items = new List<LiquidacionItem>();
 770
 37771        AgregarItemAporteLegal(
 37772            items, periodoId, cedula, version, aportes.Montepio,
 37773            BuscarCatalogoItem(catalogoPorCodigo, CodigoMontepioSubalterno, 545, 555),
 37774            codigoFallback: CodigoMontepioSubalterno.ToString(),
 37775            nombreFallback: "MONTEPIO",
 37776            rubroFallback: "5.04");
 777
 778        // Salud: Sanidad Militar por tramos (código 5010/5011/5012) o FONASA por tasa única.
 37779        if (aportes.SanidadCodigo is int sanidadCodigo)
 780        {
 0781            AgregarItemAporteLegal(
 0782                items, periodoId, cedula, version, aportes.Fonasa,
 0783                BuscarCatalogoItem(catalogoPorCodigo, sanidadCodigo),
 0784                codigoFallback: sanidadCodigo.ToString(),
 0785                nombreFallback: "SANIDAD",
 0786                rubroFallback: $"5.{sanidadCodigo % 100:D2}");
 787        }
 788        else
 789        {
 37790            AgregarItemAporteLegal(
 37791                items, periodoId, cedula, version, aportes.Fonasa,
 37792                BuscarCatalogoItem(catalogoPorCodigo, CodigoSanidad, 521, 522, 523, 524, 525, 526, 527, 528),
 37793                codigoFallback: CodigoSanidad.ToString(),
 37794                nombreFallback: "FONASA",
 37795                rubroFallback: "5.010");
 796        }
 797
 37798        AgregarItemAporteLegal(
 37799            items, periodoId, cedula, version, aportes.Frl,
 37800            null,
 37801            codigoFallback: CodigoFrl,
 37802            nombreFallback: "FRL",
 37803            rubroFallback: "000.000");
 804
 37805        return items;
 806    }
 807
 808    private static void AgregarItemAporteLegal(
 809        ICollection<LiquidacionItem> items,
 810        long periodoId,
 811        string cedula,
 812        int version,
 813        decimal importe,
 814        CatalogoItem? catalogoItem,
 815        string codigoFallback,
 816        string nombreFallback,
 817        string rubroFallback)
 818    {
 111819        if (importe <= 0m)
 33820            return;
 821
 78822        if (catalogoItem is not null)
 823        {
 7824            items.Add(CrearItemLiquidacion(periodoId, cedula, version, catalogoItem, -importe));
 7825            return;
 826        }
 827
 71828        items.Add(CrearItemLiquidacion(periodoId, cedula, version, codigoFallback, nombreFallback, -importe, rubroFallba
 71829    }
 830
 831    private static CatalogoItem? BuscarCatalogoItem(
 832        IReadOnlyDictionary<int, CatalogoItem> catalogoPorCodigo,
 833        params int[] codigos)
 834    {
 973835        foreach (var codigo in codigos)
 836        {
 416837            if (catalogoPorCodigo.TryGetValue(codigo, out var catalogoItem))
 7838                return catalogoItem;
 839        }
 840
 67841        return null;
 842    }
 843
 844    private static LiquidacionItem CrearItemLiquidacion(
 845        long periodoId,
 846        string cedula,
 847        int version,
 848        CatalogoItem catalogoItem,
 849        decimal importe)
 850    {
 24851        return CrearItemLiquidacion(
 24852            periodoId, cedula, version,
 24853            catalogoItem.Codigo.ToString(),
 24854            catalogoItem.Nombre,
 24855            importe,
 24856            catalogoItem.ClasificacionSiif ?? "000.000");
 857    }
 858
 859    private static LiquidacionItem CrearItemLiquidacion(
 860        long periodoId,
 861        string cedula,
 862        int version,
 863        string codigoConcepto,
 864        string nombreConcepto,
 865        decimal importe,
 866        string rubroContable)
 867    {
 95868        return new LiquidacionItem
 95869        {
 95870            PeriodoId = periodoId,
 95871            Cedula = cedula,
 95872            Version = version,
 95873            CodigoConcepto = codigoConcepto,
 95874            NombreConcepto = nombreConcepto,
 95875            Importe = importe,
 95876            RubroContable = rubroContable,
 95877            Rubro083 = false,
 95878            FechaCreacion = DateTime.UtcNow
 95879        };
 880    }
 881
 882    /// <summary>
 883    /// Diferencia entre el redondeo al entero más cercano y <paramref name="totalSinAjuste"/>.
 884    /// Centavos &lt;= 0.50 redondean hacia abajo; centavos &gt; 0.50 redondean hacia arriba.
 885    /// La parte fraccionaria (<c>frac</c>) siempre queda en [0, 1) vía <see cref="Math.Floor(decimal)"/>,
 886    /// por lo que la regla es válida también para totales negativos.
 887    /// </summary>
 888    internal static decimal CalcularAjusteRedondeo(decimal totalSinAjuste)
 889    {
 43890        var piso = Math.Floor(totalSinAjuste);
 43891        var frac = totalSinAjuste - piso;
 43892        var redondeado = frac <= 0.50m ? piso : piso + 1m;
 43893        return redondeado - totalSinAjuste;
 894    }
 895
 896    private static decimal CalcularSueldoBaseParaTraslado(InsumosPeriodo insumos, long gradoId)
 897    {
 898        const int CodigoSueldoBase     = 11000;
 899        const int CodigoCompOficial    = 42012;
 900        const int CodigoCompSubalterno = 42022;
 901
 38902        var sueldoBase     = insumos.ObtenerRemuneracion(gradoId, CodigoSueldoBase)?.Monto ?? 0m;
 38903        var compOficial    = insumos.ObtenerRemuneracion(gradoId, CodigoCompOficial)?.Monto ?? 0m;
 38904        var compSubalterno = insumos.ObtenerRemuneracion(gradoId, CodigoCompSubalterno)?.Monto ?? 0m;
 905
 38906        return sueldoBase + compOficial + compSubalterno;
 907    }
 908
 909    // Fonasa carries el monto del aporte de salud: FONASA por tasa única (civiles) o
 910    // Sanidad Militar por tramos (militares). SanidadCodigo indica el código del tramo
 911    // aplicado (5010/5011/5012) cuando corresponde a Sanidad Militar; null para FONASA.
 407912    private sealed record AportesLegales(decimal Montepio, decimal Fonasa, decimal Frl, int? SanidadCodigo = null)
 913    {
 37914        public decimal Total => Montepio + Fonasa + Frl;
 915    }
 916
 222917    private sealed record ResultadoIrpfLiquidacion(decimal TotalIrpf, LiquidacionItem? Item);
 918}

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