< Summary

Information
Class: FAU.API.Controllers.CatalogoItemsController
Assembly: FAU.API
File(s): /home/runner/work/Sistema.Liquidacion.BE/Sistema.Liquidacion.BE/FAU.API/Controllers/CatalogoItemsController.cs
Line coverage
87%
Covered lines: 193
Uncovered lines: 28
Coverable lines: 221
Total lines: 394
Line coverage: 87.3%
Branch coverage
76%
Covered branches: 70
Total branches: 92
Branch coverage: 76%
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%
GetCatalogoItems()94.44%181892.68%
GetCatalogoItem()100%2266.66%
CrearCatalogoItem()90%101086.66%
ActualizarCatalogoItem()73.8%474286.04%
GetUsos()100%2288.46%
CambiarVigente()100%6681.25%
ParseCodigo(...)75%44100%
EsDuplicadoCodigo(...)0%7280%
MapDto(...)100%11100%

File(s)

/home/runner/work/Sistema.Liquidacion.BE/Sistema.Liquidacion.BE/FAU.API/Controllers/CatalogoItemsController.cs

#LineLine coverage
 1using FAU.DataAccess;
 2using FAU.Entidades;
 3using FAU.Logica.DTOs;
 4using Microsoft.AspNetCore.Authorization;
 5using Microsoft.AspNetCore.Http;
 6using Microsoft.AspNetCore.Mvc;
 7using Microsoft.EntityFrameworkCore;
 8
 9namespace FAU.API.Controllers;
 10
 11[ApiController]
 12[Route("api/catalogo-items")]
 13[Authorize]
 14[Produces("application/json")]
 15public class CatalogoItemsController : ControllerBase
 16{
 17    private readonly ApplicationDbContext _context;
 18    private readonly ILogger<CatalogoItemsController> _logger;
 19
 120    private static readonly HashSet<string> TiposValidos =
 121    [
 122        "haber_normal", "haber_anual", "haber_no_gravado",
 123        "beneficio_social_grav", "beneficio_social_no_grav",
 124        "ficto_haber"
 125    ];
 26
 127    private static readonly HashSet<string> FormulasValidas =
 128    [
 129        "por_grado", "tabla_externa", "prorrateo", "fijo"
 130    ];
 31
 2932    public CatalogoItemsController(ApplicationDbContext context, ILogger<CatalogoItemsController> logger)
 33    {
 2934        _context = context;
 2935        _logger = logger;
 2936    }
 37
 38    [HttpGet]
 39    [ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status200OK)]
 40    [ProducesResponseType(typeof(ApiResponse<string>), StatusCodes.Status500InternalServerError)]
 41    public async Task<IActionResult> GetCatalogoItems(
 42        [FromQuery] bool? vigente = null,
 43        [FromQuery] string? busqueda = null,
 44        [FromQuery] string? tipo = null,
 45        [FromQuery] string? formulaCalculo = null,
 46        [FromQuery] string? excluirTipos = null,
 47        [FromQuery] int pagina = 1,
 48        [FromQuery] int tamano = 50)
 49    {
 50        try
 51        {
 1052            if (pagina < 1) pagina = 1;
 1153            if (tamano < 1 || tamano > 200) tamano = 50;
 54
 955            var query = _context.CatalogoItems.AsNoTracking();
 56
 957            if (vigente.HasValue)
 158                query = query.Where(i => i.Vigente == vigente.Value);
 59
 960            if (!string.IsNullOrWhiteSpace(tipo))
 61            {
 62                // Soporta valor único ("haber_normal") o múltiples separados por coma ("haber_normal,haber_no_gravado")
 163                var tipos = tipo.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
 164                query = tipos.Length == 1
 165                    ? query.Where(i => i.Tipo == tipos[0])
 166                    : query.Where(i => tipos.Contains(i.Tipo));
 67            }
 68
 969            if (!string.IsNullOrWhiteSpace(excluirTipos))
 70            {
 171                var excluidos = excluirTipos.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimE
 172                query = query.Where(i => !excluidos.Contains(i.Tipo));
 73            }
 74
 975            if (!string.IsNullOrWhiteSpace(formulaCalculo))
 176                query = query.Where(i => i.FormulaCalculo == formulaCalculo);
 77
 978            if (!string.IsNullOrWhiteSpace(busqueda))
 79            {
 180                var term = busqueda.Trim().ToLower();
 181                query = query.Where(i =>
 182                    i.Nombre.ToLower().Contains(term) ||
 183                    (i.ClasificacionSiif != null && i.ClasificacionSiif.Contains(term)));
 84            }
 85
 986            var totalCount = await query.CountAsync();
 87
 988            var items = await query
 989                .OrderBy(i => i.OrdenCalculo)
 990                .ThenBy(i => i.Nombre)
 991                .Skip((pagina - 1) * tamano)
 992                .Take(tamano)
 993                .Select(i => MapDto(i))
 994                .ToListAsync();
 95
 996            var response = new
 997            {
 998                items,
 999                page      = pagina,
 9100                pageSize  = tamano,
 9101                totalCount,
 9102                totalPages = (int)Math.Ceiling(totalCount / (double)tamano),
 9103            };
 104
 9105            return Ok(ApiResponse<object>.SuccessResult(response));
 106        }
 0107        catch (Exception ex)
 108        {
 0109            _logger.LogError(ex, "Error al obtener catálogo de ítems");
 0110            return StatusCode(500, ApiResponse<string>.ErrorResult("Error interno del servidor"));
 111        }
 9112    }
 113
 114    [HttpGet("{id:long}")]
 115    [ProducesResponseType(typeof(ApiResponse<CatalogoItemDto>), StatusCodes.Status200OK)]
 116    [ProducesResponseType(typeof(ApiResponse<string>), StatusCodes.Status404NotFound)]
 117    [ProducesResponseType(typeof(ApiResponse<string>), StatusCodes.Status500InternalServerError)]
 118    public async Task<IActionResult> GetCatalogoItem(long id)
 119    {
 120        try
 121        {
 2122            var item = await _context.CatalogoItems.AsNoTracking()
 2123                .FirstOrDefaultAsync(i => i.Id == id);
 124
 2125            if (item is null)
 1126                return NotFound(ApiResponse<string>.ErrorResult("Ítem no encontrado"));
 127
 1128            return Ok(ApiResponse<CatalogoItemDto>.SuccessResult(MapDto(item)));
 129        }
 0130        catch (Exception ex)
 131        {
 0132            _logger.LogError(ex, "Error al obtener catálogo ítem {Id}", id);
 0133            return StatusCode(500, ApiResponse<string>.ErrorResult("Error interno del servidor"));
 134        }
 2135    }
 136
 137    [HttpPost]
 138    [Authorize(Roles = "Administrador")]
 139    [ProducesResponseType(typeof(ApiResponse<CatalogoItemDto>), StatusCodes.Status201Created)]
 140    [ProducesResponseType(typeof(ApiResponse<string>), StatusCodes.Status400BadRequest)]
 141    [ProducesResponseType(typeof(ApiResponse<string>), StatusCodes.Status409Conflict)]
 142    [ProducesResponseType(typeof(ApiResponse<string>), StatusCodes.Status500InternalServerError)]
 143    public async Task<IActionResult> CrearCatalogoItem([FromBody] CrearCatalogoItemRequest request)
 144    {
 145        try
 146        {
 5147            if (!TiposValidos.Contains(request.Tipo))
 1148                return BadRequest(ApiResponse<string>.ErrorResult(
 1149                    $"Tipo inválido '{request.Tipo}'. Valores permitidos: {string.Join(", ", TiposValidos)}"));
 150
 4151            if (!FormulasValidas.Contains(request.FormulaCalculo))
 1152                return BadRequest(ApiResponse<string>.ErrorResult(
 1153                    $"Fórmula inválida '{request.FormulaCalculo}'. Valores permitidos: {string.Join(", ", FormulasValida
 154
 3155            var codigo = ParseCodigo(request.ClasificacionSiif);
 3156            if (codigo is null)
 1157                return BadRequest(ApiResponse<string>.ErrorResult(
 1158                    "Formato de clasificación SIIF inválido. Debe ser NNN.NNN (ej: 042.568)"));
 159
 2160            if (await _context.CatalogoItems.AnyAsync(i =>
 2161                    i.Codigo == codigo.Value ||
 2162                    i.ClasificacionSiif == request.ClasificacionSiif))
 1163                return Conflict(ApiResponse<string>.ErrorResult(
 1164                    $"Ya existe un ítem con la clasificación SIIF '{request.ClasificacionSiif}'"));
 165
 1166            var item = new CatalogoItem
 1167            {
 1168                Codigo = codigo.Value,
 1169                Nombre = request.Nombre.Trim(),
 1170                Tipo = request.Tipo,
 1171                ClasificacionSiif = request.ClasificacionSiif,
 1172                NormaLegal = request.NormaLegal?.Trim(),
 1173                EsGravado = request.EsGravado,
 1174                AcumulaAguinaldo = request.AcumulaAguinaldo,
 1175                LlevaAumento = request.LlevaAumento,
 1176                FormulaCalculo = request.FormulaCalculo,
 1177                OrdenCalculo = request.OrdenCalculo,
 1178                ContribuyeWPara53 = request.ContribuyeWPara53,
 1179                ContribuyeWPara10 = request.ContribuyeWPara10,
 1180                ContribuyeWPara20 = request.ContribuyeWPara20,
 1181                ContribuyeWNominal = request.ContribuyeWNominal,
 1182                Vigente = request.Vigente
 1183            };
 184
 1185            _context.CatalogoItems.Add(item);
 1186            await _context.SaveChangesAsync();
 187
 1188            _logger.LogInformation("CatalogoItem creado: {Siif} - {Nombre}", item.ClasificacionSiif, item.Nombre);
 189
 1190            return CreatedAtAction(nameof(GetCatalogoItem), new { id = item.Id },
 1191                ApiResponse<CatalogoItemDto>.SuccessResult(MapDto(item)));
 192        }
 0193        catch (DbUpdateException ex) when (EsDuplicadoCodigo(ex))
 194        {
 0195            return Conflict(ApiResponse<string>.ErrorResult(
 0196                $"Ya existe un ítem con la clasificación SIIF '{request.ClasificacionSiif}'"));
 197        }
 0198        catch (Exception ex)
 199        {
 0200            _logger.LogError(ex, "Error al crear catálogo ítem");
 0201            return StatusCode(500, ApiResponse<string>.ErrorResult("Error interno del servidor"));
 202        }
 5203    }
 204
 205    [HttpPut("{id:long}")]
 206    [Authorize(Roles = "Administrador")]
 207    [ProducesResponseType(typeof(ApiResponse<CatalogoItemDto>), StatusCodes.Status200OK)]
 208    [ProducesResponseType(typeof(ApiResponse<string>), StatusCodes.Status400BadRequest)]
 209    [ProducesResponseType(typeof(ApiResponse<string>), StatusCodes.Status404NotFound)]
 210    [ProducesResponseType(typeof(ApiResponse<string>), StatusCodes.Status409Conflict)]
 211    [ProducesResponseType(typeof(ApiResponse<string>), StatusCodes.Status500InternalServerError)]
 212    public async Task<IActionResult> ActualizarCatalogoItem(long id, [FromBody] ActualizarCatalogoItemRequest request)
 213    {
 214        try
 215        {
 7216            var item = await _context.CatalogoItems.FindAsync(id);
 7217            if (item is null)
 1218                return NotFound(ApiResponse<string>.ErrorResult("Ítem no encontrado"));
 219
 6220            if (request.Tipo is not null && !TiposValidos.Contains(request.Tipo))
 1221                return BadRequest(ApiResponse<string>.ErrorResult(
 1222                    $"Tipo inválido '{request.Tipo}'. Valores permitidos: {string.Join(", ", TiposValidos)}"));
 223
 5224            if (request.FormulaCalculo is not null && !FormulasValidas.Contains(request.FormulaCalculo))
 1225                return BadRequest(ApiResponse<string>.ErrorResult(
 1226                    $"Fórmula inválida '{request.FormulaCalculo}'. Valores permitidos: {string.Join(", ", FormulasValida
 227
 5228            if (request.Nombre is not null) item.Nombre = request.Nombre.Trim();
 4229            if (request.Tipo is not null) item.Tipo = request.Tipo;
 4230            if (request.NormaLegal is not null) item.NormaLegal = request.NormaLegal.Trim();
 4231            if (request.EsGravado.HasValue) item.EsGravado = request.EsGravado.Value;
 4232            if (request.AcumulaAguinaldo.HasValue) item.AcumulaAguinaldo = request.AcumulaAguinaldo.Value;
 4233            if (request.LlevaAumento.HasValue) item.LlevaAumento = request.LlevaAumento.Value;
 4234            if (request.FormulaCalculo is not null) item.FormulaCalculo = request.FormulaCalculo;
 4235            if (request.OrdenCalculo.HasValue) item.OrdenCalculo = request.OrdenCalculo.Value;
 4236            if (request.ContribuyeWPara53.HasValue) item.ContribuyeWPara53 = request.ContribuyeWPara53.Value;
 4237            if (request.ContribuyeWPara10.HasValue) item.ContribuyeWPara10 = request.ContribuyeWPara10.Value;
 4238            if (request.ContribuyeWPara20.HasValue) item.ContribuyeWPara20 = request.ContribuyeWPara20.Value;
 4239            if (request.ContribuyeWNominal.HasValue) item.ContribuyeWNominal = request.ContribuyeWNominal.Value;
 240
 4241            if (request.ClasificacionSiif is not null &&
 4242                request.ClasificacionSiif != item.ClasificacionSiif)
 243            {
 3244                var nuevoCodigo = ParseCodigo(request.ClasificacionSiif);
 3245                if (nuevoCodigo is null)
 1246                    return BadRequest(ApiResponse<string>.ErrorResult(
 1247                        "Formato de clasificación SIIF inválido. Debe ser NNN.NNN (ej: 042.568)"));
 248
 2249                if (await _context.CatalogoItems.AnyAsync(i =>
 2250                        i.Id != id &&
 2251                        (i.Codigo == nuevoCodigo.Value || i.ClasificacionSiif == request.ClasificacionSiif)))
 1252                    return Conflict(ApiResponse<string>.ErrorResult(
 1253                        $"Ya existe un ítem con la clasificación SIIF '{request.ClasificacionSiif}'"));
 254
 1255                item.ClasificacionSiif = request.ClasificacionSiif;
 1256                item.Codigo = nuevoCodigo.Value;
 257            }
 258
 2259            await _context.SaveChangesAsync();
 260
 2261            return Ok(ApiResponse<CatalogoItemDto>.SuccessResult(MapDto(item)));
 262        }
 0263        catch (DbUpdateException ex) when (EsDuplicadoCodigo(ex))
 264        {
 0265            return Conflict(ApiResponse<string>.ErrorResult(
 0266                $"Ya existe un ítem con la clasificación SIIF '{request.ClasificacionSiif}'"));
 267        }
 0268        catch (Exception ex)
 269        {
 0270            _logger.LogError(ex, "Error al actualizar catálogo ítem {Id}", id);
 0271            return StatusCode(500, ApiResponse<string>.ErrorResult("Error interno del servidor"));
 272        }
 7273    }
 274
 275    [HttpGet("{id:long}/usos")]
 276    [Authorize(Roles = "Administrador")]
 277    [ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status200OK)]
 278    [ProducesResponseType(typeof(ApiResponse<string>), StatusCodes.Status404NotFound)]
 279    [ProducesResponseType(typeof(ApiResponse<string>), StatusCodes.Status500InternalServerError)]
 280    public async Task<IActionResult> GetUsos(long id)
 281    {
 282        try
 283        {
 2284            var item = await _context.CatalogoItems.AsNoTracking()
 2285                .FirstOrDefaultAsync(i => i.Id == id);
 2286            if (item is null)
 1287                return NotFound(ApiResponse<string>.ErrorResult("Ítem no encontrado"));
 288
 1289            var codigoStr = item.Codigo.ToString();
 290
 1291            var liquidacionesHistoricas = await _context.LiquidacionItems
 1292                .CountAsync(li => li.CodigoConcepto == codigoStr);
 293
 1294            var montosPorGrado = await _context.RemuneracionesGrado
 1295                .CountAsync(r => r.ItemId == id);
 296
 1297            var compensacionesActivas = await _context.Set<FAU.Entidades.ConfigCompensacion>()
 1298                .CountAsync(c => c.CatalogoItemId == id && c.Activo);
 299
 1300            var beneficiosActivos = await _context.Set<FAU.Entidades.TipoBeneficio>()
 1301                .CountAsync(tb => tb.CatalogoItemId == id && tb.Activo);
 302
 1303            return Ok(ApiResponse<object>.SuccessResult(new
 1304            {
 1305                liquidacionesHistoricas,
 1306                montosPorGrado,
 1307                compensacionesActivas,
 1308                beneficiosActivos,
 1309                puedeEditarSiif = liquidacionesHistoricas == 0,
 1310                puedeEditarFormula = montosPorGrado == 0
 1311            }));
 312        }
 0313        catch (Exception ex)
 314        {
 0315            _logger.LogError(ex, "Error al obtener usos de catálogo ítem {Id}", id);
 0316            return StatusCode(500, ApiResponse<string>.ErrorResult("Error interno del servidor"));
 317        }
 2318    }
 319
 320    [HttpPatch("{id:long}/vigente")]
 321    [Authorize(Roles = "Administrador")]
 322    [ProducesResponseType(typeof(ApiResponse<CatalogoItemDto>), StatusCodes.Status200OK)]
 323    [ProducesResponseType(typeof(ApiResponse<string>), StatusCodes.Status404NotFound)]
 324    [ProducesResponseType(typeof(ApiResponse<string>), StatusCodes.Status409Conflict)]
 325    [ProducesResponseType(typeof(ApiResponse<string>), StatusCodes.Status500InternalServerError)]
 326    public async Task<IActionResult> CambiarVigente(long id, [FromBody] CambiarVigenteRequest request)
 327    {
 328        try
 329        {
 4330            var item = await _context.CatalogoItems.FindAsync(id);
 4331            if (item is null)
 1332                return NotFound(ApiResponse<string>.ErrorResult("Ítem no encontrado"));
 333
 3334            if (!request.Vigente)
 335            {
 2336                var enUso = await _context.LiquidacionItems
 2337                    .AnyAsync(li => li.CodigoConcepto == item.Codigo.ToString());
 2338                if (enUso)
 1339                    return Conflict(ApiResponse<string>.ErrorResult(
 1340                        $"No se puede desactivar: el ítem '{item.ClasificacionSiif}' está en uso en liquidaciones"));
 341            }
 342
 2343            item.Vigente = request.Vigente;
 2344            await _context.SaveChangesAsync();
 345
 2346            return Ok(ApiResponse<CatalogoItemDto>.SuccessResult(MapDto(item)));
 347        }
 0348        catch (Exception ex)
 349        {
 0350            _logger.LogError(ex, "Error al cambiar vigente de catálogo ítem {Id}", id);
 0351            return StatusCode(500, ApiResponse<string>.ErrorResult("Error interno del servidor"));
 352        }
 4353    }
 354
 355    // ── Helpers ───────────────────────────────────────────────────────────────
 356
 357    private static int? ParseCodigo(string clasificacionSiif)
 358    {
 6359        if (string.IsNullOrWhiteSpace(clasificacionSiif)) return null;
 6360        var sinPunto = clasificacionSiif.Replace(".", "");
 6361        return int.TryParse(sinPunto, out var codigo) ? codigo : null;
 362    }
 363
 364    private static bool EsDuplicadoCodigo(DbUpdateException ex)
 365    {
 0366        var msg = ex.InnerException?.Message ?? ex.Message;
 0367        return msg.Contains("catalogo_items_codigo_key", StringComparison.OrdinalIgnoreCase)
 0368            || msg.Contains("ux_catalogo_items_clasificacion_siif", StringComparison.OrdinalIgnoreCase)
 0369            || msg.Contains("duplicate key", StringComparison.OrdinalIgnoreCase);
 370    }
 371
 14372    private static CatalogoItemDto MapDto(CatalogoItem i) => new()
 14373    {
 14374        Id = i.Id,
 14375        Codigo = i.Codigo,
 14376        Nombre = i.Nombre,
 14377        Tipo = i.Tipo,
 14378        ClasificacionSiif = i.ClasificacionSiif,
 14379        NormaLegal = i.NormaLegal,
 14380        EsGravado = i.EsGravado,
 14381        AcumulaAguinaldo = i.AcumulaAguinaldo,
 14382        LlevaAumento = i.LlevaAumento,
 14383        FormulaCalculo = i.FormulaCalculo,
 14384        OrdenCalculo = i.OrdenCalculo,
 14385        ContribuyeWPara53 = i.ContribuyeWPara53,
 14386        ContribuyeWPara10 = i.ContribuyeWPara10,
 14387        ContribuyeWPara20 = i.ContribuyeWPara20,
 14388        ContribuyeWNominal = i.ContribuyeWNominal,
 14389        EsFicto = i.ItemParFictoId.HasValue,
 14390        Vigente = i.Vigente
 14391    };
 392}
 393
 394public record CambiarVigenteRequest(bool Vigente);