< Summary

Information
Class: FAU.Logica.Services.PeriodoService
Assembly: FAU.Logica
File(s): /home/runner/work/Sistema.Liquidacion.BE/Sistema.Liquidacion.BE/FAU.Logica/Services/PeriodoService.cs
Line coverage
68%
Covered lines: 336
Uncovered lines: 156
Coverable lines: 492
Total lines: 692
Line coverage: 68.2%
Branch coverage
49%
Covered branches: 59
Total branches: 120
Branch coverage: 49.1%
Method coverage

Feature is only available for sponsors

Upgrade to PRO version

Metrics

MethodBranch coverage Crap Score Cyclomatic complexity Line coverage
.ctor(...)100%11100%
AbrirPeriodoAsync()53.57%312884.21%
ObtenerActivoAsync()100%210%
ObtenerEnCursoAsync()100%210%
ObtenerPorIdAsync(...)100%210%
ObtenerPrecondicionesAsync()50%2020100%
Ok()100%11100%
ValidarInsumosAsync()100%22100%
CerrarInsumosAsync()80%242079.06%
ReabrirInsumosAsync()75%4495%
IniciarCalculoAsync()66.66%6690.9%
FinalizarCalculoAsync()66.66%6690.47%
ReabrirDesdeCalculadaAsync()0%2040%
CerrarAsync()83.33%6695%
EnviarAMDNAsync()0%2040%
AplicarDescuentosPersonalesAsync()0%2040%
ReabrirDesdeParaMDNAsync()0%2040%
EliminarPeriodoAsync()0%4260%
SerializarSnapshotAsync()100%11100%
CalcularHash(...)100%11100%
ObtenerUltimosAsync()0%620%

File(s)

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

#LineLine coverage
 1using System.Security.Cryptography;
 2using System.Text;
 3using System.Text.Json;
 4using FAU.DataAccess;
 5using FAU.DataAccess.Repositories;
 6using FAU.Entidades;
 7using FAU.Logica.DTOs;
 8using Microsoft.EntityFrameworkCore;
 9
 10namespace FAU.Logica.Services;
 11
 12public class PeriodoService : IPeriodoService
 13{
 14    private readonly IPeriodosRepository _periodosRepository;
 15    private readonly ISnapshotsRepository _snapshotsRepository;
 16    private readonly ApplicationDbContext _context;
 17    private readonly IAuditoriaService _auditoriaService;
 18    private readonly ICurrentUserContext _currentUser;
 19    private readonly IInsumoEvaluador _evaluador;
 20    private readonly IDescuentoPersonalRepository _descuentoPersonalRepo;
 21
 2722    public PeriodoService(
 2723        IPeriodosRepository periodosRepository,
 2724        ISnapshotsRepository snapshotsRepository,
 2725        ApplicationDbContext context,
 2726        IAuditoriaService auditoriaService,
 2727        ICurrentUserContext currentUser,
 2728        IInsumoEvaluador evaluador,
 2729        IDescuentoPersonalRepository descuentoPersonalRepo)
 30    {
 2731        _periodosRepository = periodosRepository;
 2732        _snapshotsRepository = snapshotsRepository;
 2733        _context = context;
 2734        _auditoriaService = auditoriaService;
 2735        _currentUser = currentUser;
 2736        _evaluador = evaluador;
 2737        _descuentoPersonalRepo = descuentoPersonalRepo;
 2738    }
 39
 40    public async Task<(bool Exito, Periodo? Periodo, string? Error)> AbrirPeriodoAsync(short anio, short mes)
 41    {
 1042        if (anio < 2000 || anio > 2100)
 43        {
 044            return (false, null, "El año debe estar entre 2000 y 2100");
 45        }
 46
 1047        if (mes < 1 || mes > 12)
 48        {
 049            return (false, null, "El mes debe estar entre 1 y 12");
 50        }
 51
 1052        var periodoExistente = await _periodosRepository.ObtenerPorAnioMesAsync(anio, mes);
 1053        if (periodoExistente is not null)
 54        {
 55            // Ya existe un período para este mes → devolver el existente si está abierto,
 56            // o un error descriptivo si está en otro estado (cerrado, calculado, etc.).
 157            if (periodoExistente.Estado == Periodo.EstadoAbierta && periodoExistente.Activo)
 158                return (true, periodoExistente, null);
 59
 060            var sugerencia = periodoExistente.Estado == Periodo.EstadoListoPCalculo
 061                ? " Podés reabrirlo desde el panel del período."
 062                : periodoExistente.Estado == Periodo.EstadoCerrada
 063                    ? " Un período cerrado es definitivo."
 064                    : string.Empty;
 65
 066            return (false, null,
 067                $"Ya existe un período para {mes:D2}/{anio} en estado '{periodoExistente.Estado}'.{sugerencia}");
 68        }
 69
 970        var periodoEnCurso = await _periodosRepository.ObtenerEnCursoAsync();
 971        if (periodoEnCurso is not null)
 72        {
 173            return (false, null, $"Ya existe un período activo en estado {periodoEnCurso.Estado}");
 74        }
 75
 876        var parametrosPeriodo = await _context.ParametrosPeriodo
 877            .AsNoTracking()
 878            .FirstOrDefaultAsync(p => p.Anio == anio && p.Mes == mes);
 79
 880        if (parametrosPeriodo is null)
 81        {
 282            return (false, null, "No existen parámetros de liquidación para el período indicado");
 83        }
 84
 685        var fechaInicioPeriodo = new DateTime(anio, mes, 1, 0, 0, 0, DateTimeKind.Utc);
 686        var fechaFinPeriodo = fechaInicioPeriodo.AddMonths(1).AddTicks(-1);
 87
 688        var existeRemuneraciones = await _context.RemuneracionesGrado
 689            .AsNoTracking()
 690            .AnyAsync(r => r.VigenteDesde <= fechaInicioPeriodo && (r.VigenteHasta == null || r.VigenteHasta >= fechaIni
 91
 692        if (!existeRemuneraciones)
 93        {
 094            return (false, null, "No existen remuneraciones de grado vigentes para el período");
 95        }
 96
 697        var existeFranjaIrpf = await _context.FranjasIrpf
 698            .AsNoTracking()
 699            .AnyAsync(f => f.VigenteDesde <= fechaInicioPeriodo && (f.VigenteHasta == null || f.VigenteHasta >= fechaIni
 100
 6101        if (!existeFranjaIrpf)
 102        {
 0103            return (false, null, "No existen franjas IRPF vigentes para el período");
 104        }
 105
 6106        var existeRelacionLaboral = await _context.RelacionesLaborales
 6107            .AsNoTracking()
 6108            .AnyAsync(r => r.Estado == EstadoRelacion.Activo);
 109
 6110        if (!existeRelacionLaboral)
 111        {
 0112            return (false, null, "No hay funcionarios activos en el padrón");
 113        }
 114
 6115        var snapshotJson = await SerializarSnapshotAsync(anio, mes, parametrosPeriodo, fechaInicioPeriodo, fechaFinPerio
 6116        var hashSnapshot = CalcularHash(snapshotJson);
 117
 6118        var snapshot = new SnapshotInsumos
 6119        {
 6120            ContenidoJson = snapshotJson,
 6121            HashIntegridad = hashSnapshot
 6122        };
 123
 6124        var snapshotCreado = await _snapshotsRepository.CrearAsync(snapshot);
 125
 6126        var periodo = new Periodo
 6127        {
 6128            Anio = anio,
 6129            Mes = mes,
 6130            Estado = Periodo.EstadoAbierta,
 6131            SnapshotId = snapshotCreado.Id,
 6132            FechaApertura = DateTime.Now,
 6133            UsuarioApertura = _currentUser.UserId ?? 0,
 6134            HashSnapshot = hashSnapshot,
 6135            Activo = true
 6136        };
 137
 6138        var periodoCreado = await _periodosRepository.CrearAsync(periodo);
 139
 6140        await _auditoriaService.LogAuditoriaAsync(
 6141            _currentUser.UserId ?? 0,
 6142            AccionEnum.AbrirPeriodoLiquidacion,
 6143            ContextoEnum.Liquidaciones,
 6144            _currentUser.Host,
 6145            entidad: nameof(Periodo),
 6146            entidadId: periodoCreado.Id,
 6147            detalle: new
 6148            {
 6149                anio,
 6150                mes,
 6151                snapshotId = snapshotCreado.Id,
 6152                hashSnapshot
 6153            });
 154
 6155        return (true, periodoCreado, null);
 10156    }
 157
 158    public Task<Periodo?> ObtenerActivoAsync()
 159    {
 0160        return _periodosRepository.ObtenerActivoAsync();
 161    }
 162
 163    public Task<Periodo?> ObtenerEnCursoAsync()
 164    {
 0165        return _periodosRepository.ObtenerEnCursoAsync();
 166    }
 167
 168    public Task<Periodo?> ObtenerPorIdAsync(long id)
 169    {
 0170        return _periodosRepository.ObtenerPorIdAsync(id);
 171    }
 172
 173    public async Task<PrecondicionesDto?> ObtenerPrecondicionesAsync(long periodoId)
 174    {
 2175        var validacion = await ValidarInsumosAsync(periodoId);
 3176        if (validacion is null) return null;
 177
 4178        bool Ok(string tipo) => validacion.ItemsIncompletos.All(i => i.Tipo != tipo);
 1179        var lotesOk = Ok("lote_faltante");
 1180        var novedadesOk = Ok("novedades_pendientes");
 1181        var beneficiosOk = Ok("beneficios_faltantes");
 1182        var remuneracionesOk = Ok("tabla_referencia") ||
 1183            !validacion.ItemsIncompletos.Any(i => i.Clave == "SinRemuneraciones");
 1184        var franjasOk = !validacion.ItemsIncompletos.Any(i => i.Clave == "SinFranjasIrpf");
 1185        var funcionariosOk = !validacion.ItemsIncompletos.Any(i => i.Clave == "SinFuncionarios");
 186
 1187        return new PrecondicionesDto
 1188        {
 1189            LotesAprobados = new PrecondicionItemDto
 1190            {
 1191                Ok = lotesOk,
 1192                Detail = lotesOk ? "Lotes aprobados" : "Sin lotes aprobados para el período"
 1193            },
 1194            NovedadesSinConfirmar = new PrecondicionItemDto
 1195            {
 1196                Ok = novedadesOk,
 1197                Detail = novedadesOk ? "Sin novedades pendientes" :
 0198                    validacion.ItemsIncompletos.FirstOrDefault(i => i.Tipo == "novedades_pendientes")?.Descripcion ?? "N
 1199            },
 1200            BeneficiosVigentes = new PrecondicionItemDto
 1201            {
 1202                Ok = beneficiosOk,
 1203                Detail = beneficiosOk ? "Beneficios sociales activos" : "Sin beneficios sociales activos vigentes"
 1204            },
 1205            RemuneracionesVigentes = new PrecondicionItemDto
 1206            {
 1207                Ok = remuneracionesOk,
 1208                Detail = remuneracionesOk ? "Tablas de remuneración vigentes" : "Sin tablas de remuneración para el perí
 1209            },
 1210            FranjasIrpf = new PrecondicionItemDto
 1211            {
 1212                Ok = franjasOk,
 1213                Detail = franjasOk ? "Franjas IRPF vigentes" : "Sin franjas IRPF para el período"
 1214            },
 1215            FuncionariosActivos = new PrecondicionItemDto
 1216            {
 1217                Ok = funcionariosOk,
 1218                Detail = funcionariosOk ? "Funcionarios activos en el padrón" : "No hay funcionarios activos"
 1219            }
 1220        };
 2221    }
 222
 223    public async Task<ValidacionInsumosDto?> ValidarInsumosAsync(long periodoId)
 224    {
 2225        var periodo = await _periodosRepository.ObtenerPorIdAsync(periodoId);
 3226        if (periodo is null) return null;
 1227        return await _evaluador.EvaluarAsync(periodo);
 2228    }
 229
 230    public async Task<(bool Exito, Periodo? Periodo, ValidacionInsumosDto? Validacion, string? Error)> CerrarInsumosAsyn
 231        long periodoId, List<string>? confirmaciones = null)
 232    {
 8233        var periodo = await _periodosRepository.ObtenerPorIdAsync(periodoId);
 8234        if (periodo is null)
 0235            return (false, null, null, "Período no encontrado");
 236
 8237        if (periodo.Estado != Periodo.EstadoAbierta)
 1238            return (false, null, null, $"Solo se puede cerrar insumos desde estado ABIERTA. Estado actual: {periodo.Esta
 239
 7240        var validacion = await _evaluador.EvaluarAsync(periodo);
 241
 7242        if (!validacion.TodosCompletos)
 243        {
 5244            if (confirmaciones is null || confirmaciones.Count == 0)
 4245                return (false, null, validacion, "Hay insumos incompletos. Revisá el detalle y confirmá cada omisión par
 246
 2247            var bloqueantes = validacion.ItemsIncompletos.Where(i => !i.EsConfirmable).ToList();
 1248            if (bloqueantes.Count > 0)
 0249                return (false, null, validacion, "Existen problemas bloqueantes que no pueden omitirse.");
 250
 1251            var clavesSinConfirmar = validacion.ItemsIncompletos
 1252                .Where(i => !confirmaciones.Contains(i.Clave))
 1253                .ToList();
 1254            if (clavesSinConfirmar.Count > 0)
 0255                return (false, null, new ValidacionInsumosDto
 0256                {
 0257                    TodosCompletos = false,
 0258                    PuedeConfirmar = true,
 0259                    ItemsIncompletos = clavesSinConfirmar
 0260                }, "Faltan confirmar algunos items incompletos.");
 261        }
 262
 3263        if (confirmaciones?.Contains("DescuentosBorrador") == true)
 0264            await _descuentoPersonalRepo.ConfirmarBorradoresAsync(periodoId);
 265
 3266        var usuarioId = _currentUser.UserId ?? 0;
 3267        periodo.Estado = Periodo.EstadoListoPCalculo;
 3268        periodo.FechaCierreInsumos = DateTime.Now;
 3269        periodo.UsuarioCierreInsumosId = usuarioId;
 3270        await _periodosRepository.ActualizarAsync(periodo);
 271
 3272        var omisiones = confirmaciones ?? new List<string>();
 3273        await _auditoriaService.LogAuditoriaAsync(
 3274            usuarioId,
 3275            AccionEnum.CerrarInsumosPeriodo,
 3276            ContextoEnum.Liquidaciones,
 3277            _currentUser.Host,
 3278            entidad: nameof(Periodo),
 3279            entidadId: periodo.Id,
 3280            detalle: new
 3281            {
 3282                anio = periodo.Anio,
 3283                mes = periodo.Mes,
 3284                omisionesConfirmadas = omisiones.Select(c =>
 0285                    validacion.ItemsIncompletos.FirstOrDefault(i => i.Clave == c)?.Descripcion ?? c)
 3286            });
 287
 3288        return (true, periodo, null, null);
 8289    }
 290
 291    public async Task<(bool Exito, Periodo? Periodo, string? Error)> ReabrirInsumosAsync(long periodoId)
 292    {
 3293        var periodo = await _periodosRepository.ObtenerPorIdAsync(periodoId);
 3294        if (periodo is null)
 0295            return (false, null, "Período no encontrado");
 296
 3297        if (periodo.Estado != Periodo.EstadoListoPCalculo)
 1298            return (false, null, $"Solo se puede reabrir desde estado LISTO_P_CALCULO. Estado actual: {periodo.Estado}")
 299
 2300        var usuarioId = _currentUser.UserId ?? 0;
 2301        periodo.Estado = Periodo.EstadoAbierta;
 2302        periodo.FechaCierreInsumos = null;
 2303        periodo.UsuarioCierreInsumosId = null;
 2304        await _periodosRepository.ActualizarAsync(periodo);
 305
 2306        await _auditoriaService.LogAuditoriaAsync(
 2307            usuarioId,
 2308            AccionEnum.ReabrirInsumosPeriodo,
 2309            ContextoEnum.Liquidaciones,
 2310            _currentUser.Host,
 2311            entidad: nameof(Periodo),
 2312            entidadId: periodo.Id,
 2313            detalle: new { anio = periodo.Anio, mes = periodo.Mes });
 314
 2315        return (true, periodo, null);
 3316    }
 317
 318    public async Task<(bool Exito, Periodo? Periodo, string? Error)> IniciarCalculoAsync(long periodoId)
 319    {
 2320        var periodo = await _periodosRepository.ObtenerPorIdAsync(periodoId);
 2321        if (periodo is null)
 0322            return (false, null, "Período no encontrado");
 323
 2324        if (periodo.Estado != Periodo.EstadoListoPCalculo)
 1325            return (false, null, $"Solo se puede calcular desde estado LISTO_P_CALCULO. Estado actual: {periodo.Estado}"
 326
 1327        if (await _descuentoPersonalRepo.TieneBorradoresAsync(periodoId))
 0328            return (false, null, "Existen descuentos personales sin confirmar. Confirme todos los borradores antes de in
 329
 1330        var usuarioId = _currentUser.UserId ?? 0;
 1331        periodo.Estado = Periodo.EstadoEnCalculo;
 1332        periodo.FechaCalculo = DateTime.Now;
 1333        periodo.UsuarioCalculoId = usuarioId;
 1334        await _periodosRepository.ActualizarAsync(periodo);
 335
 1336        await _auditoriaService.LogAuditoriaAsync(
 1337            usuarioId,
 1338            AccionEnum.IniciarCalculoPeriodo,
 1339            ContextoEnum.Liquidaciones,
 1340            _currentUser.Host,
 1341            entidad: nameof(Periodo),
 1342            entidadId: periodo.Id,
 1343            detalle: new { anio = periodo.Anio, mes = periodo.Mes });
 344
 1345        return (true, periodo, null);
 2346    }
 347
 348    public async Task<(bool Exito, Periodo? Periodo, string? Error)> FinalizarCalculoAsync(long periodoId, bool exitoso,
 349    {
 2350        var periodo = await _periodosRepository.ObtenerPorIdAsync(periodoId);
 2351        if (periodo is null)
 0352            return (false, null, "Período no encontrado");
 353
 2354        if (periodo.Estado != Periodo.EstadoEnCalculo)
 0355            return (false, null, $"Solo se puede finalizar el cálculo desde estado EN_CALCULO. Estado actual: {periodo.E
 356
 2357        var usuarioId = _currentUser.UserId ?? 0;
 2358        if (exitoso)
 359        {
 1360            periodo.Estado = Periodo.EstadoCalculado;
 1361            periodo.FechaCalculo = DateTime.Now;
 362        }
 363        else
 364        {
 1365            periodo.Estado = Periodo.EstadoListoPCalculo;
 366        }
 367
 2368        await _periodosRepository.ActualizarAsync(periodo);
 369
 2370        await _auditoriaService.LogAuditoriaAsync(
 2371            usuarioId,
 2372            AccionEnum.IniciarCalculoPeriodo,
 2373            ContextoEnum.Liquidaciones,
 2374            _currentUser.Host,
 2375            entidad: nameof(Periodo),
 2376            entidadId: periodo.Id,
 2377            detalle: new { exitoso, mensajeError });
 378
 2379        return (true, periodo, null);
 2380    }
 381
 382    /// <summary>
 383    /// Reabre un período CALCULADO → ABIERTA para permitir correcciones y un nuevo cálculo.
 384    /// Los ítems de la versión anterior quedan en la BD como historial de auditoría.
 385    /// El próximo cálculo genera version = max_version_anterior + 1.
 386    /// </summary>
 387    public async Task<(bool Exito, Periodo? Periodo, string? Error)> ReabrirDesdeCalculadaAsync(long periodoId)
 388    {
 0389        var periodo = await _periodosRepository.ObtenerPorIdAsync(periodoId);
 0390        if (periodo is null)
 0391            return (false, null, "Período no encontrado");
 392
 0393        if (periodo.Estado != Periodo.EstadoCalculado)
 0394            return (false, null,
 0395                $"Solo se puede reabrir desde estado CALCULADO. Estado actual: {periodo.Estado}");
 396
 0397        var usuarioId = _currentUser.UserId ?? 0;
 0398        periodo.Estado = Periodo.EstadoAbierta;
 399        // Limpiamos la fecha de cálculo para que quede como un período nuevo en proceso
 0400        periodo.FechaCalculo = null;
 0401        periodo.UsuarioCalculoId = null;
 0402        periodo.FechaCierreInsumos = null;
 0403        periodo.UsuarioCierreInsumosId = null;
 0404        await _periodosRepository.ActualizarAsync(periodo);
 405
 0406        await _auditoriaService.LogAuditoriaAsync(
 0407            usuarioId,
 0408            AccionEnum.AbrirPeriodoLiquidacion,
 0409            ContextoEnum.Liquidaciones,
 0410            _currentUser.Host,
 0411            entidad: nameof(Periodo),
 0412            entidadId: periodo.Id,
 0413            detalle: new { anio = periodo.Anio, mes = periodo.Mes, motivo = "reapertura desde CALCULADO para corrección"
 414
 0415        return (true, periodo, null);
 0416    }
 417
 418    public async Task<(bool Exito, Periodo? Periodo, string? Error)> CerrarAsync(long periodoId)
 419    {
 3420        var periodo = await _periodosRepository.ObtenerPorIdAsync(periodoId);
 3421        if (periodo is null)
 0422            return (false, null, "Período no encontrado");
 423
 3424        if (periodo.Estado != Periodo.EstadoCalculado && periodo.Estado != Periodo.EstadoDescuentosPersonales)
 1425            return (false, null, $"Solo se puede cerrar desde estado CALCULADO o DESCUENTOS_PERSONALES. Estado actual: {
 426
 2427        var usuarioId = _currentUser.UserId ?? 0;
 2428        periodo.Estado = Periodo.EstadoCerrada;
 2429        periodo.FechaCierre = DateTime.Now;
 2430        periodo.UsuarioCierreId = usuarioId;
 2431        await _periodosRepository.ActualizarAsync(periodo);
 432
 2433        await _auditoriaService.LogAuditoriaAsync(
 2434            usuarioId,
 2435            AccionEnum.CerrarPeriodo,
 2436            ContextoEnum.Liquidaciones,
 2437            _currentUser.Host,
 2438            entidad: nameof(Periodo),
 2439            entidadId: periodo.Id,
 2440            detalle: new { anio = periodo.Anio, mes = periodo.Mes });
 441
 2442        return (true, periodo, null);
 3443    }
 444
 445    public async Task<(bool Exito, Periodo? Periodo, string? Error)> EnviarAMDNAsync(long periodoId)
 446    {
 0447        var periodo = await _periodosRepository.ObtenerPorIdAsync(periodoId);
 0448        if (periodo is null)
 0449            return (false, null, "Período no encontrado");
 450
 0451        if (periodo.Estado != Periodo.EstadoCalculado)
 0452            return (false, null, $"Solo se puede enviar a MDN desde estado CALCULADO. Estado actual: {periodo.Estado}");
 453
 0454        var usuarioId = _currentUser.UserId ?? 0;
 0455        periodo.Estado = Periodo.EstadoParaMDN;
 0456        periodo.FechaEnvioMDN = DateTime.Now;
 0457        periodo.UsuarioEnvioMDNId = usuarioId;
 0458        await _periodosRepository.ActualizarAsync(periodo);
 459
 0460        await _auditoriaService.LogAuditoriaAsync(
 0461            usuarioId,
 0462            AccionEnum.EnviarAMDN,
 0463            ContextoEnum.Liquidaciones,
 0464            _currentUser.Host,
 0465            entidad: nameof(Periodo),
 0466            entidadId: periodo.Id,
 0467            detalle: new { anio = periodo.Anio, mes = periodo.Mes });
 468
 0469        return (true, periodo, null);
 0470    }
 471
 472    public async Task<(bool Exito, Periodo? Periodo, string? Error)> AplicarDescuentosPersonalesAsync(long periodoId)
 473    {
 0474        var periodo = await _periodosRepository.ObtenerPorIdAsync(periodoId);
 0475        if (periodo is null)
 0476            return (false, null, "Período no encontrado");
 477
 0478        if (periodo.Estado != Periodo.EstadoParaMDN)
 0479            return (false, null, $"Solo se pueden aplicar descuentos personales desde estado PARA_MDN. Estado actual: {p
 480
 0481        var usuarioId = _currentUser.UserId ?? 0;
 0482        periodo.Estado = Periodo.EstadoDescuentosPersonales;
 0483        periodo.FechaDescuentosPersonales = DateTime.Now;
 0484        periodo.UsuarioDescuentosPersonalesId = usuarioId;
 0485        await _periodosRepository.ActualizarAsync(periodo);
 486
 0487        await _auditoriaService.LogAuditoriaAsync(
 0488            usuarioId,
 0489            AccionEnum.AplicarDescuentosPersonalesPeriodo,
 0490            ContextoEnum.Liquidaciones,
 0491            _currentUser.Host,
 0492            entidad: nameof(Periodo),
 0493            entidadId: periodo.Id,
 0494            detalle: new { anio = periodo.Anio, mes = periodo.Mes });
 495
 0496        return (true, periodo, null);
 0497    }
 498
 499    public async Task<(bool Exito, Periodo? Periodo, string? Error)> ReabrirDesdeParaMDNAsync(long periodoId)
 500    {
 0501        var periodo = await _periodosRepository.ObtenerPorIdAsync(periodoId);
 0502        if (periodo is null)
 0503            return (false, null, "Período no encontrado");
 504
 0505        if (periodo.Estado != Periodo.EstadoParaMDN)
 0506            return (false, null, $"Solo se puede reabrir desde estado PARA_MDN. Estado actual: {periodo.Estado}");
 507
 0508        var usuarioId = _currentUser.UserId ?? 0;
 0509        periodo.Estado = Periodo.EstadoAbierta;
 0510        periodo.FechaEnvioMDN = null;
 0511        periodo.UsuarioEnvioMDNId = null;
 0512        periodo.FechaCierreInsumos = null;
 0513        periodo.UsuarioCierreInsumosId = null;
 0514        await _periodosRepository.ActualizarAsync(periodo);
 515
 0516        await _auditoriaService.LogAuditoriaAsync(
 0517            usuarioId,
 0518            AccionEnum.ReabrirDesdeParaMDN,
 0519            ContextoEnum.Liquidaciones,
 0520            _currentUser.Host,
 0521            entidad: nameof(Periodo),
 0522            entidadId: periodo.Id,
 0523            detalle: new { anio = periodo.Anio, mes = periodo.Mes, motivo = "reapertura desde PARA_MDN por correcciones 
 524
 0525        return (true, periodo, null);
 0526    }
 527
 528    public async Task<(bool Exito, string? Error)> EliminarPeriodoAsync(long periodoId)
 529    {
 0530        var periodo = await _periodosRepository.ObtenerPorIdAsync(periodoId);
 0531        if (periodo is null)
 0532            return (false, "Período no encontrado");
 533
 0534        if (periodo.Estado != Periodo.EstadoAbierta)
 0535            return (false, "Solo se pueden eliminar períodos en estado ABIERTA");
 536
 0537        var tieneCalculos = await _context.LiquidacionItems
 0538            .AnyAsync(i => i.PeriodoId == periodoId);
 539
 0540        if (tieneCalculos)
 0541            return (false, "El período tiene cálculos generados. No se puede eliminar.");
 542
 0543        var usuarioId = _currentUser.UserId ?? 0;
 0544        periodo.Activo = false;
 0545        await _periodosRepository.ActualizarAsync(periodo);
 546
 0547        await _auditoriaService.LogAuditoriaAsync(
 0548            usuarioId,
 0549            AccionEnum.EliminarPeriodo,
 0550            ContextoEnum.Liquidaciones,
 0551            _currentUser.Host,
 0552            entidad: nameof(Periodo),
 0553            entidadId: periodo.Id,
 0554            detalle: new { anio = periodo.Anio, mes = periodo.Mes });
 555
 0556        return (true, null);
 0557    }
 558
 559    private async Task<string> SerializarSnapshotAsync(
 560        short anio,
 561        short mes,
 562        ParametroPeriodo parametrosPeriodo,
 563        DateTime fechaInicioPeriodo,
 564        DateTime fechaFinPeriodo)
 565    {
 6566        var lotes = await _context.LotesCompensacion
 6567            .AsNoTracking()
 6568            .Where(l => l.Periodo.Anio == anio && l.Periodo.Mes == mes && l.Estado == "aprobado")
 6569            .Select(l => new
 6570            {
 6571                l.Id,
 6572                l.TipoCompensacionId,
 6573                l.PeriodoId,
 6574                l.Estado,
 6575                l.Fuente,
 6576                l.NombreArchivo,
 6577                l.HashArchivo,
 6578                l.UsuarioId,
 6579                l.AprobadoPor,
 6580                l.AprobadoEn
 6581            })
 6582            .ToListAsync();
 583
 6584        var beneficios = await _context.BeneficiosSociales
 6585            .AsNoTracking()
 6586            .Where(b => b.Estado == "activo" && b.VigenteDesde <= fechaFinPeriodo && (b.VigenteHasta == null || b.Vigent
 6587            .Select(b => new
 6588            {
 6589                b.Id,
 6590                b.PersonaId,
 6591                b.TipoBeneficioId,
 6592                b.Cantidad,
 6593                b.VigenteDesde,
 6594                b.VigenteHasta,
 6595                b.Estado,
 6596                b.ClaveEvento,
 6597                b.FechaEvento,
 6598                b.RetroactividadPendiente
 6599            })
 6600            .ToListAsync();
 601
 6602        var remuneraciones = await _context.RemuneracionesGrado
 6603            .AsNoTracking()
 6604            .Where(r => r.VigenteDesde <= fechaInicioPeriodo && (r.VigenteHasta == null || r.VigenteHasta >= fechaInicio
 6605            .Select(r => new
 6606            {
 6607                r.Id,
 6608                r.GradoId,
 6609                r.ItemId,
 6610                r.Monto,
 6611                r.VigenteDesde,
 6612                r.VigenteHasta
 6613            })
 6614            .ToListAsync();
 615
 6616        var franjasIrpf = await _context.FranjasIrpf
 6617            .AsNoTracking()
 6618            .Where(f => f.VigenteDesde <= fechaInicioPeriodo && (f.VigenteHasta == null || f.VigenteHasta >= fechaInicio
 6619            .Select(f => new
 6620            {
 6621                f.Id,
 6622                f.NumeroFranja,
 6623                f.DesdeBpc,
 6624                f.HastaBpc,
 6625                f.Tasa,
 6626                f.VigenteDesde,
 6627                f.VigenteHasta
 6628            })
 6629            .ToListAsync();
 630
 6631        var snapshot = new
 6632        {
 6633            anio,
 6634            mes,
 6635            fechaCreacion = DateTime.Now,
 6636            parametros = new
 6637            {
 6638                parametrosPeriodo.Anio,
 6639                parametrosPeriodo.Mes,
 6640                parametrosPeriodo.BpcAnual,
 6641                parametrosPeriodo.TasaMontepioLeyVieja,
 6642                parametrosPeriodo.TasaMontepioLeyNueva,
 6643                parametrosPeriodo.TasaFonasa,
 6644                parametrosPeriodo.TasaFrl,
 6645                parametrosPeriodo.UmbralDeduccionBpc,
 6646                parametrosPeriodo.TasaDeduccionAlta,
 6647                parametrosPeriodo.TasaDeduccionBaja,
 6648                parametrosPeriodo.PorcentajeMinimoDeficit
 6649            },
 6650            lotesCompensacion = lotes,
 6651            beneficiosSociales = beneficios,
 6652            remuneracionesGrado = remuneraciones,
 6653            franjasIrpf = franjasIrpf
 6654        };
 655
 6656        return JsonSerializer.Serialize(snapshot, new JsonSerializerOptions
 6657        {
 6658            PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
 6659            WriteIndented = false
 6660        });
 6661    }
 662
 663    private static string CalcularHash(string contenidoJson)
 664    {
 6665        using var sha256 = SHA256.Create();
 6666        var bytes = Encoding.UTF8.GetBytes(contenidoJson);
 6667        return Convert.ToHexString(sha256.ComputeHash(bytes)).ToLowerInvariant();
 6668    }
 669
 670    public async Task<List<Periodo>> ObtenerUltimosAsync(int cantidad = 6)
 671    {
 0672        var periodos = await _context.PeriodosLiquidacion
 0673            .AsNoTracking()
 0674            .Include(p => p.UsuarioCierreInsumos)
 0675            .Include(p => p.UsuarioCalculo)
 0676            .Include(p => p.UsuarioCierre)
 0677            .OrderByDescending(p => p.Anio)
 0678            .ThenByDescending(p => p.Mes)
 0679            .Take(cantidad)
 0680            .ToListAsync();
 681
 0682        var userIds = periodos.Select(p => p.UsuarioApertura).Distinct().ToList();
 0683        var usernames = await _context.Usuarios
 0684            .Where(u => userIds.Contains(u.Id))
 0685            .ToDictionaryAsync(u => u.Id, u => u.Username);
 686
 0687        foreach (var p in periodos)
 0688            p.UsuarioAperturaUsername = usernames.GetValueOrDefault(p.UsuarioApertura);
 689
 0690        return periodos;
 0691    }
 692}