ADM-008: Puntos de Venta (CRUD fundacional) #19
206
src/api/SIGCM2.Api/Controllers/PuntosDeVentaController.cs
Normal file
206
src/api/SIGCM2.Api/Controllers/PuntosDeVentaController.cs
Normal file
@@ -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;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// ADM-008: PuntoDeVenta management endpoints at /api/v1/admin/puntos-de-venta.
|
||||||
|
/// All endpoints require permission 'administracion:puntos_de_venta:gestionar'.
|
||||||
|
/// </summary>
|
||||||
|
[ApiController]
|
||||||
|
[Route("api/v1/admin/puntos-de-venta")]
|
||||||
|
public sealed class PuntosDeVentaController : ControllerBase
|
||||||
|
{
|
||||||
|
private readonly IDispatcher _dispatcher;
|
||||||
|
private readonly IValidator<CreatePuntoDeVentaCommand> _createValidator;
|
||||||
|
private readonly IValidator<UpdatePuntoDeVentaCommand> _updateValidator;
|
||||||
|
|
||||||
|
public PuntosDeVentaController(
|
||||||
|
IDispatcher dispatcher,
|
||||||
|
IValidator<CreatePuntoDeVentaCommand> createValidator,
|
||||||
|
IValidator<UpdatePuntoDeVentaCommand> updateValidator)
|
||||||
|
{
|
||||||
|
_dispatcher = dispatcher;
|
||||||
|
_createValidator = createValidator;
|
||||||
|
_updateValidator = updateValidator;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Creates a new punto de venta. Requires administracion:puntos_de_venta:gestionar.</summary>
|
||||||
|
[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<IActionResult> 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<CreatePuntoDeVentaCommand, PuntoDeVentaCreatedDto>(command);
|
||||||
|
return CreatedAtAction(nameof(GetPuntoDeVentaById), new { id = result.Id }, result);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Lists puntos de venta with optional filters.</summary>
|
||||||
|
[HttpGet]
|
||||||
|
[RequirePermission("administracion:puntos_de_venta:gestionar")]
|
||||||
|
[ProducesResponseType(typeof(PagedResult<PuntoDeVentaListItemDto>), StatusCodes.Status200OK)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status403Forbidden)]
|
||||||
|
public async Task<IActionResult> 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<ListPuntosDeVentaQuery, PagedResult<PuntoDeVentaListItemDto>>(query);
|
||||||
|
return Ok(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Gets a single punto de venta by id.</summary>
|
||||||
|
[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<IActionResult> GetPuntoDeVentaById([FromRoute] int id)
|
||||||
|
{
|
||||||
|
var query = new GetPuntoDeVentaByIdQuery(id);
|
||||||
|
var result = await _dispatcher.Send<GetPuntoDeVentaByIdQuery, PuntoDeVentaDetailDto>(query);
|
||||||
|
return Ok(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Updates a punto de venta's editable fields.</summary>
|
||||||
|
[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<IActionResult> 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<UpdatePuntoDeVentaCommand, PuntoDeVentaUpdatedDto>(command);
|
||||||
|
return Ok(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Deactivates a punto de venta.</summary>
|
||||||
|
[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<IActionResult> DeactivatePuntoDeVenta([FromRoute] int id)
|
||||||
|
{
|
||||||
|
var command = new DeactivatePuntoDeVentaCommand(id);
|
||||||
|
await _dispatcher.Send<DeactivatePuntoDeVentaCommand, PuntoDeVentaStatusDto>(command);
|
||||||
|
return NoContent();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Reactivates a punto de venta (only if parent Medio is active).</summary>
|
||||||
|
[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<IActionResult> ReactivatePuntoDeVenta([FromRoute] int id)
|
||||||
|
{
|
||||||
|
var command = new ReactivatePuntoDeVentaCommand(id);
|
||||||
|
await _dispatcher.Send<ReactivatePuntoDeVentaCommand, PuntoDeVentaStatusDto>(command);
|
||||||
|
return NoContent();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Reserves the next sequential number for a given PdV and TipoComprobante.</summary>
|
||||||
|
[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<IActionResult> ReservarNumero([FromRoute] int id, [FromRoute] TipoComprobante tipoComprobante)
|
||||||
|
{
|
||||||
|
var command = new ReservarNumeroCommand(id, tipoComprobante);
|
||||||
|
var result = await _dispatcher.Send<ReservarNumeroCommand, ReservaNumeroDto>(command);
|
||||||
|
return Ok(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Returns the next available number (read-only, no reservation).</summary>
|
||||||
|
[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<IActionResult> GetProximoNumero([FromRoute] int id, [FromRoute] TipoComprobante tipoComprobante)
|
||||||
|
{
|
||||||
|
var query = new GetProximoNumeroQuery(id, tipoComprobante);
|
||||||
|
var result = await _dispatcher.Send<GetProximoNumeroQuery, ProximoNumeroDto>(query);
|
||||||
|
return Ok(result);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Request body records ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/// <summary>ADM-008: Create punto de venta request body.</summary>
|
||||||
|
public sealed record CreatePuntoDeVentaRequest(
|
||||||
|
int? MedioId,
|
||||||
|
short? NumeroAFIP,
|
||||||
|
string? Nombre,
|
||||||
|
string? Descripcion);
|
||||||
|
|
||||||
|
/// <summary>ADM-008: Update punto de venta request body.</summary>
|
||||||
|
public sealed record UpdatePuntoDeVentaRequest(
|
||||||
|
string? Nombre,
|
||||||
|
short? NumeroAFIP,
|
||||||
|
string? Descripcion);
|
||||||
@@ -231,6 +231,43 @@ public sealed class ExceptionFilter : IExceptionFilter
|
|||||||
context.ExceptionHandled = true;
|
context.ExceptionHandled = true;
|
||||||
break;
|
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
|
// UDT-009: permiso override validation errors
|
||||||
case InvalidPermisoCodesException ipce:
|
case InvalidPermisoCodesException ipce:
|
||||||
context.Result = new ObjectResult(new Microsoft.AspNetCore.Mvc.ProblemDetails
|
context.Result = new ObjectResult(new Microsoft.AspNetCore.Mvc.ProblemDetails
|
||||||
|
|||||||
Reference in New Issue
Block a user