< Summary

Information
Class: FAU.Logica.Services.CompensacionService
Assembly: FAU.Logica
File(s): /home/runner/work/Sistema.Liquidacion.BE/Sistema.Liquidacion.BE/FAU.Logica/Services/CompensacionService.cs
Line coverage
73%
Covered lines: 451
Uncovered lines: 165
Coverable lines: 616
Total lines: 943
Line coverage: 73.2%
Branch coverage
56%
Covered branches: 259
Total branches: 460
Branch coverage: 56.3%
Method coverage

Feature is only available for sponsors

Upgrade to PRO version

Metrics

MethodBranch coverage Crap Score Cyclomatic complexity Line coverage
.cctor()100%11100%
.ctor(...)100%11100%
GetTiposAsync()100%22100%
GetTipoByIdAsync()100%22100%
CreateTipoAsync()100%66100%
UpdateTipoAsync()100%88100%
DeleteTipoAsync()100%66100%
GetConfigsAsync()100%22100%
GetConfigByIdAsync()100%22100%
CreateConfigAsync()100%44100%
UpdateConfigAsync()70.45%4444100%
DeleteConfigAsync()100%66100%
GetLotesPagedAsync()0%620%
GetLoteByIdAsync()0%620%
CreateLoteAsync()62.5%8891.66%
UpdateLoteAsync()85%2020100%
ValidarLoteParaValidarAsync()100%66100%
AprobarLoteAsync()75%44100%
RechazarLoteAsync()100%88100%
RevertirABorradorAsync()100%44100%
CancelarLoteAsync()100%88100%
GetItemsPagedAsync()50%66100%
CreateItemAsync()85%2020100%
UpdateItemAsync()63.63%504485.29%
DeleteItemAsync()100%88100%
RevertirABorradorSiValidadoAsync()100%22100%
CreateItemsBulkAsync()0%420200%
CreateItemsFromCsvAsync()0%6480800%
ValidarItemAsync()0%272160%
AplicarFormulaAsync()90.38%525295%
ValidarFormula(...)77.27%272278.57%
MapTipoToDto(...)100%11100%
MapConfigToDto(...)58.33%1212100%
MapLoteToDto(...)0%272160%
NullIfEmpty(...)0%620%
MapItemToDto(...)62.5%1616100%

File(s)

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

#LineLine coverage
 1using FAU.DataAccess.Repositories;
 2using FAU.Entidades;
 3using FAU.Logica.DTOs;
 4using Microsoft.Extensions.Logging;
 5
 6namespace FAU.Logica.Services;
 7
 8public class CompensacionService : ICompensacionService
 9{
 110    private static readonly HashSet<string> UnidadesValidas = new(StringComparer.Ordinal) { "dia", "hora", "monto" };
 111    private static readonly HashSet<string> FormulasValidas = new(StringComparer.Ordinal) { "fijo", "tabla", "prorrateo"
 12    // Estados válidos para lotes
 113    private static readonly HashSet<string> EstadosLoteValidos = new(StringComparer.Ordinal) { "borrador", "validado", "
 14    // Estados que permiten editar ítems (agregar/modificar/eliminar)
 15    // Cualquier edición en un lote 'validado' lo regresa automáticamente a 'borrador'
 116    private static readonly HashSet<string> EstadosEditablesLote = new(StringComparer.Ordinal) { "borrador", "validado" 
 17
 18    private readonly ICompensacionRepository _repo;
 19    private readonly IPersonalRepository _personalRepo;
 20    private readonly ILogger<CompensacionService> _logger;
 21    private readonly IAuditoriaService _auditoriaService;
 22    private readonly ICurrentUserContext _currentUser;
 23    private readonly IPeriodoGuard _periodoGuard;
 24
 9225    public CompensacionService(
 9226        ICompensacionRepository repo,
 9227        IPersonalRepository personalRepo,
 9228        ILogger<CompensacionService> logger,
 9229        IAuditoriaService auditoriaService,
 9230        ICurrentUserContext currentUser,
 9231        IPeriodoGuard periodoGuard)
 32    {
 9233        _repo = repo;
 9234        _personalRepo = personalRepo;
 9235        _logger = logger;
 9236        _auditoriaService = auditoriaService;
 9237        _currentUser = currentUser;
 9238        _periodoGuard = periodoGuard;
 9239    }
 40
 41    // ── Tipos ────────────────────────────────────────────────────────────────
 42
 43    public async Task<IEnumerable<TipoCompensacionDto>> GetTiposAsync(bool? vigente = null)
 44    {
 245        var tipos = await _repo.GetTiposAsync(vigente);
 246        return tipos.Select(MapTipoToDto);
 247    }
 48
 49    public async Task<TipoCompensacionDto?> GetTipoByIdAsync(long id)
 50    {
 251        var tipo = await _repo.GetTipoByIdAsync(id);
 252        return tipo == null ? null : MapTipoToDto(tipo);
 253    }
 54
 55    public async Task<(bool Success, TipoCompensacion? Tipo, string? Error)> CreateTipoAsync(CreateTipoCompensacionReque
 56    {
 457        if (string.IsNullOrWhiteSpace(request.Codigo))
 158            return (false, null, "codigo es obligatorio");
 59
 360        if (!UnidadesValidas.Contains(request.UnidadMedida))
 161            return (false, null, $"unidad_medida inválida. Valores aceptados: {string.Join(", ", UnidadesValidas)}");
 62
 263        if (await _repo.GetTipoByCodigoAsync(request.Codigo) != null)
 164            return (false, null, $"Ya existe un tipo de compensación con código '{request.Codigo}'");
 65
 166        var tipo = new TipoCompensacion
 167        {
 168            Codigo = request.Codigo.Trim().ToUpper(),
 169            Nombre = request.Nombre,
 170            UnidadMedida = request.UnidadMedida,
 171            RequiereFuente = request.RequiereFuente,
 172            Vigente = true
 173        };
 74
 175        var creado = await _repo.CreateTipoAsync(tipo);
 176        _logger.LogInformation("Tipo compensación {Codigo} creado", creado.Codigo);
 177        await _auditoriaService.LogAuditoriaAsync(_currentUser.UserId ?? 0, AccionEnum.CrearCompensacion, ContextoEnum.C
 178        return (true, creado, null);
 479    }
 80
 81    public async Task<(bool Success, TipoCompensacion? Tipo, string? Error)> UpdateTipoAsync(long id, UpdateTipoCompensa
 82    {
 383        var tipo = await _repo.GetTipoByIdAsync(id);
 484        if (tipo == null) return (false, null, "Tipo de compensación no encontrado");
 85
 486        if (request.Nombre != null) tipo.Nombre = request.Nombre;
 387        if (request.RequiereFuente.HasValue) tipo.RequiereFuente = request.RequiereFuente.Value;
 388        if (request.Vigente.HasValue) tipo.Vigente = request.Vigente.Value;
 89
 290        var actualizado = await _repo.UpdateTipoAsync(tipo);
 291        await _auditoriaService.LogAuditoriaAsync(_currentUser.UserId ?? 0, AccionEnum.ActualizarCompensacion, ContextoE
 292        return (true, actualizado, null);
 393    }
 94
 95    public async Task<(bool Success, string? Error)> DeleteTipoAsync(long id)
 96    {
 497        var tipo = await _repo.GetTipoByIdAsync(id);
 598        if (tipo == null) return (false, "Tipo de compensación no encontrado");
 99
 100        // Verificar si tiene configuraciones asociadas
 3101        if (await _repo.TieneConfiguracionesAsync(id))
 1102            return (false, "No se puede eliminar el tipo porque tiene configuraciones asociadas. Desactive las configura
 103
 104        // Verificar si tiene lotes asociados
 2105        if (await _repo.TieneLotesAsync(id))
 1106            return (false, "No se puede eliminar el tipo porque tiene lotes asociados. Elimine los lotes primero.");
 107
 1108        await _auditoriaService.LogAuditoriaAsync(_currentUser.UserId ?? 0, AccionEnum.ActualizarCompensacion, ContextoE
 1109        await _repo.DeleteTipoAsync(id);
 1110        _logger.LogInformation("Tipo compensación {Id} eliminado", id);
 1111        return (true, null);
 4112    }
 113
 114    // ── Configs ──────────────────────────────────────────────────────────────
 115
 116    public async Task<IEnumerable<ConfigCompensacionDto>> GetConfigsAsync(
 117        long? tipoId = null, long? regimenId = null, long? gradoId = null, bool? activo = null)
 118    {
 3119        var configs = await _repo.GetConfigsAsync(tipoId, regimenId, gradoId, activo);
 3120        return configs.Select(MapConfigToDto);
 3121    }
 122
 123    public async Task<ConfigCompensacionDto?> GetConfigByIdAsync(long id)
 124    {
 2125        var config = await _repo.GetConfigByIdAsync(id);
 2126        return config == null ? null : MapConfigToDto(config);
 2127    }
 128
 129    public async Task<(bool Success, ConfigCompensacion? Config, string? Error)> CreateConfigAsync(CreateConfigCompensac
 130    {
 5131        var validacion = ValidarFormula(request.Formula, request.Monto, request.ValorPorUnidad, request.ValorMinimo, req
 8132        if (validacion != null) return (false, null, validacion);
 133
 2134        var tipo = await _repo.GetTipoByIdAsync(request.TipoCompensacionId);
 3135        if (tipo == null) return (false, null, "No se encontró el tipo de compensación indicado");
 136
 137        // Desactivar configs anteriores: solo puede haber una activa por tipo
 1138        await _repo.DesactivarConfigsActivasAsync(request.TipoCompensacionId);
 139
 1140        var config = new ConfigCompensacion
 1141        {
 1142            TipoCompensacionId  = request.TipoCompensacionId,
 1143            RegimenId           = request.RegimenId,
 1144            GradoId             = request.GradoId,
 1145            EscalafonId         = request.EscalafonId,
 1146            Funcion             = request.Funcion,
 1147            UnidadMedida        = request.UnidadMedida,
 1148            Formula             = request.Formula,
 1149            ValorPorUnidad      = request.ValorPorUnidad,
 1150            Monto               = request.Monto,
 1151            Tope                = request.Tope,
 1152            TopeUnidades        = request.TopeUnidades,
 1153            ValorMinimo         = request.ValorMinimo,
 1154            ValorMaximo         = request.ValorMaximo,
 1155            VigenteDesde        = request.VigenteDesde,
 1156            VigenteHasta        = request.VigenteHasta,
 1157            SujetoDescLegales   = request.SujetoDescLegales,
 1158            CatalogoItemId      = request.CatalogoItemId,
 1159            Activo              = true
 1160        };
 161
 1162        var creada = await _repo.CreateConfigAsync(config);
 1163        await _auditoriaService.LogAuditoriaAsync(_currentUser.UserId ?? 0, AccionEnum.CrearConfigCompensacion, Contexto
 1164        return (true, creada, null);
 5165    }
 166
 167    public async Task<(bool Success, ConfigCompensacion? Config, string? Error)> UpdateConfigAsync(long id, UpdateConfig
 168    {
 4169        var config = await _repo.GetConfigByIdAsync(id);
 5170        if (config == null) return (false, null, "Configuración no encontrada");
 171
 3172        var monto = request.Monto ?? config.Monto;
 3173        var valorPorUnidad = request.ValorPorUnidad ?? config.ValorPorUnidad;
 3174        var valorMinimo = request.ValorMinimo ?? config.ValorMinimo;
 3175        var valorMaximo = request.ValorMaximo ?? config.ValorMaximo;
 3176        var topeUnidades = request.TopeUnidades ?? config.TopeUnidades;
 177
 3178        var validacion = ValidarFormula(config.Formula, monto, valorPorUnidad, valorMinimo, valorMaximo, topeUnidades);
 3179        if (validacion != null) return (false, null, validacion);
 180
 3181        if (request.ValorPorUnidad.HasValue) config.ValorPorUnidad = request.ValorPorUnidad;
 3182        if (request.Monto.HasValue) config.Monto = request.Monto;
 3183        if (request.Tope.HasValue) config.Tope = request.Tope;
 3184        if (request.TopeUnidades.HasValue) config.TopeUnidades = request.TopeUnidades;
 3185        if (request.ValorMinimo.HasValue) config.ValorMinimo = request.ValorMinimo;
 3186        if (request.ValorMaximo.HasValue) config.ValorMaximo = request.ValorMaximo;
 3187        if (request.VigenteHasta.HasValue) config.VigenteHasta = request.VigenteHasta;
 4188        if (request.SujetoDescLegales.HasValue) config.SujetoDescLegales = request.SujetoDescLegales.Value;
 4189        if (request.CatalogoItemId.HasValue) config.CatalogoItemId = request.CatalogoItemId;
 3190        if (request.Activo.HasValue)
 191        {
 2192            if (!request.Activo.Value && config.Activo && await _repo.EsUnicaConfigActivaAsync(id, config.TipoCompensaci
 1193                return (false, null, "No se puede desactivar la única configuración activa del tipo. Cree otra antes de 
 194
 195            // Si se activa una config inactiva, desactivar las demás del mismo tipo
 1196            if (request.Activo.Value && !config.Activo)
 1197                await _repo.DesactivarConfigsActivasAsync(config.TipoCompensacionId);
 1198            config.Activo = request.Activo.Value;
 199        }
 200
 2201        var actualizada = await _repo.UpdateConfigAsync(config);
 2202        await _auditoriaService.LogAuditoriaAsync(_currentUser.UserId ?? 0, AccionEnum.ActualizarConfigComprensacion, Co
 2203        return (true, actualizada, null);
 4204    }
 205
 206    public async Task<(bool Success, string? Error)> DeleteConfigAsync(long id)
 207    {
 4208        var config = await _repo.GetConfigByIdAsync(id);
 5209        if (config == null) return (false, "Configuración no encontrada");
 210
 3211        if (config.Activo && await _repo.EsUnicaConfigActivaAsync(id, config.TipoCompensacionId))
 1212            return (false, "No se puede eliminar la única configuración activa del tipo. Cree otra antes de eliminar est
 213
 2214        await _auditoriaService.LogAuditoriaAsync(_currentUser.UserId ?? 0, AccionEnum.ActualizarConfigComprensacion, Co
 2215        await _repo.DeleteConfigAsync(id);
 2216        _logger.LogInformation("Configuración compensación {Id} eliminada", id);
 2217        return (true, null);
 4218    }
 219
 220    // ── Lotes ────────────────────────────────────────────────────────────────
 221
 222    public async Task<PagedResult<LoteCompensacionDto>> GetLotesPagedAsync(
 223        int page, int pageSize, long? tipoId = null, int? anio = null, int? mes = null, string? estado = null)
 224    {
 0225        var (items, totalCount) = await _repo.GetLotesPagedAsync(page, pageSize, tipoId, anio, mes, estado);
 0226        return new PagedResult<LoteCompensacionDto>
 0227        {
 0228            Items = items.Select(MapLoteToDto),
 0229            Page = page,
 0230            PageSize = pageSize,
 0231            TotalCount = totalCount
 0232        };
 0233    }
 234
 235    public async Task<LoteCompensacionDto?> GetLoteByIdAsync(long id)
 236    {
 0237        var lote = await _repo.GetLoteByIdWithItemsAsync(id);
 0238        return lote == null ? null : MapLoteToDto(lote);
 0239    }
 240
 241    public async Task<(bool Success, LoteCompensacion? Lote, string? Error)> CreateLoteAsync(long usuarioId, CreateLoteC
 242    {
 2243        await _periodoGuard.VerificarPeriodoAbiertoAsync();
 244
 2245        var tipo = await _repo.GetTipoByIdAsync(request.TipoCompensacionId);
 3246        if (tipo == null) return (false, null, "No se encontró el tipo de compensación indicado");
 247
 1248        if (request.PeriodoMes < 1 || request.PeriodoMes > 12)
 0249            return (false, null, "periodoMes debe estar entre 1 y 12");
 250
 1251        var periodo = new DateTime(request.PeriodoAnio, request.PeriodoMes, 1, 0, 0, 0, DateTimeKind.Utc);
 252
 253        // Validar unicidad: un solo lote activo por tipo + período (igual que legacy)
 1254        if (await _repo.ExisteLoteActivoAsync(request.TipoCompensacionId, periodo))
 0255            return (false, null, $"Ya existe un lote activo para ese tipo de compensación en {request.PeriodoAnio}/{requ
 256
 1257        var lote = new LoteCompensacion
 1258        {
 1259            TipoCompensacionId = request.TipoCompensacionId,
 1260            Periodo = periodo,
 1261            Estado = "borrador",
 1262            Fuente = request.Fuente,
 1263            NombreArchivo = request.NombreArchivo,
 1264            HashArchivo = request.HashArchivo,
 1265            UsuarioId = usuarioId,
 1266            CreadoEn = DateTime.UtcNow
 1267        };
 268
 1269        var creado = await _repo.CreateLoteAsync(lote);
 1270        _logger.LogInformation("Lote compensación {Id} creado para tipo {TipoId}", creado.Id, creado.TipoCompensacionId)
 1271        await _auditoriaService.LogAuditoriaAsync(_currentUser.UserId ?? 0, AccionEnum.CrearLoteCompensacion, ContextoEn
 1272        return (true, creado, null);
 2273    }
 274
 275    // Transiciones manuales vía PUT (aprobar/rechazar/revertir tienen endpoints dedicados)
 1276    private static readonly Dictionary<string, string[]> TransicionesPermitidas = new()
 1277    {
 1278        ["borrador"] = new[] { "validado" },
 1279        ["validado"] = new[] { "borrador" }
 1280        // validado → aprobado: vía PUT /aprobar
 1281        // aprobado → borrador: vía PUT /revertir
 1282        // aprobado → rechazado: vía PUT /rechazar
 1283        // validado → rechazado: vía PUT /rechazar
 1284    };
 285
 286    public async Task<(bool Success, LoteCompensacion? Lote, string? Error)> UpdateLoteAsync(long id, UpdateLoteCompensa
 287    {
 8288        var lote = await _repo.GetLoteByIdAsync(id);
 9289        if (lote == null) return (false, null, "Lote no encontrado");
 290
 7291        if (!EstadosEditablesLote.Contains(lote.Estado))
 1292            return (false, null, $"No se puede editar un lote en estado '{lote.Estado}'");
 293
 6294        if (request.Estado != null)
 295        {
 6296            if (!TransicionesPermitidas.TryGetValue(lote.Estado, out var permitidos) ||
 12297                !Array.Exists(permitidos, e => e == request.Estado))
 1298                return (false, null, $"Transición no permitida: '{lote.Estado}' → '{request.Estado}'. Permitidas: {strin
 299
 300            // Validación al pasar a 'validado'
 5301            if (request.Estado == "validado")
 302            {
 4303                var (validOk, validError) = await ValidarLoteParaValidarAsync(lote);
 7304                if (!validOk) return (false, null, validError);
 305            }
 306
 2307            lote.Estado = request.Estado;
 308        }
 309
 2310        if (request.Fuente != null) lote.Fuente = request.Fuente;
 2311        if (request.NombreArchivo != null) lote.NombreArchivo = request.NombreArchivo;
 312
 2313        var actualizado = await _repo.UpdateLoteAsync(lote);
 2314        await _auditoriaService.LogAuditoriaAsync(_currentUser.UserId ?? 0, AccionEnum.ActualizarLoteCompensacion, Conte
 2315        return (true, actualizado, null);
 8316    }
 317
 318    private async Task<(bool Success, string? Error)> ValidarLoteParaValidarAsync(LoteCompensacion lote)
 319    {
 4320        var totalItems = await _repo.GetItemCountAsync(lote.Id);
 4321        if (totalItems == 0)
 1322            return (false, "El lote no tiene ítems. Debe cargar al menos uno antes de validar.");
 323
 3324        var itemsError = await _repo.GetItemsEnErrorCountAsync(lote.Id);
 3325        if (itemsError > 0)
 1326            return (false, $"El lote tiene {itemsError} ítem(s) en estado 'error'. Corrija o elimine esos ítems antes de
 327
 2328        var config = await _repo.GetConfigVigenteAsync(lote.TipoCompensacionId, lote.Periodo);
 2329        if (config == null)
 1330            return (false, "No existe configuración activa para este tipo de compensación en el período del lote.");
 331
 1332        return (true, null);
 4333    }
 334
 335    public async Task<(bool Success, LoteCompensacion? Lote, string? Error)> AprobarLoteAsync(long id, long aprobadoPorI
 336    {
 3337        await _periodoGuard.VerificarPeriodoAbiertoAsync();
 338
 3339        var lote = await _repo.GetLoteByIdAsync(id);
 3340        if (lote == null) return (false, null, "Lote no encontrado");
 341
 3342        if (lote.Estado != "validado")
 2343            return (false, null, $"Solo se pueden aprobar lotes en estado 'validado'. Estado actual: '{lote.Estado}'");
 344
 1345        lote.Estado = "aprobado";
 1346        lote.AprobadoPor = aprobadoPorId;
 1347        lote.AprobadoEn = DateTime.UtcNow;
 348
 1349        var actualizado = await _repo.UpdateLoteAsync(lote);
 1350        _logger.LogInformation("Lote {Id} aprobado por usuario {UsuarioId}", id, aprobadoPorId);
 1351        await _auditoriaService.LogAuditoriaAsync(aprobadoPorId, AccionEnum.AprobarLoteCompensacion, ContextoEnum.Compen
 1352        return (true, actualizado, null);
 3353    }
 354
 355    public async Task<(bool Success, LoteCompensacion? Lote, string? Error)> RechazarLoteAsync(long id)
 356    {
 5357        var lote = await _repo.GetLoteByIdAsync(id);
 6358        if (lote == null) return (false, null, "Lote no encontrado");
 359
 4360        if (lote.Estado is "exportado" or "rechazado")
 2361            return (false, null, $"No se puede rechazar un lote en estado '{lote.Estado}'");
 362
 2363        lote.Estado = "rechazado";
 2364        var actualizado = await _repo.UpdateLoteAsync(lote);
 2365        _logger.LogInformation("Lote {Id} rechazado", id);
 2366        await _auditoriaService.LogAuditoriaAsync(_currentUser.UserId ?? 0, AccionEnum.RechazarLoteCompensacion, Context
 2367        return (true, actualizado, null);
 5368    }
 369
 370    public async Task<(bool Success, LoteCompensacion? Lote, string? Error)> RevertirABorradorAsync(long id)
 371    {
 4372        var lote = await _repo.GetLoteByIdAsync(id);
 5373        if (lote == null) return (false, null, "Lote no encontrado");
 374
 3375        if (lote.Estado != "aprobado")
 2376            return (false, null, $"Solo se pueden revertir lotes en estado 'aprobado'. Estado actual: '{lote.Estado}'");
 377
 1378        lote.Estado = "borrador";
 1379        lote.AprobadoPor = null;
 1380        lote.AprobadoEn = null;
 381
 1382        var actualizado = await _repo.UpdateLoteAsync(lote);
 1383        _logger.LogInformation("Lote {Id} revertido a borrador", id);
 1384        await _auditoriaService.LogAuditoriaAsync(_currentUser.UserId ?? 0, AccionEnum.RevertirLoteCompensacion, Context
 1385        return (true, actualizado, null);
 4386    }
 387
 388    public async Task<(bool Success, string? Error)> CancelarLoteAsync(long id)
 389    {
 5390        var lote = await _repo.GetLoteByIdAsync(id);
 6391        if (lote == null) return (false, "Lote no encontrado");
 392
 4393        if (lote.Estado is not ("borrador" or "rechazado"))
 2394            return (false, $"Solo se pueden eliminar lotes en estado 'borrador' o 'rechazado'. Estado actual: '{lote.Est
 395
 2396        await _auditoriaService.LogAuditoriaAsync(_currentUser.UserId ?? 0, AccionEnum.EliminarLoteCompensacion, Context
 2397        await _repo.DeleteAllItemsAsync(id);
 2398        await _repo.DeleteLoteAsync(lote);
 2399        _logger.LogInformation("Lote {Id} cancelado y eliminado", id);
 2400        return (true, null);
 5401    }
 402
 403    public async Task<PagedResult<ItemLoteCompensacionDto>> GetItemsPagedAsync(long loteId, int page, int pageSize)
 404    {
 1405        var lote = await _repo.GetLoteByIdAsync(loteId);
 1406        var unidadMedida = lote != null
 1407            ? (await _repo.GetTipoByIdAsync(lote.TipoCompensacionId))?.UnidadMedida ?? string.Empty
 1408            : string.Empty;
 409
 1410        var (items, totalCount) = await _repo.GetItemsPagedAsync(loteId, page, pageSize);
 1411        return new PagedResult<ItemLoteCompensacionDto>
 1412        {
 2413            Items = items.Select(i => MapItemToDto(i, unidadMedida)),
 1414            Page = page,
 1415            PageSize = pageSize,
 1416            TotalCount = totalCount
 1417        };
 1418    }
 419
 420    // ── Items ────────────────────────────────────────────────────────────────
 421
 422    public async Task<(bool Success, ItemLoteCompensacion? Item, string? Error)> CreateItemAsync(long loteId, CreateItem
 423    {
 23424        var lote = await _repo.GetLoteByIdAsync(loteId);
 23425        if (lote == null) return (false, null, "Lote no encontrado");
 426
 23427        if (!EstadosEditablesLote.Contains(lote.Estado))
 1428            return (false, null, $"No se pueden agregar ítems a un lote en estado '{lote.Estado}'");
 429
 430        // Resolver cédula → personaId
 22431        var relacion = await _personalRepo.GetByCedulaAsync(request.CedulaPersona);
 22432        if (relacion == null)
 1433            return (false, null, $"No se encontró personal activo con cédula {request.CedulaPersona}");
 434
 21435        var personaId = relacion.PersonaId;
 436
 21437        if (await _repo.ExistePersonaEnLoteAsync(loteId, personaId))
 1438            return (false, null, "Ya existe un ítem para esa persona en este lote");
 439
 20440        var config = await _repo.GetConfigVigenteAsync(lote.TipoCompensacionId, lote.Periodo);
 20441        if (config?.TopeUnidades.HasValue == true && request.UnidadCantidad > config.TopeUnidades.Value)
 1442            return (false, null, $"unidad_cantidad excede el tope máximo de {config.TopeUnidades} unidades");
 443
 444        // Aplicar fórmula de la configuración vigente
 19445        decimal? montoFinal = request.Monto;
 19446        if (config != null)
 447        {
 19448            var (formulaOk, montoCalculado, formulaError) = await AplicarFormulaAsync(
 19449                loteId, config, request.UnidadCantidad, request.Monto,
 19450                request.Ala, request.CategoriaCompensacion, relacion.GradoId, lote.Periodo);
 26451            if (!formulaOk) return (false, null, formulaError);
 12452            montoFinal = montoCalculado;
 453        }
 454
 455        // SDL: derivado de la configuración del tipo — no del ítem individual
 12456        var sujetoDescLegales = config?.SujetoDescLegales ?? false;
 457
 12458        var item = new ItemLoteCompensacion
 12459        {
 12460            LoteCompensacionId  = loteId,
 12461            PersonaId           = personaId,
 12462            UnidadCantidad      = request.UnidadCantidad,
 12463            Monto               = montoFinal,
 12464            Estado              = "ok",
 12465            Incidencia          = request.Incidencia,
 12466            Observaciones       = request.Observaciones,
 12467            Funcion             = request.Funcion,
 12468            Ala                 = request.Ala,
 12469            CategoriaCompensacion = request.CategoriaCompensacion,
 12470            SujetoDescLegales   = sujetoDescLegales,
 12471            // Snapshots de la relación laboral activa al momento de carga
 12472            SnapshotGradoId     = relacion.GradoId,
 12473            SnapshotSituacionId = relacion.SituacionId,
 12474            SnapshotUnidadId    = relacion.UnidadId,
 12475            SnapshotProgramaId  = relacion.ProgramaId
 12476        };
 477
 12478        var creado = await _repo.CreateItemAsync(item);
 12479        await _auditoriaService.LogAuditoriaAsync(_currentUser.UserId ?? 0, AccionEnum.CrearItemLoteCompensacion, Contex
 12480        await RevertirABorradorSiValidadoAsync(lote);
 12481        return (true, creado, null);
 23482    }
 483
 484    public async Task<(bool Success, ItemLoteCompensacion? Item, string? Error)> UpdateItemAsync(long loteId, long itemI
 485    {
 2486        var lote = await _repo.GetLoteByIdAsync(loteId);
 2487        if (lote == null) return (false, null, "Lote no encontrado");
 488
 2489        if (!EstadosEditablesLote.Contains(lote.Estado))
 0490            return (false, null, $"No se pueden editar ítems de un lote en estado '{lote.Estado}'");
 491
 2492        var item = await _repo.GetItemByIdAsync(itemId);
 2493        if (item == null || item.LoteCompensacionId != loteId)
 0494            return (false, null, "Ítem no encontrado");
 495
 2496        if (request.Ala != null) item.Ala = request.Ala;
 2497        if (request.CategoriaCompensacion != null) item.CategoriaCompensacion = request.CategoriaCompensacion;
 498
 2499        if (request.UnidadCantidad.HasValue || request.Monto.HasValue || request.Ala != null || request.CategoriaCompens
 500        {
 2501            var config = await _repo.GetConfigVigenteAsync(lote.TipoCompensacionId, lote.Periodo);
 502
 2503            if (request.UnidadCantidad.HasValue)
 504            {
 1505                if (config?.TopeUnidades.HasValue == true && request.UnidadCantidad.Value > config.TopeUnidades.Value)
 0506                    return (false, null, $"unidad_cantidad excede el tope máximo de {config.TopeUnidades} unidades");
 1507                item.UnidadCantidad = request.UnidadCantidad.Value;
 508            }
 509
 2510            if (config != null)
 511            {
 2512                var nuevaCantidad = item.UnidadCantidad;
 2513                var nuevoMonto = request.Monto ?? item.Monto;
 2514                var (formulaOk, montoCalculado, formulaError) = await AplicarFormulaAsync(
 2515                    loteId, config, nuevaCantidad, nuevoMonto,
 2516                    item.Ala, item.CategoriaCompensacion, item.SnapshotGradoId, lote.Periodo);
 3517                if (!formulaOk) return (false, null, formulaError);
 1518                item.Monto = montoCalculado;
 519            }
 0520            else if (request.Monto.HasValue)
 521            {
 0522                item.Monto = request.Monto;
 523            }
 524        }
 525
 1526        if (request.Estado != null) item.Estado = request.Estado;
 1527        if (request.Incidencia != null) item.Incidencia = request.Incidencia;
 1528        if (request.Observaciones != null) item.Observaciones = request.Observaciones;
 1529        if (request.Funcion != null) item.Funcion = request.Funcion;
 530
 1531        var actualizado = await _repo.UpdateItemAsync(item);
 1532        await _auditoriaService.LogAuditoriaAsync(_currentUser.UserId ?? 0, AccionEnum.ActualizarItemLoteCompensacion, C
 1533        await RevertirABorradorSiValidadoAsync(lote);
 1534        return (true, actualizado, null);
 2535    }
 536
 537    public async Task<(bool Success, string? Error)> DeleteItemAsync(long loteId, long itemId)
 538    {
 6539        var lote = await _repo.GetLoteByIdAsync(loteId);
 7540        if (lote == null) return (false, "Lote no encontrado");
 541
 5542        if (!EstadosEditablesLote.Contains(lote.Estado))
 1543            return (false, $"No se pueden eliminar ítems de un lote en estado '{lote.Estado}'");
 544
 4545        var item = await _repo.GetItemByIdAsync(itemId);
 4546        if (item == null || item.LoteCompensacionId != loteId)
 2547            return (false, "Ítem no encontrado");
 548
 2549        await _auditoriaService.LogAuditoriaAsync(_currentUser.UserId ?? 0, AccionEnum.EliminarItemLoteCompensacion, Con
 2550        await _repo.DeleteItemAsync(item);
 2551        await RevertirABorradorSiValidadoAsync(lote);
 2552        return (true, null);
 6553    }
 554
 555    /// Si el lote estaba en 'validado', cualquier edición de ítems lo regresa a 'borrador'
 556    private async Task RevertirABorradorSiValidadoAsync(LoteCompensacion lote)
 557    {
 15558        if (lote.Estado == "validado")
 559        {
 1560            lote.Estado = "borrador";
 1561            await _repo.UpdateLoteAsync(lote);
 1562            _logger.LogInformation("Lote {Id} revertido a borrador por edición de ítems", lote.Id);
 563        }
 15564    }
 565
 566    // ── Bulk Import ──────────────────────────────────────────────────────────
 567
 568    public async Task<BulkImportResultDto> CreateItemsBulkAsync(long loteId, IEnumerable<CreateItemLoteRequest> items, s
 569    {
 0570        var lote = await _repo.GetLoteByIdAsync(loteId);
 0571        if (lote == null)
 0572            return new BulkImportResultDto { Fallidos = 1, Procesados = 1, Resultados = [new() { Linea = 0, Cedula = "",
 573
 0574        if (!dryRun && !EstadosEditablesLote.Contains(lote.Estado))
 0575            return new BulkImportResultDto { Fallidos = 1, Procesados = 1, Resultados = [new() { Linea = 0, Cedula = "",
 576
 0577        var resultado = new BulkImportResultDto { EsDryRun = dryRun, Modo = modo };
 578
 0579        if (!dryRun && modo == "reemplazar")
 580        {
 0581            await _repo.DeleteAllItemsAsync(loteId);
 0582            if (lote.Estado == "validado") { lote.Estado = "borrador"; await _repo.UpdateLoteAsync(lote); }
 583        }
 584
 0585        int linea = 1;
 0586        foreach (var request in items)
 587        {
 0588            resultado.Procesados++;
 0589            var (success, item, error) = dryRun
 0590                ? await ValidarItemAsync(loteId, lote, request)
 0591                : await CreateItemAsync(loteId, request);
 592
 0593            resultado.Resultados.Add(new BulkImportLineResultDto
 0594            {
 0595                Linea = linea++,
 0596                Cedula = request.CedulaPersona,
 0597                Exito = success,
 0598                Error = error,
 0599                ItemId = item?.Id
 0600            });
 0601            if (success) resultado.Exitosos++;
 0602            else resultado.Fallidos++;
 0603        }
 604
 0605        return resultado;
 0606    }
 607
 608    public async Task<BulkImportResultDto> CreateItemsFromCsvAsync(long loteId, Stream csvStream, string modo = "reempla
 609    {
 0610        var lote = await _repo.GetLoteByIdAsync(loteId);
 0611        if (lote == null)
 0612            return new BulkImportResultDto { Fallidos = 1, Procesados = 1, Resultados = [new() { Linea = 0, Cedula = "",
 613
 0614        if (!dryRun && !EstadosEditablesLote.Contains(lote.Estado))
 0615            return new BulkImportResultDto { Fallidos = 1, Procesados = 1, Resultados = [new() { Linea = 0, Cedula = "",
 616
 0617        if (!dryRun && modo == "reemplazar")
 618        {
 0619            await _repo.DeleteAllItemsAsync(loteId);
 0620            if (lote.Estado == "validado") { lote.Estado = "borrador"; await _repo.UpdateLoteAsync(lote); }
 621        }
 622
 623        // Para fórmulas fijo y prorrateo el monto lo calcula el sistema; para las demás el CSV debe proveerlo
 0624        var configCsv = await _repo.GetConfigVigenteAsync(lote.TipoCompensacionId, lote.Periodo);
 0625        var formulaCalculaMontoAutomatico = configCsv?.Formula is "fijo" or "prorrateo";
 626        // Para tabla con ala o categoria, el monto también se calcula desde la tarifa
 0627        var formulaTablaConLookup = configCsv?.Formula == "tabla" && configCsv.CatalogoItemId.HasValue;
 628
 0629        var resultado = new BulkImportResultDto { EsDryRun = dryRun, Modo = modo };
 0630        using var reader = new System.IO.StreamReader(csvStream);
 631
 632        // Leer encabezados
 0633        var headerLine = await reader.ReadLineAsync();
 0634        if (string.IsNullOrWhiteSpace(headerLine))
 0635            return resultado;
 636
 0637        var headers = headerLine.Split(',').Select(h => h.Trim().ToLower()).ToArray();
 0638        int idxCedula         = Array.IndexOf(headers, "cedula");
 0639        int idxMonto          = Array.IndexOf(headers, "monto");
 0640        int idxUnidad         = Array.IndexOf(headers, "unidad_cantidad");
 0641        int idxFuncion        = Array.IndexOf(headers, "funcion");
 0642        int idxAla            = Array.IndexOf(headers, "ala");
 0643        int idxCategoria      = Array.IndexOf(headers, "categoria");
 0644        int idxIncidencia     = Array.IndexOf(headers, "incidencia");
 0645        int idxObservaciones  = Array.IndexOf(headers, "observaciones");
 646
 0647        if (idxCedula < 0)
 0648            return new BulkImportResultDto { Fallidos = 1, Procesados = 1, Resultados = [new() { Linea = 1, Cedula = "",
 649
 0650        int linea = 2;
 0651        while (!reader.EndOfStream)
 652        {
 0653            var line = await reader.ReadLineAsync();
 0654            if (string.IsNullOrWhiteSpace(line)) { linea++; continue; }
 655
 0656            var cols = line.Split(',');
 0657            var cedula = idxCedula < cols.Length ? cols[idxCedula].Trim() : "";
 658
 0659            resultado.Procesados++;
 660
 0661            if (string.IsNullOrWhiteSpace(cedula))
 662            {
 0663                resultado.Fallidos++;
 0664                resultado.Resultados.Add(new() { Linea = linea, Cedula = cedula, Exito = false, Error = "Cédula vacía" }
 0665                linea++;
 0666                continue;
 667            }
 668
 0669            decimal monto = 0;
 0670            if (idxMonto >= 0 && idxMonto < cols.Length)
 0671                decimal.TryParse(cols[idxMonto].Trim(), System.Globalization.NumberStyles.Any, System.Globalization.Cult
 672
 0673            var ala       = idxAla < 0 || idxAla >= cols.Length ? null : NullIfEmpty(cols[idxAla].Trim()?.ToUpper());
 0674            var categoria = idxCategoria < 0 || idxCategoria >= cols.Length ? null : NullIfEmpty(cols[idxCategoria].Trim
 675
 676            // El monto no es requerido si la fórmula lo calcula automáticamente
 677            // (fijo, prorrateo, o tabla con ala/categoria)
 0678            var montoEsAutomatico = formulaCalculaMontoAutomatico || formulaTablaConLookup;
 679
 0680            if (!montoEsAutomatico && monto <= 0)
 681            {
 0682                resultado.Fallidos++;
 0683                resultado.Resultados.Add(new() { Linea = linea, Cedula = cedula, Exito = false, Error = "El monto debe s
 0684                linea++;
 0685                continue;
 686            }
 687
 0688            decimal unidadCantidad = 1;
 0689            if (idxUnidad >= 0 && idxUnidad < cols.Length && !string.IsNullOrWhiteSpace(cols[idxUnidad]))
 0690                decimal.TryParse(cols[idxUnidad].Trim(), System.Globalization.NumberStyles.Any, System.Globalization.Cul
 691
 0692            var funcion      = idxFuncion < 0 || idxFuncion >= cols.Length ? null : NullIfEmpty(cols[idxFuncion].Trim())
 0693            var incidencia   = idxIncidencia < 0 || idxIncidencia >= cols.Length ? null : NullIfEmpty(cols[idxIncidencia
 0694            var observaciones = idxObservaciones < 0 || idxObservaciones >= cols.Length ? null : NullIfEmpty(cols[idxObs
 695
 0696            var request = new CreateItemLoteRequest
 0697            {
 0698                CedulaPersona         = cedula,
 0699                Monto                 = monto > 0 ? monto : null,
 0700                UnidadCantidad        = unidadCantidad,
 0701                Funcion               = funcion,
 0702                Ala                   = ala,
 0703                CategoriaCompensacion = categoria,
 0704                Incidencia            = incidencia,
 0705                Observaciones         = observaciones
 0706            };
 707
 0708            var (success, item, error) = dryRun
 0709                ? await ValidarItemAsync(loteId, lote, request)
 0710                : await CreateItemAsync(loteId, request);
 0711            resultado.Resultados.Add(new() { Linea = linea, Cedula = cedula, Exito = success, Error = error, ItemId = it
 0712            if (success) resultado.Exitosos++;
 0713            else resultado.Fallidos++;
 714
 0715            linea++;
 0716        }
 717
 0718        return resultado;
 0719    }
 720
 721    /// Valida un ítem sin persistir (dry run). Replica las mismas validaciones de CreateItemAsync.
 722    private async Task<(bool Success, ItemLoteCompensacion? Item, string? Error)> ValidarItemAsync(
 723        long loteId, LoteCompensacion lote, CreateItemLoteRequest request)
 724    {
 0725        if (!EstadosEditablesLote.Contains(lote.Estado))
 0726            return (false, null, $"Lote en estado '{lote.Estado}' no es editable");
 727
 0728        var relacion = await _personalRepo.GetByCedulaAsync(request.CedulaPersona);
 0729        if (relacion == null)
 0730            return (false, null, $"No se encontró personal activo con cédula {request.CedulaPersona}");
 731
 0732        if (await _repo.ExistePersonaEnLoteAsync(loteId, relacion.PersonaId))
 0733            return (false, null, "Ya existe un ítem para esa persona en este lote");
 734
 0735        var config = await _repo.GetConfigVigenteAsync(lote.TipoCompensacionId, lote.Periodo);
 0736        if (config?.TopeUnidades.HasValue == true && request.UnidadCantidad > config.TopeUnidades.Value)
 0737            return (false, null, $"unidad_cantidad excede el tope máximo de {config.TopeUnidades} unidades");
 738
 0739        if (config != null)
 740        {
 0741            var (formulaOk, _, formulaError) = await AplicarFormulaAsync(
 0742                loteId, config, request.UnidadCantidad, request.Monto,
 0743                request.Ala, request.CategoriaCompensacion, relacion.GradoId, lote.Periodo);
 0744            if (!formulaOk) return (false, null, formulaError);
 745        }
 746
 747        // Validación aprobada — no se persiste
 0748        return (true, null, null);
 0749    }
 750
 751    // ── Helpers ──────────────────────────────────────────────────────────────
 752
 753    // ── Lógica de fórmulas ───────────────────────────────────────────────────
 754
 755    /// Aplica la fórmula de la configuración para calcular o validar el monto del ítem.
 756    /// Devuelve el monto final que debe persistirse.
 757    private async Task<(bool Success, decimal? MontoCalculado, string? Error)> AplicarFormulaAsync(
 758        long loteId,
 759        ConfigCompensacion config,
 760        decimal unidadCantidad,
 761        decimal? montoEntrada,
 762        string? ala = null,
 763        string? categoria = null,
 764        long? snapshotGradoId = null,
 765        DateTime? periodo = null)
 766    {
 21767        switch (config.Formula)
 768        {
 769            case "fijo":
 5770                return (true, config.Monto, null);
 771
 772            case "prorrateo":
 3773                if (!config.ValorPorUnidad.HasValue)
 1774                    return (false, null, "La configuración de prorrateo no tiene valor_por_unidad definido");
 2775                return (true, unidadCantidad * config.ValorPorUnidad.Value, null);
 776
 777            case "rango":
 5778                if (!montoEntrada.HasValue)
 1779                    return (false, null, "Se debe proveer monto para fórmula rango");
 4780                if (config.ValorMinimo.HasValue && montoEntrada.Value < config.ValorMinimo.Value)
 1781                    return (false, null, $"El monto {montoEntrada.Value:F2} es menor al mínimo permitido de {config.Valo
 3782                if (config.ValorMaximo.HasValue && montoEntrada.Value > config.ValorMaximo.Value)
 2783                    return (false, null, $"El monto {montoEntrada.Value:F2} supera el máximo permitido de {config.ValorM
 1784                return (true, montoEntrada, null);
 785
 786            case "presupuesto":
 3787                if (!montoEntrada.HasValue)
 1788                    return (false, null, "Se debe proveer monto para fórmula presupuesto");
 2789                if (config.Tope.HasValue)
 790                {
 2791                    var totalActual = await _repo.GetMontoTotalItemsAsync(loteId);
 2792                    if (totalActual + montoEntrada.Value > config.Tope.Value)
 1793                        return (false, null,
 1794                            $"El monto supera el tope presupuestario. Disponible: {config.Tope.Value - totalActual:F2}, 
 795                }
 1796                return (true, montoEntrada, null);
 797
 798            case "tabla":
 799                // Subcaso 1: VUELO — tarifa por categoría (ala A/N/S)
 5800                if (!string.IsNullOrEmpty(ala) && config.CatalogoItemId.HasValue)
 801                {
 2802                    var fechaRef = periodo ?? DateTime.UtcNow;
 2803                    var tarifa = await _repo.GetTarifaCategoriaAsync(config.CatalogoItemId.Value, ala, fechaRef);
 2804                    if (tarifa == null)
 1805                        return (false, null, $"No se encontró tarifa para categoría de vuelo '{ala}'. Verifique que la c
 1806                    return (true, tarifa.Monto, null);
 807                }
 808
 809                // Subcaso 2: SAR — tarifa por categoría (función)
 3810                if (!string.IsNullOrEmpty(categoria) && config.CatalogoItemId.HasValue)
 811                {
 1812                    var fechaRef = periodo ?? DateTime.UtcNow;
 1813                    var tarifa = await _repo.GetTarifaCategoriaAsync(config.CatalogoItemId.Value, categoria, fechaRef);
 1814                    if (tarifa == null)
 0815                        return (false, null, $"No se encontró tarifa para categoría '{categoria}'. Verifique que la conf
 1816                    return (true, tarifa.Monto, null);
 817                }
 818
 819                // Subcaso 3: CARCEL — tarifa diaria por grado
 2820                if (snapshotGradoId.HasValue && config.CatalogoItemId.HasValue)
 821                {
 1822                    var fechaRef = periodo ?? DateTime.UtcNow;
 1823                    var tarifaGrado = await _repo.GetTarifaGradoAsync(config.CatalogoItemId.Value, snapshotGradoId.Value
 1824                    if (tarifaGrado != null)
 1825                        return (true, unidadCantidad * tarifaGrado.Monto, null);
 826                    // Si no hay tarifa por grado, cae al monto del CSV
 827                }
 828
 829                // Default: monto viene precalculado del origen externo (COMP_PERS, etc.)
 1830                return (true, montoEntrada, null);
 831
 832            default:
 0833                return (false, null, $"Fórmula '{config.Formula}' no reconocida");
 834        }
 21835    }
 836
 837    private static string? ValidarFormula(string formula, decimal? monto, decimal? valorPorUnidad, decimal? valorMinimo,
 838    {
 8839        if (!FormulasValidas.Contains(formula))
 0840            return $"formula inválida. Valores aceptados: {string.Join(", ", FormulasValidas)}";
 841
 8842        if (formula == "fijo" && !monto.HasValue)
 1843            return "monto es obligatorio para fórmula fijo";
 844
 7845        if (formula == "prorrateo" && !valorPorUnidad.HasValue)
 1846            return "valor_por_unidad es obligatorio para fórmula prorrateo";
 847
 6848        if (formula == "rango")
 849        {
 1850            if (!valorMinimo.HasValue || !valorMaximo.HasValue)
 0851                return "valor_minimo y valor_maximo son obligatorios para fórmula rango";
 1852            if (valorMinimo.Value >= valorMaximo.Value)
 1853                return "valor_minimo debe ser menor que valor_maximo";
 854        }
 855
 5856        if (topeUnidades.HasValue && topeUnidades.Value < 0)
 0857            return "tope_unidades debe ser mayor o igual a 0";
 858
 5859        return null;
 860    }
 861
 4862    private static TipoCompensacionDto MapTipoToDto(TipoCompensacion t) => new()
 4863    {
 4864        Id = t.Id,
 4865        Codigo = t.Codigo,
 4866        Nombre = t.Nombre,
 4867        UnidadMedida = t.UnidadMedida,
 4868        RequiereFuente = t.RequiereFuente,
 4869        Vigente = t.Vigente
 4870    };
 871
 4872    private static ConfigCompensacionDto MapConfigToDto(ConfigCompensacion c) => new()
 4873    {
 4874        Id = c.Id,
 4875        TipoCompensacionId = c.TipoCompensacionId,
 4876        NombreTipo = c.TipoCompensacion?.Nombre ?? string.Empty,
 4877        RegimenId = c.RegimenId,
 4878        NombreRegimen = c.Regimen?.Denominacion,
 4879        GradoId = c.GradoId,
 4880        NombreGrado = c.Grado?.Denominacion,
 4881        EscalafonId = c.EscalafonId,
 4882        NombreEscalafon = c.Escalafon?.Denominacion,
 4883        Funcion = c.Funcion,
 4884        UnidadMedida = c.UnidadMedida,
 4885        Formula = c.Formula,
 4886        ValorPorUnidad = c.ValorPorUnidad,
 4887        Monto = c.Monto,
 4888        Tope = c.Tope,
 4889        TopeUnidades = c.TopeUnidades,
 4890        ValorMinimo = c.ValorMinimo,
 4891        ValorMaximo = c.ValorMaximo,
 4892        VigenteDesde = c.VigenteDesde,
 4893        VigenteHasta = c.VigenteHasta,
 4894        Activo = c.Activo,
 4895        SujetoDescLegales = c.SujetoDescLegales,
 4896        CatalogoItemId = c.CatalogoItemId,
 4897        NombreCatalogoItem = c.CatalogoItem?.Nombre
 4898    };
 899
 0900    private static LoteCompensacionDto MapLoteToDto(LoteCompensacion l) => new()
 0901    {
 0902        Id = l.Id,
 0903        TipoCompensacionId = l.TipoCompensacionId,
 0904        NombreTipo = l.TipoCompensacion?.Nombre ?? string.Empty,
 0905        UnidadMedida = l.TipoCompensacion?.UnidadMedida ?? string.Empty,
 0906        Periodo = l.Periodo,
 0907        Estado = l.Estado,
 0908        Fuente = l.Fuente,
 0909        NombreArchivo = l.NombreArchivo,
 0910        UsuarioId = l.UsuarioId,
 0911        AprobadoPor = l.AprobadoPor,
 0912        NombreAprobador = l.AprobadorUsuario?.Username,
 0913        AprobadoEn = l.AprobadoEn,
 0914        CreadoEn = l.CreadoEn,
 0915        ItemCount = l.ItemCount > 0 ? l.ItemCount : l.Items.Count(),
 0916        Items = l.Items.Select(i => MapItemToDto(i, l.TipoCompensacion?.UnidadMedida ?? string.Empty))
 0917    };
 918
 919    private static string? NullIfEmpty(string? s) =>
 0920        string.IsNullOrWhiteSpace(s) ? null : s;
 921
 2922    private static ItemLoteCompensacionDto MapItemToDto(ItemLoteCompensacion i, string unidadMedida = "") => new()
 2923    {
 2924        Id = i.Id,
 2925        LoteCompensacionId = i.LoteCompensacionId,
 2926        CedulaPersona = i.Persona?.Cedula ?? string.Empty,
 2927        NombrePersona = i.Persona?.NombreCompleto ?? string.Empty,
 2928        UnidadCantidad = i.UnidadCantidad,
 2929        UnidadMedida = unidadMedida,
 2930        Monto = i.Monto,
 2931        Estado = i.Estado,
 2932        Incidencia = i.Incidencia,
 2933        Observaciones = i.Observaciones,
 2934        Funcion = i.Funcion,
 2935        SujetoDescLegales     = i.SujetoDescLegales,
 2936        Ala                   = i.Ala,
 2937        CategoriaCompensacion = i.CategoriaCompensacion,
 2938        SnapshotGrado         = i.SnapshotGrado?.Denominacion,
 2939        SnapshotSituacion     = i.SnapshotSituacion?.Denominacion,
 2940        SnapshotUnidad        = i.SnapshotUnidad?.Denominacion,
 2941        SnapshotPrograma      = i.SnapshotPrograma?.Codigo
 2942    };
 943}