| | | 1 | | using FAU.DataAccess; |
| | | 2 | | using FAU.DataAccess.Repositories; |
| | | 3 | | using FAU.Entidades; |
| | | 4 | | using FAU.Logica.DTOs; |
| | | 5 | | using FAU.Logica.Importadores; |
| | | 6 | | using FAU.Logica.Validadores; |
| | | 7 | | using Microsoft.EntityFrameworkCore; |
| | | 8 | | using Microsoft.EntityFrameworkCore.Storage; |
| | | 9 | | |
| | | 10 | | namespace FAU.Logica.Services; |
| | | 11 | | |
| | | 12 | | public class DescuentoPersonalService : IDescuentoPersonalService |
| | | 13 | | { |
| | | 14 | | private const int MaxFilasImportacion = 20000; |
| | | 15 | | |
| | | 16 | | private readonly IDescuentoPersonalRepository _repo; |
| | | 17 | | private readonly IPersonalRepository _personalRepo; |
| | | 18 | | private readonly IPeriodosRepository _periodosRepo; |
| | | 19 | | private readonly ApplicationDbContext _context; |
| | | 20 | | private readonly IAuditoriaService _auditoria; |
| | | 21 | | |
| | 20 | 22 | | public DescuentoPersonalService( |
| | 20 | 23 | | IDescuentoPersonalRepository repo, |
| | 20 | 24 | | IPersonalRepository personalRepo, |
| | 20 | 25 | | IPeriodosRepository periodosRepo, |
| | 20 | 26 | | ApplicationDbContext context, |
| | 20 | 27 | | IAuditoriaService auditoria) |
| | | 28 | | { |
| | 20 | 29 | | _repo = repo; |
| | 20 | 30 | | _personalRepo = personalRepo; |
| | 20 | 31 | | _periodosRepo = periodosRepo; |
| | 20 | 32 | | _context = context; |
| | 20 | 33 | | _auditoria = auditoria; |
| | 20 | 34 | | } |
| | | 35 | | |
| | | 36 | | public async Task<DescuentoPersonalPeriodo> CrearAsync( |
| | | 37 | | long periodoId, string cedulaTitular, int codigoConcepto, long acreedorId, |
| | | 38 | | decimal importe, string? observaciones, long usuarioId, DateTime? fechaComunicacion = null) |
| | | 39 | | { |
| | 5 | 40 | | if (importe <= 0) |
| | 1 | 41 | | throw new ArgumentException("El importe debe ser mayor a cero."); |
| | | 42 | | |
| | 4 | 43 | | if (acreedorId <= 0) |
| | 0 | 44 | | throw new ArgumentException("El acreedor es requerido."); |
| | | 45 | | |
| | 4 | 46 | | var relacion = await _personalRepo.GetByCedulaAsync(cedulaTitular) |
| | 4 | 47 | | ?? throw new ArgumentException($"Funcionario {cedulaTitular} no encontrado."); |
| | | 48 | | |
| | 4 | 49 | | var periodo = await _periodosRepo.ObtenerPorIdAsync(periodoId) |
| | 4 | 50 | | ?? throw new ArgumentException($"Período {periodoId} no encontrado."); |
| | | 51 | | |
| | 4 | 52 | | if (periodo.Estado != "ABIERTA" && periodo.Estado != Periodo.EstadoListoPCalculo) |
| | 1 | 53 | | throw new InvalidOperationException($"El período está en estado {periodo.Estado} y no admite modificaciones. |
| | | 54 | | |
| | 3 | 55 | | var catalogoItem = await _context.CatalogoItems |
| | 3 | 56 | | .FirstOrDefaultAsync(c => c.Codigo == codigoConcepto && c.Vigente) |
| | 3 | 57 | | ?? throw new ArgumentException($"Concepto con código {codigoConcepto} no encontrado o no vigente."); |
| | | 58 | | |
| | 3 | 59 | | if (catalogoItem.Tipo != "descuento_personal") |
| | 1 | 60 | | throw new InvalidOperationException($"El concepto {codigoConcepto} (tipo '{catalogoItem.Tipo}') no es un des |
| | | 61 | | |
| | 2 | 62 | | if (catalogoItem.ItemParFictoId.HasValue) |
| | 0 | 63 | | throw new InvalidOperationException( |
| | 0 | 64 | | "Los fictos se gestionan en el padrón permanente mediante /api/fictos."); |
| | | 65 | | |
| | 2 | 66 | | var acreedor = await _context.Acreedores |
| | 2 | 67 | | .AsNoTracking() |
| | 2 | 68 | | .FirstOrDefaultAsync(a => a.Id == acreedorId && a.Activo) |
| | 2 | 69 | | ?? throw new ArgumentException($"Acreedor {acreedorId} no encontrado o no activo."); |
| | | 70 | | |
| | 1 | 71 | | var descuento = new DescuentoPersonalPeriodo |
| | 1 | 72 | | { |
| | 1 | 73 | | PeriodoId = periodoId, |
| | 1 | 74 | | PersonaId = relacion.PersonaId, |
| | 1 | 75 | | AcreedorId = acreedor.Id, |
| | 1 | 76 | | CatalogoItemId = catalogoItem.Id, |
| | 1 | 77 | | Importe = importe, |
| | 1 | 78 | | Estado = "borrador", |
| | 1 | 79 | | Observaciones = observaciones, |
| | 1 | 80 | | FechaComunicacion = fechaComunicacion ?? DateTime.Now, |
| | 1 | 81 | | UsuarioCreacionId = usuarioId |
| | 1 | 82 | | }; |
| | | 83 | | |
| | 1 | 84 | | return await _repo.CreateAsync(descuento); |
| | 1 | 85 | | } |
| | | 86 | | |
| | | 87 | | public async Task<DescuentoPersonalPeriodo> EditarAsync(long id, decimal importe, string? observaciones, DateTime? f |
| | | 88 | | { |
| | 2 | 89 | | if (importe <= 0) |
| | 0 | 90 | | throw new ArgumentException("El importe debe ser mayor a cero."); |
| | | 91 | | |
| | 2 | 92 | | var d = await _repo.GetByIdAsync(id) |
| | 2 | 93 | | ?? throw new ArgumentException($"Descuento {id} no encontrado."); |
| | | 94 | | |
| | 2 | 95 | | if (d.Estado != "borrador") |
| | 1 | 96 | | throw new InvalidOperationException("Solo se pueden editar descuentos en estado borrador."); |
| | | 97 | | |
| | 1 | 98 | | d.Importe = importe; |
| | 1 | 99 | | d.Observaciones = observaciones; |
| | 1 | 100 | | if (fechaComunicacion.HasValue) |
| | 1 | 101 | | d.FechaComunicacion = fechaComunicacion.Value; |
| | | 102 | | |
| | 1 | 103 | | return await _repo.UpdateAsync(d); |
| | 1 | 104 | | } |
| | | 105 | | |
| | | 106 | | public async Task EliminarAsync(long id) |
| | | 107 | | { |
| | 2 | 108 | | var d = await _repo.GetByIdAsync(id) |
| | 2 | 109 | | ?? throw new ArgumentException($"Descuento {id} no encontrado."); |
| | | 110 | | |
| | 2 | 111 | | if (d.Estado != "borrador") |
| | 1 | 112 | | throw new InvalidOperationException("Solo se pueden eliminar descuentos en estado borrador."); |
| | | 113 | | |
| | 1 | 114 | | await _repo.DeleteAsync(id); |
| | 1 | 115 | | } |
| | | 116 | | |
| | | 117 | | public async Task<DescuentoPersonalPeriodo> ConfirmarAsync(long id) |
| | | 118 | | { |
| | 2 | 119 | | var descuento = await _repo.GetByIdAsync(id) |
| | 2 | 120 | | ?? throw new ArgumentException($"Descuento {id} no encontrado."); |
| | | 121 | | |
| | 2 | 122 | | if (descuento.Estado != "borrador") |
| | 1 | 123 | | throw new InvalidOperationException("Solo se pueden confirmar descuentos en estado borrador."); |
| | | 124 | | |
| | 1 | 125 | | descuento.Estado = "confirmado"; |
| | 1 | 126 | | return await _repo.UpdateAsync(descuento); |
| | 1 | 127 | | } |
| | | 128 | | |
| | | 129 | | public async Task<IEnumerable<DescuentoPersonalPeriodo>> ListarAsync( |
| | | 130 | | long periodoId, string? cedulaTitular, int? codigoConcepto, string? estado) |
| | | 131 | | { |
| | 0 | 132 | | long? personaId = null; |
| | 0 | 133 | | if (!string.IsNullOrEmpty(cedulaTitular)) |
| | | 134 | | { |
| | 0 | 135 | | var relacion = await _personalRepo.GetByCedulaAsync(cedulaTitular); |
| | 0 | 136 | | personaId = relacion?.PersonaId; |
| | | 137 | | } |
| | 0 | 138 | | return await _repo.GetByPeriodoAsync(periodoId, personaId, codigoConcepto, estado); |
| | 0 | 139 | | } |
| | | 140 | | |
| | | 141 | | public Task<int> ConfirmarBorradoresAsync(long periodoId) => |
| | 1 | 142 | | _repo.ConfirmarBorradoresAsync(periodoId); |
| | | 143 | | |
| | | 144 | | public async Task<ResultadoValidacionImportacionDescuentosDto> ValidarImportacionAsync(long acreedorId, Stream archi |
| | | 145 | | { |
| | 3 | 146 | | var lote = await ValidarLoteAsync(acreedorId, archivo); |
| | 3 | 147 | | return lote.Resultado; |
| | 3 | 148 | | } |
| | | 149 | | |
| | | 150 | | public async Task<ResultadoImportacionDescuentosDto> AplicarImportacionAsync( |
| | | 151 | | long acreedorId, Stream archivo, long usuarioId, string? host) |
| | | 152 | | { |
| | 5 | 153 | | IDbContextTransaction? transaccion = null; |
| | 5 | 154 | | if (_context.Database.IsRelational()) |
| | 0 | 155 | | transaccion = await _context.Database.BeginTransactionAsync(); |
| | | 156 | | |
| | | 157 | | try |
| | | 158 | | { |
| | 5 | 159 | | var lote = await ValidarLoteAsync(acreedorId, archivo); |
| | 5 | 160 | | var resultado = new ResultadoImportacionDescuentosDto |
| | 5 | 161 | | { |
| | 5 | 162 | | TotalFilas = lote.Resultado.TotalFilas, |
| | 5 | 163 | | FilasValidas = lote.Resultado.FilasValidas, |
| | 5 | 164 | | FilasConError = lote.Resultado.FilasConError, |
| | 5 | 165 | | Errores = lote.Resultado.Errores, |
| | 5 | 166 | | Aplicado = false |
| | 5 | 167 | | }; |
| | | 168 | | |
| | 5 | 169 | | if (!lote.Resultado.EsValido || lote.Periodo is null) |
| | | 170 | | { |
| | 2 | 171 | | if (transaccion is not null) |
| | 0 | 172 | | await transaccion.RollbackAsync(); |
| | 2 | 173 | | return resultado; |
| | | 174 | | } |
| | | 175 | | |
| | 3 | 176 | | var reemplazados = await _repo.EliminarBorradoresPorAcreedorAsync(lote.Periodo.Id, acreedorId); |
| | | 177 | | |
| | 3 | 178 | | var ahora = DateTime.Now; |
| | 3 | 179 | | var usuarioValido = usuarioId > 0 ? usuarioId : (long?)null; |
| | 6 | 180 | | var nuevos = lote.Filas.Select(fila => new DescuentoPersonalPeriodo |
| | 6 | 181 | | { |
| | 6 | 182 | | PeriodoId = lote.Periodo.Id, |
| | 6 | 183 | | PersonaId = fila.PersonaId, |
| | 6 | 184 | | AcreedorId = acreedorId, |
| | 6 | 185 | | CatalogoItemId = fila.CatalogoItemId, |
| | 6 | 186 | | Importe = fila.Importe, |
| | 6 | 187 | | Estado = "borrador", |
| | 6 | 188 | | FechaComunicacion = ahora, |
| | 6 | 189 | | UsuarioCreacionId = usuarioValido |
| | 6 | 190 | | }).ToList(); |
| | | 191 | | |
| | 3 | 192 | | await _repo.CrearVariosAsync(nuevos); |
| | | 193 | | |
| | 3 | 194 | | await _auditoria.LogAuditoriaAsync( |
| | 3 | 195 | | usuarioId, |
| | 3 | 196 | | AccionEnum.ImportarDescuentosPersonales, |
| | 3 | 197 | | ContextoEnum.Descuentos, |
| | 3 | 198 | | host, |
| | 3 | 199 | | nameof(DescuentoPersonalPeriodo), |
| | 3 | 200 | | null, |
| | 3 | 201 | | new |
| | 3 | 202 | | { |
| | 3 | 203 | | AcreedorId = acreedorId, |
| | 3 | 204 | | PeriodoId = lote.Periodo.Id, |
| | 3 | 205 | | resultado.TotalFilas, |
| | 3 | 206 | | RegistrosCreados = nuevos.Count, |
| | 3 | 207 | | RegistrosReemplazados = reemplazados |
| | 3 | 208 | | }); |
| | | 209 | | |
| | 3 | 210 | | if (transaccion is not null) |
| | 0 | 211 | | await transaccion.CommitAsync(); |
| | | 212 | | |
| | 3 | 213 | | resultado.Aplicado = true; |
| | 3 | 214 | | resultado.RegistrosCreados = nuevos.Count; |
| | 3 | 215 | | resultado.RegistrosReemplazados = reemplazados; |
| | 3 | 216 | | return resultado; |
| | | 217 | | } |
| | 0 | 218 | | catch |
| | | 219 | | { |
| | 0 | 220 | | if (transaccion is not null) |
| | 0 | 221 | | await transaccion.RollbackAsync(); |
| | 0 | 222 | | throw; |
| | | 223 | | } |
| | | 224 | | finally |
| | | 225 | | { |
| | 5 | 226 | | if (transaccion is not null) |
| | 0 | 227 | | await transaccion.DisposeAsync(); |
| | | 228 | | } |
| | 5 | 229 | | } |
| | | 230 | | |
| | | 231 | | private async Task<LoteImportacion> ValidarLoteAsync(long acreedorId, Stream archivo) |
| | | 232 | | { |
| | 8 | 233 | | var resultado = new ResultadoValidacionImportacionDescuentosDto(); |
| | | 234 | | |
| | 8 | 235 | | var periodo = await _periodosRepo.ObtenerEnCursoAsync(); |
| | 8 | 236 | | if (periodo is null) |
| | | 237 | | { |
| | 0 | 238 | | AgregarError(resultado, 0, "No existe un período en curso."); |
| | 0 | 239 | | return new LoteImportacion(resultado, [], null); |
| | | 240 | | } |
| | | 241 | | |
| | 8 | 242 | | if (periodo.Estado != "ABIERTA" && periodo.Estado != Periodo.EstadoListoPCalculo) |
| | | 243 | | { |
| | 1 | 244 | | AgregarError(resultado, 0, $"El período está en estado {periodo.Estado} y no admite modificaciones."); |
| | 1 | 245 | | return new LoteImportacion(resultado, [], periodo); |
| | | 246 | | } |
| | | 247 | | |
| | 7 | 248 | | var acreedor = await _context.Acreedores |
| | 7 | 249 | | .AsNoTracking() |
| | 7 | 250 | | .FirstOrDefaultAsync(a => a.Id == acreedorId && a.Activo); |
| | 7 | 251 | | if (acreedor is null) |
| | | 252 | | { |
| | 0 | 253 | | AgregarError(resultado, 0, $"Acreedor {acreedorId} no encontrado o no activo."); |
| | 0 | 254 | | return new LoteImportacion(resultado, [], periodo); |
| | | 255 | | } |
| | | 256 | | |
| | 7 | 257 | | var lineas = await LeerLineasAsync(archivo); |
| | 7 | 258 | | if (lineas.Count == 0) |
| | | 259 | | { |
| | 0 | 260 | | AgregarError(resultado, 0, "El archivo no contiene filas para importar."); |
| | 0 | 261 | | return new LoteImportacion(resultado, [], periodo); |
| | | 262 | | } |
| | | 263 | | |
| | 7 | 264 | | if (lineas.Count > MaxFilasImportacion) |
| | | 265 | | { |
| | 0 | 266 | | AgregarError(resultado, 0, $"La importación no puede superar {MaxFilasImportacion} filas por archivo."); |
| | 0 | 267 | | return new LoteImportacion(resultado, [], periodo); |
| | | 268 | | } |
| | | 269 | | |
| | 7 | 270 | | resultado.TotalFilas = lineas.Count; |
| | | 271 | | |
| | 7 | 272 | | var filasParseadas = new List<DescuentoPersonalTxtFila>(); |
| | 30 | 273 | | foreach (var (linea, numero) in lineas) |
| | | 274 | | { |
| | 8 | 275 | | if (!DescuentoPersonalTxtLineParser.TryParse(linea, numero, out var fila, out var error)) |
| | | 276 | | { |
| | 0 | 277 | | AgregarError(resultado, numero, error!); |
| | 0 | 278 | | continue; |
| | | 279 | | } |
| | 8 | 280 | | filasParseadas.Add(fila!); |
| | | 281 | | } |
| | | 282 | | |
| | 15 | 283 | | var codigos = filasParseadas.Select(f => f.CodigoConcepto).Distinct().ToList(); |
| | 7 | 284 | | var catalogos = await _context.CatalogoItems |
| | 7 | 285 | | .AsNoTracking() |
| | 7 | 286 | | .Where(c => codigos.Contains(c.Codigo) && c.Tipo == "descuento_personal" && c.Vigente) |
| | 12 | 287 | | .ToDictionaryAsync(c => c.Codigo); |
| | | 288 | | |
| | 7 | 289 | | var cedulasCompletas = filasParseadas |
| | 8 | 290 | | .Select(f => CedulaValidator.CompletarCedula(f.CedulaSinDigito)) |
| | 7 | 291 | | .Distinct() |
| | 7 | 292 | | .ToList(); |
| | 7 | 293 | | var relaciones = await _context.RelacionesLaborales |
| | 7 | 294 | | .AsNoTracking() |
| | 7 | 295 | | .Include(r => r.Persona) |
| | 7 | 296 | | .Where(r => r.Estado == EstadoRelacion.Activo && cedulasCompletas.Contains(r.Persona.Cedula)) |
| | 7 | 297 | | .ToListAsync(); |
| | 7 | 298 | | var personaIdPorCedula = relaciones |
| | 7 | 299 | | .GroupBy(r => r.Persona.Cedula) |
| | 21 | 300 | | .ToDictionary(g => g.Key, g => g.First().PersonaId); |
| | | 301 | | |
| | 7 | 302 | | var claves = new HashSet<(string Cedula, int Codigo)>(); |
| | 7 | 303 | | var filasResueltas = new List<FilaResuelta>(); |
| | | 304 | | |
| | 30 | 305 | | foreach (var fila in filasParseadas) |
| | | 306 | | { |
| | 8 | 307 | | var cedulaCompleta = CedulaValidator.CompletarCedula(fila.CedulaSinDigito); |
| | | 308 | | |
| | 8 | 309 | | if (!catalogos.TryGetValue(fila.CodigoConcepto, out var catalogo)) |
| | | 310 | | { |
| | 2 | 311 | | AgregarError(resultado, fila.NumeroLinea, |
| | 2 | 312 | | $"El código de descuento {fila.CodigoConcepto} no está catalogado como descuento personal vigente.") |
| | 2 | 313 | | continue; |
| | | 314 | | } |
| | | 315 | | |
| | 6 | 316 | | if (catalogo.ItemParFictoId.HasValue) |
| | | 317 | | { |
| | 1 | 318 | | AgregarError(resultado, fila.NumeroLinea, |
| | 1 | 319 | | $"El código {fila.CodigoConcepto} corresponde a un ficto; se gestiona mediante /api/fictos."); |
| | 1 | 320 | | continue; |
| | | 321 | | } |
| | | 322 | | |
| | 5 | 323 | | if (!personaIdPorCedula.TryGetValue(cedulaCompleta, out var personaId)) |
| | | 324 | | { |
| | 0 | 325 | | AgregarError(resultado, fila.NumeroLinea, $"No se encontró personal activo con cédula {cedulaCompleta}." |
| | 0 | 326 | | continue; |
| | | 327 | | } |
| | | 328 | | |
| | 5 | 329 | | if (!claves.Add((cedulaCompleta, fila.CodigoConcepto))) |
| | | 330 | | { |
| | 1 | 331 | | AgregarError(resultado, fila.NumeroLinea, |
| | 1 | 332 | | $"Fila duplicada: la cédula {cedulaCompleta} ya aparece con el código {fila.CodigoConcepto} en el ar |
| | 1 | 333 | | continue; |
| | | 334 | | } |
| | | 335 | | |
| | 4 | 336 | | filasResueltas.Add(new FilaResuelta(fila.NumeroLinea, personaId, catalogo.Id, fila.Importe)); |
| | | 337 | | } |
| | | 338 | | |
| | 7 | 339 | | resultado.FilasValidas = filasResueltas.Count; |
| | 7 | 340 | | return new LoteImportacion(resultado, filasResueltas, periodo); |
| | 8 | 341 | | } |
| | | 342 | | |
| | | 343 | | private static async Task<List<(string Linea, int Numero)>> LeerLineasAsync(Stream archivo) |
| | | 344 | | { |
| | 7 | 345 | | var lineas = new List<(string, int)>(); |
| | 7 | 346 | | using var lector = new StreamReader(archivo); |
| | 7 | 347 | | var numero = 0; |
| | | 348 | | string? linea; |
| | 15 | 349 | | while ((linea = await lector.ReadLineAsync()) != null) |
| | | 350 | | { |
| | 8 | 351 | | numero++; |
| | 8 | 352 | | if (string.IsNullOrWhiteSpace(linea)) |
| | | 353 | | continue; |
| | 8 | 354 | | lineas.Add((linea, numero)); |
| | | 355 | | } |
| | 7 | 356 | | return lineas; |
| | 7 | 357 | | } |
| | | 358 | | |
| | | 359 | | private static void AgregarError(ResultadoValidacionImportacionDescuentosDto resultado, int numeroLinea, string mens |
| | | 360 | | { |
| | 5 | 361 | | resultado.Errores.Add(new ErrorImportacionDescuentoPersonalDto { NumeroLinea = numeroLinea, Mensaje = mensaje }) |
| | 5 | 362 | | resultado.FilasConError++; |
| | 5 | 363 | | } |
| | | 364 | | |
| | 13 | 365 | | private sealed record FilaResuelta(int NumeroLinea, long PersonaId, long CatalogoItemId, decimal Importe); |
| | | 366 | | |
| | 8 | 367 | | private sealed record LoteImportacion( |
| | 28 | 368 | | ResultadoValidacionImportacionDescuentosDto Resultado, |
| | 3 | 369 | | IReadOnlyList<FilaResuelta> Filas, |
| | 20 | 370 | | Periodo? Periodo); |
| | | 371 | | } |