| | | 1 | | using FAU.DataAccess.Repositories; |
| | | 2 | | using FAU.Entidades; |
| | | 3 | | using FAU.Logica.DTOs; |
| | | 4 | | using FAU.Logica.Exceptions; |
| | | 5 | | using Microsoft.Extensions.Logging; |
| | | 6 | | |
| | | 7 | | namespace FAU.Logica.Services; |
| | | 8 | | |
| | | 9 | | public class CompensacionService : ICompensacionService |
| | | 10 | | { |
| | 1 | 11 | | private static readonly HashSet<string> UnidadesValidas = new(StringComparer.Ordinal) { "dia", "hora", "monto" }; |
| | 1 | 12 | | private static readonly HashSet<string> FormulasValidas = new(StringComparer.Ordinal) { "fijo", "tabla", "prorrateo" |
| | | 13 | | // Estados válidos para lotes |
| | 1 | 14 | | 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' |
| | 1 | 17 | | 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 | | |
| | 94 | 26 | | public CompensacionService( |
| | 94 | 27 | | ICompensacionRepository repo, |
| | 94 | 28 | | IPersonalRepository personalRepo, |
| | 94 | 29 | | ILogger<CompensacionService> logger, |
| | 94 | 30 | | IAuditoriaService auditoriaService, |
| | 94 | 31 | | ICurrentUserContext currentUser, |
| | 94 | 32 | | IPeriodoGuard periodoGuard) |
| | | 33 | | { |
| | 94 | 34 | | _repo = repo; |
| | 94 | 35 | | _personalRepo = personalRepo; |
| | 94 | 36 | | _logger = logger; |
| | 94 | 37 | | _auditoriaService = auditoriaService; |
| | 94 | 38 | | _currentUser = currentUser; |
| | 94 | 39 | | _periodoGuard = periodoGuard; |
| | 94 | 40 | | } |
| | | 41 | | |
| | | 42 | | // ── Tipos ──────────────────────────────────────────────────────────────── |
| | | 43 | | |
| | | 44 | | public async Task<IEnumerable<TipoCompensacionDto>> GetTiposAsync(bool? vigente = null) |
| | | 45 | | { |
| | 2 | 46 | | var tipos = await _repo.GetTiposAsync(vigente); |
| | 2 | 47 | | return tipos.Select(MapTipoToDto); |
| | 2 | 48 | | } |
| | | 49 | | |
| | | 50 | | public async Task<TipoCompensacionDto?> GetTipoByIdAsync(long id) |
| | | 51 | | { |
| | 2 | 52 | | var tipo = await _repo.GetTipoByIdAsync(id); |
| | 2 | 53 | | return tipo == null ? null : MapTipoToDto(tipo); |
| | 2 | 54 | | } |
| | | 55 | | |
| | | 56 | | public async Task<(bool Success, TipoCompensacion? Tipo, string? Error)> CreateTipoAsync(CreateTipoCompensacionReque |
| | | 57 | | { |
| | 4 | 58 | | if (string.IsNullOrWhiteSpace(request.Codigo)) |
| | 1 | 59 | | return (false, null, "codigo es obligatorio"); |
| | | 60 | | |
| | 3 | 61 | | if (!UnidadesValidas.Contains(request.UnidadMedida)) |
| | 1 | 62 | | return (false, null, $"unidad_medida inválida. Valores aceptados: {string.Join(", ", UnidadesValidas)}"); |
| | | 63 | | |
| | 2 | 64 | | if (await _repo.GetTipoByCodigoAsync(request.Codigo) != null) |
| | 1 | 65 | | return (false, null, $"Ya existe un tipo de compensación con código '{request.Codigo}'"); |
| | | 66 | | |
| | 1 | 67 | | var tipo = new TipoCompensacion |
| | 1 | 68 | | { |
| | 1 | 69 | | Codigo = request.Codigo.Trim().ToUpper(), |
| | 1 | 70 | | Nombre = request.Nombre, |
| | 1 | 71 | | UnidadMedida = request.UnidadMedida, |
| | 1 | 72 | | RequiereFuente = request.RequiereFuente, |
| | 1 | 73 | | Vigente = true |
| | 1 | 74 | | }; |
| | | 75 | | |
| | 1 | 76 | | var creado = await _repo.CreateTipoAsync(tipo); |
| | 1 | 77 | | _logger.LogInformation("Tipo compensación {Codigo} creado", creado.Codigo); |
| | 1 | 78 | | await _auditoriaService.LogAuditoriaAsync(_currentUser.UserId ?? 0, AccionEnum.CrearCompensacion, ContextoEnum.C |
| | 1 | 79 | | return (true, creado, null); |
| | 4 | 80 | | } |
| | | 81 | | |
| | | 82 | | public async Task<(bool Success, TipoCompensacion? Tipo, string? Error)> UpdateTipoAsync(long id, UpdateTipoCompensa |
| | | 83 | | { |
| | 3 | 84 | | var tipo = await _repo.GetTipoByIdAsync(id); |
| | 4 | 85 | | if (tipo == null) return (false, null, "Tipo de compensación no encontrado"); |
| | | 86 | | |
| | 4 | 87 | | if (request.Nombre != null) tipo.Nombre = request.Nombre; |
| | 3 | 88 | | if (request.RequiereFuente.HasValue) tipo.RequiereFuente = request.RequiereFuente.Value; |
| | 3 | 89 | | if (request.Vigente.HasValue) tipo.Vigente = request.Vigente.Value; |
| | | 90 | | |
| | 2 | 91 | | var actualizado = await _repo.UpdateTipoAsync(tipo); |
| | 2 | 92 | | await _auditoriaService.LogAuditoriaAsync(_currentUser.UserId ?? 0, AccionEnum.ActualizarCompensacion, ContextoE |
| | 2 | 93 | | return (true, actualizado, null); |
| | 3 | 94 | | } |
| | | 95 | | |
| | | 96 | | public async Task<(bool Success, string? Error)> DeleteTipoAsync(long id) |
| | | 97 | | { |
| | 4 | 98 | | var tipo = await _repo.GetTipoByIdAsync(id); |
| | 5 | 99 | | if (tipo == null) return (false, "Tipo de compensación no encontrado"); |
| | | 100 | | |
| | | 101 | | // Verificar si tiene configuraciones asociadas |
| | 3 | 102 | | if (await _repo.TieneConfiguracionesAsync(id)) |
| | 1 | 103 | | return (false, "No se puede eliminar el tipo porque tiene configuraciones asociadas. Desactive las configura |
| | | 104 | | |
| | | 105 | | // Verificar si tiene lotes asociados |
| | 2 | 106 | | if (await _repo.TieneLotesAsync(id)) |
| | 1 | 107 | | return (false, "No se puede eliminar el tipo porque tiene lotes asociados. Elimine los lotes primero."); |
| | | 108 | | |
| | 1 | 109 | | await _auditoriaService.LogAuditoriaAsync(_currentUser.UserId ?? 0, AccionEnum.ActualizarCompensacion, ContextoE |
| | 1 | 110 | | await _repo.DeleteTipoAsync(id); |
| | 1 | 111 | | _logger.LogInformation("Tipo compensación {Id} eliminado", id); |
| | 1 | 112 | | return (true, null); |
| | 4 | 113 | | } |
| | | 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 | | { |
| | 3 | 120 | | var configs = await _repo.GetConfigsAsync(tipoId, regimenId, gradoId, activo); |
| | 3 | 121 | | return configs.Select(MapConfigToDto); |
| | 3 | 122 | | } |
| | | 123 | | |
| | | 124 | | public async Task<ConfigCompensacionDto?> GetConfigByIdAsync(long id) |
| | | 125 | | { |
| | 2 | 126 | | var config = await _repo.GetConfigByIdAsync(id); |
| | 2 | 127 | | return config == null ? null : MapConfigToDto(config); |
| | 2 | 128 | | } |
| | | 129 | | |
| | | 130 | | public async Task<(bool Success, ConfigCompensacion? Config, string? Error)> CreateConfigAsync(CreateConfigCompensac |
| | | 131 | | { |
| | 5 | 132 | | var validacion = ValidarFormula(request.Formula, request.Monto, request.ValorPorUnidad, request.ValorMinimo, req |
| | 8 | 133 | | if (validacion != null) return (false, null, validacion); |
| | | 134 | | |
| | 2 | 135 | | var tipo = await _repo.GetTipoByIdAsync(request.TipoCompensacionId); |
| | 3 | 136 | | 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 |
| | 1 | 139 | | await _repo.DesactivarConfigsActivasAsync(request.TipoCompensacionId); |
| | | 140 | | |
| | 1 | 141 | | var config = new ConfigCompensacion |
| | 1 | 142 | | { |
| | 1 | 143 | | TipoCompensacionId = request.TipoCompensacionId, |
| | 1 | 144 | | RegimenId = request.RegimenId, |
| | 1 | 145 | | GradoId = request.GradoId, |
| | 1 | 146 | | EscalafonId = request.EscalafonId, |
| | 1 | 147 | | Funcion = request.Funcion, |
| | 1 | 148 | | UnidadMedida = request.UnidadMedida, |
| | 1 | 149 | | Formula = request.Formula, |
| | 1 | 150 | | ValorPorUnidad = request.ValorPorUnidad, |
| | 1 | 151 | | Monto = request.Monto, |
| | 1 | 152 | | Tope = request.Tope, |
| | 1 | 153 | | TopeUnidades = request.TopeUnidades, |
| | 1 | 154 | | ValorMinimo = request.ValorMinimo, |
| | 1 | 155 | | ValorMaximo = request.ValorMaximo, |
| | 1 | 156 | | VigenteDesde = request.VigenteDesde, |
| | 1 | 157 | | VigenteHasta = request.VigenteHasta, |
| | 1 | 158 | | SujetoDescLegales = request.SujetoDescLegales, |
| | 1 | 159 | | CatalogoItemId = request.CatalogoItemId, |
| | 1 | 160 | | Activo = true |
| | 1 | 161 | | }; |
| | | 162 | | |
| | 1 | 163 | | var creada = await _repo.CreateConfigAsync(config); |
| | 1 | 164 | | await _auditoriaService.LogAuditoriaAsync(_currentUser.UserId ?? 0, AccionEnum.CrearConfigCompensacion, Contexto |
| | 1 | 165 | | return (true, creada, null); |
| | 5 | 166 | | } |
| | | 167 | | |
| | | 168 | | public async Task<(bool Success, ConfigCompensacion? Config, string? Error)> UpdateConfigAsync(long id, UpdateConfig |
| | | 169 | | { |
| | 4 | 170 | | var config = await _repo.GetConfigByIdAsync(id); |
| | 5 | 171 | | if (config == null) return (false, null, "Configuración no encontrada"); |
| | | 172 | | |
| | 3 | 173 | | var monto = request.Monto ?? config.Monto; |
| | 3 | 174 | | var valorPorUnidad = request.ValorPorUnidad ?? config.ValorPorUnidad; |
| | 3 | 175 | | var valorMinimo = request.ValorMinimo ?? config.ValorMinimo; |
| | 3 | 176 | | var valorMaximo = request.ValorMaximo ?? config.ValorMaximo; |
| | 3 | 177 | | var topeUnidades = request.TopeUnidades ?? config.TopeUnidades; |
| | | 178 | | |
| | 3 | 179 | | var validacion = ValidarFormula(config.Formula, monto, valorPorUnidad, valorMinimo, valorMaximo, topeUnidades); |
| | 3 | 180 | | if (validacion != null) return (false, null, validacion); |
| | | 181 | | |
| | 3 | 182 | | if (request.ValorPorUnidad.HasValue) config.ValorPorUnidad = request.ValorPorUnidad; |
| | 3 | 183 | | if (request.Monto.HasValue) config.Monto = request.Monto; |
| | 3 | 184 | | if (request.Tope.HasValue) config.Tope = request.Tope; |
| | 3 | 185 | | if (request.TopeUnidades.HasValue) config.TopeUnidades = request.TopeUnidades; |
| | 3 | 186 | | if (request.ValorMinimo.HasValue) config.ValorMinimo = request.ValorMinimo; |
| | 3 | 187 | | if (request.ValorMaximo.HasValue) config.ValorMaximo = request.ValorMaximo; |
| | 3 | 188 | | if (request.VigenteHasta.HasValue) config.VigenteHasta = request.VigenteHasta; |
| | 4 | 189 | | if (request.SujetoDescLegales.HasValue) config.SujetoDescLegales = request.SujetoDescLegales.Value; |
| | 4 | 190 | | if (request.CatalogoItemId.HasValue) config.CatalogoItemId = request.CatalogoItemId; |
| | 3 | 191 | | if (request.Activo.HasValue) |
| | | 192 | | { |
| | 2 | 193 | | if (!request.Activo.Value && config.Activo && await _repo.EsUnicaConfigActivaAsync(id, config.TipoCompensaci |
| | 1 | 194 | | 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 |
| | 1 | 197 | | if (request.Activo.Value && !config.Activo) |
| | 1 | 198 | | await _repo.DesactivarConfigsActivasAsync(config.TipoCompensacionId); |
| | 1 | 199 | | config.Activo = request.Activo.Value; |
| | | 200 | | } |
| | | 201 | | |
| | 2 | 202 | | var actualizada = await _repo.UpdateConfigAsync(config); |
| | 2 | 203 | | await _auditoriaService.LogAuditoriaAsync(_currentUser.UserId ?? 0, AccionEnum.ActualizarConfigComprensacion, Co |
| | 2 | 204 | | return (true, actualizada, null); |
| | 4 | 205 | | } |
| | | 206 | | |
| | | 207 | | public async Task<(bool Success, string? Error)> DeleteConfigAsync(long id) |
| | | 208 | | { |
| | 4 | 209 | | var config = await _repo.GetConfigByIdAsync(id); |
| | 5 | 210 | | if (config == null) return (false, "Configuración no encontrada"); |
| | | 211 | | |
| | 3 | 212 | | if (config.Activo && await _repo.EsUnicaConfigActivaAsync(id, config.TipoCompensacionId)) |
| | 1 | 213 | | return (false, "No se puede eliminar la única configuración activa del tipo. Cree otra antes de eliminar est |
| | | 214 | | |
| | 2 | 215 | | await _auditoriaService.LogAuditoriaAsync(_currentUser.UserId ?? 0, AccionEnum.ActualizarConfigComprensacion, Co |
| | 2 | 216 | | await _repo.DeleteConfigAsync(id); |
| | 2 | 217 | | _logger.LogInformation("Configuración compensación {Id} eliminada", id); |
| | 2 | 218 | | return (true, null); |
| | 4 | 219 | | } |
| | | 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 | | { |
| | 0 | 226 | | var (items, totalCount) = await _repo.GetLotesPagedAsync(page, pageSize, tipoId, periodoId, estado); |
| | 0 | 227 | | return new PagedResult<LoteCompensacionDto> |
| | 0 | 228 | | { |
| | 0 | 229 | | Items = items.Select(MapLoteToDto), |
| | 0 | 230 | | Page = page, |
| | 0 | 231 | | PageSize = pageSize, |
| | 0 | 232 | | TotalCount = totalCount |
| | 0 | 233 | | }; |
| | 0 | 234 | | } |
| | | 235 | | |
| | | 236 | | public async Task<LoteCompensacionDto?> GetLoteByIdAsync(long id) |
| | | 237 | | { |
| | 2 | 238 | | var lote = await _repo.GetLoteByIdWithItemsAsync(id); |
| | 2 | 239 | | return lote == null ? null : MapLoteToDto(lote); |
| | 2 | 240 | | } |
| | | 241 | | |
| | | 242 | | public async Task<(bool Success, LoteCompensacion? Lote, string? Error)> CreateLoteAsync(long usuarioId, CreateLoteC |
| | | 243 | | { |
| | 2 | 244 | | await _periodoGuard.VerificarPeriodoAbiertoAsync(); |
| | | 245 | | |
| | 2 | 246 | | var periodoActual = await _periodoGuard.ObtenerPeriodoAbiertoAsync(); |
| | 2 | 247 | | if (periodoActual == null || periodoActual.Id != request.PeriodoId) |
| | 0 | 248 | | return (false, null, "El período indicado no coincide con el período activo actual"); |
| | | 249 | | |
| | 2 | 250 | | var tipo = await _repo.GetTipoByIdAsync(request.TipoCompensacionId); |
| | 3 | 251 | | 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 |
| | 1 | 254 | | if (await _repo.ExisteLoteActivoAsync(request.TipoCompensacionId, request.PeriodoId)) |
| | 0 | 255 | | return (false, null, "Ya existe un lote activo para ese tipo de compensación en el período indicado. Para re |
| | | 256 | | |
| | 1 | 257 | | var lote = new LoteCompensacion |
| | 1 | 258 | | { |
| | 1 | 259 | | TipoCompensacionId = request.TipoCompensacionId, |
| | 1 | 260 | | PeriodoId = request.PeriodoId, |
| | 1 | 261 | | Estado = "borrador", |
| | 1 | 262 | | Fuente = request.Fuente, |
| | 1 | 263 | | NombreArchivo = request.NombreArchivo, |
| | 1 | 264 | | HashArchivo = request.HashArchivo, |
| | 1 | 265 | | UsuarioId = usuarioId, |
| | 1 | 266 | | CreadoEn = DateTime.Now |
| | 1 | 267 | | }; |
| | | 268 | | |
| | 1 | 269 | | var creado = await _repo.CreateLoteAsync(lote); |
| | 1 | 270 | | _logger.LogInformation("Lote compensación {Id} creado para tipo {TipoId}", creado.Id, creado.TipoCompensacionId) |
| | 1 | 271 | | await _auditoriaService.LogAuditoriaAsync(_currentUser.UserId ?? 0, AccionEnum.CrearLoteCompensacion, ContextoEn |
| | 1 | 272 | | return (true, creado, null); |
| | 2 | 273 | | } |
| | | 274 | | |
| | | 275 | | // Transiciones manuales vía PUT (aprobar/rechazar/revertir tienen endpoints dedicados) |
| | 1 | 276 | | private static readonly Dictionary<string, string[]> TransicionesPermitidas = new() |
| | 1 | 277 | | { |
| | 1 | 278 | | ["borrador"] = new[] { "validado" }, |
| | 1 | 279 | | ["validado"] = new[] { "borrador" } |
| | 1 | 280 | | // validado → aprobado: vía PUT /aprobar |
| | 1 | 281 | | // aprobado → borrador: vía PUT /revertir |
| | 1 | 282 | | // aprobado → rechazado: vía PUT /rechazar |
| | 1 | 283 | | // validado → rechazado: vía PUT /rechazar |
| | 1 | 284 | | }; |
| | | 285 | | |
| | | 286 | | public async Task<(bool Success, LoteCompensacion? Lote, string? Error)> UpdateLoteAsync(long id, UpdateLoteCompensa |
| | | 287 | | { |
| | 8 | 288 | | var lote = await _repo.GetLoteByIdAsync(id); |
| | 9 | 289 | | if (lote == null) return (false, null, "Lote no encontrado"); |
| | 7 | 290 | | await VerificarLoteEnPeriodoActivoAsync(lote.PeriodoId); |
| | | 291 | | |
| | 7 | 292 | | if (!EstadosEditablesLote.Contains(lote.Estado)) |
| | 1 | 293 | | return (false, null, $"No se puede editar un lote en estado '{lote.Estado}'"); |
| | | 294 | | |
| | 6 | 295 | | if (request.Estado != null) |
| | | 296 | | { |
| | 6 | 297 | | if (!TransicionesPermitidas.TryGetValue(lote.Estado, out var permitidos) || |
| | 12 | 298 | | !Array.Exists(permitidos, e => e == request.Estado)) |
| | 1 | 299 | | return (false, null, $"Transición no permitida: '{lote.Estado}' → '{request.Estado}'. Permitidas: {strin |
| | | 300 | | |
| | | 301 | | // Validación al pasar a 'validado' |
| | 5 | 302 | | if (request.Estado == "validado") |
| | | 303 | | { |
| | 4 | 304 | | var (validOk, validError) = await ValidarLoteParaValidarAsync(lote); |
| | 7 | 305 | | if (!validOk) return (false, null, validError); |
| | | 306 | | } |
| | | 307 | | |
| | 2 | 308 | | lote.Estado = request.Estado; |
| | | 309 | | } |
| | | 310 | | |
| | 2 | 311 | | if (request.Fuente != null) lote.Fuente = request.Fuente; |
| | 2 | 312 | | if (request.NombreArchivo != null) lote.NombreArchivo = request.NombreArchivo; |
| | | 313 | | |
| | 2 | 314 | | var actualizado = await _repo.UpdateLoteAsync(lote); |
| | 2 | 315 | | await _auditoriaService.LogAuditoriaAsync(_currentUser.UserId ?? 0, AccionEnum.ActualizarLoteCompensacion, Conte |
| | 2 | 316 | | return (true, actualizado, null); |
| | 8 | 317 | | } |
| | | 318 | | |
| | | 319 | | private async Task<(bool Success, string? Error)> ValidarLoteParaValidarAsync(LoteCompensacion lote) |
| | | 320 | | { |
| | 4 | 321 | | var totalItems = await _repo.GetItemCountAsync(lote.Id); |
| | 4 | 322 | | if (totalItems == 0) |
| | 1 | 323 | | return (false, "El lote no tiene ítems. Debe cargar al menos uno antes de validar."); |
| | | 324 | | |
| | 3 | 325 | | var itemsError = await _repo.GetItemsEnErrorCountAsync(lote.Id); |
| | 3 | 326 | | if (itemsError > 0) |
| | 1 | 327 | | return (false, $"El lote tiene {itemsError} ítem(s) en estado 'error'. Corrija o elimine esos ítems antes de |
| | | 328 | | |
| | 2 | 329 | | var config = await _repo.GetConfigVigenteAsync(lote.TipoCompensacionId, LotePeriodoDateTime(lote)); |
| | 2 | 330 | | if (config == null) |
| | 1 | 331 | | return (false, "No existe configuración activa para este tipo de compensación en el período del lote."); |
| | | 332 | | |
| | 1 | 333 | | return (true, null); |
| | 4 | 334 | | } |
| | | 335 | | |
| | | 336 | | public async Task<(bool Success, LoteCompensacion? Lote, string? Error)> AprobarLoteAsync(long id, long aprobadoPorI |
| | | 337 | | { |
| | 3 | 338 | | var lote = await _repo.GetLoteByIdAsync(id); |
| | 3 | 339 | | if (lote == null) return (false, null, "Lote no encontrado"); |
| | 3 | 340 | | await VerificarLoteEnPeriodoActivoAsync(lote.PeriodoId); |
| | | 341 | | |
| | 3 | 342 | | if (lote.Estado != "validado") |
| | 2 | 343 | | return (false, null, $"Solo se pueden aprobar lotes en estado 'validado'. Estado actual: '{lote.Estado}'"); |
| | | 344 | | |
| | 1 | 345 | | lote.Estado = "aprobado"; |
| | 1 | 346 | | lote.AprobadoPor = aprobadoPorId; |
| | 1 | 347 | | lote.AprobadoEn = DateTime.Now; |
| | | 348 | | |
| | 1 | 349 | | var actualizado = await _repo.UpdateLoteAsync(lote); |
| | 1 | 350 | | _logger.LogInformation("Lote {Id} aprobado por usuario {UsuarioId}", id, aprobadoPorId); |
| | 1 | 351 | | await _auditoriaService.LogAuditoriaAsync(aprobadoPorId, AccionEnum.AprobarLoteCompensacion, ContextoEnum.Compen |
| | 1 | 352 | | return (true, actualizado, null); |
| | 3 | 353 | | } |
| | | 354 | | |
| | | 355 | | public async Task<(bool Success, LoteCompensacion? Lote, string? Error)> RechazarLoteAsync(long id) |
| | | 356 | | { |
| | 5 | 357 | | var lote = await _repo.GetLoteByIdAsync(id); |
| | 6 | 358 | | if (lote == null) return (false, null, "Lote no encontrado"); |
| | 4 | 359 | | await VerificarLoteEnPeriodoActivoAsync(lote.PeriodoId); |
| | | 360 | | |
| | 4 | 361 | | if (lote.Estado is "exportado" or "rechazado") |
| | 2 | 362 | | return (false, null, $"No se puede rechazar un lote en estado '{lote.Estado}'"); |
| | | 363 | | |
| | 2 | 364 | | lote.Estado = "rechazado"; |
| | 2 | 365 | | var actualizado = await _repo.UpdateLoteAsync(lote); |
| | 2 | 366 | | _logger.LogInformation("Lote {Id} rechazado", id); |
| | 2 | 367 | | await _auditoriaService.LogAuditoriaAsync(_currentUser.UserId ?? 0, AccionEnum.RechazarLoteCompensacion, Context |
| | 2 | 368 | | return (true, actualizado, null); |
| | 5 | 369 | | } |
| | | 370 | | |
| | | 371 | | public async Task<(bool Success, LoteCompensacion? Lote, string? Error)> RevertirABorradorAsync(long id) |
| | | 372 | | { |
| | 4 | 373 | | var lote = await _repo.GetLoteByIdAsync(id); |
| | 5 | 374 | | if (lote == null) return (false, null, "Lote no encontrado"); |
| | 3 | 375 | | await VerificarLoteEnPeriodoActivoAsync(lote.PeriodoId); |
| | | 376 | | |
| | 3 | 377 | | if (lote.Estado != "aprobado") |
| | 2 | 378 | | return (false, null, $"Solo se pueden revertir lotes en estado 'aprobado'. Estado actual: '{lote.Estado}'"); |
| | | 379 | | |
| | 1 | 380 | | lote.Estado = "borrador"; |
| | 1 | 381 | | lote.AprobadoPor = null; |
| | 1 | 382 | | lote.AprobadoEn = null; |
| | | 383 | | |
| | 1 | 384 | | var actualizado = await _repo.UpdateLoteAsync(lote); |
| | 1 | 385 | | _logger.LogInformation("Lote {Id} revertido a borrador", id); |
| | 1 | 386 | | await _auditoriaService.LogAuditoriaAsync(_currentUser.UserId ?? 0, AccionEnum.RevertirLoteCompensacion, Context |
| | 1 | 387 | | return (true, actualizado, null); |
| | 4 | 388 | | } |
| | | 389 | | |
| | | 390 | | public async Task<(bool Success, string? Error)> CancelarLoteAsync(long id) |
| | | 391 | | { |
| | 5 | 392 | | var lote = await _repo.GetLoteByIdAsync(id); |
| | 6 | 393 | | if (lote == null) return (false, "Lote no encontrado"); |
| | 4 | 394 | | await VerificarLoteEnPeriodoActivoAsync(lote.PeriodoId); |
| | | 395 | | |
| | 4 | 396 | | if (lote.Estado is not ("borrador" or "rechazado")) |
| | 2 | 397 | | return (false, $"Solo se pueden eliminar lotes en estado 'borrador' o 'rechazado'. Estado actual: '{lote.Est |
| | | 398 | | |
| | 2 | 399 | | await _auditoriaService.LogAuditoriaAsync(_currentUser.UserId ?? 0, AccionEnum.EliminarLoteCompensacion, Context |
| | 2 | 400 | | await _repo.DeleteAllItemsAsync(id); |
| | 2 | 401 | | await _repo.DeleteLoteAsync(lote); |
| | 2 | 402 | | _logger.LogInformation("Lote {Id} cancelado y eliminado", id); |
| | 2 | 403 | | return (true, null); |
| | 5 | 404 | | } |
| | | 405 | | |
| | | 406 | | public async Task<PagedResult<ItemLoteCompensacionDto>> GetItemsPagedAsync(long loteId, int page, int pageSize) |
| | | 407 | | { |
| | 1 | 408 | | var lote = await _repo.GetLoteByIdAsync(loteId); |
| | 1 | 409 | | var unidadMedida = lote != null |
| | 1 | 410 | | ? (await _repo.GetTipoByIdAsync(lote.TipoCompensacionId))?.UnidadMedida ?? string.Empty |
| | 1 | 411 | | : string.Empty; |
| | | 412 | | |
| | 1 | 413 | | var (items, totalCount) = await _repo.GetItemsPagedAsync(loteId, page, pageSize); |
| | 1 | 414 | | return new PagedResult<ItemLoteCompensacionDto> |
| | 1 | 415 | | { |
| | 2 | 416 | | Items = items.Select(i => MapItemToDto(i, unidadMedida)), |
| | 1 | 417 | | Page = page, |
| | 1 | 418 | | PageSize = pageSize, |
| | 1 | 419 | | TotalCount = totalCount |
| | 1 | 420 | | }; |
| | 1 | 421 | | } |
| | | 422 | | |
| | | 423 | | // ── Items ──────────────────────────────────────────────────────────────── |
| | | 424 | | |
| | | 425 | | public async Task<(bool Success, ItemLoteCompensacion? Item, string? Error)> CreateItemAsync(long loteId, CreateItem |
| | | 426 | | { |
| | 23 | 427 | | var lote = await _repo.GetLoteByIdAsync(loteId); |
| | 23 | 428 | | if (lote == null) return (false, null, "Lote no encontrado"); |
| | 23 | 429 | | await VerificarLoteEnPeriodoActivoAsync(lote.PeriodoId); |
| | | 430 | | |
| | 23 | 431 | | if (!EstadosEditablesLote.Contains(lote.Estado)) |
| | 1 | 432 | | return (false, null, $"No se pueden agregar ítems a un lote en estado '{lote.Estado}'"); |
| | | 433 | | |
| | | 434 | | // Resolver cédula → personaId |
| | 22 | 435 | | var relacion = await _personalRepo.GetByCedulaAsync(request.CedulaPersona); |
| | 22 | 436 | | if (relacion == null) |
| | 1 | 437 | | return (false, null, $"No se encontró personal activo con cédula {request.CedulaPersona}"); |
| | | 438 | | |
| | 21 | 439 | | var personaId = relacion.PersonaId; |
| | | 440 | | |
| | 21 | 441 | | if (await _repo.ExistePersonaEnLoteAsync(loteId, personaId)) |
| | 1 | 442 | | return (false, null, "Ya existe un ítem para esa persona en este lote"); |
| | | 443 | | |
| | 20 | 444 | | var periodoFecha = LotePeriodoDateTime(lote); |
| | 20 | 445 | | var config = await _repo.GetConfigVigenteAsync(lote.TipoCompensacionId, periodoFecha); |
| | 20 | 446 | | if (config?.TopeUnidades.HasValue == true && request.UnidadCantidad > config.TopeUnidades.Value) |
| | 1 | 447 | | return (false, null, $"unidad_cantidad excede el tope máximo de {config.TopeUnidades} unidades"); |
| | | 448 | | |
| | | 449 | | // Aplicar fórmula de la configuración vigente |
| | 19 | 450 | | decimal? montoFinal = request.Monto; |
| | 19 | 451 | | if (config != null) |
| | | 452 | | { |
| | 19 | 453 | | var (formulaOk, montoCalculado, formulaError) = await AplicarFormulaAsync( |
| | 19 | 454 | | loteId, config, request.UnidadCantidad, request.Monto, |
| | 19 | 455 | | request.Ala, request.CategoriaCompensacion, relacion.GradoId, periodoFecha); |
| | 26 | 456 | | if (!formulaOk) return (false, null, formulaError); |
| | 12 | 457 | | montoFinal = montoCalculado; |
| | | 458 | | } |
| | | 459 | | |
| | | 460 | | // SDL: derivado de la configuración del tipo — no del ítem individual |
| | 12 | 461 | | var sujetoDescLegales = config?.SujetoDescLegales ?? false; |
| | | 462 | | |
| | 12 | 463 | | var item = new ItemLoteCompensacion |
| | 12 | 464 | | { |
| | 12 | 465 | | LoteCompensacionId = loteId, |
| | 12 | 466 | | PersonaId = personaId, |
| | 12 | 467 | | UnidadCantidad = request.UnidadCantidad, |
| | 12 | 468 | | Monto = montoFinal, |
| | 12 | 469 | | Estado = "ok", |
| | 12 | 470 | | Incidencia = request.Incidencia, |
| | 12 | 471 | | Observaciones = request.Observaciones, |
| | 12 | 472 | | Funcion = request.Funcion, |
| | 12 | 473 | | Ala = request.Ala, |
| | 12 | 474 | | CategoriaCompensacion = request.CategoriaCompensacion, |
| | 12 | 475 | | SujetoDescLegales = sujetoDescLegales, |
| | 12 | 476 | | // Snapshots de la relación laboral activa al momento de carga |
| | 12 | 477 | | SnapshotGradoId = relacion.GradoId, |
| | 12 | 478 | | SnapshotSituacionId = relacion.SituacionId, |
| | 12 | 479 | | SnapshotUnidadId = relacion.UnidadId, |
| | 12 | 480 | | SnapshotProgramaId = relacion.ProgramaId |
| | 12 | 481 | | }; |
| | | 482 | | |
| | 12 | 483 | | var creado = await _repo.CreateItemAsync(item); |
| | 12 | 484 | | await _auditoriaService.LogAuditoriaAsync(_currentUser.UserId ?? 0, AccionEnum.CrearItemLoteCompensacion, Contex |
| | 12 | 485 | | await RevertirABorradorSiValidadoAsync(lote); |
| | 12 | 486 | | return (true, creado, null); |
| | 23 | 487 | | } |
| | | 488 | | |
| | | 489 | | public async Task<(bool Success, ItemLoteCompensacion? Item, string? Error)> UpdateItemAsync(long loteId, long itemI |
| | | 490 | | { |
| | 2 | 491 | | var lote = await _repo.GetLoteByIdAsync(loteId); |
| | 2 | 492 | | if (lote == null) return (false, null, "Lote no encontrado"); |
| | 2 | 493 | | await VerificarLoteEnPeriodoActivoAsync(lote.PeriodoId); |
| | | 494 | | |
| | 2 | 495 | | if (!EstadosEditablesLote.Contains(lote.Estado)) |
| | 0 | 496 | | return (false, null, $"No se pueden editar ítems de un lote en estado '{lote.Estado}'"); |
| | | 497 | | |
| | 2 | 498 | | var item = await _repo.GetItemByIdAsync(itemId); |
| | 2 | 499 | | if (item == null || item.LoteCompensacionId != loteId) |
| | 0 | 500 | | return (false, null, "Ítem no encontrado"); |
| | | 501 | | |
| | 2 | 502 | | if (request.Ala != null) item.Ala = request.Ala; |
| | 2 | 503 | | if (request.CategoriaCompensacion != null) item.CategoriaCompensacion = request.CategoriaCompensacion; |
| | | 504 | | |
| | 2 | 505 | | if (request.UnidadCantidad.HasValue || request.Monto.HasValue || request.Ala != null || request.CategoriaCompens |
| | | 506 | | { |
| | 2 | 507 | | var periodoFecha = LotePeriodoDateTime(lote); |
| | 2 | 508 | | var config = await _repo.GetConfigVigenteAsync(lote.TipoCompensacionId, periodoFecha); |
| | | 509 | | |
| | 2 | 510 | | if (request.UnidadCantidad.HasValue) |
| | | 511 | | { |
| | 1 | 512 | | if (config?.TopeUnidades.HasValue == true && request.UnidadCantidad.Value > config.TopeUnidades.Value) |
| | 0 | 513 | | return (false, null, $"unidad_cantidad excede el tope máximo de {config.TopeUnidades} unidades"); |
| | 1 | 514 | | item.UnidadCantidad = request.UnidadCantidad.Value; |
| | | 515 | | } |
| | | 516 | | |
| | 2 | 517 | | if (config != null) |
| | | 518 | | { |
| | 2 | 519 | | var nuevaCantidad = item.UnidadCantidad; |
| | 2 | 520 | | var nuevoMonto = request.Monto ?? item.Monto; |
| | 2 | 521 | | var (formulaOk, montoCalculado, formulaError) = await AplicarFormulaAsync( |
| | 2 | 522 | | loteId, config, nuevaCantidad, nuevoMonto, |
| | 2 | 523 | | item.Ala, item.CategoriaCompensacion, item.SnapshotGradoId, periodoFecha); |
| | 3 | 524 | | if (!formulaOk) return (false, null, formulaError); |
| | 1 | 525 | | item.Monto = montoCalculado; |
| | | 526 | | } |
| | 0 | 527 | | else if (request.Monto.HasValue) |
| | | 528 | | { |
| | 0 | 529 | | item.Monto = request.Monto; |
| | | 530 | | } |
| | | 531 | | } |
| | | 532 | | |
| | 1 | 533 | | if (request.Estado != null) item.Estado = request.Estado; |
| | 1 | 534 | | if (request.Incidencia != null) item.Incidencia = request.Incidencia; |
| | 1 | 535 | | if (request.Observaciones != null) item.Observaciones = request.Observaciones; |
| | 1 | 536 | | if (request.Funcion != null) item.Funcion = request.Funcion; |
| | | 537 | | |
| | 1 | 538 | | var actualizado = await _repo.UpdateItemAsync(item); |
| | 1 | 539 | | await _auditoriaService.LogAuditoriaAsync(_currentUser.UserId ?? 0, AccionEnum.ActualizarItemLoteCompensacion, C |
| | 1 | 540 | | await RevertirABorradorSiValidadoAsync(lote); |
| | 1 | 541 | | return (true, actualizado, null); |
| | 2 | 542 | | } |
| | | 543 | | |
| | | 544 | | public async Task<(bool Success, string? Error)> DeleteItemAsync(long loteId, long itemId) |
| | | 545 | | { |
| | 6 | 546 | | var lote = await _repo.GetLoteByIdAsync(loteId); |
| | 7 | 547 | | if (lote == null) return (false, "Lote no encontrado"); |
| | 5 | 548 | | await VerificarLoteEnPeriodoActivoAsync(lote.PeriodoId); |
| | | 549 | | |
| | 5 | 550 | | if (!EstadosEditablesLote.Contains(lote.Estado)) |
| | 1 | 551 | | return (false, $"No se pueden eliminar ítems de un lote en estado '{lote.Estado}'"); |
| | | 552 | | |
| | 4 | 553 | | var item = await _repo.GetItemByIdAsync(itemId); |
| | 4 | 554 | | if (item == null || item.LoteCompensacionId != loteId) |
| | 2 | 555 | | return (false, "Ítem no encontrado"); |
| | | 556 | | |
| | 2 | 557 | | await _auditoriaService.LogAuditoriaAsync(_currentUser.UserId ?? 0, AccionEnum.EliminarItemLoteCompensacion, Con |
| | 2 | 558 | | await _repo.DeleteItemAsync(item); |
| | 2 | 559 | | await RevertirABorradorSiValidadoAsync(lote); |
| | 2 | 560 | | return (true, null); |
| | 6 | 561 | | } |
| | | 562 | | |
| | | 563 | | private async Task VerificarLoteEnPeriodoActivoAsync(long lotePeriodoId) |
| | | 564 | | { |
| | 51 | 565 | | var periodoActivo = await _periodoGuard.ObtenerPeriodoAbiertoAsync(); |
| | 51 | 566 | | if (periodoActivo == null) |
| | 0 | 567 | | throw new PeriodoBloqueadoException("sin período abierto"); |
| | 51 | 568 | | if (periodoActivo.Id != lotePeriodoId) |
| | 0 | 569 | | throw new PeriodoBloqueadoException("CERRADA"); |
| | 51 | 570 | | } |
| | | 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 | | { |
| | 15 | 575 | | if (lote.Estado == "validado") |
| | | 576 | | { |
| | 1 | 577 | | lote.Estado = "borrador"; |
| | 1 | 578 | | await _repo.UpdateLoteAsync(lote); |
| | 1 | 579 | | _logger.LogInformation("Lote {Id} revertido a borrador por edición de ítems", lote.Id); |
| | | 580 | | } |
| | 15 | 581 | | } |
| | | 582 | | |
| | | 583 | | // ── Bulk Import ────────────────────────────────────────────────────────── |
| | | 584 | | |
| | | 585 | | public async Task<BulkImportResultDto> CreateItemsBulkAsync(long loteId, IEnumerable<CreateItemLoteRequest> items, s |
| | | 586 | | { |
| | 0 | 587 | | var lote = await _repo.GetLoteByIdAsync(loteId); |
| | 0 | 588 | | if (lote == null) |
| | 0 | 589 | | return new BulkImportResultDto { Fallidos = 1, Procesados = 1, Resultados = [new() { Linea = 0, Cedula = "", |
| | | 590 | | |
| | 0 | 591 | | if (!dryRun) await VerificarLoteEnPeriodoActivoAsync(lote.PeriodoId); |
| | 0 | 592 | | if (!dryRun && !EstadosEditablesLote.Contains(lote.Estado)) |
| | 0 | 593 | | return new BulkImportResultDto { Fallidos = 1, Procesados = 1, Resultados = [new() { Linea = 0, Cedula = "", |
| | | 594 | | |
| | 0 | 595 | | var resultado = new BulkImportResultDto { EsDryRun = dryRun, Modo = modo }; |
| | | 596 | | |
| | 0 | 597 | | if (!dryRun && modo == "reemplazar") |
| | | 598 | | { |
| | 0 | 599 | | await _repo.DeleteAllItemsAsync(loteId); |
| | 0 | 600 | | if (lote.Estado == "validado") { lote.Estado = "borrador"; await _repo.UpdateLoteAsync(lote); } |
| | | 601 | | } |
| | | 602 | | |
| | 0 | 603 | | int linea = 1; |
| | 0 | 604 | | foreach (var request in items) |
| | | 605 | | { |
| | 0 | 606 | | resultado.Procesados++; |
| | 0 | 607 | | var (success, item, error) = dryRun |
| | 0 | 608 | | ? await ValidarItemAsync(loteId, lote, request) |
| | 0 | 609 | | : await CreateItemAsync(loteId, request); |
| | | 610 | | |
| | 0 | 611 | | resultado.Resultados.Add(new BulkImportLineResultDto |
| | 0 | 612 | | { |
| | 0 | 613 | | Linea = linea++, |
| | 0 | 614 | | Cedula = request.CedulaPersona, |
| | 0 | 615 | | Exito = success, |
| | 0 | 616 | | Error = error, |
| | 0 | 617 | | ItemId = item?.Id |
| | 0 | 618 | | }); |
| | 0 | 619 | | if (success) resultado.Exitosos++; |
| | 0 | 620 | | else resultado.Fallidos++; |
| | 0 | 621 | | } |
| | | 622 | | |
| | 0 | 623 | | return resultado; |
| | 0 | 624 | | } |
| | | 625 | | |
| | | 626 | | public async Task<BulkImportResultDto> CreateItemsFromCsvAsync(long loteId, Stream csvStream, string modo = "reempla |
| | | 627 | | { |
| | 0 | 628 | | var lote = await _repo.GetLoteByIdAsync(loteId); |
| | 0 | 629 | | if (lote == null) |
| | 0 | 630 | | return new BulkImportResultDto { Fallidos = 1, Procesados = 1, Resultados = [new() { Linea = 0, Cedula = "", |
| | | 631 | | |
| | 0 | 632 | | if (!dryRun) await VerificarLoteEnPeriodoActivoAsync(lote.PeriodoId); |
| | 0 | 633 | | if (!dryRun && !EstadosEditablesLote.Contains(lote.Estado)) |
| | 0 | 634 | | return new BulkImportResultDto { Fallidos = 1, Procesados = 1, Resultados = [new() { Linea = 0, Cedula = "", |
| | | 635 | | |
| | 0 | 636 | | if (!dryRun && modo == "reemplazar") |
| | | 637 | | { |
| | 0 | 638 | | await _repo.DeleteAllItemsAsync(loteId); |
| | 0 | 639 | | 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 |
| | 0 | 643 | | var configCsv = await _repo.GetConfigVigenteAsync(lote.TipoCompensacionId, LotePeriodoDateTime(lote)); |
| | 0 | 644 | | var formulaCalculaMontoAutomatico = configCsv?.Formula is "fijo" or "prorrateo"; |
| | | 645 | | // Para tabla con ala o categoria, el monto también se calcula desde la tarifa |
| | 0 | 646 | | var formulaTablaConLookup = configCsv?.Formula == "tabla" && configCsv.CatalogoItemId.HasValue; |
| | | 647 | | |
| | 0 | 648 | | var resultado = new BulkImportResultDto { EsDryRun = dryRun, Modo = modo }; |
| | 0 | 649 | | using var reader = new System.IO.StreamReader(csvStream); |
| | | 650 | | |
| | | 651 | | // Leer encabezados |
| | 0 | 652 | | var headerLine = await reader.ReadLineAsync(); |
| | 0 | 653 | | if (string.IsNullOrWhiteSpace(headerLine)) |
| | 0 | 654 | | return resultado; |
| | | 655 | | |
| | 0 | 656 | | var headers = headerLine.Split(',').Select(h => h.Trim().ToLower()).ToArray(); |
| | 0 | 657 | | int idxCedula = Array.IndexOf(headers, "cedula"); |
| | 0 | 658 | | int idxMonto = Array.IndexOf(headers, "monto"); |
| | 0 | 659 | | int idxUnidad = Array.IndexOf(headers, "unidad_cantidad"); |
| | 0 | 660 | | int idxFuncion = Array.IndexOf(headers, "funcion"); |
| | 0 | 661 | | int idxAla = Array.IndexOf(headers, "ala"); |
| | 0 | 662 | | int idxCategoria = Array.IndexOf(headers, "categoria"); |
| | 0 | 663 | | int idxIncidencia = Array.IndexOf(headers, "incidencia"); |
| | 0 | 664 | | int idxObservaciones = Array.IndexOf(headers, "observaciones"); |
| | | 665 | | |
| | 0 | 666 | | if (idxCedula < 0) |
| | 0 | 667 | | return new BulkImportResultDto { Fallidos = 1, Procesados = 1, Resultados = [new() { Linea = 1, Cedula = "", |
| | | 668 | | |
| | 0 | 669 | | int linea = 2; |
| | 0 | 670 | | while (!reader.EndOfStream) |
| | | 671 | | { |
| | 0 | 672 | | var line = await reader.ReadLineAsync(); |
| | 0 | 673 | | if (string.IsNullOrWhiteSpace(line)) { linea++; continue; } |
| | | 674 | | |
| | 0 | 675 | | var cols = line.Split(','); |
| | 0 | 676 | | var cedula = idxCedula < cols.Length ? cols[idxCedula].Trim() : ""; |
| | | 677 | | |
| | 0 | 678 | | resultado.Procesados++; |
| | | 679 | | |
| | 0 | 680 | | if (string.IsNullOrWhiteSpace(cedula)) |
| | | 681 | | { |
| | 0 | 682 | | resultado.Fallidos++; |
| | 0 | 683 | | resultado.Resultados.Add(new() { Linea = linea, Cedula = cedula, Exito = false, Error = "Cédula vacía" } |
| | 0 | 684 | | linea++; |
| | 0 | 685 | | continue; |
| | | 686 | | } |
| | | 687 | | |
| | 0 | 688 | | decimal monto = 0; |
| | 0 | 689 | | if (idxMonto >= 0 && idxMonto < cols.Length) |
| | 0 | 690 | | decimal.TryParse(cols[idxMonto].Trim(), System.Globalization.NumberStyles.Any, System.Globalization.Cult |
| | | 691 | | |
| | 0 | 692 | | var ala = idxAla < 0 || idxAla >= cols.Length ? null : NullIfEmpty(cols[idxAla].Trim()?.ToUpper()); |
| | 0 | 693 | | 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) |
| | 0 | 697 | | var montoEsAutomatico = formulaCalculaMontoAutomatico || formulaTablaConLookup; |
| | | 698 | | |
| | 0 | 699 | | if (!montoEsAutomatico && monto <= 0) |
| | | 700 | | { |
| | 0 | 701 | | resultado.Fallidos++; |
| | 0 | 702 | | resultado.Resultados.Add(new() { Linea = linea, Cedula = cedula, Exito = false, Error = "El monto debe s |
| | 0 | 703 | | linea++; |
| | 0 | 704 | | continue; |
| | | 705 | | } |
| | | 706 | | |
| | 0 | 707 | | decimal unidadCantidad = 1; |
| | 0 | 708 | | if (idxUnidad >= 0 && idxUnidad < cols.Length && !string.IsNullOrWhiteSpace(cols[idxUnidad])) |
| | 0 | 709 | | decimal.TryParse(cols[idxUnidad].Trim(), System.Globalization.NumberStyles.Any, System.Globalization.Cul |
| | | 710 | | |
| | 0 | 711 | | var funcion = idxFuncion < 0 || idxFuncion >= cols.Length ? null : NullIfEmpty(cols[idxFuncion].Trim()) |
| | 0 | 712 | | var incidencia = idxIncidencia < 0 || idxIncidencia >= cols.Length ? null : NullIfEmpty(cols[idxIncidencia |
| | 0 | 713 | | var observaciones = idxObservaciones < 0 || idxObservaciones >= cols.Length ? null : NullIfEmpty(cols[idxObs |
| | | 714 | | |
| | 0 | 715 | | var request = new CreateItemLoteRequest |
| | 0 | 716 | | { |
| | 0 | 717 | | CedulaPersona = cedula, |
| | 0 | 718 | | Monto = monto > 0 ? monto : null, |
| | 0 | 719 | | UnidadCantidad = unidadCantidad, |
| | 0 | 720 | | Funcion = funcion, |
| | 0 | 721 | | Ala = ala, |
| | 0 | 722 | | CategoriaCompensacion = categoria, |
| | 0 | 723 | | Incidencia = incidencia, |
| | 0 | 724 | | Observaciones = observaciones |
| | 0 | 725 | | }; |
| | | 726 | | |
| | 0 | 727 | | var (success, item, error) = dryRun |
| | 0 | 728 | | ? await ValidarItemAsync(loteId, lote, request) |
| | 0 | 729 | | : await CreateItemAsync(loteId, request); |
| | 0 | 730 | | resultado.Resultados.Add(new() { Linea = linea, Cedula = cedula, Exito = success, Error = error, ItemId = it |
| | 0 | 731 | | if (success) resultado.Exitosos++; |
| | 0 | 732 | | else resultado.Fallidos++; |
| | | 733 | | |
| | 0 | 734 | | linea++; |
| | 0 | 735 | | } |
| | | 736 | | |
| | 0 | 737 | | return resultado; |
| | 0 | 738 | | } |
| | | 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 | | { |
| | 0 | 744 | | if (!EstadosEditablesLote.Contains(lote.Estado)) |
| | 0 | 745 | | return (false, null, $"Lote en estado '{lote.Estado}' no es editable"); |
| | | 746 | | |
| | 0 | 747 | | var relacion = await _personalRepo.GetByCedulaAsync(request.CedulaPersona); |
| | 0 | 748 | | if (relacion == null) |
| | 0 | 749 | | return (false, null, $"No se encontró personal activo con cédula {request.CedulaPersona}"); |
| | | 750 | | |
| | 0 | 751 | | if (await _repo.ExistePersonaEnLoteAsync(loteId, relacion.PersonaId)) |
| | 0 | 752 | | return (false, null, "Ya existe un ítem para esa persona en este lote"); |
| | | 753 | | |
| | 0 | 754 | | var periodoFecha = LotePeriodoDateTime(lote); |
| | 0 | 755 | | var config = await _repo.GetConfigVigenteAsync(lote.TipoCompensacionId, periodoFecha); |
| | 0 | 756 | | if (config?.TopeUnidades.HasValue == true && request.UnidadCantidad > config.TopeUnidades.Value) |
| | 0 | 757 | | return (false, null, $"unidad_cantidad excede el tope máximo de {config.TopeUnidades} unidades"); |
| | | 758 | | |
| | 0 | 759 | | if (config != null) |
| | | 760 | | { |
| | 0 | 761 | | var (formulaOk, _, formulaError) = await AplicarFormulaAsync( |
| | 0 | 762 | | loteId, config, request.UnidadCantidad, request.Monto, |
| | 0 | 763 | | request.Ala, request.CategoriaCompensacion, relacion.GradoId, periodoFecha); |
| | 0 | 764 | | if (!formulaOk) return (false, null, formulaError); |
| | | 765 | | } |
| | | 766 | | |
| | | 767 | | // Validación aprobada — no se persiste |
| | 0 | 768 | | return (true, null, null); |
| | 0 | 769 | | } |
| | | 770 | | |
| | | 771 | | // ── Helpers ────────────────────────────────────────────────────────────── |
| | | 772 | | |
| | | 773 | | private static DateTime LotePeriodoDateTime(LoteCompensacion lote) => |
| | 24 | 774 | | 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 |
| | 21 | 791 | | var periodoRef = periodo ?? DateTime.Now; |
| | 21 | 792 | | switch (config.Formula) |
| | | 793 | | { |
| | | 794 | | case "fijo": |
| | 5 | 795 | | return (true, config.Monto, null); |
| | | 796 | | |
| | | 797 | | case "prorrateo": |
| | 3 | 798 | | if (!config.ValorPorUnidad.HasValue) |
| | 1 | 799 | | return (false, null, "La configuración de prorrateo no tiene valor_por_unidad definido"); |
| | 2 | 800 | | return (true, unidadCantidad * config.ValorPorUnidad.Value, null); |
| | | 801 | | |
| | | 802 | | case "rango": |
| | 5 | 803 | | if (!montoEntrada.HasValue) |
| | 1 | 804 | | return (false, null, "Se debe proveer monto para fórmula rango"); |
| | 4 | 805 | | if (config.ValorMinimo.HasValue && montoEntrada.Value < config.ValorMinimo.Value) |
| | 1 | 806 | | return (false, null, $"El monto {montoEntrada.Value:F2} es menor al mínimo permitido de {config.Valo |
| | 3 | 807 | | if (config.ValorMaximo.HasValue && montoEntrada.Value > config.ValorMaximo.Value) |
| | 2 | 808 | | return (false, null, $"El monto {montoEntrada.Value:F2} supera el máximo permitido de {config.ValorM |
| | 1 | 809 | | return (true, montoEntrada, null); |
| | | 810 | | |
| | | 811 | | case "presupuesto": |
| | 3 | 812 | | if (!montoEntrada.HasValue) |
| | 1 | 813 | | return (false, null, "Se debe proveer monto para fórmula presupuesto"); |
| | 2 | 814 | | if (config.ValorMaximo.HasValue && unidadCantidad > 0) |
| | | 815 | | { |
| | 0 | 816 | | var montoPorUnidad = montoEntrada.Value / unidadCantidad; |
| | 0 | 817 | | if (montoPorUnidad > config.ValorMaximo.Value) |
| | 0 | 818 | | return (false, null, |
| | 0 | 819 | | $"El monto por {config.UnidadMedida} ({montoPorUnidad:F2}) supera el máximo permitido de {co |
| | | 820 | | } |
| | 2 | 821 | | if (config.Tope.HasValue) |
| | | 822 | | { |
| | 2 | 823 | | var totalActual = await _repo.GetMontoTotalItemsAsync(loteId); |
| | 2 | 824 | | if (totalActual + montoEntrada.Value > config.Tope.Value) |
| | 1 | 825 | | return (false, null, |
| | 1 | 826 | | $"El monto supera el tope presupuestario. Disponible: {config.Tope.Value - totalActual:F2}, |
| | | 827 | | } |
| | 1 | 828 | | return (true, montoEntrada, null); |
| | | 829 | | |
| | | 830 | | case "tabla": |
| | | 831 | | // Subcaso 1: VUELO — tarifa por categoría (ala A/N/S) |
| | 5 | 832 | | if (!string.IsNullOrEmpty(ala) && config.CatalogoItemId.HasValue) |
| | | 833 | | { |
| | 2 | 834 | | var fechaRef = periodoRef; |
| | 2 | 835 | | var tarifa = await _repo.GetTarifaCategoriaAsync(config.CatalogoItemId.Value, ala, fechaRef); |
| | 2 | 836 | | if (tarifa == null) |
| | 1 | 837 | | return (false, null, $"No se encontró tarifa para categoría de vuelo '{ala}'. Verifique que la c |
| | 1 | 838 | | return (true, tarifa.Monto, null); |
| | | 839 | | } |
| | | 840 | | |
| | | 841 | | // Subcaso 2: SAR — tarifa por categoría (función) |
| | 3 | 842 | | if (!string.IsNullOrEmpty(categoria) && config.CatalogoItemId.HasValue) |
| | | 843 | | { |
| | 1 | 844 | | var fechaRef = periodoRef; |
| | 1 | 845 | | var tarifa = await _repo.GetTarifaCategoriaAsync(config.CatalogoItemId.Value, categoria, fechaRef); |
| | 1 | 846 | | if (tarifa == null) |
| | 0 | 847 | | return (false, null, $"No se encontró tarifa para categoría '{categoria}'. Verifique que la conf |
| | 1 | 848 | | return (true, tarifa.Monto, null); |
| | | 849 | | } |
| | | 850 | | |
| | | 851 | | // Subcaso 3: CARCEL — tarifa diaria por grado |
| | 2 | 852 | | if (snapshotGradoId.HasValue && config.CatalogoItemId.HasValue) |
| | | 853 | | { |
| | 1 | 854 | | var fechaRef = periodoRef; |
| | 1 | 855 | | var tarifaGrado = await _repo.GetTarifaGradoAsync(config.CatalogoItemId.Value, snapshotGradoId.Value |
| | 1 | 856 | | if (tarifaGrado != null) |
| | 1 | 857 | | 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.) |
| | 1 | 862 | | return (true, montoEntrada, null); |
| | | 863 | | |
| | | 864 | | default: |
| | 0 | 865 | | return (false, null, $"Fórmula '{config.Formula}' no reconocida"); |
| | | 866 | | } |
| | 21 | 867 | | } |
| | | 868 | | |
| | | 869 | | private static string? ValidarFormula(string formula, decimal? monto, decimal? valorPorUnidad, decimal? valorMinimo, |
| | | 870 | | { |
| | 8 | 871 | | if (!FormulasValidas.Contains(formula)) |
| | 0 | 872 | | return $"formula inválida. Valores aceptados: {string.Join(", ", FormulasValidas)}"; |
| | | 873 | | |
| | 8 | 874 | | if (formula == "fijo" && !monto.HasValue) |
| | 1 | 875 | | return "monto es obligatorio para fórmula fijo"; |
| | | 876 | | |
| | 7 | 877 | | if (formula == "prorrateo" && !valorPorUnidad.HasValue) |
| | 1 | 878 | | return "valor_por_unidad es obligatorio para fórmula prorrateo"; |
| | | 879 | | |
| | 6 | 880 | | if (formula == "rango") |
| | | 881 | | { |
| | 1 | 882 | | if (!valorMinimo.HasValue || !valorMaximo.HasValue) |
| | 0 | 883 | | return "valor_minimo y valor_maximo son obligatorios para fórmula rango"; |
| | 1 | 884 | | if (valorMinimo.Value >= valorMaximo.Value) |
| | 1 | 885 | | return "valor_minimo debe ser menor que valor_maximo"; |
| | | 886 | | } |
| | | 887 | | |
| | 5 | 888 | | if (topeUnidades.HasValue && topeUnidades.Value < 0) |
| | 0 | 889 | | return "tope_unidades debe ser mayor o igual a 0"; |
| | | 890 | | |
| | 5 | 891 | | return null; |
| | | 892 | | } |
| | | 893 | | |
| | 4 | 894 | | private static TipoCompensacionDto MapTipoToDto(TipoCompensacion t) => new() |
| | 4 | 895 | | { |
| | 4 | 896 | | Id = t.Id, |
| | 4 | 897 | | Codigo = t.Codigo, |
| | 4 | 898 | | Nombre = t.Nombre, |
| | 4 | 899 | | UnidadMedida = t.UnidadMedida, |
| | 4 | 900 | | RequiereFuente = t.RequiereFuente, |
| | 4 | 901 | | Vigente = t.Vigente |
| | 4 | 902 | | }; |
| | | 903 | | |
| | 4 | 904 | | private static ConfigCompensacionDto MapConfigToDto(ConfigCompensacion c) => new() |
| | 4 | 905 | | { |
| | 4 | 906 | | Id = c.Id, |
| | 4 | 907 | | TipoCompensacionId = c.TipoCompensacionId, |
| | 4 | 908 | | NombreTipo = c.TipoCompensacion?.Nombre ?? string.Empty, |
| | 4 | 909 | | RegimenId = c.RegimenId, |
| | 4 | 910 | | NombreRegimen = c.Regimen?.Denominacion, |
| | 4 | 911 | | GradoId = c.GradoId, |
| | 4 | 912 | | NombreGrado = c.Grado?.Denominacion, |
| | 4 | 913 | | EscalafonId = c.EscalafonId, |
| | 4 | 914 | | NombreEscalafon = c.Escalafon?.Denominacion, |
| | 4 | 915 | | Funcion = c.Funcion, |
| | 4 | 916 | | UnidadMedida = c.UnidadMedida, |
| | 4 | 917 | | Formula = c.Formula, |
| | 4 | 918 | | ValorPorUnidad = c.ValorPorUnidad, |
| | 4 | 919 | | Monto = c.Monto, |
| | 4 | 920 | | Tope = c.Tope, |
| | 4 | 921 | | TopeUnidades = c.TopeUnidades, |
| | 4 | 922 | | ValorMinimo = c.ValorMinimo, |
| | 4 | 923 | | ValorMaximo = c.ValorMaximo, |
| | 4 | 924 | | VigenteDesde = c.VigenteDesde, |
| | 4 | 925 | | VigenteHasta = c.VigenteHasta, |
| | 4 | 926 | | Activo = c.Activo, |
| | 4 | 927 | | SujetoDescLegales = c.SujetoDescLegales, |
| | 4 | 928 | | CatalogoItemId = c.CatalogoItemId, |
| | 4 | 929 | | NombreCatalogoItem = c.CatalogoItem?.Nombre |
| | 4 | 930 | | }; |
| | | 931 | | |
| | 2 | 932 | | private static LoteCompensacionDto MapLoteToDto(LoteCompensacion l) => new() |
| | 2 | 933 | | { |
| | 2 | 934 | | Id = l.Id, |
| | 2 | 935 | | TipoCompensacionId = l.TipoCompensacionId, |
| | 2 | 936 | | NombreTipo = l.TipoCompensacion?.Nombre ?? string.Empty, |
| | 2 | 937 | | UnidadMedida = l.TipoCompensacion?.UnidadMedida ?? string.Empty, |
| | 2 | 938 | | PeriodoId = l.PeriodoId, |
| | 2 | 939 | | Periodo = new DateTime(l.Periodo.Anio, l.Periodo.Mes, 1, 0, 0, 0, DateTimeKind.Utc), |
| | 2 | 940 | | Estado = l.Estado, |
| | 2 | 941 | | Fuente = l.Fuente, |
| | 2 | 942 | | NombreArchivo = l.NombreArchivo, |
| | 2 | 943 | | UsuarioId = l.UsuarioId, |
| | 2 | 944 | | AprobadoPor = l.AprobadoPor, |
| | 2 | 945 | | NombreAprobador = l.AprobadorUsuario?.Username, |
| | 2 | 946 | | AprobadoEn = l.AprobadoEn, |
| | 2 | 947 | | CreadoEn = l.CreadoEn, |
| | 2 | 948 | | ItemCount = l.ItemCount > 0 ? l.ItemCount : l.Items.Count(), |
| | 4 | 949 | | TotalMonto = l.TotalMonto > 0 ? l.TotalMonto : l.Items.Sum(i => i.Monto ?? 0), |
| | 0 | 950 | | Items = l.Items.Select(i => MapItemToDto(i, l.TipoCompensacion?.UnidadMedida ?? string.Empty)) |
| | 2 | 951 | | }; |
| | | 952 | | |
| | | 953 | | private static string? NullIfEmpty(string? s) => |
| | 0 | 954 | | string.IsNullOrWhiteSpace(s) ? null : s; |
| | | 955 | | |
| | 2 | 956 | | private static ItemLoteCompensacionDto MapItemToDto(ItemLoteCompensacion i, string unidadMedida = "") => new() |
| | 2 | 957 | | { |
| | 2 | 958 | | Id = i.Id, |
| | 2 | 959 | | LoteCompensacionId = i.LoteCompensacionId, |
| | 2 | 960 | | CedulaPersona = i.Persona?.Cedula ?? string.Empty, |
| | 2 | 961 | | NombrePersona = i.Persona?.NombreCompleto ?? string.Empty, |
| | 2 | 962 | | UnidadCantidad = i.UnidadCantidad, |
| | 2 | 963 | | UnidadMedida = unidadMedida, |
| | 2 | 964 | | Monto = i.Monto, |
| | 2 | 965 | | Estado = i.Estado, |
| | 2 | 966 | | Incidencia = i.Incidencia, |
| | 2 | 967 | | Observaciones = i.Observaciones, |
| | 2 | 968 | | Funcion = i.Funcion, |
| | 2 | 969 | | SujetoDescLegales = i.SujetoDescLegales, |
| | 2 | 970 | | Ala = i.Ala, |
| | 2 | 971 | | CategoriaCompensacion = i.CategoriaCompensacion, |
| | 2 | 972 | | SnapshotGrado = i.SnapshotGrado?.Denominacion, |
| | 2 | 973 | | SnapshotSituacion = i.SnapshotSituacion?.Denominacion, |
| | 2 | 974 | | SnapshotUnidad = i.SnapshotUnidad?.Denominacion, |
| | 2 | 975 | | SnapshotPrograma = i.SnapshotPrograma?.Codigo |
| | 2 | 976 | | }; |
| | | 977 | | } |