< Summary

Information
Class: FAU.API.Controllers.CompensacionesController
Assembly: FAU.API
File(s): /home/runner/work/Sistema.Liquidacion.BE/Sistema.Liquidacion.BE/FAU.API/Controllers/CompensacionesController.cs
Line coverage
40%
Covered lines: 111
Uncovered lines: 160
Coverable lines: 271
Total lines: 727
Line coverage: 40.9%
Branch coverage
43%
Covered branches: 80
Total branches: 184
Branch coverage: 43.4%
Method coverage

Feature is only available for sponsors

Upgrade to PRO version

Metrics

MethodBranch coverage Crap Score Cyclomatic complexity Line coverage
.ctor(...)100%11100%
GetTipos()100%11100%
GetTipo()100%2262.5%
CreateTipo()87.5%9872.72%
UpdateTipo()50%12860%
DeleteTipo()87.5%10870%
GetConfigs()100%1150%
GetConfig()0%620%
CreateConfig()75%12860%
UpdateConfig()37.5%16850%
DeleteConfig()87.5%10870%
GetLotes()50%8662.5%
GetLote()50%3250%
CreateLote()62.5%13857.14%
UpdateLote()50%391242.85%
AprobarLote()66.66%271253.33%
RechazarLote()0%7280%
RevertirLote()0%7280%
CancelarLote()0%7280%
GetItems()0%4260%
CreateItem()87.5%301662.5%
CreateItemsBulk()0%110100%
CreateItemsBulkCsv()0%156120%
UpdateItem()50%391242.85%
DeleteItem()8.33%301250%

File(s)

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

#LineLine coverage
 1using System.Security.Claims;
 2using FAU.Logica.DTOs;
 3using FAU.Logica.Exceptions;
 4using FAU.Logica.Services;
 5using FAU.Entidades;
 6using Microsoft.AspNetCore.Authorization;
 7using Microsoft.AspNetCore.Mvc;
 8
 9namespace FAU.API.Controllers;
 10
 11[ApiController]
 12public class CompensacionesController : ControllerBase
 13{
 14    private readonly ICompensacionService _service;
 15    private readonly ILogger<CompensacionesController> _logger;
 16
 3417    public CompensacionesController(ICompensacionService service, ILogger<CompensacionesController> logger)
 18    {
 3419        _service = service;
 3420        _logger = logger;
 3421    }
 22
 23    // ── Tipos de Compensación ────────────────────────────────────────────────
 24
 25    /// <summary>
 26    /// Listar tipos de compensación
 27    /// GET /api/tipos-compensacion?activo=
 28    /// </summary>
 29    [HttpGet("api/tipos-compensacion")]
 30    [FAU.API.Security.RequirePermission(PermisoEnum.VerCompensaciones)]
 31    public async Task<IActionResult> GetTipos([FromQuery] bool? activo = null)
 32    {
 33        try
 34        {
 235            var tipos = await _service.GetTiposAsync(activo);
 136            return Ok(ApiResponse<object>.SuccessResult(tipos));
 37        }
 138        catch (Exception ex)
 39        {
 140            _logger.LogError(ex, "Error al obtener tipos de compensación");
 141            return StatusCode(500, ApiResponse<string>.ErrorResult("Error interno del servidor"));
 42        }
 243    }
 44
 45    /// <summary>
 46    /// Obtener tipo de compensación por ID
 47    /// GET /api/tipos-compensacion/{id}
 48    /// </summary>
 49    [HttpGet("api/tipos-compensacion/{id}")]
 50    [FAU.API.Security.RequirePermission(PermisoEnum.VerCompensaciones)]
 51    public async Task<IActionResult> GetTipo(long id)
 52    {
 53        try
 54        {
 255            var tipo = await _service.GetTipoByIdAsync(id);
 256            if (tipo == null)
 157                return NotFound(ApiResponse<string>.ErrorResult("Tipo de compensación no encontrado"));
 58
 159            return Ok(ApiResponse<object>.SuccessResult(tipo));
 60        }
 061        catch (Exception ex)
 62        {
 063            _logger.LogError(ex, "Error al obtener tipo de compensación {Id}", id);
 064            return StatusCode(500, ApiResponse<string>.ErrorResult("Error interno del servidor"));
 65        }
 266    }
 67
 68    /// <summary>
 69    /// Crear tipo de compensación
 70    /// POST /api/tipos-compensacion
 71    /// </summary>
 72    [HttpPost("api/tipos-compensacion")]
 73    [FAU.API.Security.RequirePermission(PermisoEnum.CrearCompensacion)]
 74    public async Task<IActionResult> CreateTipo([FromBody] CreateTipoCompensacionRequest request)
 75    {
 76        try
 77        {
 378            var (success, tipo, error) = await _service.CreateTipoAsync(request);
 379            if (!success)
 80            {
 281                if (error?.Contains("Ya existe", StringComparison.OrdinalIgnoreCase) == true)
 182                    return Conflict(ApiResponse<string>.ErrorResult(error));
 183                return BadRequest(ApiResponse<string>.ErrorResult(error ?? "Error al crear tipo"));
 84            }
 85
 186            return CreatedAtAction(nameof(GetTipo), new { id = tipo!.Id },
 187                ApiResponse<object>.SuccessResult(new { id = tipo.Id, creado = true }, "Tipo creado exitosamente"));
 88        }
 089        catch (Exception ex)
 90        {
 091            _logger.LogError(ex, "Error al crear tipo de compensación");
 092            return StatusCode(500, ApiResponse<string>.ErrorResult("Error interno del servidor"));
 93        }
 394    }
 95
 96    /// <summary>
 97    /// Actualizar tipo de compensación
 98    /// PUT /api/tipos-compensacion/{id}
 99    /// </summary>
 100    [HttpPut("api/tipos-compensacion/{id}")]
 101    [FAU.API.Security.RequirePermission(PermisoEnum.ActualizarCompensacion)]
 102    public async Task<IActionResult> UpdateTipo(long id, [FromBody] UpdateTipoCompensacionRequest request)
 103    {
 104        try
 105        {
 2106            var (success, _, error) = await _service.UpdateTipoAsync(id, request);
 2107            if (!success)
 108            {
 1109                if (error?.Contains("no encontrado", StringComparison.OrdinalIgnoreCase) == true)
 1110                    return NotFound(ApiResponse<string>.ErrorResult(error));
 0111                return BadRequest(ApiResponse<string>.ErrorResult(error ?? "Error al actualizar tipo"));
 112            }
 113
 1114            return Ok(ApiResponse<object>.SuccessResult(new { actualizado = true }, "Tipo actualizado exitosamente"));
 115        }
 0116        catch (Exception ex)
 117        {
 0118            _logger.LogError(ex, "Error al actualizar tipo de compensación {Id}", id);
 0119            return StatusCode(500, ApiResponse<string>.ErrorResult("Error interno del servidor"));
 120        }
 2121    }
 122
 123    /// <summary>
 124    /// Eliminar tipo de compensación
 125    /// DELETE /api/tipos-compensacion/{id}
 126    /// </summary>
 127    [HttpDelete("api/tipos-compensacion/{id}")]
 128    public async Task<IActionResult> DeleteTipo(long id)
 129    {
 130        try
 131        {
 4132            var (success, error) = await _service.DeleteTipoAsync(id);
 4133            if (!success)
 134            {
 3135                if (error?.Contains("no encontrado", StringComparison.OrdinalIgnoreCase) == true)
 1136                    return NotFound(ApiResponse<string>.ErrorResult(error));
 2137                return Conflict(ApiResponse<string>.ErrorResult(error ?? "Error al eliminar tipo"));
 138            }
 139
 1140            return NoContent();
 141        }
 0142        catch (Exception ex)
 143        {
 0144            _logger.LogError(ex, "Error al eliminar tipo de compensación {Id}", id);
 0145            return StatusCode(500, ApiResponse<string>.ErrorResult("Error interno del servidor"));
 146        }
 4147    }
 148
 149    // ── Configuraciones de Compensación ─────────────────────────────────────
 150
 151    /// <summary>
 152    /// Listar configuraciones de compensación
 153    /// GET /api/config-compensaciones?tipoId=&regimenId=&gradoId=&activo=
 154    /// </summary>
 155    [HttpGet("api/config-compensaciones")]
 156    [FAU.API.Security.RequirePermission(PermisoEnum.VerCompensaciones)]
 157    public async Task<IActionResult> GetConfigs(
 158        [FromQuery] long? tipoId = null,
 159        [FromQuery] long? regimenId = null,
 160        [FromQuery] long? gradoId = null,
 161        [FromQuery] bool? activo = null)
 162    {
 163        try
 164        {
 1165            var configs = await _service.GetConfigsAsync(tipoId, regimenId, gradoId, activo);
 1166            return Ok(ApiResponse<object>.SuccessResult(configs));
 167        }
 0168        catch (Exception ex)
 169        {
 0170            _logger.LogError(ex, "Error al obtener configuraciones de compensación");
 0171            return StatusCode(500, ApiResponse<string>.ErrorResult("Error interno del servidor"));
 172        }
 1173    }
 174
 175    /// <summary>
 176    /// Obtener configuración por ID (incluye tope, fórmula, unidad)
 177    /// GET /api/config-compensaciones/{id}
 178    /// </summary>
 179    [HttpGet("api/config-compensaciones/{id}")]
 180    [FAU.API.Security.RequirePermission(PermisoEnum.VerCompensaciones)]
 181    public async Task<IActionResult> GetConfig(long id)
 182    {
 183        try
 184        {
 0185            var config = await _service.GetConfigByIdAsync(id);
 0186            if (config == null)
 0187                return NotFound(ApiResponse<string>.ErrorResult("Configuración no encontrada"));
 0188            return Ok(ApiResponse<object>.SuccessResult(config));
 189        }
 0190        catch (Exception ex)
 191        {
 0192            _logger.LogError(ex, "Error al obtener configuración {Id}", id);
 0193            return StatusCode(500, ApiResponse<string>.ErrorResult("Error interno del servidor"));
 194        }
 0195    }
 196
 197    /// <summary>
 198    /// Crear configuración de compensación
 199    /// POST /api/config-compensaciones
 200    /// </summary>
 201    [HttpPost("api/config-compensaciones")]
 202    [FAU.API.Security.RequirePermission(PermisoEnum.CrearConfigCompensacion)]
 203    public async Task<IActionResult> CreateConfig([FromBody] CreateConfigCompensacionRequest request)
 204    {
 205        try
 206        {
 2207            var (success, config, error) = await _service.CreateConfigAsync(request);
 2208            if (!success)
 209            {
 1210                if (error?.Contains("No se encontró", StringComparison.OrdinalIgnoreCase) == true)
 0211                    return BadRequest(ApiResponse<string>.ErrorResult(error));
 1212                return BadRequest(ApiResponse<string>.ErrorResult(error ?? "Error al crear configuración"));
 213            }
 214
 1215            return StatusCode(201, ApiResponse<object>.SuccessResult(new { id = config!.Id, creado = true }, "Configurac
 216        }
 0217        catch (Exception ex)
 218        {
 0219            _logger.LogError(ex, "Error al crear configuración de compensación");
 0220            return StatusCode(500, ApiResponse<string>.ErrorResult("Error interno del servidor"));
 221        }
 2222    }
 223
 224    /// <summary>
 225    /// Actualizar configuración de compensación
 226    /// PUT /api/config-compensaciones/{id}
 227    /// </summary>
 228    [HttpPut("api/config-compensaciones/{id}")]
 229    [FAU.API.Security.RequirePermission(PermisoEnum.ActualizarConfigComprensacion)]
 230    public async Task<IActionResult> UpdateConfig(long id, [FromBody] UpdateConfigCompensacionRequest request)
 231    {
 232        try
 233        {
 1234            var (success, _, error) = await _service.UpdateConfigAsync(id, request);
 1235            if (!success)
 236            {
 1237                if (error?.Contains("no encontrada", StringComparison.OrdinalIgnoreCase) == true)
 1238                    return NotFound(ApiResponse<string>.ErrorResult(error));
 0239                return BadRequest(ApiResponse<string>.ErrorResult(error ?? "Error al actualizar configuración"));
 240            }
 241
 0242            return Ok(ApiResponse<object>.SuccessResult(new { actualizado = true }, "Configuración actualizada exitosame
 243        }
 0244        catch (Exception ex)
 245        {
 0246            _logger.LogError(ex, "Error al actualizar configuración {Id}", id);
 0247            return StatusCode(500, ApiResponse<string>.ErrorResult("Error interno del servidor"));
 248        }
 1249    }
 250
 251    /// <summary>
 252    /// Eliminar configuración de compensación
 253    /// DELETE /api/config-compensaciones/{id}
 254    /// </summary>
 255    [HttpDelete("api/config-compensaciones/{id}")]
 256    public async Task<IActionResult> DeleteConfig(long id)
 257    {
 258        try
 259        {
 3260            var (success, error) = await _service.DeleteConfigAsync(id);
 3261            if (!success)
 262            {
 2263                if (error?.Contains("no encontrada", StringComparison.OrdinalIgnoreCase) == true)
 1264                    return NotFound(ApiResponse<string>.ErrorResult(error));
 1265                return Conflict(ApiResponse<string>.ErrorResult(error ?? "Error al eliminar configuración"));
 266            }
 267
 1268            return NoContent();
 269        }
 0270        catch (Exception ex)
 271        {
 0272            _logger.LogError(ex, "Error al eliminar configuración {Id}", id);
 0273            return StatusCode(500, ApiResponse<string>.ErrorResult("Error interno del servidor"));
 274        }
 3275    }
 276
 277    // ── Lotes de Compensación ────────────────────────────────────────────────
 278
 279    /// <summary>
 280    /// Listar lotes de compensación con paginación
 281    /// GET /api/lotes-compensacion?tipoId=&anio=&mes=&estado=&page=&pageSize=
 282    /// </summary>
 283    [HttpGet("api/lotes-compensacion")]
 284    [FAU.API.Security.RequirePermission(PermisoEnum.VerCompensaciones)]
 285    public async Task<IActionResult> GetLotes(
 286        [FromQuery] long? tipoId = null,
 287        [FromQuery] int? anio = null,
 288        [FromQuery] int? mes = null,
 289        [FromQuery] string? estado = null,
 290        [FromQuery] int page = 1,
 291        [FromQuery] int pageSize = 20)
 292    {
 293        try
 294        {
 1295            if (page < 1) page = 1;
 1296            if (pageSize < 1 || pageSize > 100) pageSize = 20;
 297
 1298            var resultado = await _service.GetLotesPagedAsync(page, pageSize, tipoId, anio, mes, estado);
 1299            return Ok(ApiResponse<object>.SuccessResult(resultado));
 300        }
 0301        catch (Exception ex)
 302        {
 0303            _logger.LogError(ex, "Error al obtener lotes de compensación");
 0304            return StatusCode(500, ApiResponse<string>.ErrorResult("Error interno del servidor"));
 305        }
 1306    }
 307
 308    /// <summary>
 309    /// Obtener lote con sus ítems
 310    /// GET /api/lotes-compensacion/{id}
 311    /// </summary>
 312    [HttpGet("api/lotes-compensacion/{id}")]
 313    [FAU.API.Security.RequirePermission(PermisoEnum.VerCompensaciones)]
 314    public async Task<IActionResult> GetLote(long id)
 315    {
 316        try
 317        {
 1318            var lote = await _service.GetLoteByIdAsync(id);
 1319            if (lote == null)
 1320                return NotFound(ApiResponse<string>.ErrorResult("Lote no encontrado"));
 321
 0322            return Ok(ApiResponse<object>.SuccessResult(lote));
 323        }
 0324        catch (Exception ex)
 325        {
 0326            _logger.LogError(ex, "Error al obtener lote {Id}", id);
 0327            return StatusCode(500, ApiResponse<string>.ErrorResult("Error interno del servidor"));
 328        }
 1329    }
 330
 331    /// <summary>
 332    /// Crear lote de compensación
 333    /// POST /api/lotes-compensacion
 334    /// </summary>
 335    [HttpPost("api/lotes-compensacion")]
 336    [FAU.API.Security.RequirePermission(PermisoEnum.CrearLoteCompensacion)]
 337    public async Task<IActionResult> CreateLote([FromBody] CreateLoteCompensacionRequest request)
 338    {
 339        try
 340        {
 2341            var userIdStr = User.FindFirst(ClaimTypes.NameIdentifier)?.Value;
 2342            if (!long.TryParse(userIdStr, out var usuarioId))
 0343                return Unauthorized(ApiResponse<string>.ErrorResult("No se pudo identificar al usuario"));
 344
 2345            var (success, lote, error) = await _service.CreateLoteAsync(usuarioId, request);
 2346            if (!success)
 1347                return BadRequest(ApiResponse<string>.ErrorResult(error ?? "Error al crear lote"));
 348
 1349            return CreatedAtAction(nameof(GetLote), new { id = lote!.Id },
 1350                ApiResponse<object>.SuccessResult(new { id = lote.Id, creado = true }, "Lote creado exitosamente"));
 351        }
 0352        catch (PeriodoBloqueadoException ex)
 353        {
 0354            return Conflict(ApiResponse<string>.ErrorResult(ex.Message));
 355        }
 0356        catch (Exception ex)
 357        {
 0358            _logger.LogError(ex, "Error al crear lote de compensación");
 0359            return StatusCode(500, ApiResponse<string>.ErrorResult("Error interno del servidor"));
 360        }
 2361    }
 362
 363    /// <summary>
 364    /// Actualizar lote (solo en estado borrador)
 365    /// PUT /api/lotes-compensacion/{id}
 366    /// </summary>
 367    [HttpPut("api/lotes-compensacion/{id}")]
 368    [FAU.API.Security.RequirePermission(PermisoEnum.ActualizarLoteCompensacion)]
 369    public async Task<IActionResult> UpdateLote(long id, [FromBody] UpdateLoteCompensacionRequest request)
 370    {
 371        try
 372        {
 1373            var (success, _, error) = await _service.UpdateLoteAsync(id, request);
 1374            if (!success)
 375            {
 1376                if (error?.Contains("no encontrado", StringComparison.OrdinalIgnoreCase) == true)
 0377                    return NotFound(ApiResponse<string>.ErrorResult(error));
 1378                if (error?.Contains("estado", StringComparison.OrdinalIgnoreCase) == true)
 1379                    return Conflict(ApiResponse<string>.ErrorResult(error));
 0380                return BadRequest(ApiResponse<string>.ErrorResult(error ?? "Error al actualizar lote"));
 381            }
 382
 0383            return Ok(ApiResponse<object>.SuccessResult(new { actualizado = true }, "Lote actualizado exitosamente"));
 384        }
 0385        catch (PeriodoBloqueadoException ex)
 386        {
 0387            return Conflict(ApiResponse<string>.ErrorResult(ex.Message));
 388        }
 0389        catch (Exception ex)
 390        {
 0391            _logger.LogError(ex, "Error al actualizar lote {Id}", id);
 0392            return StatusCode(500, ApiResponse<string>.ErrorResult("Error interno del servidor"));
 393        }
 1394    }
 395
 396    /// <summary>
 397    /// Aprobar lote (debe estar en estado pendiente)
 398    /// PUT /api/lotes-compensacion/{id}/aprobar
 399    /// </summary>
 400    [HttpPut("api/lotes-compensacion/{id}/aprobar")]
 401    [FAU.API.Security.RequirePermission(PermisoEnum.AprobarLoteCompensacion)]
 402    public async Task<IActionResult> AprobarLote(long id)
 403    {
 404        try
 405        {
 2406            var userIdStr = User.FindFirst(ClaimTypes.NameIdentifier)?.Value;
 2407            if (!long.TryParse(userIdStr, out var aprobadoPorId))
 0408                return Unauthorized(ApiResponse<string>.ErrorResult("No se pudo identificar al usuario"));
 409
 2410            var (success, _, error) = await _service.AprobarLoteAsync(id, aprobadoPorId);
 2411            if (!success)
 412            {
 1413                if (error?.Contains("no encontrado", StringComparison.OrdinalIgnoreCase) == true)
 0414                    return NotFound(ApiResponse<string>.ErrorResult(error));
 1415                return Conflict(ApiResponse<string>.ErrorResult(error ?? "Error al aprobar lote"));
 416            }
 417
 1418            return Ok(ApiResponse<object>.SuccessResult(new { aprobado = true }, "Lote aprobado exitosamente"));
 419        }
 0420        catch (PeriodoBloqueadoException ex)
 421        {
 0422            return Conflict(ApiResponse<string>.ErrorResult(ex.Message));
 423        }
 0424        catch (Exception ex)
 425        {
 0426            _logger.LogError(ex, "Error al aprobar lote {Id}", id);
 0427            return StatusCode(500, ApiResponse<string>.ErrorResult("Error interno del servidor"));
 428        }
 2429    }
 430
 431    /// <summary>
 432    /// Rechazar lote (desde cualquier estado editable)
 433    /// PUT /api/lotes-compensacion/{id}/rechazar
 434    /// </summary>
 435    [HttpPut("api/lotes-compensacion/{id}/rechazar")]
 436    [FAU.API.Security.RequirePermission(PermisoEnum.RechazarLoteCompensacion)]
 437    public async Task<IActionResult> RechazarLote(long id)
 438    {
 439        try
 440        {
 0441            var (success, _, error) = await _service.RechazarLoteAsync(id);
 0442            if (!success)
 443            {
 0444                if (error?.Contains("no encontrado", StringComparison.OrdinalIgnoreCase) == true)
 0445                    return NotFound(ApiResponse<string>.ErrorResult(error));
 0446                return Conflict(ApiResponse<string>.ErrorResult(error ?? "Error al rechazar lote"));
 447            }
 0448            return Ok(ApiResponse<object>.SuccessResult(new { rechazado = true }, "Lote rechazado"));
 449        }
 0450        catch (PeriodoBloqueadoException ex)
 451        {
 0452            return Conflict(ApiResponse<string>.ErrorResult(ex.Message));
 453        }
 0454        catch (Exception ex)
 455        {
 0456            _logger.LogError(ex, "Error al rechazar lote {Id}", id);
 0457            return StatusCode(500, ApiResponse<string>.ErrorResult("Error interno del servidor"));
 458        }
 0459    }
 460
 461    /// <summary>
 462    /// Revertir lote aprobado a borrador (solo admin, antes de exportar)
 463    /// PUT /api/lotes-compensacion/{id}/revertir
 464    /// </summary>
 465    [HttpPut("api/lotes-compensacion/{id}/revertir")]
 466    [FAU.API.Security.RequirePermission(PermisoEnum.RevertirLoteCompensacion)]
 467    public async Task<IActionResult> RevertirLote(long id)
 468    {
 469        try
 470        {
 0471            var (success, _, error) = await _service.RevertirABorradorAsync(id);
 0472            if (!success)
 473            {
 0474                if (error?.Contains("no encontrado", StringComparison.OrdinalIgnoreCase) == true)
 0475                    return NotFound(ApiResponse<string>.ErrorResult(error));
 0476                return Conflict(ApiResponse<string>.ErrorResult(error ?? "Error al revertir lote"));
 477            }
 0478            return Ok(ApiResponse<object>.SuccessResult(new { revertido = true }, "Lote revertido a borrador"));
 479        }
 0480        catch (PeriodoBloqueadoException ex)
 481        {
 0482            return Conflict(ApiResponse<string>.ErrorResult(ex.Message));
 483        }
 0484        catch (Exception ex)
 485        {
 0486            _logger.LogError(ex, "Error al revertir lote {Id}", id);
 0487            return StatusCode(500, ApiResponse<string>.ErrorResult("Error interno del servidor"));
 488        }
 0489    }
 490
 491    /// <summary>
 492    /// Cancelar y eliminar un lote en borrador
 493    /// DELETE /api/lotes-compensacion/{id}
 494    /// </summary>
 495    [HttpDelete("api/lotes-compensacion/{id}")]
 496    [FAU.API.Security.RequirePermission(PermisoEnum.EliminarLoteCompensacion)]
 497    public async Task<IActionResult> CancelarLote(long id)
 498    {
 499        try
 500        {
 0501            var (success, error) = await _service.CancelarLoteAsync(id);
 0502            if (!success)
 503            {
 0504                if (error?.Contains("no encontrado", StringComparison.OrdinalIgnoreCase) == true)
 0505                    return NotFound(ApiResponse<string>.ErrorResult(error));
 0506                return Conflict(ApiResponse<string>.ErrorResult(error ?? "Error al cancelar lote"));
 507            }
 0508            return Ok(ApiResponse<object>.SuccessResult(new { cancelado = true }, "Lote cancelado y eliminado"));
 509        }
 0510        catch (PeriodoBloqueadoException ex)
 511        {
 0512            return Conflict(ApiResponse<string>.ErrorResult(ex.Message));
 513        }
 0514        catch (Exception ex)
 515        {
 0516            _logger.LogError(ex, "Error al cancelar lote {Id}", id);
 0517            return StatusCode(500, ApiResponse<string>.ErrorResult("Error interno del servidor"));
 518        }
 0519    }
 520
 521    // ── Ítems de Lote ────────────────────────────────────────────────────────
 522
 523    /// <summary>
 524    /// Listar ítems de un lote con paginación
 525    /// GET /api/lotes-compensacion/{id}/items?page=1&pageSize=50
 526    /// </summary>
 527    [HttpGet("api/lotes-compensacion/{id}/items")]
 528    [FAU.API.Security.RequirePermission(PermisoEnum.VerCompensaciones)]
 529    public async Task<IActionResult> GetItems(long id,
 530        [FromQuery] int page = 1,
 531        [FromQuery] int pageSize = 50)
 532    {
 533        try
 534        {
 0535            if (page < 1) page = 1;
 0536            if (pageSize < 1 || pageSize > 200) pageSize = 50;
 0537            var resultado = await _service.GetItemsPagedAsync(id, page, pageSize);
 0538            return Ok(ApiResponse<object>.SuccessResult(resultado));
 539        }
 0540        catch (Exception ex)
 541        {
 0542            _logger.LogError(ex, "Error al obtener ítems del lote {Id}", id);
 0543            return StatusCode(500, ApiResponse<string>.ErrorResult("Error interno del servidor"));
 544        }
 0545    }
 546
 547    /// <summary>
 548    /// Agregar ítem a lote (debe estar en estado borrador)
 549    /// POST /api/lotes-compensacion/{id}/items
 550    /// </summary>
 551    [HttpPost("api/lotes-compensacion/{id}/items")]
 552    [FAU.API.Security.RequirePermission(PermisoEnum.CrearItemLoteCompensacion)]
 553    public async Task<IActionResult> CreateItem(long id, [FromBody] CreateItemLoteRequest request)
 554    {
 555        try
 556        {
 4557            var (success, item, error) = await _service.CreateItemAsync(id, request);
 4558            if (!success)
 559            {
 3560                if (error?.Contains("no encontrado", StringComparison.OrdinalIgnoreCase) == true)
 0561                    return NotFound(ApiResponse<string>.ErrorResult(error));
 3562                if (error?.Contains("Ya existe", StringComparison.OrdinalIgnoreCase) == true)
 1563                    return Conflict(ApiResponse<string>.ErrorResult(error));
 2564                if (error?.Contains("estado", StringComparison.OrdinalIgnoreCase) == true)
 1565                    return Conflict(ApiResponse<string>.ErrorResult(error));
 1566                return BadRequest(ApiResponse<string>.ErrorResult(error ?? "Error al agregar ítem"));
 567            }
 568
 1569            return StatusCode(201, ApiResponse<object>.SuccessResult(new { id = item!.Id, creado = true }, "Ítem agregad
 570        }
 0571        catch (PeriodoBloqueadoException ex)
 572        {
 0573            return Conflict(ApiResponse<string>.ErrorResult(ex.Message));
 574        }
 0575        catch (Exception ex)
 576        {
 0577            _logger.LogError(ex, "Error al agregar ítem al lote {LoteId}", id);
 0578            return StatusCode(500, ApiResponse<string>.ErrorResult("Error interno del servidor"));
 579        }
 4580    }
 581
 582    /// <summary>
 583    /// Carga masiva de ítems vía JSON array
 584    /// POST /api/lotes-compensacion/{id}/items/bulk?modo=reemplazar|agregar&amp;dryRun=false
 585    /// modo=reemplazar (default): elimina todos los ítems existentes antes de insertar (comportamiento legacy)
 586    /// modo=agregar: agrega sin borrar los existentes (para correcciones parciales)
 587    /// dryRun=true: valida sin persistir — devuelve el mismo resultado pero sin guardar nada
 588    /// </summary>
 589    [HttpPost("api/lotes-compensacion/{id}/items/bulk")]
 590    [FAU.API.Security.RequirePermission(PermisoEnum.CrearItemLoteCompensacion)]
 591    public async Task<IActionResult> CreateItemsBulk(
 592        long id,
 593        [FromBody] List<CreateItemLoteRequest> items,
 594        [FromQuery] string modo = "reemplazar",
 595        [FromQuery] bool dryRun = false)
 596    {
 597        try
 598        {
 0599            if (items == null || items.Count == 0)
 0600                return BadRequest(ApiResponse<string>.ErrorResult("La lista de ítems no puede estar vacía"));
 601
 0602            if (modo != "reemplazar" && modo != "agregar")
 0603                return BadRequest(ApiResponse<string>.ErrorResult("modo debe ser 'reemplazar' o 'agregar'"));
 604
 0605            var resultado = await _service.CreateItemsBulkAsync(id, items, modo, dryRun);
 0606            var msg = dryRun
 0607                ? $"Dry run: {resultado.Exitosos} válidos, {resultado.Fallidos} con errores (sin cambios)"
 0608                : $"Bulk {modo}: {resultado.Exitosos} exitosos, {resultado.Fallidos} fallidos";
 0609            return Ok(ApiResponse<object>.SuccessResult(resultado, msg));
 610        }
 0611        catch (PeriodoBloqueadoException ex)
 612        {
 0613            return Conflict(ApiResponse<string>.ErrorResult(ex.Message));
 614        }
 0615        catch (Exception ex)
 616        {
 0617            _logger.LogError(ex, "Error en bulk import lote {LoteId}", id);
 0618            return StatusCode(500, ApiResponse<string>.ErrorResult("Error interno del servidor"));
 619        }
 0620    }
 621
 622    /// <summary>
 623    /// Carga masiva de ítems vía archivo CSV (multipart/form-data)
 624    /// POST /api/lotes-compensacion/{id}/items/bulk-csv?modo=reemplazar|agregar&amp;dryRun=false
 625    /// Formato CSV: cedula,monto,unidad_cantidad,funcion,incidencia,observaciones
 626    /// SAR/PAN requieren columna 'funcion' con valor CA o SA
 627    /// </summary>
 628    [HttpPost("api/lotes-compensacion/{id}/items/bulk-csv")]
 629    [FAU.API.Security.RequirePermission(PermisoEnum.CrearItemLoteCompensacion)]
 630    public async Task<IActionResult> CreateItemsBulkCsv(
 631        long id,
 632        IFormFile archivo,
 633        [FromQuery] string modo = "reemplazar",
 634        [FromQuery] bool dryRun = false)
 635    {
 636        try
 637        {
 0638            if (archivo == null || archivo.Length == 0)
 0639                return BadRequest(ApiResponse<string>.ErrorResult("Debe adjuntar un archivo CSV"));
 640
 0641            if (!archivo.FileName.EndsWith(".csv", StringComparison.OrdinalIgnoreCase))
 0642                return BadRequest(ApiResponse<string>.ErrorResult("El archivo debe tener extensión .csv"));
 643
 0644            if (modo != "reemplazar" && modo != "agregar")
 0645                return BadRequest(ApiResponse<string>.ErrorResult("modo debe ser 'reemplazar' o 'agregar'"));
 646
 0647            using var stream = archivo.OpenReadStream();
 0648            var resultado = await _service.CreateItemsFromCsvAsync(id, stream, modo, dryRun);
 649
 0650            var msg = dryRun
 0651                ? $"Dry run: {resultado.Exitosos} válidos, {resultado.Fallidos} con errores (sin cambios)"
 0652                : $"CSV {modo}: {resultado.Exitosos} exitosos, {resultado.Fallidos} fallidos";
 0653            return Ok(ApiResponse<object>.SuccessResult(resultado, msg));
 654        }
 0655        catch (Exception ex)
 656        {
 0657            _logger.LogError(ex, "Error en bulk CSV lote {LoteId}", id);
 0658            return StatusCode(500, ApiResponse<string>.ErrorResult("Error interno del servidor"));
 659        }
 0660    }
 661
 662    /// <summary>
 663    /// Actualizar ítem de lote (debe estar en estado borrador)
 664    /// PUT /api/lotes-compensacion/{id}/items/{itemId}
 665    /// </summary>
 666    [HttpPut("api/lotes-compensacion/{id}/items/{itemId}")]
 667    [FAU.API.Security.RequirePermission(PermisoEnum.ActualizarItemLoteCompensacion)]
 668    public async Task<IActionResult> UpdateItem(long id, long itemId, [FromBody] UpdateItemLoteRequest request)
 669    {
 670        try
 671        {
 1672            var (success, _, error) = await _service.UpdateItemAsync(id, itemId, request);
 1673            if (!success)
 674            {
 1675                if (error?.Contains("no encontrado", StringComparison.OrdinalIgnoreCase) == true)
 0676                    return NotFound(ApiResponse<string>.ErrorResult(error));
 1677                if (error?.Contains("estado", StringComparison.OrdinalIgnoreCase) == true)
 1678                    return Conflict(ApiResponse<string>.ErrorResult(error));
 0679                return BadRequest(ApiResponse<string>.ErrorResult(error ?? "Error al actualizar ítem"));
 680            }
 681
 0682            return Ok(ApiResponse<object>.SuccessResult(new { actualizado = true }, "Ítem actualizado exitosamente"));
 683        }
 0684        catch (PeriodoBloqueadoException ex)
 685        {
 0686            return Conflict(ApiResponse<string>.ErrorResult(ex.Message));
 687        }
 0688        catch (Exception ex)
 689        {
 0690            _logger.LogError(ex, "Error al actualizar ítem {ItemId} del lote {LoteId}", itemId, id);
 0691            return StatusCode(500, ApiResponse<string>.ErrorResult("Error interno del servidor"));
 692        }
 1693    }
 694
 695    /// <summary>
 696    /// Eliminar ítem de lote (debe estar en estado borrador)
 697    /// DELETE /api/lotes-compensacion/{id}/items/{itemId}
 698    /// </summary>
 699    [HttpDelete("api/lotes-compensacion/{id}/items/{itemId}")]
 700    [FAU.API.Security.RequirePermission(PermisoEnum.EliminarItemLoteCompensacion)]
 701    public async Task<IActionResult> DeleteItem(long id, long itemId)
 702    {
 703        try
 704        {
 2705            var (success, error) = await _service.DeleteItemAsync(id, itemId);
 1706            if (!success)
 707            {
 0708                if (error?.Contains("no encontrado", StringComparison.OrdinalIgnoreCase) == true)
 0709                    return NotFound(ApiResponse<string>.ErrorResult(error));
 0710                if (error?.Contains("estado", StringComparison.OrdinalIgnoreCase) == true)
 0711                    return Conflict(ApiResponse<string>.ErrorResult(error));
 0712                return BadRequest(ApiResponse<string>.ErrorResult(error ?? "Error al eliminar ítem"));
 713            }
 714
 1715            return Ok(ApiResponse<object>.SuccessResult(new { eliminado = true }, "Ítem eliminado exitosamente"));
 716        }
 0717        catch (PeriodoBloqueadoException ex)
 718        {
 0719            return Conflict(ApiResponse<string>.ErrorResult(ex.Message));
 720        }
 1721        catch (Exception ex)
 722        {
 1723            _logger.LogError(ex, "Error al eliminar ítem {ItemId} del lote {LoteId}", itemId, id);
 1724            return StatusCode(500, ApiResponse<string>.ErrorResult("Error interno del servidor"));
 725        }
 2726    }
 727}