< 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: 726
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=&periodoId=&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] long? periodoId = null,
 288        [FromQuery] string? estado = null,
 289        [FromQuery] int page = 1,
 290        [FromQuery] int pageSize = 20)
 291    {
 292        try
 293        {
 1294            if (page < 1) page = 1;
 1295            if (pageSize < 1 || pageSize > 100) pageSize = 20;
 296
 1297            var resultado = await _service.GetLotesPagedAsync(page, pageSize, tipoId, periodoId, estado);
 1298            return Ok(ApiResponse<object>.SuccessResult(resultado));
 299        }
 0300        catch (Exception ex)
 301        {
 0302            _logger.LogError(ex, "Error al obtener lotes de compensación");
 0303            return StatusCode(500, ApiResponse<string>.ErrorResult("Error interno del servidor"));
 304        }
 1305    }
 306
 307    /// <summary>
 308    /// Obtener lote con sus ítems
 309    /// GET /api/lotes-compensacion/{id}
 310    /// </summary>
 311    [HttpGet("api/lotes-compensacion/{id}")]
 312    [FAU.API.Security.RequirePermission(PermisoEnum.VerCompensaciones)]
 313    public async Task<IActionResult> GetLote(long id)
 314    {
 315        try
 316        {
 1317            var lote = await _service.GetLoteByIdAsync(id);
 1318            if (lote == null)
 1319                return NotFound(ApiResponse<string>.ErrorResult("Lote no encontrado"));
 320
 0321            return Ok(ApiResponse<object>.SuccessResult(lote));
 322        }
 0323        catch (Exception ex)
 324        {
 0325            _logger.LogError(ex, "Error al obtener lote {Id}", id);
 0326            return StatusCode(500, ApiResponse<string>.ErrorResult("Error interno del servidor"));
 327        }
 1328    }
 329
 330    /// <summary>
 331    /// Crear lote de compensación
 332    /// POST /api/lotes-compensacion
 333    /// </summary>
 334    [HttpPost("api/lotes-compensacion")]
 335    [FAU.API.Security.RequirePermission(PermisoEnum.CrearLoteCompensacion)]
 336    public async Task<IActionResult> CreateLote([FromBody] CreateLoteCompensacionRequest request)
 337    {
 338        try
 339        {
 2340            var userIdStr = User.FindFirst(ClaimTypes.NameIdentifier)?.Value;
 2341            if (!long.TryParse(userIdStr, out var usuarioId))
 0342                return Unauthorized(ApiResponse<string>.ErrorResult("No se pudo identificar al usuario"));
 343
 2344            var (success, lote, error) = await _service.CreateLoteAsync(usuarioId, request);
 2345            if (!success)
 1346                return BadRequest(ApiResponse<string>.ErrorResult(error ?? "Error al crear lote"));
 347
 1348            return CreatedAtAction(nameof(GetLote), new { id = lote!.Id },
 1349                ApiResponse<object>.SuccessResult(new { id = lote.Id, creado = true }, "Lote creado exitosamente"));
 350        }
 0351        catch (PeriodoBloqueadoException ex)
 352        {
 0353            return Conflict(ApiResponse<string>.ErrorResult(ex.Message));
 354        }
 0355        catch (Exception ex)
 356        {
 0357            _logger.LogError(ex, "Error al crear lote de compensación");
 0358            return StatusCode(500, ApiResponse<string>.ErrorResult("Error interno del servidor"));
 359        }
 2360    }
 361
 362    /// <summary>
 363    /// Actualizar lote (solo en estado borrador)
 364    /// PUT /api/lotes-compensacion/{id}
 365    /// </summary>
 366    [HttpPut("api/lotes-compensacion/{id}")]
 367    [FAU.API.Security.RequirePermission(PermisoEnum.ActualizarLoteCompensacion)]
 368    public async Task<IActionResult> UpdateLote(long id, [FromBody] UpdateLoteCompensacionRequest request)
 369    {
 370        try
 371        {
 1372            var (success, _, error) = await _service.UpdateLoteAsync(id, request);
 1373            if (!success)
 374            {
 1375                if (error?.Contains("no encontrado", StringComparison.OrdinalIgnoreCase) == true)
 0376                    return NotFound(ApiResponse<string>.ErrorResult(error));
 1377                if (error?.Contains("estado", StringComparison.OrdinalIgnoreCase) == true)
 1378                    return Conflict(ApiResponse<string>.ErrorResult(error));
 0379                return BadRequest(ApiResponse<string>.ErrorResult(error ?? "Error al actualizar lote"));
 380            }
 381
 0382            return Ok(ApiResponse<object>.SuccessResult(new { actualizado = true }, "Lote actualizado exitosamente"));
 383        }
 0384        catch (PeriodoBloqueadoException ex)
 385        {
 0386            return Conflict(ApiResponse<string>.ErrorResult(ex.Message));
 387        }
 0388        catch (Exception ex)
 389        {
 0390            _logger.LogError(ex, "Error al actualizar lote {Id}", id);
 0391            return StatusCode(500, ApiResponse<string>.ErrorResult("Error interno del servidor"));
 392        }
 1393    }
 394
 395    /// <summary>
 396    /// Aprobar lote (debe estar en estado pendiente)
 397    /// PUT /api/lotes-compensacion/{id}/aprobar
 398    /// </summary>
 399    [HttpPut("api/lotes-compensacion/{id}/aprobar")]
 400    [FAU.API.Security.RequirePermission(PermisoEnum.AprobarLoteCompensacion)]
 401    public async Task<IActionResult> AprobarLote(long id)
 402    {
 403        try
 404        {
 2405            var userIdStr = User.FindFirst(ClaimTypes.NameIdentifier)?.Value;
 2406            if (!long.TryParse(userIdStr, out var aprobadoPorId))
 0407                return Unauthorized(ApiResponse<string>.ErrorResult("No se pudo identificar al usuario"));
 408
 2409            var (success, _, error) = await _service.AprobarLoteAsync(id, aprobadoPorId);
 2410            if (!success)
 411            {
 1412                if (error?.Contains("no encontrado", StringComparison.OrdinalIgnoreCase) == true)
 0413                    return NotFound(ApiResponse<string>.ErrorResult(error));
 1414                return Conflict(ApiResponse<string>.ErrorResult(error ?? "Error al aprobar lote"));
 415            }
 416
 1417            return Ok(ApiResponse<object>.SuccessResult(new { aprobado = true }, "Lote aprobado exitosamente"));
 418        }
 0419        catch (PeriodoBloqueadoException ex)
 420        {
 0421            return Conflict(ApiResponse<string>.ErrorResult(ex.Message));
 422        }
 0423        catch (Exception ex)
 424        {
 0425            _logger.LogError(ex, "Error al aprobar lote {Id}", id);
 0426            return StatusCode(500, ApiResponse<string>.ErrorResult("Error interno del servidor"));
 427        }
 2428    }
 429
 430    /// <summary>
 431    /// Rechazar lote (desde cualquier estado editable)
 432    /// PUT /api/lotes-compensacion/{id}/rechazar
 433    /// </summary>
 434    [HttpPut("api/lotes-compensacion/{id}/rechazar")]
 435    [FAU.API.Security.RequirePermission(PermisoEnum.RechazarLoteCompensacion)]
 436    public async Task<IActionResult> RechazarLote(long id)
 437    {
 438        try
 439        {
 0440            var (success, _, error) = await _service.RechazarLoteAsync(id);
 0441            if (!success)
 442            {
 0443                if (error?.Contains("no encontrado", StringComparison.OrdinalIgnoreCase) == true)
 0444                    return NotFound(ApiResponse<string>.ErrorResult(error));
 0445                return Conflict(ApiResponse<string>.ErrorResult(error ?? "Error al rechazar lote"));
 446            }
 0447            return Ok(ApiResponse<object>.SuccessResult(new { rechazado = true }, "Lote rechazado"));
 448        }
 0449        catch (PeriodoBloqueadoException ex)
 450        {
 0451            return Conflict(ApiResponse<string>.ErrorResult(ex.Message));
 452        }
 0453        catch (Exception ex)
 454        {
 0455            _logger.LogError(ex, "Error al rechazar lote {Id}", id);
 0456            return StatusCode(500, ApiResponse<string>.ErrorResult("Error interno del servidor"));
 457        }
 0458    }
 459
 460    /// <summary>
 461    /// Revertir lote aprobado a borrador (solo admin, antes de exportar)
 462    /// PUT /api/lotes-compensacion/{id}/revertir
 463    /// </summary>
 464    [HttpPut("api/lotes-compensacion/{id}/revertir")]
 465    [FAU.API.Security.RequirePermission(PermisoEnum.RevertirLoteCompensacion)]
 466    public async Task<IActionResult> RevertirLote(long id)
 467    {
 468        try
 469        {
 0470            var (success, _, error) = await _service.RevertirABorradorAsync(id);
 0471            if (!success)
 472            {
 0473                if (error?.Contains("no encontrado", StringComparison.OrdinalIgnoreCase) == true)
 0474                    return NotFound(ApiResponse<string>.ErrorResult(error));
 0475                return Conflict(ApiResponse<string>.ErrorResult(error ?? "Error al revertir lote"));
 476            }
 0477            return Ok(ApiResponse<object>.SuccessResult(new { revertido = true }, "Lote revertido a borrador"));
 478        }
 0479        catch (PeriodoBloqueadoException ex)
 480        {
 0481            return Conflict(ApiResponse<string>.ErrorResult(ex.Message));
 482        }
 0483        catch (Exception ex)
 484        {
 0485            _logger.LogError(ex, "Error al revertir lote {Id}", id);
 0486            return StatusCode(500, ApiResponse<string>.ErrorResult("Error interno del servidor"));
 487        }
 0488    }
 489
 490    /// <summary>
 491    /// Cancelar y eliminar un lote en borrador
 492    /// DELETE /api/lotes-compensacion/{id}
 493    /// </summary>
 494    [HttpDelete("api/lotes-compensacion/{id}")]
 495    [FAU.API.Security.RequirePermission(PermisoEnum.EliminarLoteCompensacion)]
 496    public async Task<IActionResult> CancelarLote(long id)
 497    {
 498        try
 499        {
 0500            var (success, error) = await _service.CancelarLoteAsync(id);
 0501            if (!success)
 502            {
 0503                if (error?.Contains("no encontrado", StringComparison.OrdinalIgnoreCase) == true)
 0504                    return NotFound(ApiResponse<string>.ErrorResult(error));
 0505                return Conflict(ApiResponse<string>.ErrorResult(error ?? "Error al cancelar lote"));
 506            }
 0507            return Ok(ApiResponse<object>.SuccessResult(new { cancelado = true }, "Lote cancelado y eliminado"));
 508        }
 0509        catch (PeriodoBloqueadoException ex)
 510        {
 0511            return Conflict(ApiResponse<string>.ErrorResult(ex.Message));
 512        }
 0513        catch (Exception ex)
 514        {
 0515            _logger.LogError(ex, "Error al cancelar lote {Id}", id);
 0516            return StatusCode(500, ApiResponse<string>.ErrorResult("Error interno del servidor"));
 517        }
 0518    }
 519
 520    // ── Ítems de Lote ────────────────────────────────────────────────────────
 521
 522    /// <summary>
 523    /// Listar ítems de un lote con paginación
 524    /// GET /api/lotes-compensacion/{id}/items?page=1&pageSize=50
 525    /// </summary>
 526    [HttpGet("api/lotes-compensacion/{id}/items")]
 527    [FAU.API.Security.RequirePermission(PermisoEnum.VerCompensaciones)]
 528    public async Task<IActionResult> GetItems(long id,
 529        [FromQuery] int page = 1,
 530        [FromQuery] int pageSize = 50)
 531    {
 532        try
 533        {
 0534            if (page < 1) page = 1;
 0535            if (pageSize < 1 || pageSize > 200) pageSize = 50;
 0536            var resultado = await _service.GetItemsPagedAsync(id, page, pageSize);
 0537            return Ok(ApiResponse<object>.SuccessResult(resultado));
 538        }
 0539        catch (Exception ex)
 540        {
 0541            _logger.LogError(ex, "Error al obtener ítems del lote {Id}", id);
 0542            return StatusCode(500, ApiResponse<string>.ErrorResult("Error interno del servidor"));
 543        }
 0544    }
 545
 546    /// <summary>
 547    /// Agregar ítem a lote (debe estar en estado borrador)
 548    /// POST /api/lotes-compensacion/{id}/items
 549    /// </summary>
 550    [HttpPost("api/lotes-compensacion/{id}/items")]
 551    [FAU.API.Security.RequirePermission(PermisoEnum.CrearItemLoteCompensacion)]
 552    public async Task<IActionResult> CreateItem(long id, [FromBody] CreateItemLoteRequest request)
 553    {
 554        try
 555        {
 4556            var (success, item, error) = await _service.CreateItemAsync(id, request);
 4557            if (!success)
 558            {
 3559                if (error?.Contains("no encontrado", StringComparison.OrdinalIgnoreCase) == true)
 0560                    return NotFound(ApiResponse<string>.ErrorResult(error));
 3561                if (error?.Contains("Ya existe", StringComparison.OrdinalIgnoreCase) == true)
 1562                    return Conflict(ApiResponse<string>.ErrorResult(error));
 2563                if (error?.Contains("estado", StringComparison.OrdinalIgnoreCase) == true)
 1564                    return Conflict(ApiResponse<string>.ErrorResult(error));
 1565                return BadRequest(ApiResponse<string>.ErrorResult(error ?? "Error al agregar ítem"));
 566            }
 567
 1568            return StatusCode(201, ApiResponse<object>.SuccessResult(new { id = item!.Id, creado = true }, "Ítem agregad
 569        }
 0570        catch (PeriodoBloqueadoException ex)
 571        {
 0572            return Conflict(ApiResponse<string>.ErrorResult(ex.Message));
 573        }
 0574        catch (Exception ex)
 575        {
 0576            _logger.LogError(ex, "Error al agregar ítem al lote {LoteId}", id);
 0577            return StatusCode(500, ApiResponse<string>.ErrorResult("Error interno del servidor"));
 578        }
 4579    }
 580
 581    /// <summary>
 582    /// Carga masiva de ítems vía JSON array
 583    /// POST /api/lotes-compensacion/{id}/items/bulk?modo=reemplazar|agregar&amp;dryRun=false
 584    /// modo=reemplazar (default): elimina todos los ítems existentes antes de insertar (comportamiento legacy)
 585    /// modo=agregar: agrega sin borrar los existentes (para correcciones parciales)
 586    /// dryRun=true: valida sin persistir — devuelve el mismo resultado pero sin guardar nada
 587    /// </summary>
 588    [HttpPost("api/lotes-compensacion/{id}/items/bulk")]
 589    [FAU.API.Security.RequirePermission(PermisoEnum.CrearItemLoteCompensacion)]
 590    public async Task<IActionResult> CreateItemsBulk(
 591        long id,
 592        [FromBody] List<CreateItemLoteRequest> items,
 593        [FromQuery] string modo = "reemplazar",
 594        [FromQuery] bool dryRun = false)
 595    {
 596        try
 597        {
 0598            if (items == null || items.Count == 0)
 0599                return BadRequest(ApiResponse<string>.ErrorResult("La lista de ítems no puede estar vacía"));
 600
 0601            if (modo != "reemplazar" && modo != "agregar")
 0602                return BadRequest(ApiResponse<string>.ErrorResult("modo debe ser 'reemplazar' o 'agregar'"));
 603
 0604            var resultado = await _service.CreateItemsBulkAsync(id, items, modo, dryRun);
 0605            var msg = dryRun
 0606                ? $"Dry run: {resultado.Exitosos} válidos, {resultado.Fallidos} con errores (sin cambios)"
 0607                : $"Bulk {modo}: {resultado.Exitosos} exitosos, {resultado.Fallidos} fallidos";
 0608            return Ok(ApiResponse<object>.SuccessResult(resultado, msg));
 609        }
 0610        catch (PeriodoBloqueadoException ex)
 611        {
 0612            return Conflict(ApiResponse<string>.ErrorResult(ex.Message));
 613        }
 0614        catch (Exception ex)
 615        {
 0616            _logger.LogError(ex, "Error en bulk import lote {LoteId}", id);
 0617            return StatusCode(500, ApiResponse<string>.ErrorResult("Error interno del servidor"));
 618        }
 0619    }
 620
 621    /// <summary>
 622    /// Carga masiva de ítems vía archivo CSV (multipart/form-data)
 623    /// POST /api/lotes-compensacion/{id}/items/bulk-csv?modo=reemplazar|agregar&amp;dryRun=false
 624    /// Formato CSV: cedula,monto,unidad_cantidad,funcion,incidencia,observaciones
 625    /// SAR/PAN requieren columna 'funcion' con valor CA o SA
 626    /// </summary>
 627    [HttpPost("api/lotes-compensacion/{id}/items/bulk-csv")]
 628    [FAU.API.Security.RequirePermission(PermisoEnum.CrearItemLoteCompensacion)]
 629    public async Task<IActionResult> CreateItemsBulkCsv(
 630        long id,
 631        IFormFile archivo,
 632        [FromQuery] string modo = "reemplazar",
 633        [FromQuery] bool dryRun = false)
 634    {
 635        try
 636        {
 0637            if (archivo == null || archivo.Length == 0)
 0638                return BadRequest(ApiResponse<string>.ErrorResult("Debe adjuntar un archivo CSV"));
 639
 0640            if (!archivo.FileName.EndsWith(".csv", StringComparison.OrdinalIgnoreCase))
 0641                return BadRequest(ApiResponse<string>.ErrorResult("El archivo debe tener extensión .csv"));
 642
 0643            if (modo != "reemplazar" && modo != "agregar")
 0644                return BadRequest(ApiResponse<string>.ErrorResult("modo debe ser 'reemplazar' o 'agregar'"));
 645
 0646            using var stream = archivo.OpenReadStream();
 0647            var resultado = await _service.CreateItemsFromCsvAsync(id, stream, modo, dryRun);
 648
 0649            var msg = dryRun
 0650                ? $"Dry run: {resultado.Exitosos} válidos, {resultado.Fallidos} con errores (sin cambios)"
 0651                : $"CSV {modo}: {resultado.Exitosos} exitosos, {resultado.Fallidos} fallidos";
 0652            return Ok(ApiResponse<object>.SuccessResult(resultado, msg));
 653        }
 0654        catch (Exception ex)
 655        {
 0656            _logger.LogError(ex, "Error en bulk CSV lote {LoteId}", id);
 0657            return StatusCode(500, ApiResponse<string>.ErrorResult("Error interno del servidor"));
 658        }
 0659    }
 660
 661    /// <summary>
 662    /// Actualizar ítem de lote (debe estar en estado borrador)
 663    /// PUT /api/lotes-compensacion/{id}/items/{itemId}
 664    /// </summary>
 665    [HttpPut("api/lotes-compensacion/{id}/items/{itemId}")]
 666    [FAU.API.Security.RequirePermission(PermisoEnum.ActualizarItemLoteCompensacion)]
 667    public async Task<IActionResult> UpdateItem(long id, long itemId, [FromBody] UpdateItemLoteRequest request)
 668    {
 669        try
 670        {
 1671            var (success, _, error) = await _service.UpdateItemAsync(id, itemId, request);
 1672            if (!success)
 673            {
 1674                if (error?.Contains("no encontrado", StringComparison.OrdinalIgnoreCase) == true)
 0675                    return NotFound(ApiResponse<string>.ErrorResult(error));
 1676                if (error?.Contains("estado", StringComparison.OrdinalIgnoreCase) == true)
 1677                    return Conflict(ApiResponse<string>.ErrorResult(error));
 0678                return BadRequest(ApiResponse<string>.ErrorResult(error ?? "Error al actualizar ítem"));
 679            }
 680
 0681            return Ok(ApiResponse<object>.SuccessResult(new { actualizado = true }, "Ítem actualizado exitosamente"));
 682        }
 0683        catch (PeriodoBloqueadoException ex)
 684        {
 0685            return Conflict(ApiResponse<string>.ErrorResult(ex.Message));
 686        }
 0687        catch (Exception ex)
 688        {
 0689            _logger.LogError(ex, "Error al actualizar ítem {ItemId} del lote {LoteId}", itemId, id);
 0690            return StatusCode(500, ApiResponse<string>.ErrorResult("Error interno del servidor"));
 691        }
 1692    }
 693
 694    /// <summary>
 695    /// Eliminar ítem de lote (debe estar en estado borrador)
 696    /// DELETE /api/lotes-compensacion/{id}/items/{itemId}
 697    /// </summary>
 698    [HttpDelete("api/lotes-compensacion/{id}/items/{itemId}")]
 699    [FAU.API.Security.RequirePermission(PermisoEnum.EliminarItemLoteCompensacion)]
 700    public async Task<IActionResult> DeleteItem(long id, long itemId)
 701    {
 702        try
 703        {
 2704            var (success, error) = await _service.DeleteItemAsync(id, itemId);
 1705            if (!success)
 706            {
 0707                if (error?.Contains("no encontrado", StringComparison.OrdinalIgnoreCase) == true)
 0708                    return NotFound(ApiResponse<string>.ErrorResult(error));
 0709                if (error?.Contains("estado", StringComparison.OrdinalIgnoreCase) == true)
 0710                    return Conflict(ApiResponse<string>.ErrorResult(error));
 0711                return BadRequest(ApiResponse<string>.ErrorResult(error ?? "Error al eliminar ítem"));
 712            }
 713
 1714            return Ok(ApiResponse<object>.SuccessResult(new { eliminado = true }, "Ítem eliminado exitosamente"));
 715        }
 0716        catch (PeriodoBloqueadoException ex)
 717        {
 0718            return Conflict(ApiResponse<string>.ErrorResult(ex.Message));
 719        }
 1720        catch (Exception ex)
 721        {
 1722            _logger.LogError(ex, "Error al eliminar ítem {ItemId} del lote {LoteId}", itemId, id);
 1723            return StatusCode(500, ApiResponse<string>.ErrorResult("Error interno del servidor"));
 724        }
 2725    }
 726}