< 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: 459
Uncovered lines: 166
Coverable lines: 625
Total lines: 953
Line coverage: 73.4%
Branch coverage
56%
Covered branches: 257
Total branches: 458
Branch coverage: 56.1%
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.71%
DeleteItemAsync()100%88100%
RevertirABorradorSiValidadoAsync()100%22100%
CreateItemsBulkAsync()0%506220%
CreateItemsFromCsvAsync()0%6480800%
ValidarItemAsync()0%272160%
AplicarFormulaAsync()93.75%484895.12%
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        await _periodoGuard.VerificarPeriodoAbiertoAsync();
 8289        var lote = await _repo.GetLoteByIdAsync(id);
 9290        if (lote == null) return (false, null, "Lote no encontrado");
 291
 7292        if (!EstadosEditablesLote.Contains(lote.Estado))
 1293            return (false, null, $"No se puede editar un lote en estado '{lote.Estado}'");
 294
 6295        if (request.Estado != null)
 296        {
 6297            if (!TransicionesPermitidas.TryGetValue(lote.Estado, out var permitidos) ||
 12298                !Array.Exists(permitidos, e => e == request.Estado))
 1299                return (false, null, $"Transición no permitida: '{lote.Estado}' → '{request.Estado}'. Permitidas: {strin
 300
 301            // Validación al pasar a 'validado'
 5302            if (request.Estado == "validado")
 303            {
 4304                var (validOk, validError) = await ValidarLoteParaValidarAsync(lote);
 7305                if (!validOk) return (false, null, validError);
 306            }
 307
 2308            lote.Estado = request.Estado;
 309        }
 310
 2311        if (request.Fuente != null) lote.Fuente = request.Fuente;
 2312        if (request.NombreArchivo != null) lote.NombreArchivo = request.NombreArchivo;
 313
 2314        var actualizado = await _repo.UpdateLoteAsync(lote);
 2315        await _auditoriaService.LogAuditoriaAsync(_currentUser.UserId ?? 0, AccionEnum.ActualizarLoteCompensacion, Conte
 2316        return (true, actualizado, null);
 8317    }
 318
 319    private async Task<(bool Success, string? Error)> ValidarLoteParaValidarAsync(LoteCompensacion lote)
 320    {
 4321        var totalItems = await _repo.GetItemCountAsync(lote.Id);
 4322        if (totalItems == 0)
 1323            return (false, "El lote no tiene ítems. Debe cargar al menos uno antes de validar.");
 324
 3325        var itemsError = await _repo.GetItemsEnErrorCountAsync(lote.Id);
 3326        if (itemsError > 0)
 1327            return (false, $"El lote tiene {itemsError} ítem(s) en estado 'error'. Corrija o elimine esos ítems antes de
 328
 2329        var config = await _repo.GetConfigVigenteAsync(lote.TipoCompensacionId, lote.Periodo);
 2330        if (config == null)
 1331            return (false, "No existe configuración activa para este tipo de compensación en el período del lote.");
 332
 1333        return (true, null);
 4334    }
 335
 336    public async Task<(bool Success, LoteCompensacion? Lote, string? Error)> AprobarLoteAsync(long id, long aprobadoPorI
 337    {
 3338        await _periodoGuard.VerificarPeriodoAbiertoAsync();
 339
 3340        var lote = await _repo.GetLoteByIdAsync(id);
 3341        if (lote == null) return (false, null, "Lote no encontrado");
 342
 3343        if (lote.Estado != "validado")
 2344            return (false, null, $"Solo se pueden aprobar lotes en estado 'validado'. Estado actual: '{lote.Estado}'");
 345
 1346        lote.Estado = "aprobado";
 1347        lote.AprobadoPor = aprobadoPorId;
 1348        lote.AprobadoEn = DateTime.UtcNow;
 349
 1350        var actualizado = await _repo.UpdateLoteAsync(lote);
 1351        _logger.LogInformation("Lote {Id} aprobado por usuario {UsuarioId}", id, aprobadoPorId);
 1352        await _auditoriaService.LogAuditoriaAsync(aprobadoPorId, AccionEnum.AprobarLoteCompensacion, ContextoEnum.Compen
 1353        return (true, actualizado, null);
 3354    }
 355
 356    public async Task<(bool Success, LoteCompensacion? Lote, string? Error)> RechazarLoteAsync(long id)
 357    {
 5358        await _periodoGuard.VerificarPeriodoAbiertoAsync();
 5359        var lote = await _repo.GetLoteByIdAsync(id);
 6360        if (lote == null) return (false, null, "Lote no encontrado");
 361
 4362        if (lote.Estado is "exportado" or "rechazado")
 2363            return (false, null, $"No se puede rechazar un lote en estado '{lote.Estado}'");
 364
 2365        lote.Estado = "rechazado";
 2366        var actualizado = await _repo.UpdateLoteAsync(lote);
 2367        _logger.LogInformation("Lote {Id} rechazado", id);
 2368        await _auditoriaService.LogAuditoriaAsync(_currentUser.UserId ?? 0, AccionEnum.RechazarLoteCompensacion, Context
 2369        return (true, actualizado, null);
 5370    }
 371
 372    public async Task<(bool Success, LoteCompensacion? Lote, string? Error)> RevertirABorradorAsync(long id)
 373    {
 4374        await _periodoGuard.VerificarPeriodoAbiertoAsync();
 4375        var lote = await _repo.GetLoteByIdAsync(id);
 5376        if (lote == null) return (false, null, "Lote no encontrado");
 377
 3378        if (lote.Estado != "aprobado")
 2379            return (false, null, $"Solo se pueden revertir lotes en estado 'aprobado'. Estado actual: '{lote.Estado}'");
 380
 1381        lote.Estado = "borrador";
 1382        lote.AprobadoPor = null;
 1383        lote.AprobadoEn = null;
 384
 1385        var actualizado = await _repo.UpdateLoteAsync(lote);
 1386        _logger.LogInformation("Lote {Id} revertido a borrador", id);
 1387        await _auditoriaService.LogAuditoriaAsync(_currentUser.UserId ?? 0, AccionEnum.RevertirLoteCompensacion, Context
 1388        return (true, actualizado, null);
 4389    }
 390
 391    public async Task<(bool Success, string? Error)> CancelarLoteAsync(long id)
 392    {
 5393        await _periodoGuard.VerificarPeriodoAbiertoAsync();
 5394        var lote = await _repo.GetLoteByIdAsync(id);
 6395        if (lote == null) return (false, "Lote no encontrado");
 396
 4397        if (lote.Estado is not ("borrador" or "rechazado"))
 2398            return (false, $"Solo se pueden eliminar lotes en estado 'borrador' o 'rechazado'. Estado actual: '{lote.Est
 399
 2400        await _auditoriaService.LogAuditoriaAsync(_currentUser.UserId ?? 0, AccionEnum.EliminarLoteCompensacion, Context
 2401        await _repo.DeleteAllItemsAsync(id);
 2402        await _repo.DeleteLoteAsync(lote);
 2403        _logger.LogInformation("Lote {Id} cancelado y eliminado", id);
 2404        return (true, null);
 5405    }
 406
 407    public async Task<PagedResult<ItemLoteCompensacionDto>> GetItemsPagedAsync(long loteId, int page, int pageSize)
 408    {
 1409        var lote = await _repo.GetLoteByIdAsync(loteId);
 1410        var unidadMedida = lote != null
 1411            ? (await _repo.GetTipoByIdAsync(lote.TipoCompensacionId))?.UnidadMedida ?? string.Empty
 1412            : string.Empty;
 413
 1414        var (items, totalCount) = await _repo.GetItemsPagedAsync(loteId, page, pageSize);
 1415        return new PagedResult<ItemLoteCompensacionDto>
 1416        {
 2417            Items = items.Select(i => MapItemToDto(i, unidadMedida)),
 1418            Page = page,
 1419            PageSize = pageSize,
 1420            TotalCount = totalCount
 1421        };
 1422    }
 423
 424    // ── Items ────────────────────────────────────────────────────────────────
 425
 426    public async Task<(bool Success, ItemLoteCompensacion? Item, string? Error)> CreateItemAsync(long loteId, CreateItem
 427    {
 23428        await _periodoGuard.VerificarPeriodoAbiertoAsync();
 23429        var lote = await _repo.GetLoteByIdAsync(loteId);
 23430        if (lote == null) return (false, null, "Lote no encontrado");
 431
 23432        if (!EstadosEditablesLote.Contains(lote.Estado))
 1433            return (false, null, $"No se pueden agregar ítems a un lote en estado '{lote.Estado}'");
 434
 435        // Resolver cédula → personaId
 22436        var relacion = await _personalRepo.GetByCedulaAsync(request.CedulaPersona);
 22437        if (relacion == null)
 1438            return (false, null, $"No se encontró personal activo con cédula {request.CedulaPersona}");
 439
 21440        var personaId = relacion.PersonaId;
 441
 21442        if (await _repo.ExistePersonaEnLoteAsync(loteId, personaId))
 1443            return (false, null, "Ya existe un ítem para esa persona en este lote");
 444
 20445        var config = await _repo.GetConfigVigenteAsync(lote.TipoCompensacionId, lote.Periodo);
 20446        if (config?.TopeUnidades.HasValue == true && request.UnidadCantidad > config.TopeUnidades.Value)
 1447            return (false, null, $"unidad_cantidad excede el tope máximo de {config.TopeUnidades} unidades");
 448
 449        // Aplicar fórmula de la configuración vigente
 19450        decimal? montoFinal = request.Monto;
 19451        if (config != null)
 452        {
 19453            var (formulaOk, montoCalculado, formulaError) = await AplicarFormulaAsync(
 19454                loteId, config, request.UnidadCantidad, request.Monto,
 19455                request.Ala, request.CategoriaCompensacion, relacion.GradoId, lote.Periodo);
 26456            if (!formulaOk) return (false, null, formulaError);
 12457            montoFinal = montoCalculado;
 458        }
 459
 460        // SDL: derivado de la configuración del tipo — no del ítem individual
 12461        var sujetoDescLegales = config?.SujetoDescLegales ?? false;
 462
 12463        var item = new ItemLoteCompensacion
 12464        {
 12465            LoteCompensacionId  = loteId,
 12466            PersonaId           = personaId,
 12467            UnidadCantidad      = request.UnidadCantidad,
 12468            Monto               = montoFinal,
 12469            Estado              = "ok",
 12470            Incidencia          = request.Incidencia,
 12471            Observaciones       = request.Observaciones,
 12472            Funcion             = request.Funcion,
 12473            Ala                 = request.Ala,
 12474            CategoriaCompensacion = request.CategoriaCompensacion,
 12475            SujetoDescLegales   = sujetoDescLegales,
 12476            // Snapshots de la relación laboral activa al momento de carga
 12477            SnapshotGradoId     = relacion.GradoId,
 12478            SnapshotSituacionId = relacion.SituacionId,
 12479            SnapshotUnidadId    = relacion.UnidadId,
 12480            SnapshotProgramaId  = relacion.ProgramaId
 12481        };
 482
 12483        var creado = await _repo.CreateItemAsync(item);
 12484        await _auditoriaService.LogAuditoriaAsync(_currentUser.UserId ?? 0, AccionEnum.CrearItemLoteCompensacion, Contex
 12485        await RevertirABorradorSiValidadoAsync(lote);
 12486        return (true, creado, null);
 23487    }
 488
 489    public async Task<(bool Success, ItemLoteCompensacion? Item, string? Error)> UpdateItemAsync(long loteId, long itemI
 490    {
 2491        await _periodoGuard.VerificarPeriodoAbiertoAsync();
 2492        var lote = await _repo.GetLoteByIdAsync(loteId);
 2493        if (lote == null) return (false, null, "Lote no encontrado");
 494
 2495        if (!EstadosEditablesLote.Contains(lote.Estado))
 0496            return (false, null, $"No se pueden editar ítems de un lote en estado '{lote.Estado}'");
 497
 2498        var item = await _repo.GetItemByIdAsync(itemId);
 2499        if (item == null || item.LoteCompensacionId != loteId)
 0500            return (false, null, "Ítem no encontrado");
 501
 2502        if (request.Ala != null) item.Ala = request.Ala;
 2503        if (request.CategoriaCompensacion != null) item.CategoriaCompensacion = request.CategoriaCompensacion;
 504
 2505        if (request.UnidadCantidad.HasValue || request.Monto.HasValue || request.Ala != null || request.CategoriaCompens
 506        {
 2507            var config = await _repo.GetConfigVigenteAsync(lote.TipoCompensacionId, lote.Periodo);
 508
 2509            if (request.UnidadCantidad.HasValue)
 510            {
 1511                if (config?.TopeUnidades.HasValue == true && request.UnidadCantidad.Value > config.TopeUnidades.Value)
 0512                    return (false, null, $"unidad_cantidad excede el tope máximo de {config.TopeUnidades} unidades");
 1513                item.UnidadCantidad = request.UnidadCantidad.Value;
 514            }
 515
 2516            if (config != null)
 517            {
 2518                var nuevaCantidad = item.UnidadCantidad;
 2519                var nuevoMonto = request.Monto ?? item.Monto;
 2520                var (formulaOk, montoCalculado, formulaError) = await AplicarFormulaAsync(
 2521                    loteId, config, nuevaCantidad, nuevoMonto,
 2522                    item.Ala, item.CategoriaCompensacion, item.SnapshotGradoId, lote.Periodo);
 3523                if (!formulaOk) return (false, null, formulaError);
 1524                item.Monto = montoCalculado;
 525            }
 0526            else if (request.Monto.HasValue)
 527            {
 0528                item.Monto = request.Monto;
 529            }
 530        }
 531
 1532        if (request.Estado != null) item.Estado = request.Estado;
 1533        if (request.Incidencia != null) item.Incidencia = request.Incidencia;
 1534        if (request.Observaciones != null) item.Observaciones = request.Observaciones;
 1535        if (request.Funcion != null) item.Funcion = request.Funcion;
 536
 1537        var actualizado = await _repo.UpdateItemAsync(item);
 1538        await _auditoriaService.LogAuditoriaAsync(_currentUser.UserId ?? 0, AccionEnum.ActualizarItemLoteCompensacion, C
 1539        await RevertirABorradorSiValidadoAsync(lote);
 1540        return (true, actualizado, null);
 2541    }
 542
 543    public async Task<(bool Success, string? Error)> DeleteItemAsync(long loteId, long itemId)
 544    {
 6545        await _periodoGuard.VerificarPeriodoAbiertoAsync();
 6546        var lote = await _repo.GetLoteByIdAsync(loteId);
 7547        if (lote == null) return (false, "Lote no encontrado");
 548
 5549        if (!EstadosEditablesLote.Contains(lote.Estado))
 1550            return (false, $"No se pueden eliminar ítems de un lote en estado '{lote.Estado}'");
 551
 4552        var item = await _repo.GetItemByIdAsync(itemId);
 4553        if (item == null || item.LoteCompensacionId != loteId)
 2554            return (false, "Ítem no encontrado");
 555
 2556        await _auditoriaService.LogAuditoriaAsync(_currentUser.UserId ?? 0, AccionEnum.EliminarItemLoteCompensacion, Con
 2557        await _repo.DeleteItemAsync(item);
 2558        await RevertirABorradorSiValidadoAsync(lote);
 2559        return (true, null);
 6560    }
 561
 562    /// Si el lote estaba en 'validado', cualquier edición de ítems lo regresa a 'borrador'
 563    private async Task RevertirABorradorSiValidadoAsync(LoteCompensacion lote)
 564    {
 15565        if (lote.Estado == "validado")
 566        {
 1567            lote.Estado = "borrador";
 1568            await _repo.UpdateLoteAsync(lote);
 1569            _logger.LogInformation("Lote {Id} revertido a borrador por edición de ítems", lote.Id);
 570        }
 15571    }
 572
 573    // ── Bulk Import ──────────────────────────────────────────────────────────
 574
 575    public async Task<BulkImportResultDto> CreateItemsBulkAsync(long loteId, IEnumerable<CreateItemLoteRequest> items, s
 576    {
 0577        if (!dryRun) await _periodoGuard.VerificarPeriodoAbiertoAsync();
 0578        var lote = await _repo.GetLoteByIdAsync(loteId);
 0579        if (lote == null)
 0580            return new BulkImportResultDto { Fallidos = 1, Procesados = 1, Resultados = [new() { Linea = 0, Cedula = "",
 581
 0582        if (!dryRun && !EstadosEditablesLote.Contains(lote.Estado))
 0583            return new BulkImportResultDto { Fallidos = 1, Procesados = 1, Resultados = [new() { Linea = 0, Cedula = "",
 584
 0585        var resultado = new BulkImportResultDto { EsDryRun = dryRun, Modo = modo };
 586
 0587        if (!dryRun && modo == "reemplazar")
 588        {
 0589            await _repo.DeleteAllItemsAsync(loteId);
 0590            if (lote.Estado == "validado") { lote.Estado = "borrador"; await _repo.UpdateLoteAsync(lote); }
 591        }
 592
 0593        int linea = 1;
 0594        foreach (var request in items)
 595        {
 0596            resultado.Procesados++;
 0597            var (success, item, error) = dryRun
 0598                ? await ValidarItemAsync(loteId, lote, request)
 0599                : await CreateItemAsync(loteId, request);
 600
 0601            resultado.Resultados.Add(new BulkImportLineResultDto
 0602            {
 0603                Linea = linea++,
 0604                Cedula = request.CedulaPersona,
 0605                Exito = success,
 0606                Error = error,
 0607                ItemId = item?.Id
 0608            });
 0609            if (success) resultado.Exitosos++;
 0610            else resultado.Fallidos++;
 0611        }
 612
 0613        return resultado;
 0614    }
 615
 616    public async Task<BulkImportResultDto> CreateItemsFromCsvAsync(long loteId, Stream csvStream, string modo = "reempla
 617    {
 0618        var lote = await _repo.GetLoteByIdAsync(loteId);
 0619        if (lote == null)
 0620            return new BulkImportResultDto { Fallidos = 1, Procesados = 1, Resultados = [new() { Linea = 0, Cedula = "",
 621
 0622        if (!dryRun && !EstadosEditablesLote.Contains(lote.Estado))
 0623            return new BulkImportResultDto { Fallidos = 1, Procesados = 1, Resultados = [new() { Linea = 0, Cedula = "",
 624
 0625        if (!dryRun && modo == "reemplazar")
 626        {
 0627            await _repo.DeleteAllItemsAsync(loteId);
 0628            if (lote.Estado == "validado") { lote.Estado = "borrador"; await _repo.UpdateLoteAsync(lote); }
 629        }
 630
 631        // Para fórmulas fijo y prorrateo el monto lo calcula el sistema; para las demás el CSV debe proveerlo
 0632        var configCsv = await _repo.GetConfigVigenteAsync(lote.TipoCompensacionId, lote.Periodo);
 0633        var formulaCalculaMontoAutomatico = configCsv?.Formula is "fijo" or "prorrateo";
 634        // Para tabla con ala o categoria, el monto también se calcula desde la tarifa
 0635        var formulaTablaConLookup = configCsv?.Formula == "tabla" && configCsv.CatalogoItemId.HasValue;
 636
 0637        var resultado = new BulkImportResultDto { EsDryRun = dryRun, Modo = modo };
 0638        using var reader = new System.IO.StreamReader(csvStream);
 639
 640        // Leer encabezados
 0641        var headerLine = await reader.ReadLineAsync();
 0642        if (string.IsNullOrWhiteSpace(headerLine))
 0643            return resultado;
 644
 0645        var headers = headerLine.Split(',').Select(h => h.Trim().ToLower()).ToArray();
 0646        int idxCedula         = Array.IndexOf(headers, "cedula");
 0647        int idxMonto          = Array.IndexOf(headers, "monto");
 0648        int idxUnidad         = Array.IndexOf(headers, "unidad_cantidad");
 0649        int idxFuncion        = Array.IndexOf(headers, "funcion");
 0650        int idxAla            = Array.IndexOf(headers, "ala");
 0651        int idxCategoria      = Array.IndexOf(headers, "categoria");
 0652        int idxIncidencia     = Array.IndexOf(headers, "incidencia");
 0653        int idxObservaciones  = Array.IndexOf(headers, "observaciones");
 654
 0655        if (idxCedula < 0)
 0656            return new BulkImportResultDto { Fallidos = 1, Procesados = 1, Resultados = [new() { Linea = 1, Cedula = "",
 657
 0658        int linea = 2;
 0659        while (!reader.EndOfStream)
 660        {
 0661            var line = await reader.ReadLineAsync();
 0662            if (string.IsNullOrWhiteSpace(line)) { linea++; continue; }
 663
 0664            var cols = line.Split(',');
 0665            var cedula = idxCedula < cols.Length ? cols[idxCedula].Trim() : "";
 666
 0667            resultado.Procesados++;
 668
 0669            if (string.IsNullOrWhiteSpace(cedula))
 670            {
 0671                resultado.Fallidos++;
 0672                resultado.Resultados.Add(new() { Linea = linea, Cedula = cedula, Exito = false, Error = "Cédula vacía" }
 0673                linea++;
 0674                continue;
 675            }
 676
 0677            decimal monto = 0;
 0678            if (idxMonto >= 0 && idxMonto < cols.Length)
 0679                decimal.TryParse(cols[idxMonto].Trim(), System.Globalization.NumberStyles.Any, System.Globalization.Cult
 680
 0681            var ala       = idxAla < 0 || idxAla >= cols.Length ? null : NullIfEmpty(cols[idxAla].Trim()?.ToUpper());
 0682            var categoria = idxCategoria < 0 || idxCategoria >= cols.Length ? null : NullIfEmpty(cols[idxCategoria].Trim
 683
 684            // El monto no es requerido si la fórmula lo calcula automáticamente
 685            // (fijo, prorrateo, o tabla con ala/categoria)
 0686            var montoEsAutomatico = formulaCalculaMontoAutomatico || formulaTablaConLookup;
 687
 0688            if (!montoEsAutomatico && monto <= 0)
 689            {
 0690                resultado.Fallidos++;
 0691                resultado.Resultados.Add(new() { Linea = linea, Cedula = cedula, Exito = false, Error = "El monto debe s
 0692                linea++;
 0693                continue;
 694            }
 695
 0696            decimal unidadCantidad = 1;
 0697            if (idxUnidad >= 0 && idxUnidad < cols.Length && !string.IsNullOrWhiteSpace(cols[idxUnidad]))
 0698                decimal.TryParse(cols[idxUnidad].Trim(), System.Globalization.NumberStyles.Any, System.Globalization.Cul
 699
 0700            var funcion      = idxFuncion < 0 || idxFuncion >= cols.Length ? null : NullIfEmpty(cols[idxFuncion].Trim())
 0701            var incidencia   = idxIncidencia < 0 || idxIncidencia >= cols.Length ? null : NullIfEmpty(cols[idxIncidencia
 0702            var observaciones = idxObservaciones < 0 || idxObservaciones >= cols.Length ? null : NullIfEmpty(cols[idxObs
 703
 0704            var request = new CreateItemLoteRequest
 0705            {
 0706                CedulaPersona         = cedula,
 0707                Monto                 = monto > 0 ? monto : null,
 0708                UnidadCantidad        = unidadCantidad,
 0709                Funcion               = funcion,
 0710                Ala                   = ala,
 0711                CategoriaCompensacion = categoria,
 0712                Incidencia            = incidencia,
 0713                Observaciones         = observaciones
 0714            };
 715
 0716            var (success, item, error) = dryRun
 0717                ? await ValidarItemAsync(loteId, lote, request)
 0718                : await CreateItemAsync(loteId, request);
 0719            resultado.Resultados.Add(new() { Linea = linea, Cedula = cedula, Exito = success, Error = error, ItemId = it
 0720            if (success) resultado.Exitosos++;
 0721            else resultado.Fallidos++;
 722
 0723            linea++;
 0724        }
 725
 0726        return resultado;
 0727    }
 728
 729    /// Valida un ítem sin persistir (dry run). Replica las mismas validaciones de CreateItemAsync.
 730    private async Task<(bool Success, ItemLoteCompensacion? Item, string? Error)> ValidarItemAsync(
 731        long loteId, LoteCompensacion lote, CreateItemLoteRequest request)
 732    {
 0733        if (!EstadosEditablesLote.Contains(lote.Estado))
 0734            return (false, null, $"Lote en estado '{lote.Estado}' no es editable");
 735
 0736        var relacion = await _personalRepo.GetByCedulaAsync(request.CedulaPersona);
 0737        if (relacion == null)
 0738            return (false, null, $"No se encontró personal activo con cédula {request.CedulaPersona}");
 739
 0740        if (await _repo.ExistePersonaEnLoteAsync(loteId, relacion.PersonaId))
 0741            return (false, null, "Ya existe un ítem para esa persona en este lote");
 742
 0743        var config = await _repo.GetConfigVigenteAsync(lote.TipoCompensacionId, lote.Periodo);
 0744        if (config?.TopeUnidades.HasValue == true && request.UnidadCantidad > config.TopeUnidades.Value)
 0745            return (false, null, $"unidad_cantidad excede el tope máximo de {config.TopeUnidades} unidades");
 746
 0747        if (config != null)
 748        {
 0749            var (formulaOk, _, formulaError) = await AplicarFormulaAsync(
 0750                loteId, config, request.UnidadCantidad, request.Monto,
 0751                request.Ala, request.CategoriaCompensacion, relacion.GradoId, lote.Periodo);
 0752            if (!formulaOk) return (false, null, formulaError);
 753        }
 754
 755        // Validación aprobada — no se persiste
 0756        return (true, null, null);
 0757    }
 758
 759    // ── Helpers ──────────────────────────────────────────────────────────────
 760
 761    // ── Lógica de fórmulas ───────────────────────────────────────────────────
 762
 763    /// Aplica la fórmula de la configuración para calcular o validar el monto del ítem.
 764    /// Devuelve el monto final que debe persistirse.
 765    private async Task<(bool Success, decimal? MontoCalculado, string? Error)> AplicarFormulaAsync(
 766        long loteId,
 767        ConfigCompensacion config,
 768        decimal unidadCantidad,
 769        decimal? montoEntrada,
 770        string? ala = null,
 771        string? categoria = null,
 772        long? snapshotGradoId = null,
 773        DateTime? periodo = null)
 774    {
 775        // periodo siempre es lote.Periodo (DateTime requerido) — el fallback a UtcNow no debería ejecutarse
 21776        var periodoRef = periodo ?? DateTime.UtcNow;
 21777        switch (config.Formula)
 778        {
 779            case "fijo":
 5780                return (true, config.Monto, null);
 781
 782            case "prorrateo":
 3783                if (!config.ValorPorUnidad.HasValue)
 1784                    return (false, null, "La configuración de prorrateo no tiene valor_por_unidad definido");
 2785                return (true, unidadCantidad * config.ValorPorUnidad.Value, null);
 786
 787            case "rango":
 5788                if (!montoEntrada.HasValue)
 1789                    return (false, null, "Se debe proveer monto para fórmula rango");
 4790                if (config.ValorMinimo.HasValue && montoEntrada.Value < config.ValorMinimo.Value)
 1791                    return (false, null, $"El monto {montoEntrada.Value:F2} es menor al mínimo permitido de {config.Valo
 3792                if (config.ValorMaximo.HasValue && montoEntrada.Value > config.ValorMaximo.Value)
 2793                    return (false, null, $"El monto {montoEntrada.Value:F2} supera el máximo permitido de {config.ValorM
 1794                return (true, montoEntrada, null);
 795
 796            case "presupuesto":
 3797                if (!montoEntrada.HasValue)
 1798                    return (false, null, "Se debe proveer monto para fórmula presupuesto");
 2799                if (config.Tope.HasValue)
 800                {
 2801                    var totalActual = await _repo.GetMontoTotalItemsAsync(loteId);
 2802                    if (totalActual + montoEntrada.Value > config.Tope.Value)
 1803                        return (false, null,
 1804                            $"El monto supera el tope presupuestario. Disponible: {config.Tope.Value - totalActual:F2}, 
 805                }
 1806                return (true, montoEntrada, null);
 807
 808            case "tabla":
 809                // Subcaso 1: VUELO — tarifa por categoría (ala A/N/S)
 5810                if (!string.IsNullOrEmpty(ala) && config.CatalogoItemId.HasValue)
 811                {
 2812                    var fechaRef = periodoRef;
 2813                    var tarifa = await _repo.GetTarifaCategoriaAsync(config.CatalogoItemId.Value, ala, fechaRef);
 2814                    if (tarifa == null)
 1815                        return (false, null, $"No se encontró tarifa para categoría de vuelo '{ala}'. Verifique que la c
 1816                    return (true, tarifa.Monto, null);
 817                }
 818
 819                // Subcaso 2: SAR — tarifa por categoría (función)
 3820                if (!string.IsNullOrEmpty(categoria) && config.CatalogoItemId.HasValue)
 821                {
 1822                    var fechaRef = periodoRef;
 1823                    var tarifa = await _repo.GetTarifaCategoriaAsync(config.CatalogoItemId.Value, categoria, fechaRef);
 1824                    if (tarifa == null)
 0825                        return (false, null, $"No se encontró tarifa para categoría '{categoria}'. Verifique que la conf
 1826                    return (true, tarifa.Monto, null);
 827                }
 828
 829                // Subcaso 3: CARCEL — tarifa diaria por grado
 2830                if (snapshotGradoId.HasValue && config.CatalogoItemId.HasValue)
 831                {
 1832                    var fechaRef = periodoRef;
 1833                    var tarifaGrado = await _repo.GetTarifaGradoAsync(config.CatalogoItemId.Value, snapshotGradoId.Value
 1834                    if (tarifaGrado != null)
 1835                        return (true, unidadCantidad * tarifaGrado.Monto, null);
 836                    // Si no hay tarifa por grado, cae al monto del CSV
 837                }
 838
 839                // Default: monto viene precalculado del origen externo (COMP_PERS, etc.)
 1840                return (true, montoEntrada, null);
 841
 842            default:
 0843                return (false, null, $"Fórmula '{config.Formula}' no reconocida");
 844        }
 21845    }
 846
 847    private static string? ValidarFormula(string formula, decimal? monto, decimal? valorPorUnidad, decimal? valorMinimo,
 848    {
 8849        if (!FormulasValidas.Contains(formula))
 0850            return $"formula inválida. Valores aceptados: {string.Join(", ", FormulasValidas)}";
 851
 8852        if (formula == "fijo" && !monto.HasValue)
 1853            return "monto es obligatorio para fórmula fijo";
 854
 7855        if (formula == "prorrateo" && !valorPorUnidad.HasValue)
 1856            return "valor_por_unidad es obligatorio para fórmula prorrateo";
 857
 6858        if (formula == "rango")
 859        {
 1860            if (!valorMinimo.HasValue || !valorMaximo.HasValue)
 0861                return "valor_minimo y valor_maximo son obligatorios para fórmula rango";
 1862            if (valorMinimo.Value >= valorMaximo.Value)
 1863                return "valor_minimo debe ser menor que valor_maximo";
 864        }
 865
 5866        if (topeUnidades.HasValue && topeUnidades.Value < 0)
 0867            return "tope_unidades debe ser mayor o igual a 0";
 868
 5869        return null;
 870    }
 871
 4872    private static TipoCompensacionDto MapTipoToDto(TipoCompensacion t) => new()
 4873    {
 4874        Id = t.Id,
 4875        Codigo = t.Codigo,
 4876        Nombre = t.Nombre,
 4877        UnidadMedida = t.UnidadMedida,
 4878        RequiereFuente = t.RequiereFuente,
 4879        Vigente = t.Vigente
 4880    };
 881
 4882    private static ConfigCompensacionDto MapConfigToDto(ConfigCompensacion c) => new()
 4883    {
 4884        Id = c.Id,
 4885        TipoCompensacionId = c.TipoCompensacionId,
 4886        NombreTipo = c.TipoCompensacion?.Nombre ?? string.Empty,
 4887        RegimenId = c.RegimenId,
 4888        NombreRegimen = c.Regimen?.Denominacion,
 4889        GradoId = c.GradoId,
 4890        NombreGrado = c.Grado?.Denominacion,
 4891        EscalafonId = c.EscalafonId,
 4892        NombreEscalafon = c.Escalafon?.Denominacion,
 4893        Funcion = c.Funcion,
 4894        UnidadMedida = c.UnidadMedida,
 4895        Formula = c.Formula,
 4896        ValorPorUnidad = c.ValorPorUnidad,
 4897        Monto = c.Monto,
 4898        Tope = c.Tope,
 4899        TopeUnidades = c.TopeUnidades,
 4900        ValorMinimo = c.ValorMinimo,
 4901        ValorMaximo = c.ValorMaximo,
 4902        VigenteDesde = c.VigenteDesde,
 4903        VigenteHasta = c.VigenteHasta,
 4904        Activo = c.Activo,
 4905        SujetoDescLegales = c.SujetoDescLegales,
 4906        CatalogoItemId = c.CatalogoItemId,
 4907        NombreCatalogoItem = c.CatalogoItem?.Nombre
 4908    };
 909
 0910    private static LoteCompensacionDto MapLoteToDto(LoteCompensacion l) => new()
 0911    {
 0912        Id = l.Id,
 0913        TipoCompensacionId = l.TipoCompensacionId,
 0914        NombreTipo = l.TipoCompensacion?.Nombre ?? string.Empty,
 0915        UnidadMedida = l.TipoCompensacion?.UnidadMedida ?? string.Empty,
 0916        Periodo = l.Periodo,
 0917        Estado = l.Estado,
 0918        Fuente = l.Fuente,
 0919        NombreArchivo = l.NombreArchivo,
 0920        UsuarioId = l.UsuarioId,
 0921        AprobadoPor = l.AprobadoPor,
 0922        NombreAprobador = l.AprobadorUsuario?.Username,
 0923        AprobadoEn = l.AprobadoEn,
 0924        CreadoEn = l.CreadoEn,
 0925        ItemCount = l.ItemCount > 0 ? l.ItemCount : l.Items.Count(),
 0926        Items = l.Items.Select(i => MapItemToDto(i, l.TipoCompensacion?.UnidadMedida ?? string.Empty))
 0927    };
 928
 929    private static string? NullIfEmpty(string? s) =>
 0930        string.IsNullOrWhiteSpace(s) ? null : s;
 931
 2932    private static ItemLoteCompensacionDto MapItemToDto(ItemLoteCompensacion i, string unidadMedida = "") => new()
 2933    {
 2934        Id = i.Id,
 2935        LoteCompensacionId = i.LoteCompensacionId,
 2936        CedulaPersona = i.Persona?.Cedula ?? string.Empty,
 2937        NombrePersona = i.Persona?.NombreCompleto ?? string.Empty,
 2938        UnidadCantidad = i.UnidadCantidad,
 2939        UnidadMedida = unidadMedida,
 2940        Monto = i.Monto,
 2941        Estado = i.Estado,
 2942        Incidencia = i.Incidencia,
 2943        Observaciones = i.Observaciones,
 2944        Funcion = i.Funcion,
 2945        SujetoDescLegales     = i.SujetoDescLegales,
 2946        Ala                   = i.Ala,
 2947        CategoriaCompensacion = i.CategoriaCompensacion,
 2948        SnapshotGrado         = i.SnapshotGrado?.Denominacion,
 2949        SnapshotSituacion     = i.SnapshotSituacion?.Denominacion,
 2950        SnapshotUnidad        = i.SnapshotUnidad?.Denominacion,
 2951        SnapshotPrograma      = i.SnapshotPrograma?.Codigo
 2952    };
 953}