< 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
72%
Covered lines: 467
Uncovered lines: 175
Coverable lines: 642
Total lines: 976
Line coverage: 72.7%
Branch coverage
55%
Covered branches: 262
Total branches: 470
Branch coverage: 55.7%
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%494486.11%
DeleteItemAsync()100%88100%
VerificarLoteEnPeriodoActivoAsync()50%5466.66%
RevertirABorradorSiValidadoAsync()100%22100%
CreateItemsBulkAsync()0%506220%
CreateItemsFromCsvAsync()0%6806820%
ValidarItemAsync()0%272160%
LotePeriodoDateTime(...)100%11100%
AplicarFormulaAsync()88.88%605486.95%
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 FAU.Logica.Exceptions;
 5using Microsoft.Extensions.Logging;
 6
 7namespace FAU.Logica.Services;
 8
 9public class CompensacionService : ICompensacionService
 10{
 111    private static readonly HashSet<string> UnidadesValidas = new(StringComparer.Ordinal) { "dia", "hora", "monto" };
 112    private static readonly HashSet<string> FormulasValidas = new(StringComparer.Ordinal) { "fijo", "tabla", "prorrateo"
 13    // Estados válidos para lotes
 114    private static readonly HashSet<string> EstadosLoteValidos = new(StringComparer.Ordinal) { "borrador", "validado", "
 15    // Estados que permiten editar ítems (agregar/modificar/eliminar)
 16    // Cualquier edición en un lote 'validado' lo regresa automáticamente a 'borrador'
 117    private static readonly HashSet<string> EstadosEditablesLote = new(StringComparer.Ordinal) { "borrador", "validado" 
 18
 19    private readonly ICompensacionRepository _repo;
 20    private readonly IPersonalRepository _personalRepo;
 21    private readonly ILogger<CompensacionService> _logger;
 22    private readonly IAuditoriaService _auditoriaService;
 23    private readonly ICurrentUserContext _currentUser;
 24    private readonly IPeriodoGuard _periodoGuard;
 25
 9226    public CompensacionService(
 9227        ICompensacionRepository repo,
 9228        IPersonalRepository personalRepo,
 9229        ILogger<CompensacionService> logger,
 9230        IAuditoriaService auditoriaService,
 9231        ICurrentUserContext currentUser,
 9232        IPeriodoGuard periodoGuard)
 33    {
 9234        _repo = repo;
 9235        _personalRepo = personalRepo;
 9236        _logger = logger;
 9237        _auditoriaService = auditoriaService;
 9238        _currentUser = currentUser;
 9239        _periodoGuard = periodoGuard;
 9240    }
 41
 42    // ── Tipos ────────────────────────────────────────────────────────────────
 43
 44    public async Task<IEnumerable<TipoCompensacionDto>> GetTiposAsync(bool? vigente = null)
 45    {
 246        var tipos = await _repo.GetTiposAsync(vigente);
 247        return tipos.Select(MapTipoToDto);
 248    }
 49
 50    public async Task<TipoCompensacionDto?> GetTipoByIdAsync(long id)
 51    {
 252        var tipo = await _repo.GetTipoByIdAsync(id);
 253        return tipo == null ? null : MapTipoToDto(tipo);
 254    }
 55
 56    public async Task<(bool Success, TipoCompensacion? Tipo, string? Error)> CreateTipoAsync(CreateTipoCompensacionReque
 57    {
 458        if (string.IsNullOrWhiteSpace(request.Codigo))
 159            return (false, null, "codigo es obligatorio");
 60
 361        if (!UnidadesValidas.Contains(request.UnidadMedida))
 162            return (false, null, $"unidad_medida inválida. Valores aceptados: {string.Join(", ", UnidadesValidas)}");
 63
 264        if (await _repo.GetTipoByCodigoAsync(request.Codigo) != null)
 165            return (false, null, $"Ya existe un tipo de compensación con código '{request.Codigo}'");
 66
 167        var tipo = new TipoCompensacion
 168        {
 169            Codigo = request.Codigo.Trim().ToUpper(),
 170            Nombre = request.Nombre,
 171            UnidadMedida = request.UnidadMedida,
 172            RequiereFuente = request.RequiereFuente,
 173            Vigente = true
 174        };
 75
 176        var creado = await _repo.CreateTipoAsync(tipo);
 177        _logger.LogInformation("Tipo compensación {Codigo} creado", creado.Codigo);
 178        await _auditoriaService.LogAuditoriaAsync(_currentUser.UserId ?? 0, AccionEnum.CrearCompensacion, ContextoEnum.C
 179        return (true, creado, null);
 480    }
 81
 82    public async Task<(bool Success, TipoCompensacion? Tipo, string? Error)> UpdateTipoAsync(long id, UpdateTipoCompensa
 83    {
 384        var tipo = await _repo.GetTipoByIdAsync(id);
 485        if (tipo == null) return (false, null, "Tipo de compensación no encontrado");
 86
 487        if (request.Nombre != null) tipo.Nombre = request.Nombre;
 388        if (request.RequiereFuente.HasValue) tipo.RequiereFuente = request.RequiereFuente.Value;
 389        if (request.Vigente.HasValue) tipo.Vigente = request.Vigente.Value;
 90
 291        var actualizado = await _repo.UpdateTipoAsync(tipo);
 292        await _auditoriaService.LogAuditoriaAsync(_currentUser.UserId ?? 0, AccionEnum.ActualizarCompensacion, ContextoE
 293        return (true, actualizado, null);
 394    }
 95
 96    public async Task<(bool Success, string? Error)> DeleteTipoAsync(long id)
 97    {
 498        var tipo = await _repo.GetTipoByIdAsync(id);
 599        if (tipo == null) return (false, "Tipo de compensación no encontrado");
 100
 101        // Verificar si tiene configuraciones asociadas
 3102        if (await _repo.TieneConfiguracionesAsync(id))
 1103            return (false, "No se puede eliminar el tipo porque tiene configuraciones asociadas. Desactive las configura
 104
 105        // Verificar si tiene lotes asociados
 2106        if (await _repo.TieneLotesAsync(id))
 1107            return (false, "No se puede eliminar el tipo porque tiene lotes asociados. Elimine los lotes primero.");
 108
 1109        await _auditoriaService.LogAuditoriaAsync(_currentUser.UserId ?? 0, AccionEnum.ActualizarCompensacion, ContextoE
 1110        await _repo.DeleteTipoAsync(id);
 1111        _logger.LogInformation("Tipo compensación {Id} eliminado", id);
 1112        return (true, null);
 4113    }
 114
 115    // ── Configs ──────────────────────────────────────────────────────────────
 116
 117    public async Task<IEnumerable<ConfigCompensacionDto>> GetConfigsAsync(
 118        long? tipoId = null, long? regimenId = null, long? gradoId = null, bool? activo = null)
 119    {
 3120        var configs = await _repo.GetConfigsAsync(tipoId, regimenId, gradoId, activo);
 3121        return configs.Select(MapConfigToDto);
 3122    }
 123
 124    public async Task<ConfigCompensacionDto?> GetConfigByIdAsync(long id)
 125    {
 2126        var config = await _repo.GetConfigByIdAsync(id);
 2127        return config == null ? null : MapConfigToDto(config);
 2128    }
 129
 130    public async Task<(bool Success, ConfigCompensacion? Config, string? Error)> CreateConfigAsync(CreateConfigCompensac
 131    {
 5132        var validacion = ValidarFormula(request.Formula, request.Monto, request.ValorPorUnidad, request.ValorMinimo, req
 8133        if (validacion != null) return (false, null, validacion);
 134
 2135        var tipo = await _repo.GetTipoByIdAsync(request.TipoCompensacionId);
 3136        if (tipo == null) return (false, null, "No se encontró el tipo de compensación indicado");
 137
 138        // Desactivar configs anteriores: solo puede haber una activa por tipo
 1139        await _repo.DesactivarConfigsActivasAsync(request.TipoCompensacionId);
 140
 1141        var config = new ConfigCompensacion
 1142        {
 1143            TipoCompensacionId  = request.TipoCompensacionId,
 1144            RegimenId           = request.RegimenId,
 1145            GradoId             = request.GradoId,
 1146            EscalafonId         = request.EscalafonId,
 1147            Funcion             = request.Funcion,
 1148            UnidadMedida        = request.UnidadMedida,
 1149            Formula             = request.Formula,
 1150            ValorPorUnidad      = request.ValorPorUnidad,
 1151            Monto               = request.Monto,
 1152            Tope                = request.Tope,
 1153            TopeUnidades        = request.TopeUnidades,
 1154            ValorMinimo         = request.ValorMinimo,
 1155            ValorMaximo         = request.ValorMaximo,
 1156            VigenteDesde        = request.VigenteDesde,
 1157            VigenteHasta        = request.VigenteHasta,
 1158            SujetoDescLegales   = request.SujetoDescLegales,
 1159            CatalogoItemId      = request.CatalogoItemId,
 1160            Activo              = true
 1161        };
 162
 1163        var creada = await _repo.CreateConfigAsync(config);
 1164        await _auditoriaService.LogAuditoriaAsync(_currentUser.UserId ?? 0, AccionEnum.CrearConfigCompensacion, Contexto
 1165        return (true, creada, null);
 5166    }
 167
 168    public async Task<(bool Success, ConfigCompensacion? Config, string? Error)> UpdateConfigAsync(long id, UpdateConfig
 169    {
 4170        var config = await _repo.GetConfigByIdAsync(id);
 5171        if (config == null) return (false, null, "Configuración no encontrada");
 172
 3173        var monto = request.Monto ?? config.Monto;
 3174        var valorPorUnidad = request.ValorPorUnidad ?? config.ValorPorUnidad;
 3175        var valorMinimo = request.ValorMinimo ?? config.ValorMinimo;
 3176        var valorMaximo = request.ValorMaximo ?? config.ValorMaximo;
 3177        var topeUnidades = request.TopeUnidades ?? config.TopeUnidades;
 178
 3179        var validacion = ValidarFormula(config.Formula, monto, valorPorUnidad, valorMinimo, valorMaximo, topeUnidades);
 3180        if (validacion != null) return (false, null, validacion);
 181
 3182        if (request.ValorPorUnidad.HasValue) config.ValorPorUnidad = request.ValorPorUnidad;
 3183        if (request.Monto.HasValue) config.Monto = request.Monto;
 3184        if (request.Tope.HasValue) config.Tope = request.Tope;
 3185        if (request.TopeUnidades.HasValue) config.TopeUnidades = request.TopeUnidades;
 3186        if (request.ValorMinimo.HasValue) config.ValorMinimo = request.ValorMinimo;
 3187        if (request.ValorMaximo.HasValue) config.ValorMaximo = request.ValorMaximo;
 3188        if (request.VigenteHasta.HasValue) config.VigenteHasta = request.VigenteHasta;
 4189        if (request.SujetoDescLegales.HasValue) config.SujetoDescLegales = request.SujetoDescLegales.Value;
 4190        if (request.CatalogoItemId.HasValue) config.CatalogoItemId = request.CatalogoItemId;
 3191        if (request.Activo.HasValue)
 192        {
 2193            if (!request.Activo.Value && config.Activo && await _repo.EsUnicaConfigActivaAsync(id, config.TipoCompensaci
 1194                return (false, null, "No se puede desactivar la única configuración activa del tipo. Cree otra antes de 
 195
 196            // Si se activa una config inactiva, desactivar las demás del mismo tipo
 1197            if (request.Activo.Value && !config.Activo)
 1198                await _repo.DesactivarConfigsActivasAsync(config.TipoCompensacionId);
 1199            config.Activo = request.Activo.Value;
 200        }
 201
 2202        var actualizada = await _repo.UpdateConfigAsync(config);
 2203        await _auditoriaService.LogAuditoriaAsync(_currentUser.UserId ?? 0, AccionEnum.ActualizarConfigComprensacion, Co
 2204        return (true, actualizada, null);
 4205    }
 206
 207    public async Task<(bool Success, string? Error)> DeleteConfigAsync(long id)
 208    {
 4209        var config = await _repo.GetConfigByIdAsync(id);
 5210        if (config == null) return (false, "Configuración no encontrada");
 211
 3212        if (config.Activo && await _repo.EsUnicaConfigActivaAsync(id, config.TipoCompensacionId))
 1213            return (false, "No se puede eliminar la única configuración activa del tipo. Cree otra antes de eliminar est
 214
 2215        await _auditoriaService.LogAuditoriaAsync(_currentUser.UserId ?? 0, AccionEnum.ActualizarConfigComprensacion, Co
 2216        await _repo.DeleteConfigAsync(id);
 2217        _logger.LogInformation("Configuración compensación {Id} eliminada", id);
 2218        return (true, null);
 4219    }
 220
 221    // ── Lotes ────────────────────────────────────────────────────────────────
 222
 223    public async Task<PagedResult<LoteCompensacionDto>> GetLotesPagedAsync(
 224        int page, int pageSize, long? tipoId = null, long? periodoId = null, string? estado = null)
 225    {
 0226        var (items, totalCount) = await _repo.GetLotesPagedAsync(page, pageSize, tipoId, periodoId, estado);
 0227        return new PagedResult<LoteCompensacionDto>
 0228        {
 0229            Items = items.Select(MapLoteToDto),
 0230            Page = page,
 0231            PageSize = pageSize,
 0232            TotalCount = totalCount
 0233        };
 0234    }
 235
 236    public async Task<LoteCompensacionDto?> GetLoteByIdAsync(long id)
 237    {
 0238        var lote = await _repo.GetLoteByIdWithItemsAsync(id);
 0239        return lote == null ? null : MapLoteToDto(lote);
 0240    }
 241
 242    public async Task<(bool Success, LoteCompensacion? Lote, string? Error)> CreateLoteAsync(long usuarioId, CreateLoteC
 243    {
 2244        await _periodoGuard.VerificarPeriodoAbiertoAsync();
 245
 2246        var periodoActual = await _periodoGuard.ObtenerPeriodoAbiertoAsync();
 2247        if (periodoActual == null || periodoActual.Id != request.PeriodoId)
 0248            return (false, null, "El período indicado no coincide con el período activo actual");
 249
 2250        var tipo = await _repo.GetTipoByIdAsync(request.TipoCompensacionId);
 3251        if (tipo == null) return (false, null, "No se encontró el tipo de compensación indicado");
 252
 253        // Validar unicidad: un solo lote activo por tipo + período
 1254        if (await _repo.ExisteLoteActivoAsync(request.TipoCompensacionId, request.PeriodoId))
 0255            return (false, null, "Ya existe un lote activo para ese tipo de compensación en el período indicado. Para re
 256
 1257        var lote = new LoteCompensacion
 1258        {
 1259            TipoCompensacionId = request.TipoCompensacionId,
 1260            PeriodoId = request.PeriodoId,
 1261            Estado = "borrador",
 1262            Fuente = request.Fuente,
 1263            NombreArchivo = request.NombreArchivo,
 1264            HashArchivo = request.HashArchivo,
 1265            UsuarioId = usuarioId,
 1266            CreadoEn = DateTime.Now
 1267        };
 268
 1269        var creado = await _repo.CreateLoteAsync(lote);
 1270        _logger.LogInformation("Lote compensación {Id} creado para tipo {TipoId}", creado.Id, creado.TipoCompensacionId)
 1271        await _auditoriaService.LogAuditoriaAsync(_currentUser.UserId ?? 0, AccionEnum.CrearLoteCompensacion, ContextoEn
 1272        return (true, creado, null);
 2273    }
 274
 275    // Transiciones manuales vía PUT (aprobar/rechazar/revertir tienen endpoints dedicados)
 1276    private static readonly Dictionary<string, string[]> TransicionesPermitidas = new()
 1277    {
 1278        ["borrador"] = new[] { "validado" },
 1279        ["validado"] = new[] { "borrador" }
 1280        // validado → aprobado: vía PUT /aprobar
 1281        // aprobado → borrador: vía PUT /revertir
 1282        // aprobado → rechazado: vía PUT /rechazar
 1283        // validado → rechazado: vía PUT /rechazar
 1284    };
 285
 286    public async Task<(bool Success, LoteCompensacion? Lote, string? Error)> UpdateLoteAsync(long id, UpdateLoteCompensa
 287    {
 8288        var lote = await _repo.GetLoteByIdAsync(id);
 9289        if (lote == null) return (false, null, "Lote no encontrado");
 7290        await VerificarLoteEnPeriodoActivoAsync(lote.PeriodoId);
 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, LotePeriodoDateTime(lote));
 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        var lote = await _repo.GetLoteByIdAsync(id);
 3339        if (lote == null) return (false, null, "Lote no encontrado");
 3340        await VerificarLoteEnPeriodoActivoAsync(lote.PeriodoId);
 341
 3342        if (lote.Estado != "validado")
 2343            return (false, null, $"Solo se pueden aprobar lotes en estado 'validado'. Estado actual: '{lote.Estado}'");
 344
 1345        lote.Estado = "aprobado";
 1346        lote.AprobadoPor = aprobadoPorId;
 1347        lote.AprobadoEn = DateTime.Now;
 348
 1349        var actualizado = await _repo.UpdateLoteAsync(lote);
 1350        _logger.LogInformation("Lote {Id} aprobado por usuario {UsuarioId}", id, aprobadoPorId);
 1351        await _auditoriaService.LogAuditoriaAsync(aprobadoPorId, AccionEnum.AprobarLoteCompensacion, ContextoEnum.Compen
 1352        return (true, actualizado, null);
 3353    }
 354
 355    public async Task<(bool Success, LoteCompensacion? Lote, string? Error)> RechazarLoteAsync(long id)
 356    {
 5357        var lote = await _repo.GetLoteByIdAsync(id);
 6358        if (lote == null) return (false, null, "Lote no encontrado");
 4359        await VerificarLoteEnPeriodoActivoAsync(lote.PeriodoId);
 360
 4361        if (lote.Estado is "exportado" or "rechazado")
 2362            return (false, null, $"No se puede rechazar un lote en estado '{lote.Estado}'");
 363
 2364        lote.Estado = "rechazado";
 2365        var actualizado = await _repo.UpdateLoteAsync(lote);
 2366        _logger.LogInformation("Lote {Id} rechazado", id);
 2367        await _auditoriaService.LogAuditoriaAsync(_currentUser.UserId ?? 0, AccionEnum.RechazarLoteCompensacion, Context
 2368        return (true, actualizado, null);
 5369    }
 370
 371    public async Task<(bool Success, LoteCompensacion? Lote, string? Error)> RevertirABorradorAsync(long id)
 372    {
 4373        var lote = await _repo.GetLoteByIdAsync(id);
 5374        if (lote == null) return (false, null, "Lote no encontrado");
 3375        await VerificarLoteEnPeriodoActivoAsync(lote.PeriodoId);
 376
 3377        if (lote.Estado != "aprobado")
 2378            return (false, null, $"Solo se pueden revertir lotes en estado 'aprobado'. Estado actual: '{lote.Estado}'");
 379
 1380        lote.Estado = "borrador";
 1381        lote.AprobadoPor = null;
 1382        lote.AprobadoEn = null;
 383
 1384        var actualizado = await _repo.UpdateLoteAsync(lote);
 1385        _logger.LogInformation("Lote {Id} revertido a borrador", id);
 1386        await _auditoriaService.LogAuditoriaAsync(_currentUser.UserId ?? 0, AccionEnum.RevertirLoteCompensacion, Context
 1387        return (true, actualizado, null);
 4388    }
 389
 390    public async Task<(bool Success, string? Error)> CancelarLoteAsync(long id)
 391    {
 5392        var lote = await _repo.GetLoteByIdAsync(id);
 6393        if (lote == null) return (false, "Lote no encontrado");
 4394        await VerificarLoteEnPeriodoActivoAsync(lote.PeriodoId);
 395
 4396        if (lote.Estado is not ("borrador" or "rechazado"))
 2397            return (false, $"Solo se pueden eliminar lotes en estado 'borrador' o 'rechazado'. Estado actual: '{lote.Est
 398
 2399        await _auditoriaService.LogAuditoriaAsync(_currentUser.UserId ?? 0, AccionEnum.EliminarLoteCompensacion, Context
 2400        await _repo.DeleteAllItemsAsync(id);
 2401        await _repo.DeleteLoteAsync(lote);
 2402        _logger.LogInformation("Lote {Id} cancelado y eliminado", id);
 2403        return (true, null);
 5404    }
 405
 406    public async Task<PagedResult<ItemLoteCompensacionDto>> GetItemsPagedAsync(long loteId, int page, int pageSize)
 407    {
 1408        var lote = await _repo.GetLoteByIdAsync(loteId);
 1409        var unidadMedida = lote != null
 1410            ? (await _repo.GetTipoByIdAsync(lote.TipoCompensacionId))?.UnidadMedida ?? string.Empty
 1411            : string.Empty;
 412
 1413        var (items, totalCount) = await _repo.GetItemsPagedAsync(loteId, page, pageSize);
 1414        return new PagedResult<ItemLoteCompensacionDto>
 1415        {
 2416            Items = items.Select(i => MapItemToDto(i, unidadMedida)),
 1417            Page = page,
 1418            PageSize = pageSize,
 1419            TotalCount = totalCount
 1420        };
 1421    }
 422
 423    // ── Items ────────────────────────────────────────────────────────────────
 424
 425    public async Task<(bool Success, ItemLoteCompensacion? Item, string? Error)> CreateItemAsync(long loteId, CreateItem
 426    {
 23427        var lote = await _repo.GetLoteByIdAsync(loteId);
 23428        if (lote == null) return (false, null, "Lote no encontrado");
 23429        await VerificarLoteEnPeriodoActivoAsync(lote.PeriodoId);
 430
 23431        if (!EstadosEditablesLote.Contains(lote.Estado))
 1432            return (false, null, $"No se pueden agregar ítems a un lote en estado '{lote.Estado}'");
 433
 434        // Resolver cédula → personaId
 22435        var relacion = await _personalRepo.GetByCedulaAsync(request.CedulaPersona);
 22436        if (relacion == null)
 1437            return (false, null, $"No se encontró personal activo con cédula {request.CedulaPersona}");
 438
 21439        var personaId = relacion.PersonaId;
 440
 21441        if (await _repo.ExistePersonaEnLoteAsync(loteId, personaId))
 1442            return (false, null, "Ya existe un ítem para esa persona en este lote");
 443
 20444        var periodoFecha = LotePeriodoDateTime(lote);
 20445        var config = await _repo.GetConfigVigenteAsync(lote.TipoCompensacionId, periodoFecha);
 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, periodoFecha);
 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        var lote = await _repo.GetLoteByIdAsync(loteId);
 2492        if (lote == null) return (false, null, "Lote no encontrado");
 2493        await VerificarLoteEnPeriodoActivoAsync(lote.PeriodoId);
 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 periodoFecha = LotePeriodoDateTime(lote);
 2508            var config = await _repo.GetConfigVigenteAsync(lote.TipoCompensacionId, periodoFecha);
 509
 2510            if (request.UnidadCantidad.HasValue)
 511            {
 1512                if (config?.TopeUnidades.HasValue == true && request.UnidadCantidad.Value > config.TopeUnidades.Value)
 0513                    return (false, null, $"unidad_cantidad excede el tope máximo de {config.TopeUnidades} unidades");
 1514                item.UnidadCantidad = request.UnidadCantidad.Value;
 515            }
 516
 2517            if (config != null)
 518            {
 2519                var nuevaCantidad = item.UnidadCantidad;
 2520                var nuevoMonto = request.Monto ?? item.Monto;
 2521                var (formulaOk, montoCalculado, formulaError) = await AplicarFormulaAsync(
 2522                    loteId, config, nuevaCantidad, nuevoMonto,
 2523                    item.Ala, item.CategoriaCompensacion, item.SnapshotGradoId, periodoFecha);
 3524                if (!formulaOk) return (false, null, formulaError);
 1525                item.Monto = montoCalculado;
 526            }
 0527            else if (request.Monto.HasValue)
 528            {
 0529                item.Monto = request.Monto;
 530            }
 531        }
 532
 1533        if (request.Estado != null) item.Estado = request.Estado;
 1534        if (request.Incidencia != null) item.Incidencia = request.Incidencia;
 1535        if (request.Observaciones != null) item.Observaciones = request.Observaciones;
 1536        if (request.Funcion != null) item.Funcion = request.Funcion;
 537
 1538        var actualizado = await _repo.UpdateItemAsync(item);
 1539        await _auditoriaService.LogAuditoriaAsync(_currentUser.UserId ?? 0, AccionEnum.ActualizarItemLoteCompensacion, C
 1540        await RevertirABorradorSiValidadoAsync(lote);
 1541        return (true, actualizado, null);
 2542    }
 543
 544    public async Task<(bool Success, string? Error)> DeleteItemAsync(long loteId, long itemId)
 545    {
 6546        var lote = await _repo.GetLoteByIdAsync(loteId);
 7547        if (lote == null) return (false, "Lote no encontrado");
 5548        await VerificarLoteEnPeriodoActivoAsync(lote.PeriodoId);
 549
 5550        if (!EstadosEditablesLote.Contains(lote.Estado))
 1551            return (false, $"No se pueden eliminar ítems de un lote en estado '{lote.Estado}'");
 552
 4553        var item = await _repo.GetItemByIdAsync(itemId);
 4554        if (item == null || item.LoteCompensacionId != loteId)
 2555            return (false, "Ítem no encontrado");
 556
 2557        await _auditoriaService.LogAuditoriaAsync(_currentUser.UserId ?? 0, AccionEnum.EliminarItemLoteCompensacion, Con
 2558        await _repo.DeleteItemAsync(item);
 2559        await RevertirABorradorSiValidadoAsync(lote);
 2560        return (true, null);
 6561    }
 562
 563    private async Task VerificarLoteEnPeriodoActivoAsync(long lotePeriodoId)
 564    {
 51565        var periodoActivo = await _periodoGuard.ObtenerPeriodoAbiertoAsync();
 51566        if (periodoActivo == null)
 0567            throw new PeriodoBloqueadoException("sin período abierto");
 51568        if (periodoActivo.Id != lotePeriodoId)
 0569            throw new PeriodoBloqueadoException("CERRADA");
 51570    }
 571
 572    /// Si el lote estaba en 'validado', cualquier edición de ítems lo regresa a 'borrador'
 573    private async Task RevertirABorradorSiValidadoAsync(LoteCompensacion lote)
 574    {
 15575        if (lote.Estado == "validado")
 576        {
 1577            lote.Estado = "borrador";
 1578            await _repo.UpdateLoteAsync(lote);
 1579            _logger.LogInformation("Lote {Id} revertido a borrador por edición de ítems", lote.Id);
 580        }
 15581    }
 582
 583    // ── Bulk Import ──────────────────────────────────────────────────────────
 584
 585    public async Task<BulkImportResultDto> CreateItemsBulkAsync(long loteId, IEnumerable<CreateItemLoteRequest> items, s
 586    {
 0587        var lote = await _repo.GetLoteByIdAsync(loteId);
 0588        if (lote == null)
 0589            return new BulkImportResultDto { Fallidos = 1, Procesados = 1, Resultados = [new() { Linea = 0, Cedula = "",
 590
 0591        if (!dryRun) await VerificarLoteEnPeriodoActivoAsync(lote.PeriodoId);
 0592        if (!dryRun && !EstadosEditablesLote.Contains(lote.Estado))
 0593            return new BulkImportResultDto { Fallidos = 1, Procesados = 1, Resultados = [new() { Linea = 0, Cedula = "",
 594
 0595        var resultado = new BulkImportResultDto { EsDryRun = dryRun, Modo = modo };
 596
 0597        if (!dryRun && modo == "reemplazar")
 598        {
 0599            await _repo.DeleteAllItemsAsync(loteId);
 0600            if (lote.Estado == "validado") { lote.Estado = "borrador"; await _repo.UpdateLoteAsync(lote); }
 601        }
 602
 0603        int linea = 1;
 0604        foreach (var request in items)
 605        {
 0606            resultado.Procesados++;
 0607            var (success, item, error) = dryRun
 0608                ? await ValidarItemAsync(loteId, lote, request)
 0609                : await CreateItemAsync(loteId, request);
 610
 0611            resultado.Resultados.Add(new BulkImportLineResultDto
 0612            {
 0613                Linea = linea++,
 0614                Cedula = request.CedulaPersona,
 0615                Exito = success,
 0616                Error = error,
 0617                ItemId = item?.Id
 0618            });
 0619            if (success) resultado.Exitosos++;
 0620            else resultado.Fallidos++;
 0621        }
 622
 0623        return resultado;
 0624    }
 625
 626    public async Task<BulkImportResultDto> CreateItemsFromCsvAsync(long loteId, Stream csvStream, string modo = "reempla
 627    {
 0628        var lote = await _repo.GetLoteByIdAsync(loteId);
 0629        if (lote == null)
 0630            return new BulkImportResultDto { Fallidos = 1, Procesados = 1, Resultados = [new() { Linea = 0, Cedula = "",
 631
 0632        if (!dryRun) await VerificarLoteEnPeriodoActivoAsync(lote.PeriodoId);
 0633        if (!dryRun && !EstadosEditablesLote.Contains(lote.Estado))
 0634            return new BulkImportResultDto { Fallidos = 1, Procesados = 1, Resultados = [new() { Linea = 0, Cedula = "",
 635
 0636        if (!dryRun && modo == "reemplazar")
 637        {
 0638            await _repo.DeleteAllItemsAsync(loteId);
 0639            if (lote.Estado == "validado") { lote.Estado = "borrador"; await _repo.UpdateLoteAsync(lote); }
 640        }
 641
 642        // Para fórmulas fijo y prorrateo el monto lo calcula el sistema; para las demás el CSV debe proveerlo
 0643        var configCsv = await _repo.GetConfigVigenteAsync(lote.TipoCompensacionId, LotePeriodoDateTime(lote));
 0644        var formulaCalculaMontoAutomatico = configCsv?.Formula is "fijo" or "prorrateo";
 645        // Para tabla con ala o categoria, el monto también se calcula desde la tarifa
 0646        var formulaTablaConLookup = configCsv?.Formula == "tabla" && configCsv.CatalogoItemId.HasValue;
 647
 0648        var resultado = new BulkImportResultDto { EsDryRun = dryRun, Modo = modo };
 0649        using var reader = new System.IO.StreamReader(csvStream);
 650
 651        // Leer encabezados
 0652        var headerLine = await reader.ReadLineAsync();
 0653        if (string.IsNullOrWhiteSpace(headerLine))
 0654            return resultado;
 655
 0656        var headers = headerLine.Split(',').Select(h => h.Trim().ToLower()).ToArray();
 0657        int idxCedula         = Array.IndexOf(headers, "cedula");
 0658        int idxMonto          = Array.IndexOf(headers, "monto");
 0659        int idxUnidad         = Array.IndexOf(headers, "unidad_cantidad");
 0660        int idxFuncion        = Array.IndexOf(headers, "funcion");
 0661        int idxAla            = Array.IndexOf(headers, "ala");
 0662        int idxCategoria      = Array.IndexOf(headers, "categoria");
 0663        int idxIncidencia     = Array.IndexOf(headers, "incidencia");
 0664        int idxObservaciones  = Array.IndexOf(headers, "observaciones");
 665
 0666        if (idxCedula < 0)
 0667            return new BulkImportResultDto { Fallidos = 1, Procesados = 1, Resultados = [new() { Linea = 1, Cedula = "",
 668
 0669        int linea = 2;
 0670        while (!reader.EndOfStream)
 671        {
 0672            var line = await reader.ReadLineAsync();
 0673            if (string.IsNullOrWhiteSpace(line)) { linea++; continue; }
 674
 0675            var cols = line.Split(',');
 0676            var cedula = idxCedula < cols.Length ? cols[idxCedula].Trim() : "";
 677
 0678            resultado.Procesados++;
 679
 0680            if (string.IsNullOrWhiteSpace(cedula))
 681            {
 0682                resultado.Fallidos++;
 0683                resultado.Resultados.Add(new() { Linea = linea, Cedula = cedula, Exito = false, Error = "Cédula vacía" }
 0684                linea++;
 0685                continue;
 686            }
 687
 0688            decimal monto = 0;
 0689            if (idxMonto >= 0 && idxMonto < cols.Length)
 0690                decimal.TryParse(cols[idxMonto].Trim(), System.Globalization.NumberStyles.Any, System.Globalization.Cult
 691
 0692            var ala       = idxAla < 0 || idxAla >= cols.Length ? null : NullIfEmpty(cols[idxAla].Trim()?.ToUpper());
 0693            var categoria = idxCategoria < 0 || idxCategoria >= cols.Length ? null : NullIfEmpty(cols[idxCategoria].Trim
 694
 695            // El monto no es requerido si la fórmula lo calcula automáticamente
 696            // (fijo, prorrateo, o tabla con ala/categoria)
 0697            var montoEsAutomatico = formulaCalculaMontoAutomatico || formulaTablaConLookup;
 698
 0699            if (!montoEsAutomatico && monto <= 0)
 700            {
 0701                resultado.Fallidos++;
 0702                resultado.Resultados.Add(new() { Linea = linea, Cedula = cedula, Exito = false, Error = "El monto debe s
 0703                linea++;
 0704                continue;
 705            }
 706
 0707            decimal unidadCantidad = 1;
 0708            if (idxUnidad >= 0 && idxUnidad < cols.Length && !string.IsNullOrWhiteSpace(cols[idxUnidad]))
 0709                decimal.TryParse(cols[idxUnidad].Trim(), System.Globalization.NumberStyles.Any, System.Globalization.Cul
 710
 0711            var funcion      = idxFuncion < 0 || idxFuncion >= cols.Length ? null : NullIfEmpty(cols[idxFuncion].Trim())
 0712            var incidencia   = idxIncidencia < 0 || idxIncidencia >= cols.Length ? null : NullIfEmpty(cols[idxIncidencia
 0713            var observaciones = idxObservaciones < 0 || idxObservaciones >= cols.Length ? null : NullIfEmpty(cols[idxObs
 714
 0715            var request = new CreateItemLoteRequest
 0716            {
 0717                CedulaPersona         = cedula,
 0718                Monto                 = monto > 0 ? monto : null,
 0719                UnidadCantidad        = unidadCantidad,
 0720                Funcion               = funcion,
 0721                Ala                   = ala,
 0722                CategoriaCompensacion = categoria,
 0723                Incidencia            = incidencia,
 0724                Observaciones         = observaciones
 0725            };
 726
 0727            var (success, item, error) = dryRun
 0728                ? await ValidarItemAsync(loteId, lote, request)
 0729                : await CreateItemAsync(loteId, request);
 0730            resultado.Resultados.Add(new() { Linea = linea, Cedula = cedula, Exito = success, Error = error, ItemId = it
 0731            if (success) resultado.Exitosos++;
 0732            else resultado.Fallidos++;
 733
 0734            linea++;
 0735        }
 736
 0737        return resultado;
 0738    }
 739
 740    /// Valida un ítem sin persistir (dry run). Replica las mismas validaciones de CreateItemAsync.
 741    private async Task<(bool Success, ItemLoteCompensacion? Item, string? Error)> ValidarItemAsync(
 742        long loteId, LoteCompensacion lote, CreateItemLoteRequest request)
 743    {
 0744        if (!EstadosEditablesLote.Contains(lote.Estado))
 0745            return (false, null, $"Lote en estado '{lote.Estado}' no es editable");
 746
 0747        var relacion = await _personalRepo.GetByCedulaAsync(request.CedulaPersona);
 0748        if (relacion == null)
 0749            return (false, null, $"No se encontró personal activo con cédula {request.CedulaPersona}");
 750
 0751        if (await _repo.ExistePersonaEnLoteAsync(loteId, relacion.PersonaId))
 0752            return (false, null, "Ya existe un ítem para esa persona en este lote");
 753
 0754        var periodoFecha = LotePeriodoDateTime(lote);
 0755        var config = await _repo.GetConfigVigenteAsync(lote.TipoCompensacionId, periodoFecha);
 0756        if (config?.TopeUnidades.HasValue == true && request.UnidadCantidad > config.TopeUnidades.Value)
 0757            return (false, null, $"unidad_cantidad excede el tope máximo de {config.TopeUnidades} unidades");
 758
 0759        if (config != null)
 760        {
 0761            var (formulaOk, _, formulaError) = await AplicarFormulaAsync(
 0762                loteId, config, request.UnidadCantidad, request.Monto,
 0763                request.Ala, request.CategoriaCompensacion, relacion.GradoId, periodoFecha);
 0764            if (!formulaOk) return (false, null, formulaError);
 765        }
 766
 767        // Validación aprobada — no se persiste
 0768        return (true, null, null);
 0769    }
 770
 771    // ── Helpers ──────────────────────────────────────────────────────────────
 772
 773    private static DateTime LotePeriodoDateTime(LoteCompensacion lote) =>
 24774        new(lote.Periodo.Anio, lote.Periodo.Mes, 1, 0, 0, 0, DateTimeKind.Utc);
 775
 776    // ── Lógica de fórmulas ───────────────────────────────────────────────────
 777
 778    /// Aplica la fórmula de la configuración para calcular o validar el monto del ítem.
 779    /// Devuelve el monto final que debe persistirse.
 780    private async Task<(bool Success, decimal? MontoCalculado, string? Error)> AplicarFormulaAsync(
 781        long loteId,
 782        ConfigCompensacion config,
 783        decimal unidadCantidad,
 784        decimal? montoEntrada,
 785        string? ala = null,
 786        string? categoria = null,
 787        long? snapshotGradoId = null,
 788        DateTime? periodo = null)
 789    {
 790        // periodo deriva de lote.Periodo (nav property → Anio/Mes) vía LotePeriodoDateTime — el fallback a UtcNow no de
 21791        var periodoRef = periodo ?? DateTime.Now;
 21792        switch (config.Formula)
 793        {
 794            case "fijo":
 5795                return (true, config.Monto, null);
 796
 797            case "prorrateo":
 3798                if (!config.ValorPorUnidad.HasValue)
 1799                    return (false, null, "La configuración de prorrateo no tiene valor_por_unidad definido");
 2800                return (true, unidadCantidad * config.ValorPorUnidad.Value, null);
 801
 802            case "rango":
 5803                if (!montoEntrada.HasValue)
 1804                    return (false, null, "Se debe proveer monto para fórmula rango");
 4805                if (config.ValorMinimo.HasValue && montoEntrada.Value < config.ValorMinimo.Value)
 1806                    return (false, null, $"El monto {montoEntrada.Value:F2} es menor al mínimo permitido de {config.Valo
 3807                if (config.ValorMaximo.HasValue && montoEntrada.Value > config.ValorMaximo.Value)
 2808                    return (false, null, $"El monto {montoEntrada.Value:F2} supera el máximo permitido de {config.ValorM
 1809                return (true, montoEntrada, null);
 810
 811            case "presupuesto":
 3812                if (!montoEntrada.HasValue)
 1813                    return (false, null, "Se debe proveer monto para fórmula presupuesto");
 2814                if (config.ValorMaximo.HasValue && unidadCantidad > 0)
 815                {
 0816                    var montoPorUnidad = montoEntrada.Value / unidadCantidad;
 0817                    if (montoPorUnidad > config.ValorMaximo.Value)
 0818                        return (false, null,
 0819                            $"El monto por {config.UnidadMedida} ({montoPorUnidad:F2}) supera el máximo permitido de {co
 820                }
 2821                if (config.Tope.HasValue)
 822                {
 2823                    var totalActual = await _repo.GetMontoTotalItemsAsync(loteId);
 2824                    if (totalActual + montoEntrada.Value > config.Tope.Value)
 1825                        return (false, null,
 1826                            $"El monto supera el tope presupuestario. Disponible: {config.Tope.Value - totalActual:F2}, 
 827                }
 1828                return (true, montoEntrada, null);
 829
 830            case "tabla":
 831                // Subcaso 1: VUELO — tarifa por categoría (ala A/N/S)
 5832                if (!string.IsNullOrEmpty(ala) && config.CatalogoItemId.HasValue)
 833                {
 2834                    var fechaRef = periodoRef;
 2835                    var tarifa = await _repo.GetTarifaCategoriaAsync(config.CatalogoItemId.Value, ala, fechaRef);
 2836                    if (tarifa == null)
 1837                        return (false, null, $"No se encontró tarifa para categoría de vuelo '{ala}'. Verifique que la c
 1838                    return (true, tarifa.Monto, null);
 839                }
 840
 841                // Subcaso 2: SAR — tarifa por categoría (función)
 3842                if (!string.IsNullOrEmpty(categoria) && config.CatalogoItemId.HasValue)
 843                {
 1844                    var fechaRef = periodoRef;
 1845                    var tarifa = await _repo.GetTarifaCategoriaAsync(config.CatalogoItemId.Value, categoria, fechaRef);
 1846                    if (tarifa == null)
 0847                        return (false, null, $"No se encontró tarifa para categoría '{categoria}'. Verifique que la conf
 1848                    return (true, tarifa.Monto, null);
 849                }
 850
 851                // Subcaso 3: CARCEL — tarifa diaria por grado
 2852                if (snapshotGradoId.HasValue && config.CatalogoItemId.HasValue)
 853                {
 1854                    var fechaRef = periodoRef;
 1855                    var tarifaGrado = await _repo.GetTarifaGradoAsync(config.CatalogoItemId.Value, snapshotGradoId.Value
 1856                    if (tarifaGrado != null)
 1857                        return (true, unidadCantidad * tarifaGrado.Monto, null);
 858                    // Si no hay tarifa por grado, cae al monto del CSV
 859                }
 860
 861                // Default: monto viene precalculado del origen externo (COMP_PERS, etc.)
 1862                return (true, montoEntrada, null);
 863
 864            default:
 0865                return (false, null, $"Fórmula '{config.Formula}' no reconocida");
 866        }
 21867    }
 868
 869    private static string? ValidarFormula(string formula, decimal? monto, decimal? valorPorUnidad, decimal? valorMinimo,
 870    {
 8871        if (!FormulasValidas.Contains(formula))
 0872            return $"formula inválida. Valores aceptados: {string.Join(", ", FormulasValidas)}";
 873
 8874        if (formula == "fijo" && !monto.HasValue)
 1875            return "monto es obligatorio para fórmula fijo";
 876
 7877        if (formula == "prorrateo" && !valorPorUnidad.HasValue)
 1878            return "valor_por_unidad es obligatorio para fórmula prorrateo";
 879
 6880        if (formula == "rango")
 881        {
 1882            if (!valorMinimo.HasValue || !valorMaximo.HasValue)
 0883                return "valor_minimo y valor_maximo son obligatorios para fórmula rango";
 1884            if (valorMinimo.Value >= valorMaximo.Value)
 1885                return "valor_minimo debe ser menor que valor_maximo";
 886        }
 887
 5888        if (topeUnidades.HasValue && topeUnidades.Value < 0)
 0889            return "tope_unidades debe ser mayor o igual a 0";
 890
 5891        return null;
 892    }
 893
 4894    private static TipoCompensacionDto MapTipoToDto(TipoCompensacion t) => new()
 4895    {
 4896        Id = t.Id,
 4897        Codigo = t.Codigo,
 4898        Nombre = t.Nombre,
 4899        UnidadMedida = t.UnidadMedida,
 4900        RequiereFuente = t.RequiereFuente,
 4901        Vigente = t.Vigente
 4902    };
 903
 4904    private static ConfigCompensacionDto MapConfigToDto(ConfigCompensacion c) => new()
 4905    {
 4906        Id = c.Id,
 4907        TipoCompensacionId = c.TipoCompensacionId,
 4908        NombreTipo = c.TipoCompensacion?.Nombre ?? string.Empty,
 4909        RegimenId = c.RegimenId,
 4910        NombreRegimen = c.Regimen?.Denominacion,
 4911        GradoId = c.GradoId,
 4912        NombreGrado = c.Grado?.Denominacion,
 4913        EscalafonId = c.EscalafonId,
 4914        NombreEscalafon = c.Escalafon?.Denominacion,
 4915        Funcion = c.Funcion,
 4916        UnidadMedida = c.UnidadMedida,
 4917        Formula = c.Formula,
 4918        ValorPorUnidad = c.ValorPorUnidad,
 4919        Monto = c.Monto,
 4920        Tope = c.Tope,
 4921        TopeUnidades = c.TopeUnidades,
 4922        ValorMinimo = c.ValorMinimo,
 4923        ValorMaximo = c.ValorMaximo,
 4924        VigenteDesde = c.VigenteDesde,
 4925        VigenteHasta = c.VigenteHasta,
 4926        Activo = c.Activo,
 4927        SujetoDescLegales = c.SujetoDescLegales,
 4928        CatalogoItemId = c.CatalogoItemId,
 4929        NombreCatalogoItem = c.CatalogoItem?.Nombre
 4930    };
 931
 0932    private static LoteCompensacionDto MapLoteToDto(LoteCompensacion l) => new()
 0933    {
 0934        Id = l.Id,
 0935        TipoCompensacionId = l.TipoCompensacionId,
 0936        NombreTipo = l.TipoCompensacion?.Nombre ?? string.Empty,
 0937        UnidadMedida = l.TipoCompensacion?.UnidadMedida ?? string.Empty,
 0938        PeriodoId = l.PeriodoId,
 0939        Periodo = new DateTime(l.Periodo.Anio, l.Periodo.Mes, 1, 0, 0, 0, DateTimeKind.Utc),
 0940        Estado = l.Estado,
 0941        Fuente = l.Fuente,
 0942        NombreArchivo = l.NombreArchivo,
 0943        UsuarioId = l.UsuarioId,
 0944        AprobadoPor = l.AprobadoPor,
 0945        NombreAprobador = l.AprobadorUsuario?.Username,
 0946        AprobadoEn = l.AprobadoEn,
 0947        CreadoEn = l.CreadoEn,
 0948        ItemCount = l.ItemCount > 0 ? l.ItemCount : l.Items.Count(),
 0949        Items = l.Items.Select(i => MapItemToDto(i, l.TipoCompensacion?.UnidadMedida ?? string.Empty))
 0950    };
 951
 952    private static string? NullIfEmpty(string? s) =>
 0953        string.IsNullOrWhiteSpace(s) ? null : s;
 954
 2955    private static ItemLoteCompensacionDto MapItemToDto(ItemLoteCompensacion i, string unidadMedida = "") => new()
 2956    {
 2957        Id = i.Id,
 2958        LoteCompensacionId = i.LoteCompensacionId,
 2959        CedulaPersona = i.Persona?.Cedula ?? string.Empty,
 2960        NombrePersona = i.Persona?.NombreCompleto ?? string.Empty,
 2961        UnidadCantidad = i.UnidadCantidad,
 2962        UnidadMedida = unidadMedida,
 2963        Monto = i.Monto,
 2964        Estado = i.Estado,
 2965        Incidencia = i.Incidencia,
 2966        Observaciones = i.Observaciones,
 2967        Funcion = i.Funcion,
 2968        SujetoDescLegales     = i.SujetoDescLegales,
 2969        Ala                   = i.Ala,
 2970        CategoriaCompensacion = i.CategoriaCompensacion,
 2971        SnapshotGrado         = i.SnapshotGrado?.Denominacion,
 2972        SnapshotSituacion     = i.SnapshotSituacion?.Denominacion,
 2973        SnapshotUnidad        = i.SnapshotUnidad?.Denominacion,
 2974        SnapshotPrograma      = i.SnapshotPrograma?.Codigo
 2975    };
 976}