< 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: 817
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.Http;
 8using Microsoft.AspNetCore.Mvc;
 9
 10namespace FAU.API.Controllers;
 11
 12[ApiController]
 13[Produces("application/json")]
 14public class CompensacionesController : ControllerBase
 15{
 16    private readonly ICompensacionService _service;
 17    private readonly ILogger<CompensacionesController> _logger;
 18
 3419    public CompensacionesController(ICompensacionService service, ILogger<CompensacionesController> logger)
 20    {
 3421        _service = service;
 3422        _logger = logger;
 3423    }
 24
 25    // ── Tipos de Compensación ────────────────────────────────────────────────
 26
 27    /// <summary>
 28    /// Listar tipos de compensación
 29    /// GET /api/tipos-compensacion?activo=
 30    /// </summary>
 31    [HttpGet("api/tipos-compensacion")]
 32    [FAU.API.Security.RequirePermission(PermisoEnum.VerCompensaciones)]
 33    [ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status200OK)]
 34    [ProducesResponseType(typeof(ApiResponse<string>), StatusCodes.Status500InternalServerError)]
 35    public async Task<IActionResult> GetTipos([FromQuery] bool? activo = null)
 36    {
 37        try
 38        {
 239            var tipos = await _service.GetTiposAsync(activo);
 140            return Ok(ApiResponse<object>.SuccessResult(tipos));
 41        }
 142        catch (Exception ex)
 43        {
 144            _logger.LogError(ex, "Error al obtener tipos de compensación");
 145            return StatusCode(500, ApiResponse<string>.ErrorResult("Error interno del servidor"));
 46        }
 247    }
 48
 49    /// <summary>
 50    /// Obtener tipo de compensación por ID
 51    /// GET /api/tipos-compensacion/{id}
 52    /// </summary>
 53    [HttpGet("api/tipos-compensacion/{id}")]
 54    [FAU.API.Security.RequirePermission(PermisoEnum.VerCompensaciones)]
 55    [ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status200OK)]
 56    [ProducesResponseType(typeof(ApiResponse<string>), StatusCodes.Status404NotFound)]
 57    [ProducesResponseType(typeof(ApiResponse<string>), StatusCodes.Status500InternalServerError)]
 58    public async Task<IActionResult> GetTipo(long id)
 59    {
 60        try
 61        {
 262            var tipo = await _service.GetTipoByIdAsync(id);
 263            if (tipo == null)
 164                return NotFound(ApiResponse<string>.ErrorResult("Tipo de compensación no encontrado"));
 65
 166            return Ok(ApiResponse<object>.SuccessResult(tipo));
 67        }
 068        catch (Exception ex)
 69        {
 070            _logger.LogError(ex, "Error al obtener tipo de compensación {Id}", id);
 071            return StatusCode(500, ApiResponse<string>.ErrorResult("Error interno del servidor"));
 72        }
 273    }
 74
 75    /// <summary>
 76    /// Crear tipo de compensación
 77    /// POST /api/tipos-compensacion
 78    /// </summary>
 79    [HttpPost("api/tipos-compensacion")]
 80    [FAU.API.Security.RequirePermission(PermisoEnum.CrearCompensacion)]
 81    [ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status201Created)]
 82    [ProducesResponseType(typeof(ApiResponse<string>), StatusCodes.Status400BadRequest)]
 83    [ProducesResponseType(typeof(ApiResponse<string>), StatusCodes.Status409Conflict)]
 84    [ProducesResponseType(typeof(ApiResponse<string>), StatusCodes.Status500InternalServerError)]
 85    public async Task<IActionResult> CreateTipo([FromBody] CreateTipoCompensacionRequest request)
 86    {
 87        try
 88        {
 389            var (success, tipo, error) = await _service.CreateTipoAsync(request);
 390            if (!success)
 91            {
 292                if (error?.Contains("Ya existe", StringComparison.OrdinalIgnoreCase) == true)
 193                    return Conflict(ApiResponse<string>.ErrorResult(error));
 194                return BadRequest(ApiResponse<string>.ErrorResult(error ?? "Error al crear tipo"));
 95            }
 96
 197            return CreatedAtAction(nameof(GetTipo), new { id = tipo!.Id },
 198                ApiResponse<object>.SuccessResult(new { id = tipo.Id, creado = true }, "Tipo creado exitosamente"));
 99        }
 0100        catch (Exception ex)
 101        {
 0102            _logger.LogError(ex, "Error al crear tipo de compensación");
 0103            return StatusCode(500, ApiResponse<string>.ErrorResult("Error interno del servidor"));
 104        }
 3105    }
 106
 107    /// <summary>
 108    /// Actualizar tipo de compensación
 109    /// PUT /api/tipos-compensacion/{id}
 110    /// </summary>
 111    [HttpPut("api/tipos-compensacion/{id}")]
 112    [FAU.API.Security.RequirePermission(PermisoEnum.ActualizarCompensacion)]
 113    [ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status200OK)]
 114    [ProducesResponseType(typeof(ApiResponse<string>), StatusCodes.Status400BadRequest)]
 115    [ProducesResponseType(typeof(ApiResponse<string>), StatusCodes.Status404NotFound)]
 116    [ProducesResponseType(typeof(ApiResponse<string>), StatusCodes.Status500InternalServerError)]
 117    public async Task<IActionResult> UpdateTipo(long id, [FromBody] UpdateTipoCompensacionRequest request)
 118    {
 119        try
 120        {
 2121            var (success, _, error) = await _service.UpdateTipoAsync(id, request);
 2122            if (!success)
 123            {
 1124                if (error?.Contains("no encontrado", StringComparison.OrdinalIgnoreCase) == true)
 1125                    return NotFound(ApiResponse<string>.ErrorResult(error));
 0126                return BadRequest(ApiResponse<string>.ErrorResult(error ?? "Error al actualizar tipo"));
 127            }
 128
 1129            return Ok(ApiResponse<object>.SuccessResult(new { actualizado = true }, "Tipo actualizado exitosamente"));
 130        }
 0131        catch (Exception ex)
 132        {
 0133            _logger.LogError(ex, "Error al actualizar tipo de compensación {Id}", id);
 0134            return StatusCode(500, ApiResponse<string>.ErrorResult("Error interno del servidor"));
 135        }
 2136    }
 137
 138    /// <summary>
 139    /// Eliminar tipo de compensación
 140    /// DELETE /api/tipos-compensacion/{id}
 141    /// </summary>
 142    [HttpDelete("api/tipos-compensacion/{id}")]
 143    [ProducesResponseType(StatusCodes.Status204NoContent)]
 144    [ProducesResponseType(typeof(ApiResponse<string>), StatusCodes.Status404NotFound)]
 145    [ProducesResponseType(typeof(ApiResponse<string>), StatusCodes.Status409Conflict)]
 146    [ProducesResponseType(typeof(ApiResponse<string>), StatusCodes.Status500InternalServerError)]
 147    public async Task<IActionResult> DeleteTipo(long id)
 148    {
 149        try
 150        {
 4151            var (success, error) = await _service.DeleteTipoAsync(id);
 4152            if (!success)
 153            {
 3154                if (error?.Contains("no encontrado", StringComparison.OrdinalIgnoreCase) == true)
 1155                    return NotFound(ApiResponse<string>.ErrorResult(error));
 2156                return Conflict(ApiResponse<string>.ErrorResult(error ?? "Error al eliminar tipo"));
 157            }
 158
 1159            return NoContent();
 160        }
 0161        catch (Exception ex)
 162        {
 0163            _logger.LogError(ex, "Error al eliminar tipo de compensación {Id}", id);
 0164            return StatusCode(500, ApiResponse<string>.ErrorResult("Error interno del servidor"));
 165        }
 4166    }
 167
 168    // ── Configuraciones de Compensación ─────────────────────────────────────
 169
 170    /// <summary>
 171    /// Listar configuraciones de compensación
 172    /// GET /api/config-compensaciones?tipoId=&regimenId=&gradoId=&activo=
 173    /// </summary>
 174    [HttpGet("api/config-compensaciones")]
 175    [FAU.API.Security.RequirePermission(PermisoEnum.VerCompensaciones)]
 176    [ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status200OK)]
 177    [ProducesResponseType(typeof(ApiResponse<string>), StatusCodes.Status500InternalServerError)]
 178    public async Task<IActionResult> GetConfigs(
 179        [FromQuery] long? tipoId = null,
 180        [FromQuery] long? regimenId = null,
 181        [FromQuery] long? gradoId = null,
 182        [FromQuery] bool? activo = null)
 183    {
 184        try
 185        {
 1186            var configs = await _service.GetConfigsAsync(tipoId, regimenId, gradoId, activo);
 1187            return Ok(ApiResponse<object>.SuccessResult(configs));
 188        }
 0189        catch (Exception ex)
 190        {
 0191            _logger.LogError(ex, "Error al obtener configuraciones de compensación");
 0192            return StatusCode(500, ApiResponse<string>.ErrorResult("Error interno del servidor"));
 193        }
 1194    }
 195
 196    /// <summary>
 197    /// Obtener configuración por ID (incluye tope, fórmula, unidad)
 198    /// GET /api/config-compensaciones/{id}
 199    /// </summary>
 200    [HttpGet("api/config-compensaciones/{id}")]
 201    [FAU.API.Security.RequirePermission(PermisoEnum.VerCompensaciones)]
 202    [ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status200OK)]
 203    [ProducesResponseType(typeof(ApiResponse<string>), StatusCodes.Status404NotFound)]
 204    [ProducesResponseType(typeof(ApiResponse<string>), StatusCodes.Status500InternalServerError)]
 205    public async Task<IActionResult> GetConfig(long id)
 206    {
 207        try
 208        {
 0209            var config = await _service.GetConfigByIdAsync(id);
 0210            if (config == null)
 0211                return NotFound(ApiResponse<string>.ErrorResult("Configuración no encontrada"));
 0212            return Ok(ApiResponse<object>.SuccessResult(config));
 213        }
 0214        catch (Exception ex)
 215        {
 0216            _logger.LogError(ex, "Error al obtener configuración {Id}", id);
 0217            return StatusCode(500, ApiResponse<string>.ErrorResult("Error interno del servidor"));
 218        }
 0219    }
 220
 221    /// <summary>
 222    /// Crear configuración de compensación
 223    /// POST /api/config-compensaciones
 224    /// </summary>
 225    [HttpPost("api/config-compensaciones")]
 226    [FAU.API.Security.RequirePermission(PermisoEnum.CrearConfigCompensacion)]
 227    [ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status201Created)]
 228    [ProducesResponseType(typeof(ApiResponse<string>), StatusCodes.Status400BadRequest)]
 229    [ProducesResponseType(typeof(ApiResponse<string>), StatusCodes.Status500InternalServerError)]
 230    public async Task<IActionResult> CreateConfig([FromBody] CreateConfigCompensacionRequest request)
 231    {
 232        try
 233        {
 2234            var (success, config, error) = await _service.CreateConfigAsync(request);
 2235            if (!success)
 236            {
 1237                if (error?.Contains("No se encontró", StringComparison.OrdinalIgnoreCase) == true)
 0238                    return BadRequest(ApiResponse<string>.ErrorResult(error));
 1239                return BadRequest(ApiResponse<string>.ErrorResult(error ?? "Error al crear configuración"));
 240            }
 241
 1242            return StatusCode(201, ApiResponse<object>.SuccessResult(new { id = config!.Id, creado = true }, "Configurac
 243        }
 0244        catch (Exception ex)
 245        {
 0246            _logger.LogError(ex, "Error al crear configuración de compensación");
 0247            return StatusCode(500, ApiResponse<string>.ErrorResult("Error interno del servidor"));
 248        }
 2249    }
 250
 251    /// <summary>
 252    /// Actualizar configuración de compensación
 253    /// PUT /api/config-compensaciones/{id}
 254    /// </summary>
 255    [HttpPut("api/config-compensaciones/{id}")]
 256    [FAU.API.Security.RequirePermission(PermisoEnum.ActualizarConfigComprensacion)]
 257    [ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status200OK)]
 258    [ProducesResponseType(typeof(ApiResponse<string>), StatusCodes.Status400BadRequest)]
 259    [ProducesResponseType(typeof(ApiResponse<string>), StatusCodes.Status404NotFound)]
 260    [ProducesResponseType(typeof(ApiResponse<string>), StatusCodes.Status500InternalServerError)]
 261    public async Task<IActionResult> UpdateConfig(long id, [FromBody] UpdateConfigCompensacionRequest request)
 262    {
 263        try
 264        {
 1265            var (success, _, error) = await _service.UpdateConfigAsync(id, request);
 1266            if (!success)
 267            {
 1268                if (error?.Contains("no encontrada", StringComparison.OrdinalIgnoreCase) == true)
 1269                    return NotFound(ApiResponse<string>.ErrorResult(error));
 0270                return BadRequest(ApiResponse<string>.ErrorResult(error ?? "Error al actualizar configuración"));
 271            }
 272
 0273            return Ok(ApiResponse<object>.SuccessResult(new { actualizado = true }, "Configuración actualizada exitosame
 274        }
 0275        catch (Exception ex)
 276        {
 0277            _logger.LogError(ex, "Error al actualizar configuración {Id}", id);
 0278            return StatusCode(500, ApiResponse<string>.ErrorResult("Error interno del servidor"));
 279        }
 1280    }
 281
 282    /// <summary>
 283    /// Eliminar configuración de compensación
 284    /// DELETE /api/config-compensaciones/{id}
 285    /// </summary>
 286    [HttpDelete("api/config-compensaciones/{id}")]
 287    [ProducesResponseType(StatusCodes.Status204NoContent)]
 288    [ProducesResponseType(typeof(ApiResponse<string>), StatusCodes.Status404NotFound)]
 289    [ProducesResponseType(typeof(ApiResponse<string>), StatusCodes.Status409Conflict)]
 290    [ProducesResponseType(typeof(ApiResponse<string>), StatusCodes.Status500InternalServerError)]
 291    public async Task<IActionResult> DeleteConfig(long id)
 292    {
 293        try
 294        {
 3295            var (success, error) = await _service.DeleteConfigAsync(id);
 3296            if (!success)
 297            {
 2298                if (error?.Contains("no encontrada", StringComparison.OrdinalIgnoreCase) == true)
 1299                    return NotFound(ApiResponse<string>.ErrorResult(error));
 1300                return Conflict(ApiResponse<string>.ErrorResult(error ?? "Error al eliminar configuración"));
 301            }
 302
 1303            return NoContent();
 304        }
 0305        catch (Exception ex)
 306        {
 0307            _logger.LogError(ex, "Error al eliminar configuración {Id}", id);
 0308            return StatusCode(500, ApiResponse<string>.ErrorResult("Error interno del servidor"));
 309        }
 3310    }
 311
 312    // ── Lotes de Compensación ────────────────────────────────────────────────
 313
 314    /// <summary>
 315    /// Listar lotes de compensación con paginación
 316    /// GET /api/lotes-compensacion?tipoId=&periodoId=&estado=&page=&pageSize=
 317    /// </summary>
 318    [HttpGet("api/lotes-compensacion")]
 319    [FAU.API.Security.RequirePermission(PermisoEnum.VerCompensaciones)]
 320    [ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status200OK)]
 321    [ProducesResponseType(typeof(ApiResponse<string>), StatusCodes.Status500InternalServerError)]
 322    public async Task<IActionResult> GetLotes(
 323        [FromQuery] long? tipoId = null,
 324        [FromQuery] long? periodoId = null,
 325        [FromQuery] string? estado = null,
 326        [FromQuery] int page = 1,
 327        [FromQuery] int pageSize = 20)
 328    {
 329        try
 330        {
 1331            if (page < 1) page = 1;
 1332            if (pageSize < 1 || pageSize > 100) pageSize = 20;
 333
 1334            var resultado = await _service.GetLotesPagedAsync(page, pageSize, tipoId, periodoId, estado);
 1335            return Ok(ApiResponse<object>.SuccessResult(resultado));
 336        }
 0337        catch (Exception ex)
 338        {
 0339            _logger.LogError(ex, "Error al obtener lotes de compensación");
 0340            return StatusCode(500, ApiResponse<string>.ErrorResult("Error interno del servidor"));
 341        }
 1342    }
 343
 344    /// <summary>
 345    /// Obtener lote con sus ítems
 346    /// GET /api/lotes-compensacion/{id}
 347    /// </summary>
 348    [HttpGet("api/lotes-compensacion/{id}")]
 349    [FAU.API.Security.RequirePermission(PermisoEnum.VerCompensaciones)]
 350    [ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status200OK)]
 351    [ProducesResponseType(typeof(ApiResponse<string>), StatusCodes.Status404NotFound)]
 352    [ProducesResponseType(typeof(ApiResponse<string>), StatusCodes.Status500InternalServerError)]
 353    public async Task<IActionResult> GetLote(long id)
 354    {
 355        try
 356        {
 1357            var lote = await _service.GetLoteByIdAsync(id);
 1358            if (lote == null)
 1359                return NotFound(ApiResponse<string>.ErrorResult("Lote no encontrado"));
 360
 0361            return Ok(ApiResponse<object>.SuccessResult(lote));
 362        }
 0363        catch (Exception ex)
 364        {
 0365            _logger.LogError(ex, "Error al obtener lote {Id}", id);
 0366            return StatusCode(500, ApiResponse<string>.ErrorResult("Error interno del servidor"));
 367        }
 1368    }
 369
 370    /// <summary>
 371    /// Crear lote de compensación
 372    /// POST /api/lotes-compensacion
 373    /// </summary>
 374    [HttpPost("api/lotes-compensacion")]
 375    [FAU.API.Security.RequirePermission(PermisoEnum.CrearLoteCompensacion)]
 376    [ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status201Created)]
 377    [ProducesResponseType(typeof(ApiResponse<string>), StatusCodes.Status400BadRequest)]
 378    [ProducesResponseType(typeof(ApiResponse<string>), StatusCodes.Status401Unauthorized)]
 379    [ProducesResponseType(typeof(ApiResponse<string>), StatusCodes.Status409Conflict)]
 380    [ProducesResponseType(typeof(ApiResponse<string>), StatusCodes.Status500InternalServerError)]
 381    public async Task<IActionResult> CreateLote([FromBody] CreateLoteCompensacionRequest request)
 382    {
 383        try
 384        {
 2385            var userIdStr = User.FindFirst(ClaimTypes.NameIdentifier)?.Value;
 2386            if (!long.TryParse(userIdStr, out var usuarioId))
 0387                return Unauthorized(ApiResponse<string>.ErrorResult("No se pudo identificar al usuario"));
 388
 2389            var (success, lote, error) = await _service.CreateLoteAsync(usuarioId, request);
 2390            if (!success)
 1391                return BadRequest(ApiResponse<string>.ErrorResult(error ?? "Error al crear lote"));
 392
 1393            return CreatedAtAction(nameof(GetLote), new { id = lote!.Id },
 1394                ApiResponse<object>.SuccessResult(new { id = lote.Id, creado = true }, "Lote creado exitosamente"));
 395        }
 0396        catch (PeriodoBloqueadoException ex)
 397        {
 0398            return Conflict(ApiResponse<string>.ErrorResult(ex.Message));
 399        }
 0400        catch (Exception ex)
 401        {
 0402            _logger.LogError(ex, "Error al crear lote de compensación");
 0403            return StatusCode(500, ApiResponse<string>.ErrorResult("Error interno del servidor"));
 404        }
 2405    }
 406
 407    /// <summary>
 408    /// Actualizar lote (solo en estado borrador)
 409    /// PUT /api/lotes-compensacion/{id}
 410    /// </summary>
 411    [HttpPut("api/lotes-compensacion/{id}")]
 412    [FAU.API.Security.RequirePermission(PermisoEnum.ActualizarLoteCompensacion)]
 413    [ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status200OK)]
 414    [ProducesResponseType(typeof(ApiResponse<string>), StatusCodes.Status400BadRequest)]
 415    [ProducesResponseType(typeof(ApiResponse<string>), StatusCodes.Status404NotFound)]
 416    [ProducesResponseType(typeof(ApiResponse<string>), StatusCodes.Status409Conflict)]
 417    [ProducesResponseType(typeof(ApiResponse<string>), StatusCodes.Status500InternalServerError)]
 418    public async Task<IActionResult> UpdateLote(long id, [FromBody] UpdateLoteCompensacionRequest request)
 419    {
 420        try
 421        {
 1422            var (success, _, error) = await _service.UpdateLoteAsync(id, request);
 1423            if (!success)
 424            {
 1425                if (error?.Contains("no encontrado", StringComparison.OrdinalIgnoreCase) == true)
 0426                    return NotFound(ApiResponse<string>.ErrorResult(error));
 1427                if (error?.Contains("estado", StringComparison.OrdinalIgnoreCase) == true)
 1428                    return Conflict(ApiResponse<string>.ErrorResult(error));
 0429                return BadRequest(ApiResponse<string>.ErrorResult(error ?? "Error al actualizar lote"));
 430            }
 431
 0432            return Ok(ApiResponse<object>.SuccessResult(new { actualizado = true }, "Lote actualizado exitosamente"));
 433        }
 0434        catch (PeriodoBloqueadoException ex)
 435        {
 0436            return Conflict(ApiResponse<string>.ErrorResult(ex.Message));
 437        }
 0438        catch (Exception ex)
 439        {
 0440            _logger.LogError(ex, "Error al actualizar lote {Id}", id);
 0441            return StatusCode(500, ApiResponse<string>.ErrorResult("Error interno del servidor"));
 442        }
 1443    }
 444
 445    /// <summary>
 446    /// Aprobar lote (debe estar en estado pendiente)
 447    /// PUT /api/lotes-compensacion/{id}/aprobar
 448    /// </summary>
 449    [HttpPut("api/lotes-compensacion/{id}/aprobar")]
 450    [FAU.API.Security.RequirePermission(PermisoEnum.AprobarLoteCompensacion)]
 451    [ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status200OK)]
 452    [ProducesResponseType(typeof(ApiResponse<string>), StatusCodes.Status401Unauthorized)]
 453    [ProducesResponseType(typeof(ApiResponse<string>), StatusCodes.Status404NotFound)]
 454    [ProducesResponseType(typeof(ApiResponse<string>), StatusCodes.Status409Conflict)]
 455    [ProducesResponseType(typeof(ApiResponse<string>), StatusCodes.Status500InternalServerError)]
 456    public async Task<IActionResult> AprobarLote(long id)
 457    {
 458        try
 459        {
 2460            var userIdStr = User.FindFirst(ClaimTypes.NameIdentifier)?.Value;
 2461            if (!long.TryParse(userIdStr, out var aprobadoPorId))
 0462                return Unauthorized(ApiResponse<string>.ErrorResult("No se pudo identificar al usuario"));
 463
 2464            var (success, _, error) = await _service.AprobarLoteAsync(id, aprobadoPorId);
 2465            if (!success)
 466            {
 1467                if (error?.Contains("no encontrado", StringComparison.OrdinalIgnoreCase) == true)
 0468                    return NotFound(ApiResponse<string>.ErrorResult(error));
 1469                return Conflict(ApiResponse<string>.ErrorResult(error ?? "Error al aprobar lote"));
 470            }
 471
 1472            return Ok(ApiResponse<object>.SuccessResult(new { aprobado = true }, "Lote aprobado exitosamente"));
 473        }
 0474        catch (PeriodoBloqueadoException ex)
 475        {
 0476            return Conflict(ApiResponse<string>.ErrorResult(ex.Message));
 477        }
 0478        catch (Exception ex)
 479        {
 0480            _logger.LogError(ex, "Error al aprobar lote {Id}", id);
 0481            return StatusCode(500, ApiResponse<string>.ErrorResult("Error interno del servidor"));
 482        }
 2483    }
 484
 485    /// <summary>
 486    /// Rechazar lote (desde cualquier estado editable)
 487    /// PUT /api/lotes-compensacion/{id}/rechazar
 488    /// </summary>
 489    [HttpPut("api/lotes-compensacion/{id}/rechazar")]
 490    [FAU.API.Security.RequirePermission(PermisoEnum.RechazarLoteCompensacion)]
 491    [ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status200OK)]
 492    [ProducesResponseType(typeof(ApiResponse<string>), StatusCodes.Status404NotFound)]
 493    [ProducesResponseType(typeof(ApiResponse<string>), StatusCodes.Status409Conflict)]
 494    [ProducesResponseType(typeof(ApiResponse<string>), StatusCodes.Status500InternalServerError)]
 495    public async Task<IActionResult> RechazarLote(long id)
 496    {
 497        try
 498        {
 0499            var (success, _, error) = await _service.RechazarLoteAsync(id);
 0500            if (!success)
 501            {
 0502                if (error?.Contains("no encontrado", StringComparison.OrdinalIgnoreCase) == true)
 0503                    return NotFound(ApiResponse<string>.ErrorResult(error));
 0504                return Conflict(ApiResponse<string>.ErrorResult(error ?? "Error al rechazar lote"));
 505            }
 0506            return Ok(ApiResponse<object>.SuccessResult(new { rechazado = true }, "Lote rechazado"));
 507        }
 0508        catch (PeriodoBloqueadoException ex)
 509        {
 0510            return Conflict(ApiResponse<string>.ErrorResult(ex.Message));
 511        }
 0512        catch (Exception ex)
 513        {
 0514            _logger.LogError(ex, "Error al rechazar lote {Id}", id);
 0515            return StatusCode(500, ApiResponse<string>.ErrorResult("Error interno del servidor"));
 516        }
 0517    }
 518
 519    /// <summary>
 520    /// Revertir lote aprobado a borrador (solo admin, antes de exportar)
 521    /// PUT /api/lotes-compensacion/{id}/revertir
 522    /// </summary>
 523    [HttpPut("api/lotes-compensacion/{id}/revertir")]
 524    [FAU.API.Security.RequirePermission(PermisoEnum.RevertirLoteCompensacion)]
 525    [ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status200OK)]
 526    [ProducesResponseType(typeof(ApiResponse<string>), StatusCodes.Status404NotFound)]
 527    [ProducesResponseType(typeof(ApiResponse<string>), StatusCodes.Status409Conflict)]
 528    [ProducesResponseType(typeof(ApiResponse<string>), StatusCodes.Status500InternalServerError)]
 529    public async Task<IActionResult> RevertirLote(long id)
 530    {
 531        try
 532        {
 0533            var (success, _, error) = await _service.RevertirABorradorAsync(id);
 0534            if (!success)
 535            {
 0536                if (error?.Contains("no encontrado", StringComparison.OrdinalIgnoreCase) == true)
 0537                    return NotFound(ApiResponse<string>.ErrorResult(error));
 0538                return Conflict(ApiResponse<string>.ErrorResult(error ?? "Error al revertir lote"));
 539            }
 0540            return Ok(ApiResponse<object>.SuccessResult(new { revertido = true }, "Lote revertido a borrador"));
 541        }
 0542        catch (PeriodoBloqueadoException ex)
 543        {
 0544            return Conflict(ApiResponse<string>.ErrorResult(ex.Message));
 545        }
 0546        catch (Exception ex)
 547        {
 0548            _logger.LogError(ex, "Error al revertir lote {Id}", id);
 0549            return StatusCode(500, ApiResponse<string>.ErrorResult("Error interno del servidor"));
 550        }
 0551    }
 552
 553    /// <summary>
 554    /// Cancelar y eliminar un lote en borrador
 555    /// DELETE /api/lotes-compensacion/{id}
 556    /// </summary>
 557    [HttpDelete("api/lotes-compensacion/{id}")]
 558    [FAU.API.Security.RequirePermission(PermisoEnum.EliminarLoteCompensacion)]
 559    [ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status200OK)]
 560    [ProducesResponseType(typeof(ApiResponse<string>), StatusCodes.Status404NotFound)]
 561    [ProducesResponseType(typeof(ApiResponse<string>), StatusCodes.Status409Conflict)]
 562    [ProducesResponseType(typeof(ApiResponse<string>), StatusCodes.Status500InternalServerError)]
 563    public async Task<IActionResult> CancelarLote(long id)
 564    {
 565        try
 566        {
 0567            var (success, error) = await _service.CancelarLoteAsync(id);
 0568            if (!success)
 569            {
 0570                if (error?.Contains("no encontrado", StringComparison.OrdinalIgnoreCase) == true)
 0571                    return NotFound(ApiResponse<string>.ErrorResult(error));
 0572                return Conflict(ApiResponse<string>.ErrorResult(error ?? "Error al cancelar lote"));
 573            }
 0574            return Ok(ApiResponse<object>.SuccessResult(new { cancelado = true }, "Lote cancelado y eliminado"));
 575        }
 0576        catch (PeriodoBloqueadoException ex)
 577        {
 0578            return Conflict(ApiResponse<string>.ErrorResult(ex.Message));
 579        }
 0580        catch (Exception ex)
 581        {
 0582            _logger.LogError(ex, "Error al cancelar lote {Id}", id);
 0583            return StatusCode(500, ApiResponse<string>.ErrorResult("Error interno del servidor"));
 584        }
 0585    }
 586
 587    // ── Ítems de Lote ────────────────────────────────────────────────────────
 588
 589    /// <summary>
 590    /// Listar ítems de un lote con paginación
 591    /// GET /api/lotes-compensacion/{id}/items?page=1&pageSize=50
 592    /// </summary>
 593    [HttpGet("api/lotes-compensacion/{id}/items")]
 594    [FAU.API.Security.RequirePermission(PermisoEnum.VerCompensaciones)]
 595    [ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status200OK)]
 596    [ProducesResponseType(typeof(ApiResponse<string>), StatusCodes.Status500InternalServerError)]
 597    public async Task<IActionResult> GetItems(long id,
 598        [FromQuery] int page = 1,
 599        [FromQuery] int pageSize = 50)
 600    {
 601        try
 602        {
 0603            if (page < 1) page = 1;
 0604            if (pageSize < 1 || pageSize > 200) pageSize = 50;
 0605            var resultado = await _service.GetItemsPagedAsync(id, page, pageSize);
 0606            return Ok(ApiResponse<object>.SuccessResult(resultado));
 607        }
 0608        catch (Exception ex)
 609        {
 0610            _logger.LogError(ex, "Error al obtener ítems del lote {Id}", id);
 0611            return StatusCode(500, ApiResponse<string>.ErrorResult("Error interno del servidor"));
 612        }
 0613    }
 614
 615    /// <summary>
 616    /// Agregar ítem a lote (debe estar en estado borrador)
 617    /// POST /api/lotes-compensacion/{id}/items
 618    /// </summary>
 619    [HttpPost("api/lotes-compensacion/{id}/items")]
 620    [FAU.API.Security.RequirePermission(PermisoEnum.CrearItemLoteCompensacion)]
 621    [ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status201Created)]
 622    [ProducesResponseType(typeof(ApiResponse<string>), StatusCodes.Status400BadRequest)]
 623    [ProducesResponseType(typeof(ApiResponse<string>), StatusCodes.Status404NotFound)]
 624    [ProducesResponseType(typeof(ApiResponse<string>), StatusCodes.Status409Conflict)]
 625    [ProducesResponseType(typeof(ApiResponse<string>), StatusCodes.Status500InternalServerError)]
 626    public async Task<IActionResult> CreateItem(long id, [FromBody] CreateItemLoteRequest request)
 627    {
 628        try
 629        {
 4630            var (success, item, error) = await _service.CreateItemAsync(id, request);
 4631            if (!success)
 632            {
 3633                if (error?.Contains("no encontrado", StringComparison.OrdinalIgnoreCase) == true)
 0634                    return NotFound(ApiResponse<string>.ErrorResult(error));
 3635                if (error?.Contains("Ya existe", StringComparison.OrdinalIgnoreCase) == true)
 1636                    return Conflict(ApiResponse<string>.ErrorResult(error));
 2637                if (error?.Contains("estado", StringComparison.OrdinalIgnoreCase) == true)
 1638                    return Conflict(ApiResponse<string>.ErrorResult(error));
 1639                return BadRequest(ApiResponse<string>.ErrorResult(error ?? "Error al agregar ítem"));
 640            }
 641
 1642            return StatusCode(201, ApiResponse<object>.SuccessResult(new { id = item!.Id, creado = true }, "Ítem agregad
 643        }
 0644        catch (PeriodoBloqueadoException ex)
 645        {
 0646            return Conflict(ApiResponse<string>.ErrorResult(ex.Message));
 647        }
 0648        catch (Exception ex)
 649        {
 0650            _logger.LogError(ex, "Error al agregar ítem al lote {LoteId}", id);
 0651            return StatusCode(500, ApiResponse<string>.ErrorResult("Error interno del servidor"));
 652        }
 4653    }
 654
 655    /// <summary>
 656    /// Carga masiva de ítems vía JSON array
 657    /// POST /api/lotes-compensacion/{id}/items/bulk?modo=reemplazar|agregar&amp;dryRun=false
 658    /// modo=reemplazar (default): elimina todos los ítems existentes antes de insertar (comportamiento legacy)
 659    /// modo=agregar: agrega sin borrar los existentes (para correcciones parciales)
 660    /// dryRun=true: valida sin persistir — devuelve el mismo resultado pero sin guardar nada
 661    /// </summary>
 662    [HttpPost("api/lotes-compensacion/{id}/items/bulk")]
 663    [FAU.API.Security.RequirePermission(PermisoEnum.CrearItemLoteCompensacion)]
 664    [ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status200OK)]
 665    [ProducesResponseType(typeof(ApiResponse<string>), StatusCodes.Status400BadRequest)]
 666    [ProducesResponseType(typeof(ApiResponse<string>), StatusCodes.Status409Conflict)]
 667    [ProducesResponseType(typeof(ApiResponse<string>), StatusCodes.Status500InternalServerError)]
 668    public async Task<IActionResult> CreateItemsBulk(
 669        long id,
 670        [FromBody] List<CreateItemLoteRequest> items,
 671        [FromQuery] string modo = "reemplazar",
 672        [FromQuery] bool dryRun = false)
 673    {
 674        try
 675        {
 0676            if (items == null || items.Count == 0)
 0677                return BadRequest(ApiResponse<string>.ErrorResult("La lista de ítems no puede estar vacía"));
 678
 0679            if (modo != "reemplazar" && modo != "agregar")
 0680                return BadRequest(ApiResponse<string>.ErrorResult("modo debe ser 'reemplazar' o 'agregar'"));
 681
 0682            var resultado = await _service.CreateItemsBulkAsync(id, items, modo, dryRun);
 0683            var msg = dryRun
 0684                ? $"Dry run: {resultado.Exitosos} válidos, {resultado.Fallidos} con errores (sin cambios)"
 0685                : $"Bulk {modo}: {resultado.Exitosos} exitosos, {resultado.Fallidos} fallidos";
 0686            return Ok(ApiResponse<object>.SuccessResult(resultado, msg));
 687        }
 0688        catch (PeriodoBloqueadoException ex)
 689        {
 0690            return Conflict(ApiResponse<string>.ErrorResult(ex.Message));
 691        }
 0692        catch (Exception ex)
 693        {
 0694            _logger.LogError(ex, "Error en bulk import lote {LoteId}", id);
 0695            return StatusCode(500, ApiResponse<string>.ErrorResult("Error interno del servidor"));
 696        }
 0697    }
 698
 699    /// <summary>
 700    /// Carga masiva de ítems vía archivo CSV (multipart/form-data)
 701    /// POST /api/lotes-compensacion/{id}/items/bulk-csv?modo=reemplazar|agregar&amp;dryRun=false
 702    /// Formato CSV: cedula,monto,unidad_cantidad,funcion,incidencia,observaciones
 703    /// SAR/PAN requieren columna 'funcion' con valor CA o SA
 704    /// </summary>
 705    [HttpPost("api/lotes-compensacion/{id}/items/bulk-csv")]
 706    [FAU.API.Security.RequirePermission(PermisoEnum.CrearItemLoteCompensacion)]
 707    [ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status200OK)]
 708    [ProducesResponseType(typeof(ApiResponse<string>), StatusCodes.Status400BadRequest)]
 709    [ProducesResponseType(typeof(ApiResponse<string>), StatusCodes.Status500InternalServerError)]
 710    public async Task<IActionResult> CreateItemsBulkCsv(
 711        long id,
 712        IFormFile archivo,
 713        [FromQuery] string modo = "reemplazar",
 714        [FromQuery] bool dryRun = false)
 715    {
 716        try
 717        {
 0718            if (archivo == null || archivo.Length == 0)
 0719                return BadRequest(ApiResponse<string>.ErrorResult("Debe adjuntar un archivo CSV"));
 720
 0721            if (!archivo.FileName.EndsWith(".csv", StringComparison.OrdinalIgnoreCase))
 0722                return BadRequest(ApiResponse<string>.ErrorResult("El archivo debe tener extensión .csv"));
 723
 0724            if (modo != "reemplazar" && modo != "agregar")
 0725                return BadRequest(ApiResponse<string>.ErrorResult("modo debe ser 'reemplazar' o 'agregar'"));
 726
 0727            using var stream = archivo.OpenReadStream();
 0728            var resultado = await _service.CreateItemsFromCsvAsync(id, stream, modo, dryRun);
 729
 0730            var msg = dryRun
 0731                ? $"Dry run: {resultado.Exitosos} válidos, {resultado.Fallidos} con errores (sin cambios)"
 0732                : $"CSV {modo}: {resultado.Exitosos} exitosos, {resultado.Fallidos} fallidos";
 0733            return Ok(ApiResponse<object>.SuccessResult(resultado, msg));
 734        }
 0735        catch (Exception ex)
 736        {
 0737            _logger.LogError(ex, "Error en bulk CSV lote {LoteId}", id);
 0738            return StatusCode(500, ApiResponse<string>.ErrorResult("Error interno del servidor"));
 739        }
 0740    }
 741
 742    /// <summary>
 743    /// Actualizar ítem de lote (debe estar en estado borrador)
 744    /// PUT /api/lotes-compensacion/{id}/items/{itemId}
 745    /// </summary>
 746    [HttpPut("api/lotes-compensacion/{id}/items/{itemId}")]
 747    [FAU.API.Security.RequirePermission(PermisoEnum.ActualizarItemLoteCompensacion)]
 748    [ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status200OK)]
 749    [ProducesResponseType(typeof(ApiResponse<string>), StatusCodes.Status400BadRequest)]
 750    [ProducesResponseType(typeof(ApiResponse<string>), StatusCodes.Status404NotFound)]
 751    [ProducesResponseType(typeof(ApiResponse<string>), StatusCodes.Status409Conflict)]
 752    [ProducesResponseType(typeof(ApiResponse<string>), StatusCodes.Status500InternalServerError)]
 753    public async Task<IActionResult> UpdateItem(long id, long itemId, [FromBody] UpdateItemLoteRequest request)
 754    {
 755        try
 756        {
 1757            var (success, _, error) = await _service.UpdateItemAsync(id, itemId, request);
 1758            if (!success)
 759            {
 1760                if (error?.Contains("no encontrado", StringComparison.OrdinalIgnoreCase) == true)
 0761                    return NotFound(ApiResponse<string>.ErrorResult(error));
 1762                if (error?.Contains("estado", StringComparison.OrdinalIgnoreCase) == true)
 1763                    return Conflict(ApiResponse<string>.ErrorResult(error));
 0764                return BadRequest(ApiResponse<string>.ErrorResult(error ?? "Error al actualizar ítem"));
 765            }
 766
 0767            return Ok(ApiResponse<object>.SuccessResult(new { actualizado = true }, "Ítem actualizado exitosamente"));
 768        }
 0769        catch (PeriodoBloqueadoException ex)
 770        {
 0771            return Conflict(ApiResponse<string>.ErrorResult(ex.Message));
 772        }
 0773        catch (Exception ex)
 774        {
 0775            _logger.LogError(ex, "Error al actualizar ítem {ItemId} del lote {LoteId}", itemId, id);
 0776            return StatusCode(500, ApiResponse<string>.ErrorResult("Error interno del servidor"));
 777        }
 1778    }
 779
 780    /// <summary>
 781    /// Eliminar ítem de lote (debe estar en estado borrador)
 782    /// DELETE /api/lotes-compensacion/{id}/items/{itemId}
 783    /// </summary>
 784    [HttpDelete("api/lotes-compensacion/{id}/items/{itemId}")]
 785    [FAU.API.Security.RequirePermission(PermisoEnum.EliminarItemLoteCompensacion)]
 786    [ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status200OK)]
 787    [ProducesResponseType(typeof(ApiResponse<string>), StatusCodes.Status400BadRequest)]
 788    [ProducesResponseType(typeof(ApiResponse<string>), StatusCodes.Status404NotFound)]
 789    [ProducesResponseType(typeof(ApiResponse<string>), StatusCodes.Status409Conflict)]
 790    [ProducesResponseType(typeof(ApiResponse<string>), StatusCodes.Status500InternalServerError)]
 791    public async Task<IActionResult> DeleteItem(long id, long itemId)
 792    {
 793        try
 794        {
 2795            var (success, error) = await _service.DeleteItemAsync(id, itemId);
 1796            if (!success)
 797            {
 0798                if (error?.Contains("no encontrado", StringComparison.OrdinalIgnoreCase) == true)
 0799                    return NotFound(ApiResponse<string>.ErrorResult(error));
 0800                if (error?.Contains("estado", StringComparison.OrdinalIgnoreCase) == true)
 0801                    return Conflict(ApiResponse<string>.ErrorResult(error));
 0802                return BadRequest(ApiResponse<string>.ErrorResult(error ?? "Error al eliminar ítem"));
 803            }
 804
 1805            return Ok(ApiResponse<object>.SuccessResult(new { eliminado = true }, "Ítem eliminado exitosamente"));
 806        }
 0807        catch (PeriodoBloqueadoException ex)
 808        {
 0809            return Conflict(ApiResponse<string>.ErrorResult(ex.Message));
 810        }
 1811        catch (Exception ex)
 812        {
 1813            _logger.LogError(ex, "Error al eliminar ítem {ItemId} del lote {LoteId}", itemId, id);
 1814            return StatusCode(500, ApiResponse<string>.ErrorResult("Error interno del servidor"));
 815        }
 2816    }
 817}