diff --git a/src/api/SIGCM2.Api/Controllers/PuntosDeVentaController.cs b/src/api/SIGCM2.Api/Controllers/PuntosDeVentaController.cs new file mode 100644 index 0000000..cc3b2c2 --- /dev/null +++ b/src/api/SIGCM2.Api/Controllers/PuntosDeVentaController.cs @@ -0,0 +1,206 @@ +using FluentValidation; +using Microsoft.AspNetCore.Mvc; +using SIGCM2.Api.Authorization; +using SIGCM2.Application.Abstractions; +using SIGCM2.Application.Common; +using SIGCM2.Application.PuntosDeVenta.Create; +using SIGCM2.Application.PuntosDeVenta.Deactivate; +using SIGCM2.Application.PuntosDeVenta.GetById; +using SIGCM2.Application.PuntosDeVenta.List; +using SIGCM2.Application.PuntosDeVenta.ProximoNumero; +using SIGCM2.Application.PuntosDeVenta.Reactivate; +using SIGCM2.Application.PuntosDeVenta.Reservar; +using SIGCM2.Application.PuntosDeVenta.Update; +using SIGCM2.Domain.Enums; + +namespace SIGCM2.Api.Controllers; + +/// +/// ADM-008: PuntoDeVenta management endpoints at /api/v1/admin/puntos-de-venta. +/// All endpoints require permission 'administracion:puntos_de_venta:gestionar'. +/// +[ApiController] +[Route("api/v1/admin/puntos-de-venta")] +public sealed class PuntosDeVentaController : ControllerBase +{ + private readonly IDispatcher _dispatcher; + private readonly IValidator _createValidator; + private readonly IValidator _updateValidator; + + public PuntosDeVentaController( + IDispatcher dispatcher, + IValidator createValidator, + IValidator updateValidator) + { + _dispatcher = dispatcher; + _createValidator = createValidator; + _updateValidator = updateValidator; + } + + /// Creates a new punto de venta. Requires administracion:puntos_de_venta:gestionar. + [HttpPost] + [RequirePermission("administracion:puntos_de_venta:gestionar")] + [ProducesResponseType(typeof(PuntoDeVentaCreatedDto), StatusCodes.Status201Created)] + [ProducesResponseType(StatusCodes.Status400BadRequest)] + [ProducesResponseType(StatusCodes.Status401Unauthorized)] + [ProducesResponseType(StatusCodes.Status403Forbidden)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + [ProducesResponseType(StatusCodes.Status409Conflict)] + public async Task CreatePuntoDeVenta([FromBody] CreatePuntoDeVentaRequest request) + { + var command = new CreatePuntoDeVentaCommand( + MedioId: request.MedioId ?? 0, + NumeroAFIP: request.NumeroAFIP ?? 0, + Nombre: request.Nombre ?? string.Empty, + Descripcion: request.Descripcion); + + var validation = await _createValidator.ValidateAsync(command); + if (!validation.IsValid) + { + var errors = validation.Errors + .GroupBy(e => e.PropertyName) + .ToDictionary(g => g.Key, g => g.Select(e => e.ErrorMessage).ToArray()); + return BadRequest(new { errors }); + } + + var result = await _dispatcher.Send(command); + return CreatedAtAction(nameof(GetPuntoDeVentaById), new { id = result.Id }, result); + } + + /// Lists puntos de venta with optional filters. + [HttpGet] + [RequirePermission("administracion:puntos_de_venta:gestionar")] + [ProducesResponseType(typeof(PagedResult), StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status401Unauthorized)] + [ProducesResponseType(StatusCodes.Status403Forbidden)] + public async Task ListPuntosDeVenta( + [FromQuery] int page = 1, + [FromQuery] int pageSize = 20, + [FromQuery] int? medioId = null, + [FromQuery] bool? activo = null) + { + if (page < 1) return BadRequest(new { error = "page must be >= 1" }); + if (pageSize < 1) return BadRequest(new { error = "pageSize must be >= 1" }); + + var query = new ListPuntosDeVentaQuery(page, pageSize, medioId, activo); + var result = await _dispatcher.Send>(query); + return Ok(result); + } + + /// Gets a single punto de venta by id. + [HttpGet("{id:int}")] + [RequirePermission("administracion:puntos_de_venta:gestionar")] + [ProducesResponseType(typeof(PuntoDeVentaDetailDto), StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status401Unauthorized)] + [ProducesResponseType(StatusCodes.Status403Forbidden)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task GetPuntoDeVentaById([FromRoute] int id) + { + var query = new GetPuntoDeVentaByIdQuery(id); + var result = await _dispatcher.Send(query); + return Ok(result); + } + + /// Updates a punto de venta's editable fields. + [HttpPut("{id:int}")] + [RequirePermission("administracion:puntos_de_venta:gestionar")] + [ProducesResponseType(typeof(PuntoDeVentaUpdatedDto), StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status400BadRequest)] + [ProducesResponseType(StatusCodes.Status401Unauthorized)] + [ProducesResponseType(StatusCodes.Status403Forbidden)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + [ProducesResponseType(StatusCodes.Status409Conflict)] + public async Task UpdatePuntoDeVenta([FromRoute] int id, [FromBody] UpdatePuntoDeVentaRequest request) + { + var command = new UpdatePuntoDeVentaCommand( + Id: id, + Nombre: request.Nombre ?? string.Empty, + NumeroAFIP: request.NumeroAFIP ?? 0, + Descripcion: request.Descripcion); + + var validation = await _updateValidator.ValidateAsync(command); + if (!validation.IsValid) + { + var errors = validation.Errors + .GroupBy(e => e.PropertyName) + .ToDictionary(g => g.Key, g => g.Select(e => e.ErrorMessage).ToArray()); + return BadRequest(new { errors }); + } + + var result = await _dispatcher.Send(command); + return Ok(result); + } + + /// Deactivates a punto de venta. + [HttpPost("{id:int}/deactivate")] + [RequirePermission("administracion:puntos_de_venta:gestionar")] + [ProducesResponseType(StatusCodes.Status204NoContent)] + [ProducesResponseType(StatusCodes.Status401Unauthorized)] + [ProducesResponseType(StatusCodes.Status403Forbidden)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task DeactivatePuntoDeVenta([FromRoute] int id) + { + var command = new DeactivatePuntoDeVentaCommand(id); + await _dispatcher.Send(command); + return NoContent(); + } + + /// Reactivates a punto de venta (only if parent Medio is active). + [HttpPost("{id:int}/reactivate")] + [RequirePermission("administracion:puntos_de_venta:gestionar")] + [ProducesResponseType(StatusCodes.Status204NoContent)] + [ProducesResponseType(StatusCodes.Status401Unauthorized)] + [ProducesResponseType(StatusCodes.Status403Forbidden)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + [ProducesResponseType(StatusCodes.Status409Conflict)] + public async Task ReactivatePuntoDeVenta([FromRoute] int id) + { + var command = new ReactivatePuntoDeVentaCommand(id); + await _dispatcher.Send(command); + return NoContent(); + } + + /// Reserves the next sequential number for a given PdV and TipoComprobante. + [HttpPost("{id:int}/secuencias/{tipoComprobante}/reservar")] + [RequirePermission("administracion:puntos_de_venta:gestionar")] + [ProducesResponseType(typeof(ReservaNumeroDto), StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status401Unauthorized)] + [ProducesResponseType(StatusCodes.Status403Forbidden)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + [ProducesResponseType(StatusCodes.Status409Conflict)] + public async Task ReservarNumero([FromRoute] int id, [FromRoute] TipoComprobante tipoComprobante) + { + var command = new ReservarNumeroCommand(id, tipoComprobante); + var result = await _dispatcher.Send(command); + return Ok(result); + } + + /// Returns the next available number (read-only, no reservation). + [HttpGet("{id:int}/secuencias/{tipoComprobante}/proximo")] + [RequirePermission("administracion:puntos_de_venta:gestionar")] + [ProducesResponseType(typeof(ProximoNumeroDto), StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status401Unauthorized)] + [ProducesResponseType(StatusCodes.Status403Forbidden)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task GetProximoNumero([FromRoute] int id, [FromRoute] TipoComprobante tipoComprobante) + { + var query = new GetProximoNumeroQuery(id, tipoComprobante); + var result = await _dispatcher.Send(query); + return Ok(result); + } +} + +// ── Request body records ────────────────────────────────────────────────────── + +/// ADM-008: Create punto de venta request body. +public sealed record CreatePuntoDeVentaRequest( + int? MedioId, + short? NumeroAFIP, + string? Nombre, + string? Descripcion); + +/// ADM-008: Update punto de venta request body. +public sealed record UpdatePuntoDeVentaRequest( + string? Nombre, + short? NumeroAFIP, + string? Descripcion); diff --git a/src/api/SIGCM2.Api/Filters/ExceptionFilter.cs b/src/api/SIGCM2.Api/Filters/ExceptionFilter.cs index a9682ff..35fb5be 100644 --- a/src/api/SIGCM2.Api/Filters/ExceptionFilter.cs +++ b/src/api/SIGCM2.Api/Filters/ExceptionFilter.cs @@ -231,6 +231,43 @@ public sealed class ExceptionFilter : IExceptionFilter context.ExceptionHandled = true; break; + // ADM-008: PuntoDeVenta exceptions + case PuntoDeVentaNotFoundException puntoDeVentaNotFoundEx: + context.Result = new ObjectResult(new + { + error = "punto_de_venta_not_found", + message = puntoDeVentaNotFoundEx.Message + }) + { + StatusCode = StatusCodes.Status404NotFound + }; + context.ExceptionHandled = true; + break; + + case PuntoDeVentaInactivoException puntoDeVentaInactivoEx: + context.Result = new ObjectResult(new + { + error = "punto_de_venta_inactivo", + message = puntoDeVentaInactivoEx.Message + }) + { + StatusCode = StatusCodes.Status409Conflict + }; + context.ExceptionHandled = true; + break; + + case NumeroAFIPDuplicadoException numeroAFIPDupEx: + context.Result = new ObjectResult(new + { + error = "numero_afip_duplicado", + message = numeroAFIPDupEx.Message + }) + { + StatusCode = StatusCodes.Status409Conflict + }; + context.ExceptionHandled = true; + break; + // UDT-009: permiso override validation errors case InvalidPermisoCodesException ipce: context.Result = new ObjectResult(new Microsoft.AspNetCore.Mvc.ProblemDetails