< 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: 428
Uncovered lines: 165
Coverable lines: 593
Total lines: 915
Line coverage: 72.1%
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%8890.9%
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%514484.84%
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
 9222    public CompensacionService(
 9223        ICompensacionRepository repo,
 9224        IPersonalRepository personalRepo,
 9225        ILogger<CompensacionService> logger)
 26    {
 9227        _repo = repo;
 9228        _personalRepo = personalRepo;
 9229        _logger = logger;
 9230    }
 31
 32    // ── Tipos ────────────────────────────────────────────────────────────────
 33
 34    public async Task<IEnumerable<TipoCompensacionDto>> GetTiposAsync(bool? vigente = null)
 35    {
 236        var tipos = await _repo.GetTiposAsync(vigente);
 237        return tipos.Select(MapTipoToDto);
 238    }
 39
 40    public async Task<TipoCompensacionDto?> GetTipoByIdAsync(long id)
 41    {
 242        var tipo = await _repo.GetTipoByIdAsync(id);
 243        return tipo == null ? null : MapTipoToDto(tipo);
 244    }
 45
 46    public async Task<(bool Success, TipoCompensacion? Tipo, string? Error)> CreateTipoAsync(CreateTipoCompensacionReque
 47    {
 448        if (string.IsNullOrWhiteSpace(request.Codigo))
 149            return (false, null, "codigo es obligatorio");
 50
 351        if (!UnidadesValidas.Contains(request.UnidadMedida))
 152            return (false, null, $"unidad_medida inválida. Valores aceptados: {string.Join(", ", UnidadesValidas)}");
 53
 254        if (await _repo.GetTipoByCodigoAsync(request.Codigo) != null)
 155            return (false, null, $"Ya existe un tipo de compensación con código '{request.Codigo}'");
 56
 157        var tipo = new TipoCompensacion
 158        {
 159            Codigo = request.Codigo.Trim().ToUpper(),
 160            Nombre = request.Nombre,
 161            UnidadMedida = request.UnidadMedida,
 162            RequiereFuente = request.RequiereFuente,
 163            Vigente = true
 164        };
 65
 166        var creado = await _repo.CreateTipoAsync(tipo);
 167        _logger.LogInformation("Tipo compensación {Codigo} creado", creado.Codigo);
 168        return (true, creado, null);
 469    }
 70
 71    public async Task<(bool Success, TipoCompensacion? Tipo, string? Error)> UpdateTipoAsync(long id, UpdateTipoCompensa
 72    {
 373        var tipo = await _repo.GetTipoByIdAsync(id);
 474        if (tipo == null) return (false, null, "Tipo de compensación no encontrado");
 75
 476        if (request.Nombre != null) tipo.Nombre = request.Nombre;
 377        if (request.RequiereFuente.HasValue) tipo.RequiereFuente = request.RequiereFuente.Value;
 378        if (request.Vigente.HasValue) tipo.Vigente = request.Vigente.Value;
 79
 280        var actualizado = await _repo.UpdateTipoAsync(tipo);
 281        return (true, actualizado, null);
 382    }
 83
 84    public async Task<(bool Success, string? Error)> DeleteTipoAsync(long id)
 85    {
 486        var tipo = await _repo.GetTipoByIdAsync(id);
 587        if (tipo == null) return (false, "Tipo de compensación no encontrado");
 88
 89        // Verificar si tiene configuraciones asociadas
 390        if (await _repo.TieneConfiguracionesAsync(id))
 191            return (false, "No se puede eliminar el tipo porque tiene configuraciones asociadas. Desactive las configura
 92
 93        // Verificar si tiene lotes asociados
 294        if (await _repo.TieneLotesAsync(id))
 195            return (false, "No se puede eliminar el tipo porque tiene lotes asociados. Elimine los lotes primero.");
 96
 197        await _repo.DeleteTipoAsync(id);
 198        _logger.LogInformation("Tipo compensación {Id} eliminado", id);
 199        return (true, null);
 4100    }
 101
 102    // ── Configs ──────────────────────────────────────────────────────────────
 103
 104    public async Task<IEnumerable<ConfigCompensacionDto>> GetConfigsAsync(
 105        long? tipoId = null, long? regimenId = null, long? gradoId = null, bool? activo = null)
 106    {
 3107        var configs = await _repo.GetConfigsAsync(tipoId, regimenId, gradoId, activo);
 3108        return configs.Select(MapConfigToDto);
 3109    }
 110
 111    public async Task<ConfigCompensacionDto?> GetConfigByIdAsync(long id)
 112    {
 2113        var config = await _repo.GetConfigByIdAsync(id);
 2114        return config == null ? null : MapConfigToDto(config);
 2115    }
 116
 117    public async Task<(bool Success, ConfigCompensacion? Config, string? Error)> CreateConfigAsync(CreateConfigCompensac
 118    {
 5119        var validacion = ValidarFormula(request.Formula, request.Monto, request.ValorPorUnidad, request.ValorMinimo, req
 8120        if (validacion != null) return (false, null, validacion);
 121
 2122        var tipo = await _repo.GetTipoByIdAsync(request.TipoCompensacionId);
 3123        if (tipo == null) return (false, null, "No se encontró el tipo de compensación indicado");
 124
 125        // Desactivar configs anteriores: solo puede haber una activa por tipo
 1126        await _repo.DesactivarConfigsActivasAsync(request.TipoCompensacionId);
 127
 1128        var config = new ConfigCompensacion
 1129        {
 1130            TipoCompensacionId  = request.TipoCompensacionId,
 1131            RegimenId           = request.RegimenId,
 1132            GradoId             = request.GradoId,
 1133            EscalafonId         = request.EscalafonId,
 1134            Funcion             = request.Funcion,
 1135            UnidadMedida        = request.UnidadMedida,
 1136            Formula             = request.Formula,
 1137            ValorPorUnidad      = request.ValorPorUnidad,
 1138            Monto               = request.Monto,
 1139            Tope                = request.Tope,
 1140            TopeUnidades        = request.TopeUnidades,
 1141            ValorMinimo         = request.ValorMinimo,
 1142            ValorMaximo         = request.ValorMaximo,
 1143            VigenteDesde        = request.VigenteDesde,
 1144            VigenteHasta        = request.VigenteHasta,
 1145            SujetoDescLegales   = request.SujetoDescLegales,
 1146            CatalogoItemId      = request.CatalogoItemId,
 1147            Activo              = true
 1148        };
 149
 1150        var creada = await _repo.CreateConfigAsync(config);
 1151        return (true, creada, null);
 5152    }
 153
 154    public async Task<(bool Success, ConfigCompensacion? Config, string? Error)> UpdateConfigAsync(long id, UpdateConfig
 155    {
 4156        var config = await _repo.GetConfigByIdAsync(id);
 5157        if (config == null) return (false, null, "Configuración no encontrada");
 158
 3159        var monto = request.Monto ?? config.Monto;
 3160        var valorPorUnidad = request.ValorPorUnidad ?? config.ValorPorUnidad;
 3161        var valorMinimo = request.ValorMinimo ?? config.ValorMinimo;
 3162        var valorMaximo = request.ValorMaximo ?? config.ValorMaximo;
 3163        var topeUnidades = request.TopeUnidades ?? config.TopeUnidades;
 164
 3165        var validacion = ValidarFormula(config.Formula, monto, valorPorUnidad, valorMinimo, valorMaximo, topeUnidades);
 3166        if (validacion != null) return (false, null, validacion);
 167
 3168        if (request.ValorPorUnidad.HasValue) config.ValorPorUnidad = request.ValorPorUnidad;
 3169        if (request.Monto.HasValue) config.Monto = request.Monto;
 3170        if (request.Tope.HasValue) config.Tope = request.Tope;
 3171        if (request.TopeUnidades.HasValue) config.TopeUnidades = request.TopeUnidades;
 3172        if (request.ValorMinimo.HasValue) config.ValorMinimo = request.ValorMinimo;
 3173        if (request.ValorMaximo.HasValue) config.ValorMaximo = request.ValorMaximo;
 3174        if (request.VigenteHasta.HasValue) config.VigenteHasta = request.VigenteHasta;
 4175        if (request.SujetoDescLegales.HasValue) config.SujetoDescLegales = request.SujetoDescLegales.Value;
 4176        if (request.CatalogoItemId.HasValue) config.CatalogoItemId = request.CatalogoItemId;
 3177        if (request.Activo.HasValue)
 178        {
 2179            if (!request.Activo.Value && config.Activo && await _repo.EsUnicaConfigActivaAsync(id, config.TipoCompensaci
 1180                return (false, null, "No se puede desactivar la única configuración activa del tipo. Cree otra antes de 
 181
 182            // Si se activa una config inactiva, desactivar las demás del mismo tipo
 1183            if (request.Activo.Value && !config.Activo)
 1184                await _repo.DesactivarConfigsActivasAsync(config.TipoCompensacionId);
 1185            config.Activo = request.Activo.Value;
 186        }
 187
 2188        var actualizada = await _repo.UpdateConfigAsync(config);
 2189        return (true, actualizada, null);
 4190    }
 191
 192    public async Task<(bool Success, string? Error)> DeleteConfigAsync(long id)
 193    {
 4194        var config = await _repo.GetConfigByIdAsync(id);
 5195        if (config == null) return (false, "Configuración no encontrada");
 196
 3197        if (config.Activo && await _repo.EsUnicaConfigActivaAsync(id, config.TipoCompensacionId))
 1198            return (false, "No se puede eliminar la única configuración activa del tipo. Cree otra antes de eliminar est
 199
 2200        await _repo.DeleteConfigAsync(id);
 2201        _logger.LogInformation("Configuración compensación {Id} eliminada", id);
 2202        return (true, null);
 4203    }
 204
 205    // ── Lotes ────────────────────────────────────────────────────────────────
 206
 207    public async Task<PagedResult<LoteCompensacionDto>> GetLotesPagedAsync(
 208        int page, int pageSize, long? tipoId = null, int? anio = null, int? mes = null, string? estado = null)
 209    {
 0210        var (items, totalCount) = await _repo.GetLotesPagedAsync(page, pageSize, tipoId, anio, mes, estado);
 0211        return new PagedResult<LoteCompensacionDto>
 0212        {
 0213            Items = items.Select(MapLoteToDto),
 0214            Page = page,
 0215            PageSize = pageSize,
 0216            TotalCount = totalCount
 0217        };
 0218    }
 219
 220    public async Task<LoteCompensacionDto?> GetLoteByIdAsync(long id)
 221    {
 0222        var lote = await _repo.GetLoteByIdWithItemsAsync(id);
 0223        return lote == null ? null : MapLoteToDto(lote);
 0224    }
 225
 226    public async Task<(bool Success, LoteCompensacion? Lote, string? Error)> CreateLoteAsync(long usuarioId, CreateLoteC
 227    {
 2228        var tipo = await _repo.GetTipoByIdAsync(request.TipoCompensacionId);
 3229        if (tipo == null) return (false, null, "No se encontró el tipo de compensación indicado");
 230
 1231        if (request.PeriodoMes < 1 || request.PeriodoMes > 12)
 0232            return (false, null, "periodoMes debe estar entre 1 y 12");
 233
 1234        var periodo = new DateTime(request.PeriodoAnio, request.PeriodoMes, 1, 0, 0, 0, DateTimeKind.Utc);
 235
 236        // Validar unicidad: un solo lote activo por tipo + período (igual que legacy)
 1237        if (await _repo.ExisteLoteActivoAsync(request.TipoCompensacionId, periodo))
 0238            return (false, null, $"Ya existe un lote activo para ese tipo de compensación en {request.PeriodoAnio}/{requ
 239
 1240        var lote = new LoteCompensacion
 1241        {
 1242            TipoCompensacionId = request.TipoCompensacionId,
 1243            Periodo = periodo,
 1244            Estado = "borrador",
 1245            Fuente = request.Fuente,
 1246            NombreArchivo = request.NombreArchivo,
 1247            HashArchivo = request.HashArchivo,
 1248            UsuarioId = usuarioId,
 1249            CreadoEn = DateTime.UtcNow
 1250        };
 251
 1252        var creado = await _repo.CreateLoteAsync(lote);
 1253        _logger.LogInformation("Lote compensación {Id} creado para tipo {TipoId}", creado.Id, creado.TipoCompensacionId)
 1254        return (true, creado, null);
 2255    }
 256
 257    // Transiciones manuales vía PUT (aprobar/rechazar/revertir tienen endpoints dedicados)
 1258    private static readonly Dictionary<string, string[]> TransicionesPermitidas = new()
 1259    {
 1260        ["borrador"] = new[] { "validado" },
 1261        ["validado"] = new[] { "borrador" }
 1262        // validado → aprobado: vía PUT /aprobar
 1263        // aprobado → borrador: vía PUT /revertir
 1264        // aprobado → rechazado: vía PUT /rechazar
 1265        // validado → rechazado: vía PUT /rechazar
 1266    };
 267
 268    public async Task<(bool Success, LoteCompensacion? Lote, string? Error)> UpdateLoteAsync(long id, UpdateLoteCompensa
 269    {
 8270        var lote = await _repo.GetLoteByIdAsync(id);
 9271        if (lote == null) return (false, null, "Lote no encontrado");
 272
 7273        if (!EstadosEditablesLote.Contains(lote.Estado))
 1274            return (false, null, $"No se puede editar un lote en estado '{lote.Estado}'");
 275
 6276        if (request.Estado != null)
 277        {
 6278            if (!TransicionesPermitidas.TryGetValue(lote.Estado, out var permitidos) ||
 12279                !Array.Exists(permitidos, e => e == request.Estado))
 1280                return (false, null, $"Transición no permitida: '{lote.Estado}' → '{request.Estado}'. Permitidas: {strin
 281
 282            // Validación al pasar a 'validado'
 5283            if (request.Estado == "validado")
 284            {
 4285                var (validOk, validError) = await ValidarLoteParaValidarAsync(lote);
 7286                if (!validOk) return (false, null, validError);
 287            }
 288
 2289            lote.Estado = request.Estado;
 290        }
 291
 2292        if (request.Fuente != null) lote.Fuente = request.Fuente;
 2293        if (request.NombreArchivo != null) lote.NombreArchivo = request.NombreArchivo;
 294
 2295        var actualizado = await _repo.UpdateLoteAsync(lote);
 2296        return (true, actualizado, null);
 8297    }
 298
 299    private async Task<(bool Success, string? Error)> ValidarLoteParaValidarAsync(LoteCompensacion lote)
 300    {
 4301        var totalItems = await _repo.GetItemCountAsync(lote.Id);
 4302        if (totalItems == 0)
 1303            return (false, "El lote no tiene ítems. Debe cargar al menos uno antes de validar.");
 304
 3305        var itemsError = await _repo.GetItemsEnErrorCountAsync(lote.Id);
 3306        if (itemsError > 0)
 1307            return (false, $"El lote tiene {itemsError} ítem(s) en estado 'error'. Corrija o elimine esos ítems antes de
 308
 2309        var config = await _repo.GetConfigVigenteAsync(lote.TipoCompensacionId, lote.Periodo);
 2310        if (config == null)
 1311            return (false, "No existe configuración activa para este tipo de compensación en el período del lote.");
 312
 1313        return (true, null);
 4314    }
 315
 316    public async Task<(bool Success, LoteCompensacion? Lote, string? Error)> AprobarLoteAsync(long id, long aprobadoPorI
 317    {
 3318        var lote = await _repo.GetLoteByIdAsync(id);
 3319        if (lote == null) return (false, null, "Lote no encontrado");
 320
 3321        if (lote.Estado != "validado")
 2322            return (false, null, $"Solo se pueden aprobar lotes en estado 'validado'. Estado actual: '{lote.Estado}'");
 323
 1324        lote.Estado = "aprobado";
 1325        lote.AprobadoPor = aprobadoPorId;
 1326        lote.AprobadoEn = DateTime.UtcNow;
 327
 1328        var actualizado = await _repo.UpdateLoteAsync(lote);
 1329        _logger.LogInformation("Lote {Id} aprobado por usuario {UsuarioId}", id, aprobadoPorId);
 1330        return (true, actualizado, null);
 3331    }
 332
 333    public async Task<(bool Success, LoteCompensacion? Lote, string? Error)> RechazarLoteAsync(long id)
 334    {
 5335        var lote = await _repo.GetLoteByIdAsync(id);
 6336        if (lote == null) return (false, null, "Lote no encontrado");
 337
 4338        if (lote.Estado is "exportado" or "rechazado")
 2339            return (false, null, $"No se puede rechazar un lote en estado '{lote.Estado}'");
 340
 2341        lote.Estado = "rechazado";
 2342        var actualizado = await _repo.UpdateLoteAsync(lote);
 2343        _logger.LogInformation("Lote {Id} rechazado", id);
 2344        return (true, actualizado, null);
 5345    }
 346
 347    public async Task<(bool Success, LoteCompensacion? Lote, string? Error)> RevertirABorradorAsync(long id)
 348    {
 4349        var lote = await _repo.GetLoteByIdAsync(id);
 5350        if (lote == null) return (false, null, "Lote no encontrado");
 351
 3352        if (lote.Estado != "aprobado")
 2353            return (false, null, $"Solo se pueden revertir lotes en estado 'aprobado'. Estado actual: '{lote.Estado}'");
 354
 1355        lote.Estado = "borrador";
 1356        lote.AprobadoPor = null;
 1357        lote.AprobadoEn = null;
 358
 1359        var actualizado = await _repo.UpdateLoteAsync(lote);
 1360        _logger.LogInformation("Lote {Id} revertido a borrador", id);
 1361        return (true, actualizado, null);
 4362    }
 363
 364    public async Task<(bool Success, string? Error)> CancelarLoteAsync(long id)
 365    {
 5366        var lote = await _repo.GetLoteByIdAsync(id);
 6367        if (lote == null) return (false, "Lote no encontrado");
 368
 4369        if (lote.Estado is not ("borrador" or "rechazado"))
 2370            return (false, $"Solo se pueden eliminar lotes en estado 'borrador' o 'rechazado'. Estado actual: '{lote.Est
 371
 2372        await _repo.DeleteAllItemsAsync(id);
 2373        await _repo.DeleteLoteAsync(lote);
 2374        _logger.LogInformation("Lote {Id} cancelado y eliminado", id);
 2375        return (true, null);
 5376    }
 377
 378    public async Task<PagedResult<ItemLoteCompensacionDto>> GetItemsPagedAsync(long loteId, int page, int pageSize)
 379    {
 1380        var lote = await _repo.GetLoteByIdAsync(loteId);
 1381        var unidadMedida = lote != null
 1382            ? (await _repo.GetTipoByIdAsync(lote.TipoCompensacionId))?.UnidadMedida ?? string.Empty
 1383            : string.Empty;
 384
 1385        var (items, totalCount) = await _repo.GetItemsPagedAsync(loteId, page, pageSize);
 1386        return new PagedResult<ItemLoteCompensacionDto>
 1387        {
 2388            Items = items.Select(i => MapItemToDto(i, unidadMedida)),
 1389            Page = page,
 1390            PageSize = pageSize,
 1391            TotalCount = totalCount
 1392        };
 1393    }
 394
 395    // ── Items ────────────────────────────────────────────────────────────────
 396
 397    public async Task<(bool Success, ItemLoteCompensacion? Item, string? Error)> CreateItemAsync(long loteId, CreateItem
 398    {
 23399        var lote = await _repo.GetLoteByIdAsync(loteId);
 23400        if (lote == null) return (false, null, "Lote no encontrado");
 401
 23402        if (!EstadosEditablesLote.Contains(lote.Estado))
 1403            return (false, null, $"No se pueden agregar ítems a un lote en estado '{lote.Estado}'");
 404
 405        // Resolver cédula → personaId
 22406        var relacion = await _personalRepo.GetByCedulaAsync(request.CedulaPersona);
 22407        if (relacion == null)
 1408            return (false, null, $"No se encontró personal activo con cédula {request.CedulaPersona}");
 409
 21410        var personaId = relacion.PersonaId;
 411
 21412        if (await _repo.ExistePersonaEnLoteAsync(loteId, personaId))
 1413            return (false, null, "Ya existe un ítem para esa persona en este lote");
 414
 20415        var config = await _repo.GetConfigVigenteAsync(lote.TipoCompensacionId, lote.Periodo);
 20416        if (config?.TopeUnidades.HasValue == true && request.UnidadCantidad > config.TopeUnidades.Value)
 1417            return (false, null, $"unidad_cantidad excede el tope máximo de {config.TopeUnidades} unidades");
 418
 419        // Aplicar fórmula de la configuración vigente
 19420        decimal? montoFinal = request.Monto;
 19421        if (config != null)
 422        {
 19423            var (formulaOk, montoCalculado, formulaError) = await AplicarFormulaAsync(
 19424                loteId, config, request.UnidadCantidad, request.Monto,
 19425                request.Ala, request.CategoriaCompensacion, relacion.GradoId, lote.Periodo);
 26426            if (!formulaOk) return (false, null, formulaError);
 12427            montoFinal = montoCalculado;
 428        }
 429
 430        // SDL: derivado de la configuración del tipo — no del ítem individual
 12431        var sujetoDescLegales = config?.SujetoDescLegales ?? false;
 432
 12433        var item = new ItemLoteCompensacion
 12434        {
 12435            LoteCompensacionId  = loteId,
 12436            PersonaId           = personaId,
 12437            UnidadCantidad      = request.UnidadCantidad,
 12438            Monto               = montoFinal,
 12439            Estado              = "ok",
 12440            Incidencia          = request.Incidencia,
 12441            Observaciones       = request.Observaciones,
 12442            Funcion             = request.Funcion,
 12443            Ala                 = request.Ala,
 12444            CategoriaCompensacion = request.CategoriaCompensacion,
 12445            SujetoDescLegales   = sujetoDescLegales,
 12446            // Snapshots de la relación laboral activa al momento de carga
 12447            SnapshotGradoId     = relacion.GradoId,
 12448            SnapshotSituacionId = relacion.SituacionId,
 12449            SnapshotUnidadId    = relacion.UnidadId,
 12450            SnapshotProgramaId  = relacion.ProgramaId
 12451        };
 452
 12453        var creado = await _repo.CreateItemAsync(item);
 12454        await RevertirABorradorSiValidadoAsync(lote);
 12455        return (true, creado, null);
 23456    }
 457
 458    public async Task<(bool Success, ItemLoteCompensacion? Item, string? Error)> UpdateItemAsync(long loteId, long itemI
 459    {
 2460        var lote = await _repo.GetLoteByIdAsync(loteId);
 2461        if (lote == null) return (false, null, "Lote no encontrado");
 462
 2463        if (!EstadosEditablesLote.Contains(lote.Estado))
 0464            return (false, null, $"No se pueden editar ítems de un lote en estado '{lote.Estado}'");
 465
 2466        var item = await _repo.GetItemByIdAsync(itemId);
 2467        if (item == null || item.LoteCompensacionId != loteId)
 0468            return (false, null, "Ítem no encontrado");
 469
 2470        if (request.Ala != null) item.Ala = request.Ala;
 2471        if (request.CategoriaCompensacion != null) item.CategoriaCompensacion = request.CategoriaCompensacion;
 472
 2473        if (request.UnidadCantidad.HasValue || request.Monto.HasValue || request.Ala != null || request.CategoriaCompens
 474        {
 2475            var config = await _repo.GetConfigVigenteAsync(lote.TipoCompensacionId, lote.Periodo);
 476
 2477            if (request.UnidadCantidad.HasValue)
 478            {
 1479                if (config?.TopeUnidades.HasValue == true && request.UnidadCantidad.Value > config.TopeUnidades.Value)
 0480                    return (false, null, $"unidad_cantidad excede el tope máximo de {config.TopeUnidades} unidades");
 1481                item.UnidadCantidad = request.UnidadCantidad.Value;
 482            }
 483
 2484            if (config != null)
 485            {
 2486                var nuevaCantidad = item.UnidadCantidad;
 2487                var nuevoMonto = request.Monto ?? item.Monto;
 2488                var (formulaOk, montoCalculado, formulaError) = await AplicarFormulaAsync(
 2489                    loteId, config, nuevaCantidad, nuevoMonto,
 2490                    item.Ala, item.CategoriaCompensacion, item.SnapshotGradoId, lote.Periodo);
 3491                if (!formulaOk) return (false, null, formulaError);
 1492                item.Monto = montoCalculado;
 493            }
 0494            else if (request.Monto.HasValue)
 495            {
 0496                item.Monto = request.Monto;
 497            }
 498        }
 499
 1500        if (request.Estado != null) item.Estado = request.Estado;
 1501        if (request.Incidencia != null) item.Incidencia = request.Incidencia;
 1502        if (request.Observaciones != null) item.Observaciones = request.Observaciones;
 1503        if (request.Funcion != null) item.Funcion = request.Funcion;
 504
 1505        var actualizado = await _repo.UpdateItemAsync(item);
 1506        await RevertirABorradorSiValidadoAsync(lote);
 1507        return (true, actualizado, null);
 2508    }
 509
 510    public async Task<(bool Success, string? Error)> DeleteItemAsync(long loteId, long itemId)
 511    {
 6512        var lote = await _repo.GetLoteByIdAsync(loteId);
 7513        if (lote == null) return (false, "Lote no encontrado");
 514
 5515        if (!EstadosEditablesLote.Contains(lote.Estado))
 1516            return (false, $"No se pueden eliminar ítems de un lote en estado '{lote.Estado}'");
 517
 4518        var item = await _repo.GetItemByIdAsync(itemId);
 4519        if (item == null || item.LoteCompensacionId != loteId)
 2520            return (false, "Ítem no encontrado");
 521
 2522        await _repo.DeleteItemAsync(item);
 2523        await RevertirABorradorSiValidadoAsync(lote);
 2524        return (true, null);
 6525    }
 526
 527    /// Si el lote estaba en 'validado', cualquier edición de ítems lo regresa a 'borrador'
 528    private async Task RevertirABorradorSiValidadoAsync(LoteCompensacion lote)
 529    {
 15530        if (lote.Estado == "validado")
 531        {
 1532            lote.Estado = "borrador";
 1533            await _repo.UpdateLoteAsync(lote);
 1534            _logger.LogInformation("Lote {Id} revertido a borrador por edición de ítems", lote.Id);
 535        }
 15536    }
 537
 538    // ── Bulk Import ──────────────────────────────────────────────────────────
 539
 540    public async Task<BulkImportResultDto> CreateItemsBulkAsync(long loteId, IEnumerable<CreateItemLoteRequest> items, s
 541    {
 0542        var lote = await _repo.GetLoteByIdAsync(loteId);
 0543        if (lote == null)
 0544            return new BulkImportResultDto { Fallidos = 1, Procesados = 1, Resultados = [new() { Linea = 0, Cedula = "",
 545
 0546        if (!dryRun && !EstadosEditablesLote.Contains(lote.Estado))
 0547            return new BulkImportResultDto { Fallidos = 1, Procesados = 1, Resultados = [new() { Linea = 0, Cedula = "",
 548
 0549        var resultado = new BulkImportResultDto { EsDryRun = dryRun, Modo = modo };
 550
 0551        if (!dryRun && modo == "reemplazar")
 552        {
 0553            await _repo.DeleteAllItemsAsync(loteId);
 0554            if (lote.Estado == "validado") { lote.Estado = "borrador"; await _repo.UpdateLoteAsync(lote); }
 555        }
 556
 0557        int linea = 1;
 0558        foreach (var request in items)
 559        {
 0560            resultado.Procesados++;
 0561            var (success, item, error) = dryRun
 0562                ? await ValidarItemAsync(loteId, lote, request)
 0563                : await CreateItemAsync(loteId, request);
 564
 0565            resultado.Resultados.Add(new BulkImportLineResultDto
 0566            {
 0567                Linea = linea++,
 0568                Cedula = request.CedulaPersona,
 0569                Exito = success,
 0570                Error = error,
 0571                ItemId = item?.Id
 0572            });
 0573            if (success) resultado.Exitosos++;
 0574            else resultado.Fallidos++;
 0575        }
 576
 0577        return resultado;
 0578    }
 579
 580    public async Task<BulkImportResultDto> CreateItemsFromCsvAsync(long loteId, Stream csvStream, string modo = "reempla
 581    {
 0582        var lote = await _repo.GetLoteByIdAsync(loteId);
 0583        if (lote == null)
 0584            return new BulkImportResultDto { Fallidos = 1, Procesados = 1, Resultados = [new() { Linea = 0, Cedula = "",
 585
 0586        if (!dryRun && !EstadosEditablesLote.Contains(lote.Estado))
 0587            return new BulkImportResultDto { Fallidos = 1, Procesados = 1, Resultados = [new() { Linea = 0, Cedula = "",
 588
 0589        if (!dryRun && modo == "reemplazar")
 590        {
 0591            await _repo.DeleteAllItemsAsync(loteId);
 0592            if (lote.Estado == "validado") { lote.Estado = "borrador"; await _repo.UpdateLoteAsync(lote); }
 593        }
 594
 595        // Para fórmulas fijo y prorrateo el monto lo calcula el sistema; para las demás el CSV debe proveerlo
 0596        var configCsv = await _repo.GetConfigVigenteAsync(lote.TipoCompensacionId, lote.Periodo);
 0597        var formulaCalculaMontoAutomatico = configCsv?.Formula is "fijo" or "prorrateo";
 598        // Para tabla con ala o categoria, el monto también se calcula desde la tarifa
 0599        var formulaTablaConLookup = configCsv?.Formula == "tabla" && configCsv.CatalogoItemId.HasValue;
 600
 0601        var resultado = new BulkImportResultDto { EsDryRun = dryRun, Modo = modo };
 0602        using var reader = new System.IO.StreamReader(csvStream);
 603
 604        // Leer encabezados
 0605        var headerLine = await reader.ReadLineAsync();
 0606        if (string.IsNullOrWhiteSpace(headerLine))
 0607            return resultado;
 608
 0609        var headers = headerLine.Split(',').Select(h => h.Trim().ToLower()).ToArray();
 0610        int idxCedula         = Array.IndexOf(headers, "cedula");
 0611        int idxMonto          = Array.IndexOf(headers, "monto");
 0612        int idxUnidad         = Array.IndexOf(headers, "unidad_cantidad");
 0613        int idxFuncion        = Array.IndexOf(headers, "funcion");
 0614        int idxAla            = Array.IndexOf(headers, "ala");
 0615        int idxCategoria      = Array.IndexOf(headers, "categoria");
 0616        int idxIncidencia     = Array.IndexOf(headers, "incidencia");
 0617        int idxObservaciones  = Array.IndexOf(headers, "observaciones");
 618
 0619        if (idxCedula < 0)
 0620            return new BulkImportResultDto { Fallidos = 1, Procesados = 1, Resultados = [new() { Linea = 1, Cedula = "",
 621
 0622        int linea = 2;
 0623        while (!reader.EndOfStream)
 624        {
 0625            var line = await reader.ReadLineAsync();
 0626            if (string.IsNullOrWhiteSpace(line)) { linea++; continue; }
 627
 0628            var cols = line.Split(',');
 0629            var cedula = idxCedula < cols.Length ? cols[idxCedula].Trim() : "";
 630
 0631            resultado.Procesados++;
 632
 0633            if (string.IsNullOrWhiteSpace(cedula))
 634            {
 0635                resultado.Fallidos++;
 0636                resultado.Resultados.Add(new() { Linea = linea, Cedula = cedula, Exito = false, Error = "Cédula vacía" }
 0637                linea++;
 0638                continue;
 639            }
 640
 0641            decimal monto = 0;
 0642            if (idxMonto >= 0 && idxMonto < cols.Length)
 0643                decimal.TryParse(cols[idxMonto].Trim(), System.Globalization.NumberStyles.Any, System.Globalization.Cult
 644
 0645            var ala       = idxAla < 0 || idxAla >= cols.Length ? null : NullIfEmpty(cols[idxAla].Trim()?.ToUpper());
 0646            var categoria = idxCategoria < 0 || idxCategoria >= cols.Length ? null : NullIfEmpty(cols[idxCategoria].Trim
 647
 648            // El monto no es requerido si la fórmula lo calcula automáticamente
 649            // (fijo, prorrateo, o tabla con ala/categoria)
 0650            var montoEsAutomatico = formulaCalculaMontoAutomatico || formulaTablaConLookup;
 651
 0652            if (!montoEsAutomatico && monto <= 0)
 653            {
 0654                resultado.Fallidos++;
 0655                resultado.Resultados.Add(new() { Linea = linea, Cedula = cedula, Exito = false, Error = "El monto debe s
 0656                linea++;
 0657                continue;
 658            }
 659
 0660            decimal unidadCantidad = 1;
 0661            if (idxUnidad >= 0 && idxUnidad < cols.Length && !string.IsNullOrWhiteSpace(cols[idxUnidad]))
 0662                decimal.TryParse(cols[idxUnidad].Trim(), System.Globalization.NumberStyles.Any, System.Globalization.Cul
 663
 0664            var funcion      = idxFuncion < 0 || idxFuncion >= cols.Length ? null : NullIfEmpty(cols[idxFuncion].Trim())
 0665            var incidencia   = idxIncidencia < 0 || idxIncidencia >= cols.Length ? null : NullIfEmpty(cols[idxIncidencia
 0666            var observaciones = idxObservaciones < 0 || idxObservaciones >= cols.Length ? null : NullIfEmpty(cols[idxObs
 667
 0668            var request = new CreateItemLoteRequest
 0669            {
 0670                CedulaPersona         = cedula,
 0671                Monto                 = monto > 0 ? monto : null,
 0672                UnidadCantidad        = unidadCantidad,
 0673                Funcion               = funcion,
 0674                Ala                   = ala,
 0675                CategoriaCompensacion = categoria,
 0676                Incidencia            = incidencia,
 0677                Observaciones         = observaciones
 0678            };
 679
 0680            var (success, item, error) = dryRun
 0681                ? await ValidarItemAsync(loteId, lote, request)
 0682                : await CreateItemAsync(loteId, request);
 0683            resultado.Resultados.Add(new() { Linea = linea, Cedula = cedula, Exito = success, Error = error, ItemId = it
 0684            if (success) resultado.Exitosos++;
 0685            else resultado.Fallidos++;
 686
 0687            linea++;
 0688        }
 689
 0690        return resultado;
 0691    }
 692
 693    /// Valida un ítem sin persistir (dry run). Replica las mismas validaciones de CreateItemAsync.
 694    private async Task<(bool Success, ItemLoteCompensacion? Item, string? Error)> ValidarItemAsync(
 695        long loteId, LoteCompensacion lote, CreateItemLoteRequest request)
 696    {
 0697        if (!EstadosEditablesLote.Contains(lote.Estado))
 0698            return (false, null, $"Lote en estado '{lote.Estado}' no es editable");
 699
 0700        var relacion = await _personalRepo.GetByCedulaAsync(request.CedulaPersona);
 0701        if (relacion == null)
 0702            return (false, null, $"No se encontró personal activo con cédula {request.CedulaPersona}");
 703
 0704        if (await _repo.ExistePersonaEnLoteAsync(loteId, relacion.PersonaId))
 0705            return (false, null, "Ya existe un ítem para esa persona en este lote");
 706
 0707        var config = await _repo.GetConfigVigenteAsync(lote.TipoCompensacionId, lote.Periodo);
 0708        if (config?.TopeUnidades.HasValue == true && request.UnidadCantidad > config.TopeUnidades.Value)
 0709            return (false, null, $"unidad_cantidad excede el tope máximo de {config.TopeUnidades} unidades");
 710
 0711        if (config != null)
 712        {
 0713            var (formulaOk, _, formulaError) = await AplicarFormulaAsync(
 0714                loteId, config, request.UnidadCantidad, request.Monto,
 0715                request.Ala, request.CategoriaCompensacion, relacion.GradoId, lote.Periodo);
 0716            if (!formulaOk) return (false, null, formulaError);
 717        }
 718
 719        // Validación aprobada — no se persiste
 0720        return (true, null, null);
 0721    }
 722
 723    // ── Helpers ──────────────────────────────────────────────────────────────
 724
 725    // ── Lógica de fórmulas ───────────────────────────────────────────────────
 726
 727    /// Aplica la fórmula de la configuración para calcular o validar el monto del ítem.
 728    /// Devuelve el monto final que debe persistirse.
 729    private async Task<(bool Success, decimal? MontoCalculado, string? Error)> AplicarFormulaAsync(
 730        long loteId,
 731        ConfigCompensacion config,
 732        decimal unidadCantidad,
 733        decimal? montoEntrada,
 734        string? ala = null,
 735        string? categoria = null,
 736        long? snapshotGradoId = null,
 737        DateTime? periodo = null)
 738    {
 21739        switch (config.Formula)
 740        {
 741            case "fijo":
 5742                return (true, config.Monto, null);
 743
 744            case "prorrateo":
 3745                if (!config.ValorPorUnidad.HasValue)
 1746                    return (false, null, "La configuración de prorrateo no tiene valor_por_unidad definido");
 2747                return (true, unidadCantidad * config.ValorPorUnidad.Value, null);
 748
 749            case "rango":
 5750                if (!montoEntrada.HasValue)
 1751                    return (false, null, "Se debe proveer monto para fórmula rango");
 4752                if (config.ValorMinimo.HasValue && montoEntrada.Value < config.ValorMinimo.Value)
 1753                    return (false, null, $"El monto {montoEntrada.Value:F2} es menor al mínimo permitido de {config.Valo
 3754                if (config.ValorMaximo.HasValue && montoEntrada.Value > config.ValorMaximo.Value)
 2755                    return (false, null, $"El monto {montoEntrada.Value:F2} supera el máximo permitido de {config.ValorM
 1756                return (true, montoEntrada, null);
 757
 758            case "presupuesto":
 3759                if (!montoEntrada.HasValue)
 1760                    return (false, null, "Se debe proveer monto para fórmula presupuesto");
 2761                if (config.Tope.HasValue)
 762                {
 2763                    var totalActual = await _repo.GetMontoTotalItemsAsync(loteId);
 2764                    if (totalActual + montoEntrada.Value > config.Tope.Value)
 1765                        return (false, null,
 1766                            $"El monto supera el tope presupuestario. Disponible: {config.Tope.Value - totalActual:F2}, 
 767                }
 1768                return (true, montoEntrada, null);
 769
 770            case "tabla":
 771                // Subcaso 1: VUELO — tarifa por categoría (ala A/N/S)
 5772                if (!string.IsNullOrEmpty(ala) && config.CatalogoItemId.HasValue)
 773                {
 2774                    var fechaRef = periodo ?? DateTime.UtcNow;
 2775                    var tarifa = await _repo.GetTarifaCategoriaAsync(config.CatalogoItemId.Value, ala, fechaRef);
 2776                    if (tarifa == null)
 1777                        return (false, null, $"No se encontró tarifa para categoría de vuelo '{ala}'. Verifique que la c
 1778                    return (true, tarifa.Monto, null);
 779                }
 780
 781                // Subcaso 2: SAR — tarifa por categoría (función)
 3782                if (!string.IsNullOrEmpty(categoria) && config.CatalogoItemId.HasValue)
 783                {
 1784                    var fechaRef = periodo ?? DateTime.UtcNow;
 1785                    var tarifa = await _repo.GetTarifaCategoriaAsync(config.CatalogoItemId.Value, categoria, fechaRef);
 1786                    if (tarifa == null)
 0787                        return (false, null, $"No se encontró tarifa para categoría '{categoria}'. Verifique que la conf
 1788                    return (true, tarifa.Monto, null);
 789                }
 790
 791                // Subcaso 3: CARCEL — tarifa diaria por grado
 2792                if (snapshotGradoId.HasValue && config.CatalogoItemId.HasValue)
 793                {
 1794                    var fechaRef = periodo ?? DateTime.UtcNow;
 1795                    var tarifaGrado = await _repo.GetTarifaGradoAsync(config.CatalogoItemId.Value, snapshotGradoId.Value
 1796                    if (tarifaGrado != null)
 1797                        return (true, unidadCantidad * tarifaGrado.Monto, null);
 798                    // Si no hay tarifa por grado, cae al monto del CSV
 799                }
 800
 801                // Default: monto viene precalculado del origen externo (COMP_PERS, etc.)
 1802                return (true, montoEntrada, null);
 803
 804            default:
 0805                return (false, null, $"Fórmula '{config.Formula}' no reconocida");
 806        }
 21807    }
 808
 809    private static string? ValidarFormula(string formula, decimal? monto, decimal? valorPorUnidad, decimal? valorMinimo,
 810    {
 8811        if (!FormulasValidas.Contains(formula))
 0812            return $"formula inválida. Valores aceptados: {string.Join(", ", FormulasValidas)}";
 813
 8814        if (formula == "fijo" && !monto.HasValue)
 1815            return "monto es obligatorio para fórmula fijo";
 816
 7817        if (formula == "prorrateo" && !valorPorUnidad.HasValue)
 1818            return "valor_por_unidad es obligatorio para fórmula prorrateo";
 819
 6820        if (formula == "rango")
 821        {
 1822            if (!valorMinimo.HasValue || !valorMaximo.HasValue)
 0823                return "valor_minimo y valor_maximo son obligatorios para fórmula rango";
 1824            if (valorMinimo.Value >= valorMaximo.Value)
 1825                return "valor_minimo debe ser menor que valor_maximo";
 826        }
 827
 5828        if (topeUnidades.HasValue && topeUnidades.Value < 0)
 0829            return "tope_unidades debe ser mayor o igual a 0";
 830
 5831        return null;
 832    }
 833
 4834    private static TipoCompensacionDto MapTipoToDto(TipoCompensacion t) => new()
 4835    {
 4836        Id = t.Id,
 4837        Codigo = t.Codigo,
 4838        Nombre = t.Nombre,
 4839        UnidadMedida = t.UnidadMedida,
 4840        RequiereFuente = t.RequiereFuente,
 4841        Vigente = t.Vigente
 4842    };
 843
 4844    private static ConfigCompensacionDto MapConfigToDto(ConfigCompensacion c) => new()
 4845    {
 4846        Id = c.Id,
 4847        TipoCompensacionId = c.TipoCompensacionId,
 4848        NombreTipo = c.TipoCompensacion?.Nombre ?? string.Empty,
 4849        RegimenId = c.RegimenId,
 4850        NombreRegimen = c.Regimen?.Denominacion,
 4851        GradoId = c.GradoId,
 4852        NombreGrado = c.Grado?.Denominacion,
 4853        EscalafonId = c.EscalafonId,
 4854        NombreEscalafon = c.Escalafon?.Denominacion,
 4855        Funcion = c.Funcion,
 4856        UnidadMedida = c.UnidadMedida,
 4857        Formula = c.Formula,
 4858        ValorPorUnidad = c.ValorPorUnidad,
 4859        Monto = c.Monto,
 4860        Tope = c.Tope,
 4861        TopeUnidades = c.TopeUnidades,
 4862        ValorMinimo = c.ValorMinimo,
 4863        ValorMaximo = c.ValorMaximo,
 4864        VigenteDesde = c.VigenteDesde,
 4865        VigenteHasta = c.VigenteHasta,
 4866        Activo = c.Activo,
 4867        SujetoDescLegales = c.SujetoDescLegales,
 4868        CatalogoItemId = c.CatalogoItemId,
 4869        NombreCatalogoItem = c.CatalogoItem?.Nombre
 4870    };
 871
 0872    private static LoteCompensacionDto MapLoteToDto(LoteCompensacion l) => new()
 0873    {
 0874        Id = l.Id,
 0875        TipoCompensacionId = l.TipoCompensacionId,
 0876        NombreTipo = l.TipoCompensacion?.Nombre ?? string.Empty,
 0877        UnidadMedida = l.TipoCompensacion?.UnidadMedida ?? string.Empty,
 0878        Periodo = l.Periodo,
 0879        Estado = l.Estado,
 0880        Fuente = l.Fuente,
 0881        NombreArchivo = l.NombreArchivo,
 0882        UsuarioId = l.UsuarioId,
 0883        AprobadoPor = l.AprobadoPor,
 0884        NombreAprobador = l.AprobadorUsuario?.Username,
 0885        AprobadoEn = l.AprobadoEn,
 0886        CreadoEn = l.CreadoEn,
 0887        ItemCount = l.ItemCount > 0 ? l.ItemCount : l.Items.Count(),
 0888        Items = l.Items.Select(i => MapItemToDto(i, l.TipoCompensacion?.UnidadMedida ?? string.Empty))
 0889    };
 890
 891    private static string? NullIfEmpty(string? s) =>
 0892        string.IsNullOrWhiteSpace(s) ? null : s;
 893
 2894    private static ItemLoteCompensacionDto MapItemToDto(ItemLoteCompensacion i, string unidadMedida = "") => new()
 2895    {
 2896        Id = i.Id,
 2897        LoteCompensacionId = i.LoteCompensacionId,
 2898        CedulaPersona = i.Persona?.Cedula ?? string.Empty,
 2899        NombrePersona = i.Persona?.NombreCompleto ?? string.Empty,
 2900        UnidadCantidad = i.UnidadCantidad,
 2901        UnidadMedida = unidadMedida,
 2902        Monto = i.Monto,
 2903        Estado = i.Estado,
 2904        Incidencia = i.Incidencia,
 2905        Observaciones = i.Observaciones,
 2906        Funcion = i.Funcion,
 2907        SujetoDescLegales     = i.SujetoDescLegales,
 2908        Ala                   = i.Ala,
 2909        CategoriaCompensacion = i.CategoriaCompensacion,
 2910        SnapshotGrado         = i.SnapshotGrado?.Denominacion,
 2911        SnapshotSituacion     = i.SnapshotSituacion?.Denominacion,
 2912        SnapshotUnidad        = i.SnapshotUnidad?.Denominacion,
 2913        SnapshotPrograma      = i.SnapshotPrograma?.Codigo
 2914    };
 915}