< 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: 447
Uncovered lines: 165
Coverable lines: 612
Total lines: 936
Line coverage: 73%
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.3%
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
 9224    public CompensacionService(
 9225        ICompensacionRepository repo,
 9226        IPersonalRepository personalRepo,
 9227        ILogger<CompensacionService> logger,
 9228        IAuditoriaService auditoriaService,
 9229        ICurrentUserContext currentUser)
 30    {
 9231        _repo = repo;
 9232        _personalRepo = personalRepo;
 9233        _logger = logger;
 9234        _auditoriaService = auditoriaService;
 9235        _currentUser = currentUser;
 9236    }
 37
 38    // ── Tipos ────────────────────────────────────────────────────────────────
 39
 40    public async Task<IEnumerable<TipoCompensacionDto>> GetTiposAsync(bool? vigente = null)
 41    {
 242        var tipos = await _repo.GetTiposAsync(vigente);
 243        return tipos.Select(MapTipoToDto);
 244    }
 45
 46    public async Task<TipoCompensacionDto?> GetTipoByIdAsync(long id)
 47    {
 248        var tipo = await _repo.GetTipoByIdAsync(id);
 249        return tipo == null ? null : MapTipoToDto(tipo);
 250    }
 51
 52    public async Task<(bool Success, TipoCompensacion? Tipo, string? Error)> CreateTipoAsync(CreateTipoCompensacionReque
 53    {
 454        if (string.IsNullOrWhiteSpace(request.Codigo))
 155            return (false, null, "codigo es obligatorio");
 56
 357        if (!UnidadesValidas.Contains(request.UnidadMedida))
 158            return (false, null, $"unidad_medida inválida. Valores aceptados: {string.Join(", ", UnidadesValidas)}");
 59
 260        if (await _repo.GetTipoByCodigoAsync(request.Codigo) != null)
 161            return (false, null, $"Ya existe un tipo de compensación con código '{request.Codigo}'");
 62
 163        var tipo = new TipoCompensacion
 164        {
 165            Codigo = request.Codigo.Trim().ToUpper(),
 166            Nombre = request.Nombre,
 167            UnidadMedida = request.UnidadMedida,
 168            RequiereFuente = request.RequiereFuente,
 169            Vigente = true
 170        };
 71
 172        var creado = await _repo.CreateTipoAsync(tipo);
 173        _logger.LogInformation("Tipo compensación {Codigo} creado", creado.Codigo);
 174        await _auditoriaService.LogAuditoriaAsync(_currentUser.UserId ?? 0, AccionEnum.CrearCompensacion, ContextoEnum.C
 175        return (true, creado, null);
 476    }
 77
 78    public async Task<(bool Success, TipoCompensacion? Tipo, string? Error)> UpdateTipoAsync(long id, UpdateTipoCompensa
 79    {
 380        var tipo = await _repo.GetTipoByIdAsync(id);
 481        if (tipo == null) return (false, null, "Tipo de compensación no encontrado");
 82
 483        if (request.Nombre != null) tipo.Nombre = request.Nombre;
 384        if (request.RequiereFuente.HasValue) tipo.RequiereFuente = request.RequiereFuente.Value;
 385        if (request.Vigente.HasValue) tipo.Vigente = request.Vigente.Value;
 86
 287        var actualizado = await _repo.UpdateTipoAsync(tipo);
 288        await _auditoriaService.LogAuditoriaAsync(_currentUser.UserId ?? 0, AccionEnum.ActualizarCompensacion, ContextoE
 289        return (true, actualizado, null);
 390    }
 91
 92    public async Task<(bool Success, string? Error)> DeleteTipoAsync(long id)
 93    {
 494        var tipo = await _repo.GetTipoByIdAsync(id);
 595        if (tipo == null) return (false, "Tipo de compensación no encontrado");
 96
 97        // Verificar si tiene configuraciones asociadas
 398        if (await _repo.TieneConfiguracionesAsync(id))
 199            return (false, "No se puede eliminar el tipo porque tiene configuraciones asociadas. Desactive las configura
 100
 101        // Verificar si tiene lotes asociados
 2102        if (await _repo.TieneLotesAsync(id))
 1103            return (false, "No se puede eliminar el tipo porque tiene lotes asociados. Elimine los lotes primero.");
 104
 1105        await _auditoriaService.LogAuditoriaAsync(_currentUser.UserId ?? 0, AccionEnum.ActualizarCompensacion, ContextoE
 1106        await _repo.DeleteTipoAsync(id);
 1107        _logger.LogInformation("Tipo compensación {Id} eliminado", id);
 1108        return (true, null);
 4109    }
 110
 111    // ── Configs ──────────────────────────────────────────────────────────────
 112
 113    public async Task<IEnumerable<ConfigCompensacionDto>> GetConfigsAsync(
 114        long? tipoId = null, long? regimenId = null, long? gradoId = null, bool? activo = null)
 115    {
 3116        var configs = await _repo.GetConfigsAsync(tipoId, regimenId, gradoId, activo);
 3117        return configs.Select(MapConfigToDto);
 3118    }
 119
 120    public async Task<ConfigCompensacionDto?> GetConfigByIdAsync(long id)
 121    {
 2122        var config = await _repo.GetConfigByIdAsync(id);
 2123        return config == null ? null : MapConfigToDto(config);
 2124    }
 125
 126    public async Task<(bool Success, ConfigCompensacion? Config, string? Error)> CreateConfigAsync(CreateConfigCompensac
 127    {
 5128        var validacion = ValidarFormula(request.Formula, request.Monto, request.ValorPorUnidad, request.ValorMinimo, req
 8129        if (validacion != null) return (false, null, validacion);
 130
 2131        var tipo = await _repo.GetTipoByIdAsync(request.TipoCompensacionId);
 3132        if (tipo == null) return (false, null, "No se encontró el tipo de compensación indicado");
 133
 134        // Desactivar configs anteriores: solo puede haber una activa por tipo
 1135        await _repo.DesactivarConfigsActivasAsync(request.TipoCompensacionId);
 136
 1137        var config = new ConfigCompensacion
 1138        {
 1139            TipoCompensacionId  = request.TipoCompensacionId,
 1140            RegimenId           = request.RegimenId,
 1141            GradoId             = request.GradoId,
 1142            EscalafonId         = request.EscalafonId,
 1143            Funcion             = request.Funcion,
 1144            UnidadMedida        = request.UnidadMedida,
 1145            Formula             = request.Formula,
 1146            ValorPorUnidad      = request.ValorPorUnidad,
 1147            Monto               = request.Monto,
 1148            Tope                = request.Tope,
 1149            TopeUnidades        = request.TopeUnidades,
 1150            ValorMinimo         = request.ValorMinimo,
 1151            ValorMaximo         = request.ValorMaximo,
 1152            VigenteDesde        = request.VigenteDesde,
 1153            VigenteHasta        = request.VigenteHasta,
 1154            SujetoDescLegales   = request.SujetoDescLegales,
 1155            CatalogoItemId      = request.CatalogoItemId,
 1156            Activo              = true
 1157        };
 158
 1159        var creada = await _repo.CreateConfigAsync(config);
 1160        await _auditoriaService.LogAuditoriaAsync(_currentUser.UserId ?? 0, AccionEnum.CrearConfigCompensacion, Contexto
 1161        return (true, creada, null);
 5162    }
 163
 164    public async Task<(bool Success, ConfigCompensacion? Config, string? Error)> UpdateConfigAsync(long id, UpdateConfig
 165    {
 4166        var config = await _repo.GetConfigByIdAsync(id);
 5167        if (config == null) return (false, null, "Configuración no encontrada");
 168
 3169        var monto = request.Monto ?? config.Monto;
 3170        var valorPorUnidad = request.ValorPorUnidad ?? config.ValorPorUnidad;
 3171        var valorMinimo = request.ValorMinimo ?? config.ValorMinimo;
 3172        var valorMaximo = request.ValorMaximo ?? config.ValorMaximo;
 3173        var topeUnidades = request.TopeUnidades ?? config.TopeUnidades;
 174
 3175        var validacion = ValidarFormula(config.Formula, monto, valorPorUnidad, valorMinimo, valorMaximo, topeUnidades);
 3176        if (validacion != null) return (false, null, validacion);
 177
 3178        if (request.ValorPorUnidad.HasValue) config.ValorPorUnidad = request.ValorPorUnidad;
 3179        if (request.Monto.HasValue) config.Monto = request.Monto;
 3180        if (request.Tope.HasValue) config.Tope = request.Tope;
 3181        if (request.TopeUnidades.HasValue) config.TopeUnidades = request.TopeUnidades;
 3182        if (request.ValorMinimo.HasValue) config.ValorMinimo = request.ValorMinimo;
 3183        if (request.ValorMaximo.HasValue) config.ValorMaximo = request.ValorMaximo;
 3184        if (request.VigenteHasta.HasValue) config.VigenteHasta = request.VigenteHasta;
 4185        if (request.SujetoDescLegales.HasValue) config.SujetoDescLegales = request.SujetoDescLegales.Value;
 4186        if (request.CatalogoItemId.HasValue) config.CatalogoItemId = request.CatalogoItemId;
 3187        if (request.Activo.HasValue)
 188        {
 2189            if (!request.Activo.Value && config.Activo && await _repo.EsUnicaConfigActivaAsync(id, config.TipoCompensaci
 1190                return (false, null, "No se puede desactivar la única configuración activa del tipo. Cree otra antes de 
 191
 192            // Si se activa una config inactiva, desactivar las demás del mismo tipo
 1193            if (request.Activo.Value && !config.Activo)
 1194                await _repo.DesactivarConfigsActivasAsync(config.TipoCompensacionId);
 1195            config.Activo = request.Activo.Value;
 196        }
 197
 2198        var actualizada = await _repo.UpdateConfigAsync(config);
 2199        await _auditoriaService.LogAuditoriaAsync(_currentUser.UserId ?? 0, AccionEnum.ActualizarConfigComprensacion, Co
 2200        return (true, actualizada, null);
 4201    }
 202
 203    public async Task<(bool Success, string? Error)> DeleteConfigAsync(long id)
 204    {
 4205        var config = await _repo.GetConfigByIdAsync(id);
 5206        if (config == null) return (false, "Configuración no encontrada");
 207
 3208        if (config.Activo && await _repo.EsUnicaConfigActivaAsync(id, config.TipoCompensacionId))
 1209            return (false, "No se puede eliminar la única configuración activa del tipo. Cree otra antes de eliminar est
 210
 2211        await _auditoriaService.LogAuditoriaAsync(_currentUser.UserId ?? 0, AccionEnum.ActualizarConfigComprensacion, Co
 2212        await _repo.DeleteConfigAsync(id);
 2213        _logger.LogInformation("Configuración compensación {Id} eliminada", id);
 2214        return (true, null);
 4215    }
 216
 217    // ── Lotes ────────────────────────────────────────────────────────────────
 218
 219    public async Task<PagedResult<LoteCompensacionDto>> GetLotesPagedAsync(
 220        int page, int pageSize, long? tipoId = null, int? anio = null, int? mes = null, string? estado = null)
 221    {
 0222        var (items, totalCount) = await _repo.GetLotesPagedAsync(page, pageSize, tipoId, anio, mes, estado);
 0223        return new PagedResult<LoteCompensacionDto>
 0224        {
 0225            Items = items.Select(MapLoteToDto),
 0226            Page = page,
 0227            PageSize = pageSize,
 0228            TotalCount = totalCount
 0229        };
 0230    }
 231
 232    public async Task<LoteCompensacionDto?> GetLoteByIdAsync(long id)
 233    {
 0234        var lote = await _repo.GetLoteByIdWithItemsAsync(id);
 0235        return lote == null ? null : MapLoteToDto(lote);
 0236    }
 237
 238    public async Task<(bool Success, LoteCompensacion? Lote, string? Error)> CreateLoteAsync(long usuarioId, CreateLoteC
 239    {
 2240        var tipo = await _repo.GetTipoByIdAsync(request.TipoCompensacionId);
 3241        if (tipo == null) return (false, null, "No se encontró el tipo de compensación indicado");
 242
 1243        if (request.PeriodoMes < 1 || request.PeriodoMes > 12)
 0244            return (false, null, "periodoMes debe estar entre 1 y 12");
 245
 1246        var periodo = new DateTime(request.PeriodoAnio, request.PeriodoMes, 1, 0, 0, 0, DateTimeKind.Utc);
 247
 248        // Validar unicidad: un solo lote activo por tipo + período (igual que legacy)
 1249        if (await _repo.ExisteLoteActivoAsync(request.TipoCompensacionId, periodo))
 0250            return (false, null, $"Ya existe un lote activo para ese tipo de compensación en {request.PeriodoAnio}/{requ
 251
 1252        var lote = new LoteCompensacion
 1253        {
 1254            TipoCompensacionId = request.TipoCompensacionId,
 1255            Periodo = periodo,
 1256            Estado = "borrador",
 1257            Fuente = request.Fuente,
 1258            NombreArchivo = request.NombreArchivo,
 1259            HashArchivo = request.HashArchivo,
 1260            UsuarioId = usuarioId,
 1261            CreadoEn = DateTime.UtcNow
 1262        };
 263
 1264        var creado = await _repo.CreateLoteAsync(lote);
 1265        _logger.LogInformation("Lote compensación {Id} creado para tipo {TipoId}", creado.Id, creado.TipoCompensacionId)
 1266        await _auditoriaService.LogAuditoriaAsync(_currentUser.UserId ?? 0, AccionEnum.CrearLoteCompensacion, ContextoEn
 1267        return (true, creado, null);
 2268    }
 269
 270    // Transiciones manuales vía PUT (aprobar/rechazar/revertir tienen endpoints dedicados)
 1271    private static readonly Dictionary<string, string[]> TransicionesPermitidas = new()
 1272    {
 1273        ["borrador"] = new[] { "validado" },
 1274        ["validado"] = new[] { "borrador" }
 1275        // validado → aprobado: vía PUT /aprobar
 1276        // aprobado → borrador: vía PUT /revertir
 1277        // aprobado → rechazado: vía PUT /rechazar
 1278        // validado → rechazado: vía PUT /rechazar
 1279    };
 280
 281    public async Task<(bool Success, LoteCompensacion? Lote, string? Error)> UpdateLoteAsync(long id, UpdateLoteCompensa
 282    {
 8283        var lote = await _repo.GetLoteByIdAsync(id);
 9284        if (lote == null) return (false, null, "Lote no encontrado");
 285
 7286        if (!EstadosEditablesLote.Contains(lote.Estado))
 1287            return (false, null, $"No se puede editar un lote en estado '{lote.Estado}'");
 288
 6289        if (request.Estado != null)
 290        {
 6291            if (!TransicionesPermitidas.TryGetValue(lote.Estado, out var permitidos) ||
 12292                !Array.Exists(permitidos, e => e == request.Estado))
 1293                return (false, null, $"Transición no permitida: '{lote.Estado}' → '{request.Estado}'. Permitidas: {strin
 294
 295            // Validación al pasar a 'validado'
 5296            if (request.Estado == "validado")
 297            {
 4298                var (validOk, validError) = await ValidarLoteParaValidarAsync(lote);
 7299                if (!validOk) return (false, null, validError);
 300            }
 301
 2302            lote.Estado = request.Estado;
 303        }
 304
 2305        if (request.Fuente != null) lote.Fuente = request.Fuente;
 2306        if (request.NombreArchivo != null) lote.NombreArchivo = request.NombreArchivo;
 307
 2308        var actualizado = await _repo.UpdateLoteAsync(lote);
 2309        await _auditoriaService.LogAuditoriaAsync(_currentUser.UserId ?? 0, AccionEnum.ActualizarLoteCompensacion, Conte
 2310        return (true, actualizado, null);
 8311    }
 312
 313    private async Task<(bool Success, string? Error)> ValidarLoteParaValidarAsync(LoteCompensacion lote)
 314    {
 4315        var totalItems = await _repo.GetItemCountAsync(lote.Id);
 4316        if (totalItems == 0)
 1317            return (false, "El lote no tiene ítems. Debe cargar al menos uno antes de validar.");
 318
 3319        var itemsError = await _repo.GetItemsEnErrorCountAsync(lote.Id);
 3320        if (itemsError > 0)
 1321            return (false, $"El lote tiene {itemsError} ítem(s) en estado 'error'. Corrija o elimine esos ítems antes de
 322
 2323        var config = await _repo.GetConfigVigenteAsync(lote.TipoCompensacionId, lote.Periodo);
 2324        if (config == null)
 1325            return (false, "No existe configuración activa para este tipo de compensación en el período del lote.");
 326
 1327        return (true, null);
 4328    }
 329
 330    public async Task<(bool Success, LoteCompensacion? Lote, string? Error)> AprobarLoteAsync(long id, long aprobadoPorI
 331    {
 3332        var lote = await _repo.GetLoteByIdAsync(id);
 3333        if (lote == null) return (false, null, "Lote no encontrado");
 334
 3335        if (lote.Estado != "validado")
 2336            return (false, null, $"Solo se pueden aprobar lotes en estado 'validado'. Estado actual: '{lote.Estado}'");
 337
 1338        lote.Estado = "aprobado";
 1339        lote.AprobadoPor = aprobadoPorId;
 1340        lote.AprobadoEn = DateTime.UtcNow;
 341
 1342        var actualizado = await _repo.UpdateLoteAsync(lote);
 1343        _logger.LogInformation("Lote {Id} aprobado por usuario {UsuarioId}", id, aprobadoPorId);
 1344        await _auditoriaService.LogAuditoriaAsync(aprobadoPorId, AccionEnum.AprobarLoteCompensacion, ContextoEnum.Compen
 1345        return (true, actualizado, null);
 3346    }
 347
 348    public async Task<(bool Success, LoteCompensacion? Lote, string? Error)> RechazarLoteAsync(long id)
 349    {
 5350        var lote = await _repo.GetLoteByIdAsync(id);
 6351        if (lote == null) return (false, null, "Lote no encontrado");
 352
 4353        if (lote.Estado is "exportado" or "rechazado")
 2354            return (false, null, $"No se puede rechazar un lote en estado '{lote.Estado}'");
 355
 2356        lote.Estado = "rechazado";
 2357        var actualizado = await _repo.UpdateLoteAsync(lote);
 2358        _logger.LogInformation("Lote {Id} rechazado", id);
 2359        await _auditoriaService.LogAuditoriaAsync(_currentUser.UserId ?? 0, AccionEnum.RechazarLoteCompensacion, Context
 2360        return (true, actualizado, null);
 5361    }
 362
 363    public async Task<(bool Success, LoteCompensacion? Lote, string? Error)> RevertirABorradorAsync(long id)
 364    {
 4365        var lote = await _repo.GetLoteByIdAsync(id);
 5366        if (lote == null) return (false, null, "Lote no encontrado");
 367
 3368        if (lote.Estado != "aprobado")
 2369            return (false, null, $"Solo se pueden revertir lotes en estado 'aprobado'. Estado actual: '{lote.Estado}'");
 370
 1371        lote.Estado = "borrador";
 1372        lote.AprobadoPor = null;
 1373        lote.AprobadoEn = null;
 374
 1375        var actualizado = await _repo.UpdateLoteAsync(lote);
 1376        _logger.LogInformation("Lote {Id} revertido a borrador", id);
 1377        await _auditoriaService.LogAuditoriaAsync(_currentUser.UserId ?? 0, AccionEnum.RevertirLoteCompensacion, Context
 1378        return (true, actualizado, null);
 4379    }
 380
 381    public async Task<(bool Success, string? Error)> CancelarLoteAsync(long id)
 382    {
 5383        var lote = await _repo.GetLoteByIdAsync(id);
 6384        if (lote == null) return (false, "Lote no encontrado");
 385
 4386        if (lote.Estado is not ("borrador" or "rechazado"))
 2387            return (false, $"Solo se pueden eliminar lotes en estado 'borrador' o 'rechazado'. Estado actual: '{lote.Est
 388
 2389        await _auditoriaService.LogAuditoriaAsync(_currentUser.UserId ?? 0, AccionEnum.EliminarLoteCompensacion, Context
 2390        await _repo.DeleteAllItemsAsync(id);
 2391        await _repo.DeleteLoteAsync(lote);
 2392        _logger.LogInformation("Lote {Id} cancelado y eliminado", id);
 2393        return (true, null);
 5394    }
 395
 396    public async Task<PagedResult<ItemLoteCompensacionDto>> GetItemsPagedAsync(long loteId, int page, int pageSize)
 397    {
 1398        var lote = await _repo.GetLoteByIdAsync(loteId);
 1399        var unidadMedida = lote != null
 1400            ? (await _repo.GetTipoByIdAsync(lote.TipoCompensacionId))?.UnidadMedida ?? string.Empty
 1401            : string.Empty;
 402
 1403        var (items, totalCount) = await _repo.GetItemsPagedAsync(loteId, page, pageSize);
 1404        return new PagedResult<ItemLoteCompensacionDto>
 1405        {
 2406            Items = items.Select(i => MapItemToDto(i, unidadMedida)),
 1407            Page = page,
 1408            PageSize = pageSize,
 1409            TotalCount = totalCount
 1410        };
 1411    }
 412
 413    // ── Items ────────────────────────────────────────────────────────────────
 414
 415    public async Task<(bool Success, ItemLoteCompensacion? Item, string? Error)> CreateItemAsync(long loteId, CreateItem
 416    {
 23417        var lote = await _repo.GetLoteByIdAsync(loteId);
 23418        if (lote == null) return (false, null, "Lote no encontrado");
 419
 23420        if (!EstadosEditablesLote.Contains(lote.Estado))
 1421            return (false, null, $"No se pueden agregar ítems a un lote en estado '{lote.Estado}'");
 422
 423        // Resolver cédula → personaId
 22424        var relacion = await _personalRepo.GetByCedulaAsync(request.CedulaPersona);
 22425        if (relacion == null)
 1426            return (false, null, $"No se encontró personal activo con cédula {request.CedulaPersona}");
 427
 21428        var personaId = relacion.PersonaId;
 429
 21430        if (await _repo.ExistePersonaEnLoteAsync(loteId, personaId))
 1431            return (false, null, "Ya existe un ítem para esa persona en este lote");
 432
 20433        var config = await _repo.GetConfigVigenteAsync(lote.TipoCompensacionId, lote.Periodo);
 20434        if (config?.TopeUnidades.HasValue == true && request.UnidadCantidad > config.TopeUnidades.Value)
 1435            return (false, null, $"unidad_cantidad excede el tope máximo de {config.TopeUnidades} unidades");
 436
 437        // Aplicar fórmula de la configuración vigente
 19438        decimal? montoFinal = request.Monto;
 19439        if (config != null)
 440        {
 19441            var (formulaOk, montoCalculado, formulaError) = await AplicarFormulaAsync(
 19442                loteId, config, request.UnidadCantidad, request.Monto,
 19443                request.Ala, request.CategoriaCompensacion, relacion.GradoId, lote.Periodo);
 26444            if (!formulaOk) return (false, null, formulaError);
 12445            montoFinal = montoCalculado;
 446        }
 447
 448        // SDL: derivado de la configuración del tipo — no del ítem individual
 12449        var sujetoDescLegales = config?.SujetoDescLegales ?? false;
 450
 12451        var item = new ItemLoteCompensacion
 12452        {
 12453            LoteCompensacionId  = loteId,
 12454            PersonaId           = personaId,
 12455            UnidadCantidad      = request.UnidadCantidad,
 12456            Monto               = montoFinal,
 12457            Estado              = "ok",
 12458            Incidencia          = request.Incidencia,
 12459            Observaciones       = request.Observaciones,
 12460            Funcion             = request.Funcion,
 12461            Ala                 = request.Ala,
 12462            CategoriaCompensacion = request.CategoriaCompensacion,
 12463            SujetoDescLegales   = sujetoDescLegales,
 12464            // Snapshots de la relación laboral activa al momento de carga
 12465            SnapshotGradoId     = relacion.GradoId,
 12466            SnapshotSituacionId = relacion.SituacionId,
 12467            SnapshotUnidadId    = relacion.UnidadId,
 12468            SnapshotProgramaId  = relacion.ProgramaId
 12469        };
 470
 12471        var creado = await _repo.CreateItemAsync(item);
 12472        await _auditoriaService.LogAuditoriaAsync(_currentUser.UserId ?? 0, AccionEnum.CrearItemLoteCompensacion, Contex
 12473        await RevertirABorradorSiValidadoAsync(lote);
 12474        return (true, creado, null);
 23475    }
 476
 477    public async Task<(bool Success, ItemLoteCompensacion? Item, string? Error)> UpdateItemAsync(long loteId, long itemI
 478    {
 2479        var lote = await _repo.GetLoteByIdAsync(loteId);
 2480        if (lote == null) return (false, null, "Lote no encontrado");
 481
 2482        if (!EstadosEditablesLote.Contains(lote.Estado))
 0483            return (false, null, $"No se pueden editar ítems de un lote en estado '{lote.Estado}'");
 484
 2485        var item = await _repo.GetItemByIdAsync(itemId);
 2486        if (item == null || item.LoteCompensacionId != loteId)
 0487            return (false, null, "Ítem no encontrado");
 488
 2489        if (request.Ala != null) item.Ala = request.Ala;
 2490        if (request.CategoriaCompensacion != null) item.CategoriaCompensacion = request.CategoriaCompensacion;
 491
 2492        if (request.UnidadCantidad.HasValue || request.Monto.HasValue || request.Ala != null || request.CategoriaCompens
 493        {
 2494            var config = await _repo.GetConfigVigenteAsync(lote.TipoCompensacionId, lote.Periodo);
 495
 2496            if (request.UnidadCantidad.HasValue)
 497            {
 1498                if (config?.TopeUnidades.HasValue == true && request.UnidadCantidad.Value > config.TopeUnidades.Value)
 0499                    return (false, null, $"unidad_cantidad excede el tope máximo de {config.TopeUnidades} unidades");
 1500                item.UnidadCantidad = request.UnidadCantidad.Value;
 501            }
 502
 2503            if (config != null)
 504            {
 2505                var nuevaCantidad = item.UnidadCantidad;
 2506                var nuevoMonto = request.Monto ?? item.Monto;
 2507                var (formulaOk, montoCalculado, formulaError) = await AplicarFormulaAsync(
 2508                    loteId, config, nuevaCantidad, nuevoMonto,
 2509                    item.Ala, item.CategoriaCompensacion, item.SnapshotGradoId, lote.Periodo);
 3510                if (!formulaOk) return (false, null, formulaError);
 1511                item.Monto = montoCalculado;
 512            }
 0513            else if (request.Monto.HasValue)
 514            {
 0515                item.Monto = request.Monto;
 516            }
 517        }
 518
 1519        if (request.Estado != null) item.Estado = request.Estado;
 1520        if (request.Incidencia != null) item.Incidencia = request.Incidencia;
 1521        if (request.Observaciones != null) item.Observaciones = request.Observaciones;
 1522        if (request.Funcion != null) item.Funcion = request.Funcion;
 523
 1524        var actualizado = await _repo.UpdateItemAsync(item);
 1525        await _auditoriaService.LogAuditoriaAsync(_currentUser.UserId ?? 0, AccionEnum.ActualizarItemLoteCompensacion, C
 1526        await RevertirABorradorSiValidadoAsync(lote);
 1527        return (true, actualizado, null);
 2528    }
 529
 530    public async Task<(bool Success, string? Error)> DeleteItemAsync(long loteId, long itemId)
 531    {
 6532        var lote = await _repo.GetLoteByIdAsync(loteId);
 7533        if (lote == null) return (false, "Lote no encontrado");
 534
 5535        if (!EstadosEditablesLote.Contains(lote.Estado))
 1536            return (false, $"No se pueden eliminar ítems de un lote en estado '{lote.Estado}'");
 537
 4538        var item = await _repo.GetItemByIdAsync(itemId);
 4539        if (item == null || item.LoteCompensacionId != loteId)
 2540            return (false, "Ítem no encontrado");
 541
 2542        await _auditoriaService.LogAuditoriaAsync(_currentUser.UserId ?? 0, AccionEnum.EliminarItemLoteCompensacion, Con
 2543        await _repo.DeleteItemAsync(item);
 2544        await RevertirABorradorSiValidadoAsync(lote);
 2545        return (true, null);
 6546    }
 547
 548    /// Si el lote estaba en 'validado', cualquier edición de ítems lo regresa a 'borrador'
 549    private async Task RevertirABorradorSiValidadoAsync(LoteCompensacion lote)
 550    {
 15551        if (lote.Estado == "validado")
 552        {
 1553            lote.Estado = "borrador";
 1554            await _repo.UpdateLoteAsync(lote);
 1555            _logger.LogInformation("Lote {Id} revertido a borrador por edición de ítems", lote.Id);
 556        }
 15557    }
 558
 559    // ── Bulk Import ──────────────────────────────────────────────────────────
 560
 561    public async Task<BulkImportResultDto> CreateItemsBulkAsync(long loteId, IEnumerable<CreateItemLoteRequest> items, s
 562    {
 0563        var lote = await _repo.GetLoteByIdAsync(loteId);
 0564        if (lote == null)
 0565            return new BulkImportResultDto { Fallidos = 1, Procesados = 1, Resultados = [new() { Linea = 0, Cedula = "",
 566
 0567        if (!dryRun && !EstadosEditablesLote.Contains(lote.Estado))
 0568            return new BulkImportResultDto { Fallidos = 1, Procesados = 1, Resultados = [new() { Linea = 0, Cedula = "",
 569
 0570        var resultado = new BulkImportResultDto { EsDryRun = dryRun, Modo = modo };
 571
 0572        if (!dryRun && modo == "reemplazar")
 573        {
 0574            await _repo.DeleteAllItemsAsync(loteId);
 0575            if (lote.Estado == "validado") { lote.Estado = "borrador"; await _repo.UpdateLoteAsync(lote); }
 576        }
 577
 0578        int linea = 1;
 0579        foreach (var request in items)
 580        {
 0581            resultado.Procesados++;
 0582            var (success, item, error) = dryRun
 0583                ? await ValidarItemAsync(loteId, lote, request)
 0584                : await CreateItemAsync(loteId, request);
 585
 0586            resultado.Resultados.Add(new BulkImportLineResultDto
 0587            {
 0588                Linea = linea++,
 0589                Cedula = request.CedulaPersona,
 0590                Exito = success,
 0591                Error = error,
 0592                ItemId = item?.Id
 0593            });
 0594            if (success) resultado.Exitosos++;
 0595            else resultado.Fallidos++;
 0596        }
 597
 0598        return resultado;
 0599    }
 600
 601    public async Task<BulkImportResultDto> CreateItemsFromCsvAsync(long loteId, Stream csvStream, string modo = "reempla
 602    {
 0603        var lote = await _repo.GetLoteByIdAsync(loteId);
 0604        if (lote == null)
 0605            return new BulkImportResultDto { Fallidos = 1, Procesados = 1, Resultados = [new() { Linea = 0, Cedula = "",
 606
 0607        if (!dryRun && !EstadosEditablesLote.Contains(lote.Estado))
 0608            return new BulkImportResultDto { Fallidos = 1, Procesados = 1, Resultados = [new() { Linea = 0, Cedula = "",
 609
 0610        if (!dryRun && modo == "reemplazar")
 611        {
 0612            await _repo.DeleteAllItemsAsync(loteId);
 0613            if (lote.Estado == "validado") { lote.Estado = "borrador"; await _repo.UpdateLoteAsync(lote); }
 614        }
 615
 616        // Para fórmulas fijo y prorrateo el monto lo calcula el sistema; para las demás el CSV debe proveerlo
 0617        var configCsv = await _repo.GetConfigVigenteAsync(lote.TipoCompensacionId, lote.Periodo);
 0618        var formulaCalculaMontoAutomatico = configCsv?.Formula is "fijo" or "prorrateo";
 619        // Para tabla con ala o categoria, el monto también se calcula desde la tarifa
 0620        var formulaTablaConLookup = configCsv?.Formula == "tabla" && configCsv.CatalogoItemId.HasValue;
 621
 0622        var resultado = new BulkImportResultDto { EsDryRun = dryRun, Modo = modo };
 0623        using var reader = new System.IO.StreamReader(csvStream);
 624
 625        // Leer encabezados
 0626        var headerLine = await reader.ReadLineAsync();
 0627        if (string.IsNullOrWhiteSpace(headerLine))
 0628            return resultado;
 629
 0630        var headers = headerLine.Split(',').Select(h => h.Trim().ToLower()).ToArray();
 0631        int idxCedula         = Array.IndexOf(headers, "cedula");
 0632        int idxMonto          = Array.IndexOf(headers, "monto");
 0633        int idxUnidad         = Array.IndexOf(headers, "unidad_cantidad");
 0634        int idxFuncion        = Array.IndexOf(headers, "funcion");
 0635        int idxAla            = Array.IndexOf(headers, "ala");
 0636        int idxCategoria      = Array.IndexOf(headers, "categoria");
 0637        int idxIncidencia     = Array.IndexOf(headers, "incidencia");
 0638        int idxObservaciones  = Array.IndexOf(headers, "observaciones");
 639
 0640        if (idxCedula < 0)
 0641            return new BulkImportResultDto { Fallidos = 1, Procesados = 1, Resultados = [new() { Linea = 1, Cedula = "",
 642
 0643        int linea = 2;
 0644        while (!reader.EndOfStream)
 645        {
 0646            var line = await reader.ReadLineAsync();
 0647            if (string.IsNullOrWhiteSpace(line)) { linea++; continue; }
 648
 0649            var cols = line.Split(',');
 0650            var cedula = idxCedula < cols.Length ? cols[idxCedula].Trim() : "";
 651
 0652            resultado.Procesados++;
 653
 0654            if (string.IsNullOrWhiteSpace(cedula))
 655            {
 0656                resultado.Fallidos++;
 0657                resultado.Resultados.Add(new() { Linea = linea, Cedula = cedula, Exito = false, Error = "Cédula vacía" }
 0658                linea++;
 0659                continue;
 660            }
 661
 0662            decimal monto = 0;
 0663            if (idxMonto >= 0 && idxMonto < cols.Length)
 0664                decimal.TryParse(cols[idxMonto].Trim(), System.Globalization.NumberStyles.Any, System.Globalization.Cult
 665
 0666            var ala       = idxAla < 0 || idxAla >= cols.Length ? null : NullIfEmpty(cols[idxAla].Trim()?.ToUpper());
 0667            var categoria = idxCategoria < 0 || idxCategoria >= cols.Length ? null : NullIfEmpty(cols[idxCategoria].Trim
 668
 669            // El monto no es requerido si la fórmula lo calcula automáticamente
 670            // (fijo, prorrateo, o tabla con ala/categoria)
 0671            var montoEsAutomatico = formulaCalculaMontoAutomatico || formulaTablaConLookup;
 672
 0673            if (!montoEsAutomatico && monto <= 0)
 674            {
 0675                resultado.Fallidos++;
 0676                resultado.Resultados.Add(new() { Linea = linea, Cedula = cedula, Exito = false, Error = "El monto debe s
 0677                linea++;
 0678                continue;
 679            }
 680
 0681            decimal unidadCantidad = 1;
 0682            if (idxUnidad >= 0 && idxUnidad < cols.Length && !string.IsNullOrWhiteSpace(cols[idxUnidad]))
 0683                decimal.TryParse(cols[idxUnidad].Trim(), System.Globalization.NumberStyles.Any, System.Globalization.Cul
 684
 0685            var funcion      = idxFuncion < 0 || idxFuncion >= cols.Length ? null : NullIfEmpty(cols[idxFuncion].Trim())
 0686            var incidencia   = idxIncidencia < 0 || idxIncidencia >= cols.Length ? null : NullIfEmpty(cols[idxIncidencia
 0687            var observaciones = idxObservaciones < 0 || idxObservaciones >= cols.Length ? null : NullIfEmpty(cols[idxObs
 688
 0689            var request = new CreateItemLoteRequest
 0690            {
 0691                CedulaPersona         = cedula,
 0692                Monto                 = monto > 0 ? monto : null,
 0693                UnidadCantidad        = unidadCantidad,
 0694                Funcion               = funcion,
 0695                Ala                   = ala,
 0696                CategoriaCompensacion = categoria,
 0697                Incidencia            = incidencia,
 0698                Observaciones         = observaciones
 0699            };
 700
 0701            var (success, item, error) = dryRun
 0702                ? await ValidarItemAsync(loteId, lote, request)
 0703                : await CreateItemAsync(loteId, request);
 0704            resultado.Resultados.Add(new() { Linea = linea, Cedula = cedula, Exito = success, Error = error, ItemId = it
 0705            if (success) resultado.Exitosos++;
 0706            else resultado.Fallidos++;
 707
 0708            linea++;
 0709        }
 710
 0711        return resultado;
 0712    }
 713
 714    /// Valida un ítem sin persistir (dry run). Replica las mismas validaciones de CreateItemAsync.
 715    private async Task<(bool Success, ItemLoteCompensacion? Item, string? Error)> ValidarItemAsync(
 716        long loteId, LoteCompensacion lote, CreateItemLoteRequest request)
 717    {
 0718        if (!EstadosEditablesLote.Contains(lote.Estado))
 0719            return (false, null, $"Lote en estado '{lote.Estado}' no es editable");
 720
 0721        var relacion = await _personalRepo.GetByCedulaAsync(request.CedulaPersona);
 0722        if (relacion == null)
 0723            return (false, null, $"No se encontró personal activo con cédula {request.CedulaPersona}");
 724
 0725        if (await _repo.ExistePersonaEnLoteAsync(loteId, relacion.PersonaId))
 0726            return (false, null, "Ya existe un ítem para esa persona en este lote");
 727
 0728        var config = await _repo.GetConfigVigenteAsync(lote.TipoCompensacionId, lote.Periodo);
 0729        if (config?.TopeUnidades.HasValue == true && request.UnidadCantidad > config.TopeUnidades.Value)
 0730            return (false, null, $"unidad_cantidad excede el tope máximo de {config.TopeUnidades} unidades");
 731
 0732        if (config != null)
 733        {
 0734            var (formulaOk, _, formulaError) = await AplicarFormulaAsync(
 0735                loteId, config, request.UnidadCantidad, request.Monto,
 0736                request.Ala, request.CategoriaCompensacion, relacion.GradoId, lote.Periodo);
 0737            if (!formulaOk) return (false, null, formulaError);
 738        }
 739
 740        // Validación aprobada — no se persiste
 0741        return (true, null, null);
 0742    }
 743
 744    // ── Helpers ──────────────────────────────────────────────────────────────
 745
 746    // ── Lógica de fórmulas ───────────────────────────────────────────────────
 747
 748    /// Aplica la fórmula de la configuración para calcular o validar el monto del ítem.
 749    /// Devuelve el monto final que debe persistirse.
 750    private async Task<(bool Success, decimal? MontoCalculado, string? Error)> AplicarFormulaAsync(
 751        long loteId,
 752        ConfigCompensacion config,
 753        decimal unidadCantidad,
 754        decimal? montoEntrada,
 755        string? ala = null,
 756        string? categoria = null,
 757        long? snapshotGradoId = null,
 758        DateTime? periodo = null)
 759    {
 21760        switch (config.Formula)
 761        {
 762            case "fijo":
 5763                return (true, config.Monto, null);
 764
 765            case "prorrateo":
 3766                if (!config.ValorPorUnidad.HasValue)
 1767                    return (false, null, "La configuración de prorrateo no tiene valor_por_unidad definido");
 2768                return (true, unidadCantidad * config.ValorPorUnidad.Value, null);
 769
 770            case "rango":
 5771                if (!montoEntrada.HasValue)
 1772                    return (false, null, "Se debe proveer monto para fórmula rango");
 4773                if (config.ValorMinimo.HasValue && montoEntrada.Value < config.ValorMinimo.Value)
 1774                    return (false, null, $"El monto {montoEntrada.Value:F2} es menor al mínimo permitido de {config.Valo
 3775                if (config.ValorMaximo.HasValue && montoEntrada.Value > config.ValorMaximo.Value)
 2776                    return (false, null, $"El monto {montoEntrada.Value:F2} supera el máximo permitido de {config.ValorM
 1777                return (true, montoEntrada, null);
 778
 779            case "presupuesto":
 3780                if (!montoEntrada.HasValue)
 1781                    return (false, null, "Se debe proveer monto para fórmula presupuesto");
 2782                if (config.Tope.HasValue)
 783                {
 2784                    var totalActual = await _repo.GetMontoTotalItemsAsync(loteId);
 2785                    if (totalActual + montoEntrada.Value > config.Tope.Value)
 1786                        return (false, null,
 1787                            $"El monto supera el tope presupuestario. Disponible: {config.Tope.Value - totalActual:F2}, 
 788                }
 1789                return (true, montoEntrada, null);
 790
 791            case "tabla":
 792                // Subcaso 1: VUELO — tarifa por categoría (ala A/N/S)
 5793                if (!string.IsNullOrEmpty(ala) && config.CatalogoItemId.HasValue)
 794                {
 2795                    var fechaRef = periodo ?? DateTime.UtcNow;
 2796                    var tarifa = await _repo.GetTarifaCategoriaAsync(config.CatalogoItemId.Value, ala, fechaRef);
 2797                    if (tarifa == null)
 1798                        return (false, null, $"No se encontró tarifa para categoría de vuelo '{ala}'. Verifique que la c
 1799                    return (true, tarifa.Monto, null);
 800                }
 801
 802                // Subcaso 2: SAR — tarifa por categoría (función)
 3803                if (!string.IsNullOrEmpty(categoria) && config.CatalogoItemId.HasValue)
 804                {
 1805                    var fechaRef = periodo ?? DateTime.UtcNow;
 1806                    var tarifa = await _repo.GetTarifaCategoriaAsync(config.CatalogoItemId.Value, categoria, fechaRef);
 1807                    if (tarifa == null)
 0808                        return (false, null, $"No se encontró tarifa para categoría '{categoria}'. Verifique que la conf
 1809                    return (true, tarifa.Monto, null);
 810                }
 811
 812                // Subcaso 3: CARCEL — tarifa diaria por grado
 2813                if (snapshotGradoId.HasValue && config.CatalogoItemId.HasValue)
 814                {
 1815                    var fechaRef = periodo ?? DateTime.UtcNow;
 1816                    var tarifaGrado = await _repo.GetTarifaGradoAsync(config.CatalogoItemId.Value, snapshotGradoId.Value
 1817                    if (tarifaGrado != null)
 1818                        return (true, unidadCantidad * tarifaGrado.Monto, null);
 819                    // Si no hay tarifa por grado, cae al monto del CSV
 820                }
 821
 822                // Default: monto viene precalculado del origen externo (COMP_PERS, etc.)
 1823                return (true, montoEntrada, null);
 824
 825            default:
 0826                return (false, null, $"Fórmula '{config.Formula}' no reconocida");
 827        }
 21828    }
 829
 830    private static string? ValidarFormula(string formula, decimal? monto, decimal? valorPorUnidad, decimal? valorMinimo,
 831    {
 8832        if (!FormulasValidas.Contains(formula))
 0833            return $"formula inválida. Valores aceptados: {string.Join(", ", FormulasValidas)}";
 834
 8835        if (formula == "fijo" && !monto.HasValue)
 1836            return "monto es obligatorio para fórmula fijo";
 837
 7838        if (formula == "prorrateo" && !valorPorUnidad.HasValue)
 1839            return "valor_por_unidad es obligatorio para fórmula prorrateo";
 840
 6841        if (formula == "rango")
 842        {
 1843            if (!valorMinimo.HasValue || !valorMaximo.HasValue)
 0844                return "valor_minimo y valor_maximo son obligatorios para fórmula rango";
 1845            if (valorMinimo.Value >= valorMaximo.Value)
 1846                return "valor_minimo debe ser menor que valor_maximo";
 847        }
 848
 5849        if (topeUnidades.HasValue && topeUnidades.Value < 0)
 0850            return "tope_unidades debe ser mayor o igual a 0";
 851
 5852        return null;
 853    }
 854
 4855    private static TipoCompensacionDto MapTipoToDto(TipoCompensacion t) => new()
 4856    {
 4857        Id = t.Id,
 4858        Codigo = t.Codigo,
 4859        Nombre = t.Nombre,
 4860        UnidadMedida = t.UnidadMedida,
 4861        RequiereFuente = t.RequiereFuente,
 4862        Vigente = t.Vigente
 4863    };
 864
 4865    private static ConfigCompensacionDto MapConfigToDto(ConfigCompensacion c) => new()
 4866    {
 4867        Id = c.Id,
 4868        TipoCompensacionId = c.TipoCompensacionId,
 4869        NombreTipo = c.TipoCompensacion?.Nombre ?? string.Empty,
 4870        RegimenId = c.RegimenId,
 4871        NombreRegimen = c.Regimen?.Denominacion,
 4872        GradoId = c.GradoId,
 4873        NombreGrado = c.Grado?.Denominacion,
 4874        EscalafonId = c.EscalafonId,
 4875        NombreEscalafon = c.Escalafon?.Denominacion,
 4876        Funcion = c.Funcion,
 4877        UnidadMedida = c.UnidadMedida,
 4878        Formula = c.Formula,
 4879        ValorPorUnidad = c.ValorPorUnidad,
 4880        Monto = c.Monto,
 4881        Tope = c.Tope,
 4882        TopeUnidades = c.TopeUnidades,
 4883        ValorMinimo = c.ValorMinimo,
 4884        ValorMaximo = c.ValorMaximo,
 4885        VigenteDesde = c.VigenteDesde,
 4886        VigenteHasta = c.VigenteHasta,
 4887        Activo = c.Activo,
 4888        SujetoDescLegales = c.SujetoDescLegales,
 4889        CatalogoItemId = c.CatalogoItemId,
 4890        NombreCatalogoItem = c.CatalogoItem?.Nombre
 4891    };
 892
 0893    private static LoteCompensacionDto MapLoteToDto(LoteCompensacion l) => new()
 0894    {
 0895        Id = l.Id,
 0896        TipoCompensacionId = l.TipoCompensacionId,
 0897        NombreTipo = l.TipoCompensacion?.Nombre ?? string.Empty,
 0898        UnidadMedida = l.TipoCompensacion?.UnidadMedida ?? string.Empty,
 0899        Periodo = l.Periodo,
 0900        Estado = l.Estado,
 0901        Fuente = l.Fuente,
 0902        NombreArchivo = l.NombreArchivo,
 0903        UsuarioId = l.UsuarioId,
 0904        AprobadoPor = l.AprobadoPor,
 0905        NombreAprobador = l.AprobadorUsuario?.Username,
 0906        AprobadoEn = l.AprobadoEn,
 0907        CreadoEn = l.CreadoEn,
 0908        ItemCount = l.ItemCount > 0 ? l.ItemCount : l.Items.Count(),
 0909        Items = l.Items.Select(i => MapItemToDto(i, l.TipoCompensacion?.UnidadMedida ?? string.Empty))
 0910    };
 911
 912    private static string? NullIfEmpty(string? s) =>
 0913        string.IsNullOrWhiteSpace(s) ? null : s;
 914
 2915    private static ItemLoteCompensacionDto MapItemToDto(ItemLoteCompensacion i, string unidadMedida = "") => new()
 2916    {
 2917        Id = i.Id,
 2918        LoteCompensacionId = i.LoteCompensacionId,
 2919        CedulaPersona = i.Persona?.Cedula ?? string.Empty,
 2920        NombrePersona = i.Persona?.NombreCompleto ?? string.Empty,
 2921        UnidadCantidad = i.UnidadCantidad,
 2922        UnidadMedida = unidadMedida,
 2923        Monto = i.Monto,
 2924        Estado = i.Estado,
 2925        Incidencia = i.Incidencia,
 2926        Observaciones = i.Observaciones,
 2927        Funcion = i.Funcion,
 2928        SujetoDescLegales     = i.SujetoDescLegales,
 2929        Ala                   = i.Ala,
 2930        CategoriaCompensacion = i.CategoriaCompensacion,
 2931        SnapshotGrado         = i.SnapshotGrado?.Denominacion,
 2932        SnapshotSituacion     = i.SnapshotSituacion?.Denominacion,
 2933        SnapshotUnidad        = i.SnapshotUnidad?.Denominacion,
 2934        SnapshotPrograma      = i.SnapshotPrograma?.Codigo
 2935    };
 936}