< 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
18%
Covered lines: 39
Uncovered lines: 170
Coverable lines: 209
Total lines: 380
Line coverage: 18.6%
Branch coverage
9%
Covered branches: 9
Total branches: 92
Branch coverage: 9.7%
Method coverage

Feature is only available for sponsors

Upgrade to PRO version

Metrics

MethodBranch coverage Crap Score Cyclomatic complexity Line coverage
.cctor()100%210%
.ctor(...)100%11100%
GetCatalogoItems()50%511853.33%
GetCatalogoItem()0%620%
CrearCatalogoItem()0%110100%
ActualizarCatalogoItem()0%1806420%
GetUsos()0%620%
CambiarVigente()0%4260%
ParseCodigo(...)0%2040%
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
 020    private static readonly HashSet<string> TiposValidos =
 021    [
 022        "haber_normal", "haber_anual", "haber_no_gravado",
 023        "beneficio_social_grav", "beneficio_social_no_grav",
 024        "ficto_haber"
 025    ];
 26
 027    private static readonly HashSet<string> FormulasValidas =
 028    [
 029        "por_grado", "tabla_externa", "prorrateo", "fijo"
 030    ];
 31
 332    public CatalogoItemsController(ApplicationDbContext context, ILogger<CatalogoItemsController> logger)
 33    {
 334        _context = context;
 335        _logger = logger;
 336    }
 37
 38    [HttpGet]
 39    [ProducesResponseType(typeof(ApiResponse<IEnumerable<CatalogoItemDto>>), 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        {
 352            if (pagina < 1) pagina = 1;
 353            if (tamano < 1 || tamano > 200) tamano = 50;
 54
 355            var query = _context.CatalogoItems.AsNoTracking();
 56
 357            if (vigente.HasValue)
 158                query = query.Where(i => i.Vigente == vigente.Value);
 59
 360            if (!string.IsNullOrWhiteSpace(tipo))
 61            {
 62                // Soporta valor único ("haber_normal") o múltiples separados por coma ("haber_normal,haber_no_gravado")
 063                var tipos = tipo.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
 064                query = tipos.Length == 1
 065                    ? query.Where(i => i.Tipo == tipos[0])
 066                    : query.Where(i => tipos.Contains(i.Tipo));
 67            }
 68
 369            if (!string.IsNullOrWhiteSpace(excluirTipos))
 70            {
 071                var excluidos = excluirTipos.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimE
 072                query = query.Where(i => !excluidos.Contains(i.Tipo));
 73            }
 74
 375            if (!string.IsNullOrWhiteSpace(formulaCalculo))
 076                query = query.Where(i => i.FormulaCalculo == formulaCalculo);
 77
 378            if (!string.IsNullOrWhiteSpace(busqueda))
 79            {
 080                var term = busqueda.Trim().ToLower();
 081                query = query.Where(i =>
 082                    i.Nombre.ToLower().Contains(term) ||
 083                    (i.ClasificacionSiif != null && i.ClasificacionSiif.Contains(term)));
 84            }
 85
 386            var items = await query
 387                .OrderBy(i => i.OrdenCalculo)
 388                .ThenBy(i => i.Nombre)
 389                .Select(i => MapDto(i))
 390                .ToListAsync();
 91
 392            return Ok(ApiResponse<IEnumerable<CatalogoItemDto>>.SuccessResult(items));
 93        }
 094        catch (Exception ex)
 95        {
 096            _logger.LogError(ex, "Error al obtener catálogo de ítems");
 097            return StatusCode(500, ApiResponse<string>.ErrorResult("Error interno del servidor"));
 98        }
 399    }
 100
 101    [HttpGet("{id:long}")]
 102    [ProducesResponseType(typeof(ApiResponse<CatalogoItemDto>), StatusCodes.Status200OK)]
 103    [ProducesResponseType(typeof(ApiResponse<string>), StatusCodes.Status404NotFound)]
 104    [ProducesResponseType(typeof(ApiResponse<string>), StatusCodes.Status500InternalServerError)]
 105    public async Task<IActionResult> GetCatalogoItem(long id)
 106    {
 107        try
 108        {
 0109            var item = await _context.CatalogoItems.AsNoTracking()
 0110                .FirstOrDefaultAsync(i => i.Id == id);
 111
 0112            if (item is null)
 0113                return NotFound(ApiResponse<string>.ErrorResult("Ítem no encontrado"));
 114
 0115            return Ok(ApiResponse<CatalogoItemDto>.SuccessResult(MapDto(item)));
 116        }
 0117        catch (Exception ex)
 118        {
 0119            _logger.LogError(ex, "Error al obtener catálogo ítem {Id}", id);
 0120            return StatusCode(500, ApiResponse<string>.ErrorResult("Error interno del servidor"));
 121        }
 0122    }
 123
 124    [HttpPost]
 125    [Authorize(Roles = "Administrador")]
 126    [ProducesResponseType(typeof(ApiResponse<CatalogoItemDto>), StatusCodes.Status201Created)]
 127    [ProducesResponseType(typeof(ApiResponse<string>), StatusCodes.Status400BadRequest)]
 128    [ProducesResponseType(typeof(ApiResponse<string>), StatusCodes.Status409Conflict)]
 129    [ProducesResponseType(typeof(ApiResponse<string>), StatusCodes.Status500InternalServerError)]
 130    public async Task<IActionResult> CrearCatalogoItem([FromBody] CrearCatalogoItemRequest request)
 131    {
 132        try
 133        {
 0134            if (!TiposValidos.Contains(request.Tipo))
 0135                return BadRequest(ApiResponse<string>.ErrorResult(
 0136                    $"Tipo inválido '{request.Tipo}'. Valores permitidos: {string.Join(", ", TiposValidos)}"));
 137
 0138            if (!FormulasValidas.Contains(request.FormulaCalculo))
 0139                return BadRequest(ApiResponse<string>.ErrorResult(
 0140                    $"Fórmula inválida '{request.FormulaCalculo}'. Valores permitidos: {string.Join(", ", FormulasValida
 141
 0142            var codigo = ParseCodigo(request.ClasificacionSiif);
 0143            if (codigo is null)
 0144                return BadRequest(ApiResponse<string>.ErrorResult(
 0145                    "Formato de clasificación SIIF inválido. Debe ser NNN.NNN (ej: 042.568)"));
 146
 0147            if (await _context.CatalogoItems.AnyAsync(i =>
 0148                    i.Codigo == codigo.Value ||
 0149                    i.ClasificacionSiif == request.ClasificacionSiif))
 0150                return Conflict(ApiResponse<string>.ErrorResult(
 0151                    $"Ya existe un ítem con la clasificación SIIF '{request.ClasificacionSiif}'"));
 152
 0153            var item = new CatalogoItem
 0154            {
 0155                Codigo = codigo.Value,
 0156                Nombre = request.Nombre.Trim(),
 0157                Tipo = request.Tipo,
 0158                ClasificacionSiif = request.ClasificacionSiif,
 0159                NormaLegal = request.NormaLegal?.Trim(),
 0160                EsGravado = request.EsGravado,
 0161                AcumulaAguinaldo = request.AcumulaAguinaldo,
 0162                LlevaAumento = request.LlevaAumento,
 0163                FormulaCalculo = request.FormulaCalculo,
 0164                OrdenCalculo = request.OrdenCalculo,
 0165                ContribuyeWPara53 = request.ContribuyeWPara53,
 0166                ContribuyeWPara10 = request.ContribuyeWPara10,
 0167                ContribuyeWPara20 = request.ContribuyeWPara20,
 0168                ContribuyeWNominal = request.ContribuyeWNominal,
 0169                Vigente = request.Vigente
 0170            };
 171
 0172            _context.CatalogoItems.Add(item);
 0173            await _context.SaveChangesAsync();
 174
 0175            _logger.LogInformation("CatalogoItem creado: {Siif} - {Nombre}", item.ClasificacionSiif, item.Nombre);
 176
 0177            return CreatedAtAction(nameof(GetCatalogoItem), new { id = item.Id },
 0178                ApiResponse<CatalogoItemDto>.SuccessResult(MapDto(item)));
 179        }
 0180        catch (DbUpdateException ex) when (EsDuplicadoCodigo(ex))
 181        {
 0182            return Conflict(ApiResponse<string>.ErrorResult(
 0183                $"Ya existe un ítem con la clasificación SIIF '{request.ClasificacionSiif}'"));
 184        }
 0185        catch (Exception ex)
 186        {
 0187            _logger.LogError(ex, "Error al crear catálogo ítem");
 0188            return StatusCode(500, ApiResponse<string>.ErrorResult("Error interno del servidor"));
 189        }
 0190    }
 191
 192    [HttpPut("{id:long}")]
 193    [Authorize(Roles = "Administrador")]
 194    [ProducesResponseType(typeof(ApiResponse<CatalogoItemDto>), StatusCodes.Status200OK)]
 195    [ProducesResponseType(typeof(ApiResponse<string>), StatusCodes.Status400BadRequest)]
 196    [ProducesResponseType(typeof(ApiResponse<string>), StatusCodes.Status404NotFound)]
 197    [ProducesResponseType(typeof(ApiResponse<string>), StatusCodes.Status409Conflict)]
 198    [ProducesResponseType(typeof(ApiResponse<string>), StatusCodes.Status500InternalServerError)]
 199    public async Task<IActionResult> ActualizarCatalogoItem(long id, [FromBody] ActualizarCatalogoItemRequest request)
 200    {
 201        try
 202        {
 0203            var item = await _context.CatalogoItems.FindAsync(id);
 0204            if (item is null)
 0205                return NotFound(ApiResponse<string>.ErrorResult("Ítem no encontrado"));
 206
 0207            if (request.Tipo is not null && !TiposValidos.Contains(request.Tipo))
 0208                return BadRequest(ApiResponse<string>.ErrorResult(
 0209                    $"Tipo inválido '{request.Tipo}'. Valores permitidos: {string.Join(", ", TiposValidos)}"));
 210
 0211            if (request.FormulaCalculo is not null && !FormulasValidas.Contains(request.FormulaCalculo))
 0212                return BadRequest(ApiResponse<string>.ErrorResult(
 0213                    $"Fórmula inválida '{request.FormulaCalculo}'. Valores permitidos: {string.Join(", ", FormulasValida
 214
 0215            if (request.Nombre is not null) item.Nombre = request.Nombre.Trim();
 0216            if (request.Tipo is not null) item.Tipo = request.Tipo;
 0217            if (request.NormaLegal is not null) item.NormaLegal = request.NormaLegal.Trim();
 0218            if (request.EsGravado.HasValue) item.EsGravado = request.EsGravado.Value;
 0219            if (request.AcumulaAguinaldo.HasValue) item.AcumulaAguinaldo = request.AcumulaAguinaldo.Value;
 0220            if (request.LlevaAumento.HasValue) item.LlevaAumento = request.LlevaAumento.Value;
 0221            if (request.FormulaCalculo is not null) item.FormulaCalculo = request.FormulaCalculo;
 0222            if (request.OrdenCalculo.HasValue) item.OrdenCalculo = request.OrdenCalculo.Value;
 0223            if (request.ContribuyeWPara53.HasValue) item.ContribuyeWPara53 = request.ContribuyeWPara53.Value;
 0224            if (request.ContribuyeWPara10.HasValue) item.ContribuyeWPara10 = request.ContribuyeWPara10.Value;
 0225            if (request.ContribuyeWPara20.HasValue) item.ContribuyeWPara20 = request.ContribuyeWPara20.Value;
 0226            if (request.ContribuyeWNominal.HasValue) item.ContribuyeWNominal = request.ContribuyeWNominal.Value;
 227
 0228            if (request.ClasificacionSiif is not null &&
 0229                request.ClasificacionSiif != item.ClasificacionSiif)
 230            {
 0231                var nuevoCodigo = ParseCodigo(request.ClasificacionSiif);
 0232                if (nuevoCodigo is null)
 0233                    return BadRequest(ApiResponse<string>.ErrorResult(
 0234                        "Formato de clasificación SIIF inválido. Debe ser NNN.NNN (ej: 042.568)"));
 235
 0236                if (await _context.CatalogoItems.AnyAsync(i =>
 0237                        i.Id != id &&
 0238                        (i.Codigo == nuevoCodigo.Value || i.ClasificacionSiif == request.ClasificacionSiif)))
 0239                    return Conflict(ApiResponse<string>.ErrorResult(
 0240                        $"Ya existe un ítem con la clasificación SIIF '{request.ClasificacionSiif}'"));
 241
 0242                item.ClasificacionSiif = request.ClasificacionSiif;
 0243                item.Codigo = nuevoCodigo.Value;
 244            }
 245
 0246            await _context.SaveChangesAsync();
 247
 0248            return Ok(ApiResponse<CatalogoItemDto>.SuccessResult(MapDto(item)));
 249        }
 0250        catch (DbUpdateException ex) when (EsDuplicadoCodigo(ex))
 251        {
 0252            return Conflict(ApiResponse<string>.ErrorResult(
 0253                $"Ya existe un ítem con la clasificación SIIF '{request.ClasificacionSiif}'"));
 254        }
 0255        catch (Exception ex)
 256        {
 0257            _logger.LogError(ex, "Error al actualizar catálogo ítem {Id}", id);
 0258            return StatusCode(500, ApiResponse<string>.ErrorResult("Error interno del servidor"));
 259        }
 0260    }
 261
 262    [HttpGet("{id:long}/usos")]
 263    [Authorize(Roles = "Administrador")]
 264    [ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status200OK)]
 265    [ProducesResponseType(typeof(ApiResponse<string>), StatusCodes.Status404NotFound)]
 266    [ProducesResponseType(typeof(ApiResponse<string>), StatusCodes.Status500InternalServerError)]
 267    public async Task<IActionResult> GetUsos(long id)
 268    {
 269        try
 270        {
 0271            var item = await _context.CatalogoItems.AsNoTracking()
 0272                .FirstOrDefaultAsync(i => i.Id == id);
 0273            if (item is null)
 0274                return NotFound(ApiResponse<string>.ErrorResult("Ítem no encontrado"));
 275
 0276            var codigoStr = item.Codigo.ToString();
 277
 0278            var liquidacionesHistoricas = await _context.LiquidacionItems
 0279                .CountAsync(li => li.CodigoConcepto == codigoStr);
 280
 0281            var montosPorGrado = await _context.RemuneracionesGrado
 0282                .CountAsync(r => r.ItemId == id);
 283
 0284            var compensacionesActivas = await _context.Set<FAU.Entidades.ConfigCompensacion>()
 0285                .CountAsync(c => c.CatalogoItemId == id && c.Activo);
 286
 0287            var beneficiosActivos = await _context.Set<FAU.Entidades.TipoBeneficio>()
 0288                .CountAsync(tb => tb.CatalogoItemId == id && tb.Activo);
 289
 0290            return Ok(ApiResponse<object>.SuccessResult(new
 0291            {
 0292                liquidacionesHistoricas,
 0293                montosPorGrado,
 0294                compensacionesActivas,
 0295                beneficiosActivos,
 0296                puedeEditarSiif = liquidacionesHistoricas == 0,
 0297                puedeEditarFormula = montosPorGrado == 0
 0298            }));
 299        }
 0300        catch (Exception ex)
 301        {
 0302            _logger.LogError(ex, "Error al obtener usos de catálogo ítem {Id}", id);
 0303            return StatusCode(500, ApiResponse<string>.ErrorResult("Error interno del servidor"));
 304        }
 0305    }
 306
 307    [HttpPatch("{id:long}/vigente")]
 308    [Authorize(Roles = "Administrador")]
 309    [ProducesResponseType(typeof(ApiResponse<CatalogoItemDto>), StatusCodes.Status200OK)]
 310    [ProducesResponseType(typeof(ApiResponse<string>), StatusCodes.Status404NotFound)]
 311    [ProducesResponseType(typeof(ApiResponse<string>), StatusCodes.Status409Conflict)]
 312    [ProducesResponseType(typeof(ApiResponse<string>), StatusCodes.Status500InternalServerError)]
 313    public async Task<IActionResult> CambiarVigente(long id, [FromBody] CambiarVigenteRequest request)
 314    {
 315        try
 316        {
 0317            var item = await _context.CatalogoItems.FindAsync(id);
 0318            if (item is null)
 0319                return NotFound(ApiResponse<string>.ErrorResult("Ítem no encontrado"));
 320
 0321            if (!request.Vigente)
 322            {
 0323                var enUso = await _context.LiquidacionItems
 0324                    .AnyAsync(li => li.CodigoConcepto == item.Codigo.ToString());
 0325                if (enUso)
 0326                    return Conflict(ApiResponse<string>.ErrorResult(
 0327                        $"No se puede desactivar: el ítem '{item.ClasificacionSiif}' está en uso en liquidaciones"));
 328            }
 329
 0330            item.Vigente = request.Vigente;
 0331            await _context.SaveChangesAsync();
 332
 0333            return Ok(ApiResponse<CatalogoItemDto>.SuccessResult(MapDto(item)));
 334        }
 0335        catch (Exception ex)
 336        {
 0337            _logger.LogError(ex, "Error al cambiar vigente de catálogo ítem {Id}", id);
 0338            return StatusCode(500, ApiResponse<string>.ErrorResult("Error interno del servidor"));
 339        }
 0340    }
 341
 342    // ── Helpers ───────────────────────────────────────────────────────────────
 343
 344    private static int? ParseCodigo(string clasificacionSiif)
 345    {
 0346        if (string.IsNullOrWhiteSpace(clasificacionSiif)) return null;
 0347        var sinPunto = clasificacionSiif.Replace(".", "");
 0348        return int.TryParse(sinPunto, out var codigo) ? codigo : null;
 349    }
 350
 351    private static bool EsDuplicadoCodigo(DbUpdateException ex)
 352    {
 0353        var msg = ex.InnerException?.Message ?? ex.Message;
 0354        return msg.Contains("catalogo_items_codigo_key", StringComparison.OrdinalIgnoreCase)
 0355            || msg.Contains("ux_catalogo_items_clasificacion_siif", StringComparison.OrdinalIgnoreCase)
 0356            || msg.Contains("duplicate key", StringComparison.OrdinalIgnoreCase);
 357    }
 358
 4359    private static CatalogoItemDto MapDto(CatalogoItem i) => new()
 4360    {
 4361        Id = i.Id,
 4362        Codigo = i.Codigo,
 4363        Nombre = i.Nombre,
 4364        Tipo = i.Tipo,
 4365        ClasificacionSiif = i.ClasificacionSiif,
 4366        NormaLegal = i.NormaLegal,
 4367        EsGravado = i.EsGravado,
 4368        AcumulaAguinaldo = i.AcumulaAguinaldo,
 4369        LlevaAumento = i.LlevaAumento,
 4370        FormulaCalculo = i.FormulaCalculo,
 4371        OrdenCalculo = i.OrdenCalculo,
 4372        ContribuyeWPara53 = i.ContribuyeWPara53,
 4373        ContribuyeWPara10 = i.ContribuyeWPara10,
 4374        ContribuyeWPara20 = i.ContribuyeWPara20,
 4375        ContribuyeWNominal = i.ContribuyeWNominal,
 4376        Vigente = i.Vigente
 4377    };
 378}
 379
 380public record CambiarVigenteRequest(bool Vigente);