< 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
43%
Covered lines: 111
Uncovered lines: 144
Coverable lines: 255
Total lines: 695
Line coverage: 43.5%
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%301250%
AprobarLote()66.66%271253.33%
RechazarLote()0%7280%
RevertirLote()0%7280%
CancelarLote()0%7280%
GetItems()0%4260%
CreateItem()87.5%221671.42%
CreateItemsBulk()0%110100%
CreateItemsBulkCsv()0%156120%
UpdateItem()50%301250%
DeleteItem()8.33%221258.33%

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 (Exception ex)
 386        {
 0387            _logger.LogError(ex, "Error al actualizar lote {Id}", id);
 0388            return StatusCode(500, ApiResponse<string>.ErrorResult("Error interno del servidor"));
 389        }
 1390    }
 391
 392    /// <summary>
 393    /// Aprobar lote (debe estar en estado pendiente)
 394    /// PUT /api/lotes-compensacion/{id}/aprobar
 395    /// </summary>
 396    [HttpPut("api/lotes-compensacion/{id}/aprobar")]
 397    [FAU.API.Security.RequirePermission(PermisoEnum.AprobarLoteCompensacion)]
 398    public async Task<IActionResult> AprobarLote(long id)
 399    {
 400        try
 401        {
 2402            var userIdStr = User.FindFirst(ClaimTypes.NameIdentifier)?.Value;
 2403            if (!long.TryParse(userIdStr, out var aprobadoPorId))
 0404                return Unauthorized(ApiResponse<string>.ErrorResult("No se pudo identificar al usuario"));
 405
 2406            var (success, _, error) = await _service.AprobarLoteAsync(id, aprobadoPorId);
 2407            if (!success)
 408            {
 1409                if (error?.Contains("no encontrado", StringComparison.OrdinalIgnoreCase) == true)
 0410                    return NotFound(ApiResponse<string>.ErrorResult(error));
 1411                return Conflict(ApiResponse<string>.ErrorResult(error ?? "Error al aprobar lote"));
 412            }
 413
 1414            return Ok(ApiResponse<object>.SuccessResult(new { aprobado = true }, "Lote aprobado exitosamente"));
 415        }
 0416        catch (PeriodoBloqueadoException ex)
 417        {
 0418            return Conflict(ApiResponse<string>.ErrorResult(ex.Message));
 419        }
 0420        catch (Exception ex)
 421        {
 0422            _logger.LogError(ex, "Error al aprobar lote {Id}", id);
 0423            return StatusCode(500, ApiResponse<string>.ErrorResult("Error interno del servidor"));
 424        }
 2425    }
 426
 427    /// <summary>
 428    /// Rechazar lote (desde cualquier estado editable)
 429    /// PUT /api/lotes-compensacion/{id}/rechazar
 430    /// </summary>
 431    [HttpPut("api/lotes-compensacion/{id}/rechazar")]
 432    [FAU.API.Security.RequirePermission(PermisoEnum.RechazarLoteCompensacion)]
 433    public async Task<IActionResult> RechazarLote(long id)
 434    {
 435        try
 436        {
 0437            var (success, _, error) = await _service.RechazarLoteAsync(id);
 0438            if (!success)
 439            {
 0440                if (error?.Contains("no encontrado", StringComparison.OrdinalIgnoreCase) == true)
 0441                    return NotFound(ApiResponse<string>.ErrorResult(error));
 0442                return Conflict(ApiResponse<string>.ErrorResult(error ?? "Error al rechazar lote"));
 443            }
 0444            return Ok(ApiResponse<object>.SuccessResult(new { rechazado = true }, "Lote rechazado"));
 445        }
 0446        catch (Exception ex)
 447        {
 0448            _logger.LogError(ex, "Error al rechazar lote {Id}", id);
 0449            return StatusCode(500, ApiResponse<string>.ErrorResult("Error interno del servidor"));
 450        }
 0451    }
 452
 453    /// <summary>
 454    /// Revertir lote aprobado a borrador (solo admin, antes de exportar)
 455    /// PUT /api/lotes-compensacion/{id}/revertir
 456    /// </summary>
 457    [HttpPut("api/lotes-compensacion/{id}/revertir")]
 458    [FAU.API.Security.RequirePermission(PermisoEnum.RevertirLoteCompensacion)]
 459    public async Task<IActionResult> RevertirLote(long id)
 460    {
 461        try
 462        {
 0463            var (success, _, error) = await _service.RevertirABorradorAsync(id);
 0464            if (!success)
 465            {
 0466                if (error?.Contains("no encontrado", StringComparison.OrdinalIgnoreCase) == true)
 0467                    return NotFound(ApiResponse<string>.ErrorResult(error));
 0468                return Conflict(ApiResponse<string>.ErrorResult(error ?? "Error al revertir lote"));
 469            }
 0470            return Ok(ApiResponse<object>.SuccessResult(new { revertido = true }, "Lote revertido a borrador"));
 471        }
 0472        catch (Exception ex)
 473        {
 0474            _logger.LogError(ex, "Error al revertir lote {Id}", id);
 0475            return StatusCode(500, ApiResponse<string>.ErrorResult("Error interno del servidor"));
 476        }
 0477    }
 478
 479    /// <summary>
 480    /// Cancelar y eliminar un lote en borrador
 481    /// DELETE /api/lotes-compensacion/{id}
 482    /// </summary>
 483    [HttpDelete("api/lotes-compensacion/{id}")]
 484    [FAU.API.Security.RequirePermission(PermisoEnum.EliminarLoteCompensacion)]
 485    public async Task<IActionResult> CancelarLote(long id)
 486    {
 487        try
 488        {
 0489            var (success, error) = await _service.CancelarLoteAsync(id);
 0490            if (!success)
 491            {
 0492                if (error?.Contains("no encontrado", StringComparison.OrdinalIgnoreCase) == true)
 0493                    return NotFound(ApiResponse<string>.ErrorResult(error));
 0494                return Conflict(ApiResponse<string>.ErrorResult(error ?? "Error al cancelar lote"));
 495            }
 0496            return Ok(ApiResponse<object>.SuccessResult(new { cancelado = true }, "Lote cancelado y eliminado"));
 497        }
 0498        catch (Exception ex)
 499        {
 0500            _logger.LogError(ex, "Error al cancelar lote {Id}", id);
 0501            return StatusCode(500, ApiResponse<string>.ErrorResult("Error interno del servidor"));
 502        }
 0503    }
 504
 505    // ── Ítems de Lote ────────────────────────────────────────────────────────
 506
 507    /// <summary>
 508    /// Listar ítems de un lote con paginación
 509    /// GET /api/lotes-compensacion/{id}/items?page=1&pageSize=50
 510    /// </summary>
 511    [HttpGet("api/lotes-compensacion/{id}/items")]
 512    [FAU.API.Security.RequirePermission(PermisoEnum.VerCompensaciones)]
 513    public async Task<IActionResult> GetItems(long id,
 514        [FromQuery] int page = 1,
 515        [FromQuery] int pageSize = 50)
 516    {
 517        try
 518        {
 0519            if (page < 1) page = 1;
 0520            if (pageSize < 1 || pageSize > 200) pageSize = 50;
 0521            var resultado = await _service.GetItemsPagedAsync(id, page, pageSize);
 0522            return Ok(ApiResponse<object>.SuccessResult(resultado));
 523        }
 0524        catch (Exception ex)
 525        {
 0526            _logger.LogError(ex, "Error al obtener ítems del lote {Id}", id);
 0527            return StatusCode(500, ApiResponse<string>.ErrorResult("Error interno del servidor"));
 528        }
 0529    }
 530
 531    /// <summary>
 532    /// Agregar ítem a lote (debe estar en estado borrador)
 533    /// POST /api/lotes-compensacion/{id}/items
 534    /// </summary>
 535    [HttpPost("api/lotes-compensacion/{id}/items")]
 536    [FAU.API.Security.RequirePermission(PermisoEnum.CrearItemLoteCompensacion)]
 537    public async Task<IActionResult> CreateItem(long id, [FromBody] CreateItemLoteRequest request)
 538    {
 539        try
 540        {
 4541            var (success, item, error) = await _service.CreateItemAsync(id, request);
 4542            if (!success)
 543            {
 3544                if (error?.Contains("no encontrado", StringComparison.OrdinalIgnoreCase) == true)
 0545                    return NotFound(ApiResponse<string>.ErrorResult(error));
 3546                if (error?.Contains("Ya existe", StringComparison.OrdinalIgnoreCase) == true)
 1547                    return Conflict(ApiResponse<string>.ErrorResult(error));
 2548                if (error?.Contains("estado", StringComparison.OrdinalIgnoreCase) == true)
 1549                    return Conflict(ApiResponse<string>.ErrorResult(error));
 1550                return BadRequest(ApiResponse<string>.ErrorResult(error ?? "Error al agregar ítem"));
 551            }
 552
 1553            return StatusCode(201, ApiResponse<object>.SuccessResult(new { id = item!.Id, creado = true }, "Ítem agregad
 554        }
 0555        catch (Exception ex)
 556        {
 0557            _logger.LogError(ex, "Error al agregar ítem al lote {LoteId}", id);
 0558            return StatusCode(500, ApiResponse<string>.ErrorResult("Error interno del servidor"));
 559        }
 4560    }
 561
 562    /// <summary>
 563    /// Carga masiva de ítems vía JSON array
 564    /// POST /api/lotes-compensacion/{id}/items/bulk?modo=reemplazar|agregar&amp;dryRun=false
 565    /// modo=reemplazar (default): elimina todos los ítems existentes antes de insertar (comportamiento legacy)
 566    /// modo=agregar: agrega sin borrar los existentes (para correcciones parciales)
 567    /// dryRun=true: valida sin persistir — devuelve el mismo resultado pero sin guardar nada
 568    /// </summary>
 569    [HttpPost("api/lotes-compensacion/{id}/items/bulk")]
 570    [FAU.API.Security.RequirePermission(PermisoEnum.CrearItemLoteCompensacion)]
 571    public async Task<IActionResult> CreateItemsBulk(
 572        long id,
 573        [FromBody] List<CreateItemLoteRequest> items,
 574        [FromQuery] string modo = "reemplazar",
 575        [FromQuery] bool dryRun = false)
 576    {
 577        try
 578        {
 0579            if (items == null || items.Count == 0)
 0580                return BadRequest(ApiResponse<string>.ErrorResult("La lista de ítems no puede estar vacía"));
 581
 0582            if (modo != "reemplazar" && modo != "agregar")
 0583                return BadRequest(ApiResponse<string>.ErrorResult("modo debe ser 'reemplazar' o 'agregar'"));
 584
 0585            var resultado = await _service.CreateItemsBulkAsync(id, items, modo, dryRun);
 0586            var msg = dryRun
 0587                ? $"Dry run: {resultado.Exitosos} válidos, {resultado.Fallidos} con errores (sin cambios)"
 0588                : $"Bulk {modo}: {resultado.Exitosos} exitosos, {resultado.Fallidos} fallidos";
 0589            return Ok(ApiResponse<object>.SuccessResult(resultado, msg));
 590        }
 0591        catch (Exception ex)
 592        {
 0593            _logger.LogError(ex, "Error en bulk import lote {LoteId}", id);
 0594            return StatusCode(500, ApiResponse<string>.ErrorResult("Error interno del servidor"));
 595        }
 0596    }
 597
 598    /// <summary>
 599    /// Carga masiva de ítems vía archivo CSV (multipart/form-data)
 600    /// POST /api/lotes-compensacion/{id}/items/bulk-csv?modo=reemplazar|agregar&amp;dryRun=false
 601    /// Formato CSV: cedula,monto,unidad_cantidad,funcion,incidencia,observaciones
 602    /// SAR/PAN requieren columna 'funcion' con valor CA o SA
 603    /// </summary>
 604    [HttpPost("api/lotes-compensacion/{id}/items/bulk-csv")]
 605    [FAU.API.Security.RequirePermission(PermisoEnum.CrearItemLoteCompensacion)]
 606    public async Task<IActionResult> CreateItemsBulkCsv(
 607        long id,
 608        IFormFile archivo,
 609        [FromQuery] string modo = "reemplazar",
 610        [FromQuery] bool dryRun = false)
 611    {
 612        try
 613        {
 0614            if (archivo == null || archivo.Length == 0)
 0615                return BadRequest(ApiResponse<string>.ErrorResult("Debe adjuntar un archivo CSV"));
 616
 0617            if (!archivo.FileName.EndsWith(".csv", StringComparison.OrdinalIgnoreCase))
 0618                return BadRequest(ApiResponse<string>.ErrorResult("El archivo debe tener extensión .csv"));
 619
 0620            if (modo != "reemplazar" && modo != "agregar")
 0621                return BadRequest(ApiResponse<string>.ErrorResult("modo debe ser 'reemplazar' o 'agregar'"));
 622
 0623            using var stream = archivo.OpenReadStream();
 0624            var resultado = await _service.CreateItemsFromCsvAsync(id, stream, modo, dryRun);
 625
 0626            var msg = dryRun
 0627                ? $"Dry run: {resultado.Exitosos} válidos, {resultado.Fallidos} con errores (sin cambios)"
 0628                : $"CSV {modo}: {resultado.Exitosos} exitosos, {resultado.Fallidos} fallidos";
 0629            return Ok(ApiResponse<object>.SuccessResult(resultado, msg));
 630        }
 0631        catch (Exception ex)
 632        {
 0633            _logger.LogError(ex, "Error en bulk CSV lote {LoteId}", id);
 0634            return StatusCode(500, ApiResponse<string>.ErrorResult("Error interno del servidor"));
 635        }
 0636    }
 637
 638    /// <summary>
 639    /// Actualizar ítem de lote (debe estar en estado borrador)
 640    /// PUT /api/lotes-compensacion/{id}/items/{itemId}
 641    /// </summary>
 642    [HttpPut("api/lotes-compensacion/{id}/items/{itemId}")]
 643    [FAU.API.Security.RequirePermission(PermisoEnum.ActualizarItemLoteCompensacion)]
 644    public async Task<IActionResult> UpdateItem(long id, long itemId, [FromBody] UpdateItemLoteRequest request)
 645    {
 646        try
 647        {
 1648            var (success, _, error) = await _service.UpdateItemAsync(id, itemId, request);
 1649            if (!success)
 650            {
 1651                if (error?.Contains("no encontrado", StringComparison.OrdinalIgnoreCase) == true)
 0652                    return NotFound(ApiResponse<string>.ErrorResult(error));
 1653                if (error?.Contains("estado", StringComparison.OrdinalIgnoreCase) == true)
 1654                    return Conflict(ApiResponse<string>.ErrorResult(error));
 0655                return BadRequest(ApiResponse<string>.ErrorResult(error ?? "Error al actualizar ítem"));
 656            }
 657
 0658            return Ok(ApiResponse<object>.SuccessResult(new { actualizado = true }, "Ítem actualizado exitosamente"));
 659        }
 0660        catch (Exception ex)
 661        {
 0662            _logger.LogError(ex, "Error al actualizar ítem {ItemId} del lote {LoteId}", itemId, id);
 0663            return StatusCode(500, ApiResponse<string>.ErrorResult("Error interno del servidor"));
 664        }
 1665    }
 666
 667    /// <summary>
 668    /// Eliminar ítem de lote (debe estar en estado borrador)
 669    /// DELETE /api/lotes-compensacion/{id}/items/{itemId}
 670    /// </summary>
 671    [HttpDelete("api/lotes-compensacion/{id}/items/{itemId}")]
 672    [FAU.API.Security.RequirePermission(PermisoEnum.EliminarItemLoteCompensacion)]
 673    public async Task<IActionResult> DeleteItem(long id, long itemId)
 674    {
 675        try
 676        {
 2677            var (success, error) = await _service.DeleteItemAsync(id, itemId);
 1678            if (!success)
 679            {
 0680                if (error?.Contains("no encontrado", StringComparison.OrdinalIgnoreCase) == true)
 0681                    return NotFound(ApiResponse<string>.ErrorResult(error));
 0682                if (error?.Contains("estado", StringComparison.OrdinalIgnoreCase) == true)
 0683                    return Conflict(ApiResponse<string>.ErrorResult(error));
 0684                return BadRequest(ApiResponse<string>.ErrorResult(error ?? "Error al eliminar ítem"));
 685            }
 686
 1687            return Ok(ApiResponse<object>.SuccessResult(new { eliminado = true }, "Ítem eliminado exitosamente"));
 688        }
 1689        catch (Exception ex)
 690        {
 1691            _logger.LogError(ex, "Error al eliminar ítem {ItemId} del lote {LoteId}", itemId, id);
 1692            return StatusCode(500, ApiResponse<string>.ErrorResult("Error interno del servidor"));
 693        }
 2694    }
 695}