< 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
86%
Covered lines: 234
Uncovered lines: 37
Coverable lines: 271
Total lines: 819
Line coverage: 86.3%
Branch coverage
84%
Covered branches: 156
Total branches: 184
Branch coverage: 84.7%
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%11100%
GetConfig()100%22100%
CreateConfig()75%12860%
UpdateConfig()87.5%88100%
DeleteConfig()87.5%10870%
GetLotes()100%66100%
GetLote()100%22100%
CreateLote()62.5%9871.42%
UpdateLote()91.66%131278.57%
AprobarLote()75%151273.33%
RechazarLote()87.5%88100%
RevertirLote()87.5%88100%
CancelarLote()87.5%88100%
GetItems()50%66100%
CreateItem()93.75%181681.25%
CreateItemsBulk()90%1010100%
CreateItemsBulkCsv()91.66%1212100%
UpdateItem()91.66%131278.57%
DeleteItem()91.66%1212100%

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
 8919    public CompensacionesController(ICompensacionService service, ILogger<CompensacionesController> logger)
 20    {
 8921        _service = service;
 8922        _logger = logger;
 8923    }
 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    [Authorize(Roles = "Administrador,Supervisor,Usuario")]
 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    [Authorize(Roles = "Administrador,Supervisor,Usuario")]
 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    [Authorize(Roles = "Administrador")]
 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    [Authorize(Roles = "Administrador")]
 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    [Authorize(Roles = "Administrador")]
 144    [ProducesResponseType(StatusCodes.Status204NoContent)]
 145    [ProducesResponseType(typeof(ApiResponse<string>), StatusCodes.Status404NotFound)]
 146    [ProducesResponseType(typeof(ApiResponse<string>), StatusCodes.Status409Conflict)]
 147    [ProducesResponseType(typeof(ApiResponse<string>), StatusCodes.Status500InternalServerError)]
 148    public async Task<IActionResult> DeleteTipo(long id)
 149    {
 150        try
 151        {
 4152            var (success, error) = await _service.DeleteTipoAsync(id);
 4153            if (!success)
 154            {
 3155                if (error?.Contains("no encontrado", StringComparison.OrdinalIgnoreCase) == true)
 1156                    return NotFound(ApiResponse<string>.ErrorResult(error));
 2157                return Conflict(ApiResponse<string>.ErrorResult(error ?? "Error al eliminar tipo"));
 158            }
 159
 1160            return NoContent();
 161        }
 0162        catch (Exception ex)
 163        {
 0164            _logger.LogError(ex, "Error al eliminar tipo de compensación {Id}", id);
 0165            return StatusCode(500, ApiResponse<string>.ErrorResult("Error interno del servidor"));
 166        }
 4167    }
 168
 169    // ── Configuraciones de Compensación ─────────────────────────────────────
 170
 171    /// <summary>
 172    /// Listar configuraciones de compensación
 173    /// GET /api/config-compensaciones?tipoId=&amp;regimenId=&amp;gradoId=&amp;activo=
 174    /// </summary>
 175    [HttpGet("api/config-compensaciones")]
 176    [Authorize(Roles = "Administrador,Supervisor,Usuario")]
 177    [ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status200OK)]
 178    [ProducesResponseType(typeof(ApiResponse<string>), StatusCodes.Status500InternalServerError)]
 179    public async Task<IActionResult> GetConfigs(
 180        [FromQuery] long? tipoId = null,
 181        [FromQuery] long? regimenId = null,
 182        [FromQuery] long? gradoId = null,
 183        [FromQuery] bool? activo = null)
 184    {
 185        try
 186        {
 2187            var configs = await _service.GetConfigsAsync(tipoId, regimenId, gradoId, activo);
 1188            return Ok(ApiResponse<object>.SuccessResult(configs));
 189        }
 1190        catch (Exception ex)
 191        {
 1192            _logger.LogError(ex, "Error al obtener configuraciones de compensación");
 1193            return StatusCode(500, ApiResponse<string>.ErrorResult("Error interno del servidor"));
 194        }
 2195    }
 196
 197    /// <summary>
 198    /// Obtener configuración por ID (incluye tope, fórmula, unidad)
 199    /// GET /api/config-compensaciones/{id}
 200    /// </summary>
 201    [HttpGet("api/config-compensaciones/{id}")]
 202    [Authorize(Roles = "Administrador,Supervisor,Usuario")]
 203    [ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status200OK)]
 204    [ProducesResponseType(typeof(ApiResponse<string>), StatusCodes.Status404NotFound)]
 205    [ProducesResponseType(typeof(ApiResponse<string>), StatusCodes.Status500InternalServerError)]
 206    public async Task<IActionResult> GetConfig(long id)
 207    {
 208        try
 209        {
 3210            var config = await _service.GetConfigByIdAsync(id);
 2211            if (config == null)
 1212                return NotFound(ApiResponse<string>.ErrorResult("Configuración no encontrada"));
 1213            return Ok(ApiResponse<object>.SuccessResult(config));
 214        }
 1215        catch (Exception ex)
 216        {
 1217            _logger.LogError(ex, "Error al obtener configuración {Id}", id);
 1218            return StatusCode(500, ApiResponse<string>.ErrorResult("Error interno del servidor"));
 219        }
 3220    }
 221
 222    /// <summary>
 223    /// Crear configuración de compensación
 224    /// POST /api/config-compensaciones
 225    /// </summary>
 226    [HttpPost("api/config-compensaciones")]
 227    [Authorize(Roles = "Administrador")]
 228    [ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status201Created)]
 229    [ProducesResponseType(typeof(ApiResponse<string>), StatusCodes.Status400BadRequest)]
 230    [ProducesResponseType(typeof(ApiResponse<string>), StatusCodes.Status500InternalServerError)]
 231    public async Task<IActionResult> CreateConfig([FromBody] CreateConfigCompensacionRequest request)
 232    {
 233        try
 234        {
 2235            var (success, config, error) = await _service.CreateConfigAsync(request);
 2236            if (!success)
 237            {
 1238                if (error?.Contains("No se encontró", StringComparison.OrdinalIgnoreCase) == true)
 0239                    return BadRequest(ApiResponse<string>.ErrorResult(error));
 1240                return BadRequest(ApiResponse<string>.ErrorResult(error ?? "Error al crear configuración"));
 241            }
 242
 1243            return StatusCode(201, ApiResponse<object>.SuccessResult(new { id = config!.Id, creado = true }, "Configurac
 244        }
 0245        catch (Exception ex)
 246        {
 0247            _logger.LogError(ex, "Error al crear configuración de compensación");
 0248            return StatusCode(500, ApiResponse<string>.ErrorResult("Error interno del servidor"));
 249        }
 2250    }
 251
 252    /// <summary>
 253    /// Actualizar configuración de compensación
 254    /// PUT /api/config-compensaciones/{id}
 255    /// </summary>
 256    [HttpPut("api/config-compensaciones/{id}")]
 257    [Authorize(Roles = "Administrador")]
 258    [ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status200OK)]
 259    [ProducesResponseType(typeof(ApiResponse<string>), StatusCodes.Status400BadRequest)]
 260    [ProducesResponseType(typeof(ApiResponse<string>), StatusCodes.Status404NotFound)]
 261    [ProducesResponseType(typeof(ApiResponse<string>), StatusCodes.Status500InternalServerError)]
 262    public async Task<IActionResult> UpdateConfig(long id, [FromBody] UpdateConfigCompensacionRequest request)
 263    {
 264        try
 265        {
 4266            var (success, _, error) = await _service.UpdateConfigAsync(id, request);
 3267            if (!success)
 268            {
 2269                if (error?.Contains("no encontrada", StringComparison.OrdinalIgnoreCase) == true)
 1270                    return NotFound(ApiResponse<string>.ErrorResult(error));
 1271                return BadRequest(ApiResponse<string>.ErrorResult(error ?? "Error al actualizar configuración"));
 272            }
 273
 1274            return Ok(ApiResponse<object>.SuccessResult(new { actualizado = true }, "Configuración actualizada exitosame
 275        }
 1276        catch (Exception ex)
 277        {
 1278            _logger.LogError(ex, "Error al actualizar configuración {Id}", id);
 1279            return StatusCode(500, ApiResponse<string>.ErrorResult("Error interno del servidor"));
 280        }
 4281    }
 282
 283    /// <summary>
 284    /// Eliminar configuración de compensación
 285    /// DELETE /api/config-compensaciones/{id}
 286    /// </summary>
 287    [HttpDelete("api/config-compensaciones/{id}")]
 288    [Authorize(Roles = "Administrador")]
 289    [ProducesResponseType(StatusCodes.Status204NoContent)]
 290    [ProducesResponseType(typeof(ApiResponse<string>), StatusCodes.Status404NotFound)]
 291    [ProducesResponseType(typeof(ApiResponse<string>), StatusCodes.Status409Conflict)]
 292    [ProducesResponseType(typeof(ApiResponse<string>), StatusCodes.Status500InternalServerError)]
 293    public async Task<IActionResult> DeleteConfig(long id)
 294    {
 295        try
 296        {
 3297            var (success, error) = await _service.DeleteConfigAsync(id);
 3298            if (!success)
 299            {
 2300                if (error?.Contains("no encontrada", StringComparison.OrdinalIgnoreCase) == true)
 1301                    return NotFound(ApiResponse<string>.ErrorResult(error));
 1302                return Conflict(ApiResponse<string>.ErrorResult(error ?? "Error al eliminar configuración"));
 303            }
 304
 1305            return NoContent();
 306        }
 0307        catch (Exception ex)
 308        {
 0309            _logger.LogError(ex, "Error al eliminar configuración {Id}", id);
 0310            return StatusCode(500, ApiResponse<string>.ErrorResult("Error interno del servidor"));
 311        }
 3312    }
 313
 314    // ── Lotes de Compensación ────────────────────────────────────────────────
 315
 316    /// <summary>
 317    /// Listar lotes de compensación con paginación
 318    /// GET /api/lotes-compensacion?tipoId=&amp;periodoId=&amp;estado=&amp;page=&amp;pageSize=
 319    /// </summary>
 320    [HttpGet("api/lotes-compensacion")]
 321    [Authorize(Roles = "Administrador,Supervisor,Usuario")]
 322    [ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status200OK)]
 323    [ProducesResponseType(typeof(ApiResponse<string>), StatusCodes.Status500InternalServerError)]
 324    public async Task<IActionResult> GetLotes(
 325        [FromQuery] long? tipoId = null,
 326        [FromQuery] long? periodoId = null,
 327        [FromQuery] string? estado = null,
 328        [FromQuery] int page = 1,
 329        [FromQuery] int pageSize = 20)
 330    {
 331        try
 332        {
 4333            if (page < 1) page = 1;
 4334            if (pageSize < 1 || pageSize > 100) pageSize = 20;
 335
 3336            var resultado = await _service.GetLotesPagedAsync(page, pageSize, tipoId, periodoId, estado);
 2337            return Ok(ApiResponse<object>.SuccessResult(resultado));
 338        }
 1339        catch (Exception ex)
 340        {
 1341            _logger.LogError(ex, "Error al obtener lotes de compensación");
 1342            return StatusCode(500, ApiResponse<string>.ErrorResult("Error interno del servidor"));
 343        }
 3344    }
 345
 346    /// <summary>
 347    /// Obtener lote con sus ítems
 348    /// GET /api/lotes-compensacion/{id}
 349    /// </summary>
 350    [HttpGet("api/lotes-compensacion/{id}")]
 351    [Authorize(Roles = "Administrador,Supervisor,Usuario")]
 352    [ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status200OK)]
 353    [ProducesResponseType(typeof(ApiResponse<string>), StatusCodes.Status404NotFound)]
 354    [ProducesResponseType(typeof(ApiResponse<string>), StatusCodes.Status500InternalServerError)]
 355    public async Task<IActionResult> GetLote(long id)
 356    {
 357        try
 358        {
 3359            var lote = await _service.GetLoteByIdAsync(id);
 2360            if (lote == null)
 1361                return NotFound(ApiResponse<string>.ErrorResult("Lote no encontrado"));
 362
 1363            return Ok(ApiResponse<object>.SuccessResult(lote));
 364        }
 1365        catch (Exception ex)
 366        {
 1367            _logger.LogError(ex, "Error al obtener lote {Id}", id);
 1368            return StatusCode(500, ApiResponse<string>.ErrorResult("Error interno del servidor"));
 369        }
 3370    }
 371
 372    /// <summary>
 373    /// Crear lote de compensación
 374    /// POST /api/lotes-compensacion
 375    /// </summary>
 376    [HttpPost("api/lotes-compensacion")]
 377    [Authorize(Roles = "Administrador,Supervisor,Usuario")]
 378    [ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status201Created)]
 379    [ProducesResponseType(typeof(ApiResponse<string>), StatusCodes.Status400BadRequest)]
 380    [ProducesResponseType(typeof(ApiResponse<string>), StatusCodes.Status401Unauthorized)]
 381    [ProducesResponseType(typeof(ApiResponse<string>), StatusCodes.Status409Conflict)]
 382    [ProducesResponseType(typeof(ApiResponse<string>), StatusCodes.Status500InternalServerError)]
 383    public async Task<IActionResult> CreateLote([FromBody] CreateLoteCompensacionRequest request)
 384    {
 385        try
 386        {
 3387            var userIdStr = User.FindFirst(ClaimTypes.NameIdentifier)?.Value;
 3388            if (!long.TryParse(userIdStr, out var usuarioId))
 0389                return Unauthorized(ApiResponse<string>.ErrorResult("No se pudo identificar al usuario"));
 390
 3391            var (success, lote, error) = await _service.CreateLoteAsync(usuarioId, request);
 2392            if (!success)
 1393                return BadRequest(ApiResponse<string>.ErrorResult(error ?? "Error al crear lote"));
 394
 1395            return CreatedAtAction(nameof(GetLote), new { id = lote!.Id },
 1396                ApiResponse<object>.SuccessResult(new { id = lote.Id, creado = true }, "Lote creado exitosamente"));
 397        }
 1398        catch (PeriodoBloqueadoException ex)
 399        {
 1400            return Conflict(ApiResponse<string>.ErrorResult(ex.Message));
 401        }
 0402        catch (Exception ex)
 403        {
 0404            _logger.LogError(ex, "Error al crear lote de compensación");
 0405            return StatusCode(500, ApiResponse<string>.ErrorResult("Error interno del servidor"));
 406        }
 3407    }
 408
 409    /// <summary>
 410    /// Actualizar lote (solo en estado borrador)
 411    /// PUT /api/lotes-compensacion/{id}
 412    /// </summary>
 413    [HttpPut("api/lotes-compensacion/{id}")]
 414    [Authorize(Roles = "Administrador,Supervisor,Usuario")]
 415    [ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status200OK)]
 416    [ProducesResponseType(typeof(ApiResponse<string>), StatusCodes.Status400BadRequest)]
 417    [ProducesResponseType(typeof(ApiResponse<string>), StatusCodes.Status404NotFound)]
 418    [ProducesResponseType(typeof(ApiResponse<string>), StatusCodes.Status409Conflict)]
 419    [ProducesResponseType(typeof(ApiResponse<string>), StatusCodes.Status500InternalServerError)]
 420    public async Task<IActionResult> UpdateLote(long id, [FromBody] UpdateLoteCompensacionRequest request)
 421    {
 422        try
 423        {
 5424            var (success, _, error) = await _service.UpdateLoteAsync(id, request);
 4425            if (!success)
 426            {
 3427                if (error?.Contains("no encontrado", StringComparison.OrdinalIgnoreCase) == true)
 1428                    return NotFound(ApiResponse<string>.ErrorResult(error));
 2429                if (error?.Contains("estado", StringComparison.OrdinalIgnoreCase) == true)
 1430                    return Conflict(ApiResponse<string>.ErrorResult(error));
 1431                return BadRequest(ApiResponse<string>.ErrorResult(error ?? "Error al actualizar lote"));
 432            }
 433
 1434            return Ok(ApiResponse<object>.SuccessResult(new { actualizado = true }, "Lote actualizado exitosamente"));
 435        }
 1436        catch (PeriodoBloqueadoException ex)
 437        {
 1438            return Conflict(ApiResponse<string>.ErrorResult(ex.Message));
 439        }
 0440        catch (Exception ex)
 441        {
 0442            _logger.LogError(ex, "Error al actualizar lote {Id}", id);
 0443            return StatusCode(500, ApiResponse<string>.ErrorResult("Error interno del servidor"));
 444        }
 5445    }
 446
 447    /// <summary>
 448    /// Aprobar lote (debe estar en estado pendiente)
 449    /// PUT /api/lotes-compensacion/{id}/aprobar
 450    /// </summary>
 451    [HttpPut("api/lotes-compensacion/{id}/aprobar")]
 452    [Authorize(Roles = "Administrador,Supervisor")]
 453    [ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status200OK)]
 454    [ProducesResponseType(typeof(ApiResponse<string>), StatusCodes.Status401Unauthorized)]
 455    [ProducesResponseType(typeof(ApiResponse<string>), StatusCodes.Status404NotFound)]
 456    [ProducesResponseType(typeof(ApiResponse<string>), StatusCodes.Status409Conflict)]
 457    [ProducesResponseType(typeof(ApiResponse<string>), StatusCodes.Status500InternalServerError)]
 458    public async Task<IActionResult> AprobarLote(long id)
 459    {
 460        try
 461        {
 4462            var userIdStr = User.FindFirst(ClaimTypes.NameIdentifier)?.Value;
 4463            if (!long.TryParse(userIdStr, out var aprobadoPorId))
 0464                return Unauthorized(ApiResponse<string>.ErrorResult("No se pudo identificar al usuario"));
 465
 4466            var (success, _, error) = await _service.AprobarLoteAsync(id, aprobadoPorId);
 3467            if (!success)
 468            {
 2469                if (error?.Contains("no encontrado", StringComparison.OrdinalIgnoreCase) == true)
 1470                    return NotFound(ApiResponse<string>.ErrorResult(error));
 1471                return Conflict(ApiResponse<string>.ErrorResult(error ?? "Error al aprobar lote"));
 472            }
 473
 1474            return Ok(ApiResponse<object>.SuccessResult(new { aprobado = true }, "Lote aprobado exitosamente"));
 475        }
 1476        catch (PeriodoBloqueadoException ex)
 477        {
 1478            return Conflict(ApiResponse<string>.ErrorResult(ex.Message));
 479        }
 0480        catch (Exception ex)
 481        {
 0482            _logger.LogError(ex, "Error al aprobar lote {Id}", id);
 0483            return StatusCode(500, ApiResponse<string>.ErrorResult("Error interno del servidor"));
 484        }
 4485    }
 486
 487    /// <summary>
 488    /// Rechazar lote (desde cualquier estado editable)
 489    /// PUT /api/lotes-compensacion/{id}/rechazar
 490    /// </summary>
 491    [HttpPut("api/lotes-compensacion/{id}/rechazar")]
 492    [Authorize(Roles = "Administrador,Supervisor")]
 493    [ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status200OK)]
 494    [ProducesResponseType(typeof(ApiResponse<string>), StatusCodes.Status404NotFound)]
 495    [ProducesResponseType(typeof(ApiResponse<string>), StatusCodes.Status409Conflict)]
 496    [ProducesResponseType(typeof(ApiResponse<string>), StatusCodes.Status500InternalServerError)]
 497    public async Task<IActionResult> RechazarLote(long id)
 498    {
 499        try
 500        {
 5501            var (success, _, error) = await _service.RechazarLoteAsync(id);
 3502            if (!success)
 503            {
 2504                if (error?.Contains("no encontrado", StringComparison.OrdinalIgnoreCase) == true)
 1505                    return NotFound(ApiResponse<string>.ErrorResult(error));
 1506                return Conflict(ApiResponse<string>.ErrorResult(error ?? "Error al rechazar lote"));
 507            }
 1508            return Ok(ApiResponse<object>.SuccessResult(new { rechazado = true }, "Lote rechazado"));
 509        }
 1510        catch (PeriodoBloqueadoException ex)
 511        {
 1512            return Conflict(ApiResponse<string>.ErrorResult(ex.Message));
 513        }
 1514        catch (Exception ex)
 515        {
 1516            _logger.LogError(ex, "Error al rechazar lote {Id}", id);
 1517            return StatusCode(500, ApiResponse<string>.ErrorResult("Error interno del servidor"));
 518        }
 5519    }
 520
 521    /// <summary>
 522    /// Revertir lote aprobado a borrador (solo admin, antes de exportar)
 523    /// PUT /api/lotes-compensacion/{id}/revertir
 524    /// </summary>
 525    [HttpPut("api/lotes-compensacion/{id}/revertir")]
 526    [Authorize(Roles = "Administrador,Supervisor")]
 527    [ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status200OK)]
 528    [ProducesResponseType(typeof(ApiResponse<string>), StatusCodes.Status404NotFound)]
 529    [ProducesResponseType(typeof(ApiResponse<string>), StatusCodes.Status409Conflict)]
 530    [ProducesResponseType(typeof(ApiResponse<string>), StatusCodes.Status500InternalServerError)]
 531    public async Task<IActionResult> RevertirLote(long id)
 532    {
 533        try
 534        {
 5535            var (success, _, error) = await _service.RevertirABorradorAsync(id);
 3536            if (!success)
 537            {
 2538                if (error?.Contains("no encontrado", StringComparison.OrdinalIgnoreCase) == true)
 1539                    return NotFound(ApiResponse<string>.ErrorResult(error));
 1540                return Conflict(ApiResponse<string>.ErrorResult(error ?? "Error al revertir lote"));
 541            }
 1542            return Ok(ApiResponse<object>.SuccessResult(new { revertido = true }, "Lote revertido a borrador"));
 543        }
 1544        catch (PeriodoBloqueadoException ex)
 545        {
 1546            return Conflict(ApiResponse<string>.ErrorResult(ex.Message));
 547        }
 1548        catch (Exception ex)
 549        {
 1550            _logger.LogError(ex, "Error al revertir lote {Id}", id);
 1551            return StatusCode(500, ApiResponse<string>.ErrorResult("Error interno del servidor"));
 552        }
 5553    }
 554
 555    /// <summary>
 556    /// Cancelar y eliminar un lote en borrador
 557    /// DELETE /api/lotes-compensacion/{id}
 558    /// </summary>
 559    [HttpDelete("api/lotes-compensacion/{id}")]
 560    [Authorize(Roles = "Administrador,Supervisor,Usuario")]
 561    [ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status200OK)]
 562    [ProducesResponseType(typeof(ApiResponse<string>), StatusCodes.Status404NotFound)]
 563    [ProducesResponseType(typeof(ApiResponse<string>), StatusCodes.Status409Conflict)]
 564    [ProducesResponseType(typeof(ApiResponse<string>), StatusCodes.Status500InternalServerError)]
 565    public async Task<IActionResult> CancelarLote(long id)
 566    {
 567        try
 568        {
 5569            var (success, error) = await _service.CancelarLoteAsync(id);
 3570            if (!success)
 571            {
 2572                if (error?.Contains("no encontrado", StringComparison.OrdinalIgnoreCase) == true)
 1573                    return NotFound(ApiResponse<string>.ErrorResult(error));
 1574                return Conflict(ApiResponse<string>.ErrorResult(error ?? "Error al cancelar lote"));
 575            }
 1576            return Ok(ApiResponse<object>.SuccessResult(new { cancelado = true }, "Lote cancelado y eliminado"));
 577        }
 1578        catch (PeriodoBloqueadoException ex)
 579        {
 1580            return Conflict(ApiResponse<string>.ErrorResult(ex.Message));
 581        }
 1582        catch (Exception ex)
 583        {
 1584            _logger.LogError(ex, "Error al cancelar lote {Id}", id);
 1585            return StatusCode(500, ApiResponse<string>.ErrorResult("Error interno del servidor"));
 586        }
 5587    }
 588
 589    // ── Ítems de Lote ────────────────────────────────────────────────────────
 590
 591    /// <summary>
 592    /// Listar ítems de un lote con paginación
 593    /// GET /api/lotes-compensacion/{id}/items?page=1&amp;pageSize=50
 594    /// </summary>
 595    [HttpGet("api/lotes-compensacion/{id}/items")]
 596    [Authorize(Roles = "Administrador,Supervisor,Usuario")]
 597    [ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status200OK)]
 598    [ProducesResponseType(typeof(ApiResponse<string>), StatusCodes.Status500InternalServerError)]
 599    public async Task<IActionResult> GetItems(long id,
 600        [FromQuery] int page = 1,
 601        [FromQuery] int pageSize = 50)
 602    {
 603        try
 604        {
 2605            if (page < 1) page = 1;
 2606            if (pageSize < 1 || pageSize > 200) pageSize = 50;
 2607            var resultado = await _service.GetItemsPagedAsync(id, page, pageSize);
 1608            return Ok(ApiResponse<object>.SuccessResult(resultado));
 609        }
 1610        catch (Exception ex)
 611        {
 1612            _logger.LogError(ex, "Error al obtener ítems del lote {Id}", id);
 1613            return StatusCode(500, ApiResponse<string>.ErrorResult("Error interno del servidor"));
 614        }
 2615    }
 616
 617    /// <summary>
 618    /// Agregar ítem a lote (debe estar en estado borrador)
 619    /// POST /api/lotes-compensacion/{id}/items
 620    /// </summary>
 621    [HttpPost("api/lotes-compensacion/{id}/items")]
 622    [Authorize(Roles = "Administrador,Supervisor,Usuario")]
 623    [ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status201Created)]
 624    [ProducesResponseType(typeof(ApiResponse<string>), StatusCodes.Status400BadRequest)]
 625    [ProducesResponseType(typeof(ApiResponse<string>), StatusCodes.Status404NotFound)]
 626    [ProducesResponseType(typeof(ApiResponse<string>), StatusCodes.Status409Conflict)]
 627    [ProducesResponseType(typeof(ApiResponse<string>), StatusCodes.Status500InternalServerError)]
 628    public async Task<IActionResult> CreateItem(long id, [FromBody] CreateItemLoteRequest request)
 629    {
 630        try
 631        {
 6632            var (success, item, error) = await _service.CreateItemAsync(id, request);
 5633            if (!success)
 634            {
 4635                if (error?.Contains("no encontrado", StringComparison.OrdinalIgnoreCase) == true)
 1636                    return NotFound(ApiResponse<string>.ErrorResult(error));
 3637                if (error?.Contains("Ya existe", StringComparison.OrdinalIgnoreCase) == true)
 1638                    return Conflict(ApiResponse<string>.ErrorResult(error));
 2639                if (error?.Contains("estado", StringComparison.OrdinalIgnoreCase) == true)
 1640                    return Conflict(ApiResponse<string>.ErrorResult(error));
 1641                return BadRequest(ApiResponse<string>.ErrorResult(error ?? "Error al agregar ítem"));
 642            }
 643
 1644            return StatusCode(201, ApiResponse<object>.SuccessResult(new { id = item!.Id, creado = true }, "Ítem agregad
 645        }
 1646        catch (PeriodoBloqueadoException ex)
 647        {
 1648            return Conflict(ApiResponse<string>.ErrorResult(ex.Message));
 649        }
 0650        catch (Exception ex)
 651        {
 0652            _logger.LogError(ex, "Error al agregar ítem al lote {LoteId}", id);
 0653            return StatusCode(500, ApiResponse<string>.ErrorResult("Error interno del servidor"));
 654        }
 6655    }
 656
 657    /// <summary>
 658    /// Carga masiva de ítems vía JSON array
 659    /// POST /api/lotes-compensacion/{id}/items/bulk?modo=reemplazar|agregar&amp;dryRun=false
 660    /// modo=reemplazar (default): elimina todos los ítems existentes antes de insertar (comportamiento legacy)
 661    /// modo=agregar: agrega sin borrar los existentes (para correcciones parciales)
 662    /// dryRun=true: valida sin persistir — devuelve el mismo resultado pero sin guardar nada
 663    /// </summary>
 664    [HttpPost("api/lotes-compensacion/{id}/items/bulk")]
 665    [Authorize(Roles = "Administrador,Supervisor,Usuario")]
 666    [ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status200OK)]
 667    [ProducesResponseType(typeof(ApiResponse<string>), StatusCodes.Status400BadRequest)]
 668    [ProducesResponseType(typeof(ApiResponse<string>), StatusCodes.Status409Conflict)]
 669    [ProducesResponseType(typeof(ApiResponse<string>), StatusCodes.Status500InternalServerError)]
 670    public async Task<IActionResult> CreateItemsBulk(
 671        long id,
 672        [FromBody] List<CreateItemLoteRequest> items,
 673        [FromQuery] string modo = "reemplazar",
 674        [FromQuery] bool dryRun = false)
 675    {
 676        try
 677        {
 5678            if (items == null || items.Count == 0)
 1679                return BadRequest(ApiResponse<string>.ErrorResult("La lista de ítems no puede estar vacía"));
 680
 4681            if (modo != "reemplazar" && modo != "agregar")
 1682                return BadRequest(ApiResponse<string>.ErrorResult("modo debe ser 'reemplazar' o 'agregar'"));
 683
 3684            var resultado = await _service.CreateItemsBulkAsync(id, items, modo, dryRun);
 1685            var msg = dryRun
 1686                ? $"Dry run: {resultado.Exitosos} válidos, {resultado.Fallidos} con errores (sin cambios)"
 1687                : $"Bulk {modo}: {resultado.Exitosos} exitosos, {resultado.Fallidos} fallidos";
 1688            return Ok(ApiResponse<object>.SuccessResult(resultado, msg));
 689        }
 1690        catch (PeriodoBloqueadoException ex)
 691        {
 1692            return Conflict(ApiResponse<string>.ErrorResult(ex.Message));
 693        }
 1694        catch (Exception ex)
 695        {
 1696            _logger.LogError(ex, "Error en bulk import lote {LoteId}", id);
 1697            return StatusCode(500, ApiResponse<string>.ErrorResult("Error interno del servidor"));
 698        }
 5699    }
 700
 701    /// <summary>
 702    /// Carga masiva de ítems vía archivo CSV (multipart/form-data)
 703    /// POST /api/lotes-compensacion/{id}/items/bulk-csv?modo=reemplazar|agregar&amp;dryRun=false
 704    /// Formato CSV: cedula,monto,unidad_cantidad,funcion,incidencia,observaciones
 705    /// SAR/PAN requieren columna 'funcion' con valor CA o SA
 706    /// </summary>
 707    [HttpPost("api/lotes-compensacion/{id}/items/bulk-csv")]
 708    [Authorize(Roles = "Administrador,Supervisor,Usuario")]
 709    [ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status200OK)]
 710    [ProducesResponseType(typeof(ApiResponse<string>), StatusCodes.Status400BadRequest)]
 711    [ProducesResponseType(typeof(ApiResponse<string>), StatusCodes.Status500InternalServerError)]
 712    public async Task<IActionResult> CreateItemsBulkCsv(
 713        long id,
 714        IFormFile archivo,
 715        [FromQuery] string modo = "reemplazar",
 716        [FromQuery] bool dryRun = false)
 717    {
 718        try
 719        {
 5720            if (archivo == null || archivo.Length == 0)
 1721                return BadRequest(ApiResponse<string>.ErrorResult("Debe adjuntar un archivo CSV"));
 722
 4723            if (!archivo.FileName.EndsWith(".csv", StringComparison.OrdinalIgnoreCase))
 1724                return BadRequest(ApiResponse<string>.ErrorResult("El archivo debe tener extensión .csv"));
 725
 3726            if (modo != "reemplazar" && modo != "agregar")
 1727                return BadRequest(ApiResponse<string>.ErrorResult("modo debe ser 'reemplazar' o 'agregar'"));
 728
 2729            using var stream = archivo.OpenReadStream();
 2730            var resultado = await _service.CreateItemsFromCsvAsync(id, stream, modo, dryRun);
 731
 1732            var msg = dryRun
 1733                ? $"Dry run: {resultado.Exitosos} válidos, {resultado.Fallidos} con errores (sin cambios)"
 1734                : $"CSV {modo}: {resultado.Exitosos} exitosos, {resultado.Fallidos} fallidos";
 1735            return Ok(ApiResponse<object>.SuccessResult(resultado, msg));
 736        }
 1737        catch (Exception ex)
 738        {
 1739            _logger.LogError(ex, "Error en bulk CSV lote {LoteId}", id);
 1740            return StatusCode(500, ApiResponse<string>.ErrorResult("Error interno del servidor"));
 741        }
 5742    }
 743
 744    /// <summary>
 745    /// Actualizar ítem de lote (debe estar en estado borrador)
 746    /// PUT /api/lotes-compensacion/{id}/items/{itemId}
 747    /// </summary>
 748    [HttpPut("api/lotes-compensacion/{id}/items/{itemId}")]
 749    [Authorize(Roles = "Administrador,Supervisor,Usuario")]
 750    [ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status200OK)]
 751    [ProducesResponseType(typeof(ApiResponse<string>), StatusCodes.Status400BadRequest)]
 752    [ProducesResponseType(typeof(ApiResponse<string>), StatusCodes.Status404NotFound)]
 753    [ProducesResponseType(typeof(ApiResponse<string>), StatusCodes.Status409Conflict)]
 754    [ProducesResponseType(typeof(ApiResponse<string>), StatusCodes.Status500InternalServerError)]
 755    public async Task<IActionResult> UpdateItem(long id, long itemId, [FromBody] UpdateItemLoteRequest request)
 756    {
 757        try
 758        {
 5759            var (success, _, error) = await _service.UpdateItemAsync(id, itemId, request);
 4760            if (!success)
 761            {
 3762                if (error?.Contains("no encontrado", StringComparison.OrdinalIgnoreCase) == true)
 1763                    return NotFound(ApiResponse<string>.ErrorResult(error));
 2764                if (error?.Contains("estado", StringComparison.OrdinalIgnoreCase) == true)
 1765                    return Conflict(ApiResponse<string>.ErrorResult(error));
 1766                return BadRequest(ApiResponse<string>.ErrorResult(error ?? "Error al actualizar ítem"));
 767            }
 768
 1769            return Ok(ApiResponse<object>.SuccessResult(new { actualizado = true }, "Ítem actualizado exitosamente"));
 770        }
 1771        catch (PeriodoBloqueadoException ex)
 772        {
 1773            return Conflict(ApiResponse<string>.ErrorResult(ex.Message));
 774        }
 0775        catch (Exception ex)
 776        {
 0777            _logger.LogError(ex, "Error al actualizar ítem {ItemId} del lote {LoteId}", itemId, id);
 0778            return StatusCode(500, ApiResponse<string>.ErrorResult("Error interno del servidor"));
 779        }
 5780    }
 781
 782    /// <summary>
 783    /// Eliminar ítem de lote (debe estar en estado borrador)
 784    /// DELETE /api/lotes-compensacion/{id}/items/{itemId}
 785    /// </summary>
 786    [HttpDelete("api/lotes-compensacion/{id}/items/{itemId}")]
 787    [Authorize(Roles = "Administrador,Supervisor,Usuario")]
 788    [ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status200OK)]
 789    [ProducesResponseType(typeof(ApiResponse<string>), StatusCodes.Status400BadRequest)]
 790    [ProducesResponseType(typeof(ApiResponse<string>), StatusCodes.Status404NotFound)]
 791    [ProducesResponseType(typeof(ApiResponse<string>), StatusCodes.Status409Conflict)]
 792    [ProducesResponseType(typeof(ApiResponse<string>), StatusCodes.Status500InternalServerError)]
 793    public async Task<IActionResult> DeleteItem(long id, long itemId)
 794    {
 795        try
 796        {
 6797            var (success, error) = await _service.DeleteItemAsync(id, itemId);
 4798            if (!success)
 799            {
 3800                if (error?.Contains("no encontrado", StringComparison.OrdinalIgnoreCase) == true)
 1801                    return NotFound(ApiResponse<string>.ErrorResult(error));
 2802                if (error?.Contains("estado", StringComparison.OrdinalIgnoreCase) == true)
 1803                    return Conflict(ApiResponse<string>.ErrorResult(error));
 1804                return BadRequest(ApiResponse<string>.ErrorResult(error ?? "Error al eliminar ítem"));
 805            }
 806
 1807            return Ok(ApiResponse<object>.SuccessResult(new { eliminado = true }, "Ítem eliminado exitosamente"));
 808        }
 1809        catch (PeriodoBloqueadoException ex)
 810        {
 1811            return Conflict(ApiResponse<string>.ErrorResult(ex.Message));
 812        }
 1813        catch (Exception ex)
 814        {
 1815            _logger.LogError(ex, "Error al eliminar ítem {ItemId} del lote {LoteId}", itemId, id);
 1816            return StatusCode(500, ApiResponse<string>.ErrorResult("Error interno del servidor"));
 817        }
 6818    }
 819}