feat(api): ProductPricesController + DI + ExceptionFilter integration (PRD-003)
- GET /api/v1/products/{id}/prices [Authorize] → 200 IReadOnlyList<ProductPriceDto>
- POST /api/v1/admin/products/{id}/prices [RequirePermission catalogo:productos:gestionar] → 201 AddProductPriceResponse + Location header
- ExceptionFilter: 3 new cases (ProductPriceForwardOnlyException→409, ProductPriceInvalidException→400, ProductSinPrecioActivoException→404)
- Fix AddProductPriceCommandHandler: move GetByProductIdAsync outside TransactionScope using block to avoid InvalidOperationException (scope already complete)
- 16 e2e tests in ProductPricesControllerTests: 401/403, 200 history ordered DESC, 404 not found, 201 first/second price, 400 validation, 409 forward-only, audit event, DateOnly yyyy-MM-dd roundtrip
- 305 Api.Tests + 1088 Application.Tests = 1393 total, 0 red
This commit is contained in:
93
src/api/SIGCM2.Api/Controllers/ProductPricesController.cs
Normal file
93
src/api/SIGCM2.Api/Controllers/ProductPricesController.cs
Normal file
@@ -0,0 +1,93 @@
|
||||
using FluentValidation;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using SIGCM2.Api.Authorization;
|
||||
using SIGCM2.Application.Abstractions;
|
||||
using SIGCM2.Application.Products.Prices;
|
||||
using SIGCM2.Application.Products.Prices.AddPrice;
|
||||
using SIGCM2.Application.Products.Prices.GetHistory;
|
||||
|
||||
namespace SIGCM2.Api.Controllers;
|
||||
|
||||
/// <summary>
|
||||
/// PRD-003: ProductPrices historic pricing management.
|
||||
/// Read endpoint at GET /api/v1/products/{id}/prices — requires authentication (any role).
|
||||
/// Write endpoint at POST /api/v1/admin/products/{id}/prices — requires 'catalogo:productos:gestionar'.
|
||||
/// </summary>
|
||||
[ApiController]
|
||||
public sealed class ProductPricesController : ControllerBase
|
||||
{
|
||||
private readonly IDispatcher _dispatcher;
|
||||
private readonly IValidator<AddProductPriceCommand> _addValidator;
|
||||
|
||||
public ProductPricesController(
|
||||
IDispatcher dispatcher,
|
||||
IValidator<AddProductPriceCommand> addValidator)
|
||||
{
|
||||
_dispatcher = dispatcher;
|
||||
_addValidator = addValidator;
|
||||
}
|
||||
|
||||
// ── READ endpoint ──────────────────────────────────────────────────────────
|
||||
|
||||
/// <summary>
|
||||
/// Returns the full price history for a Product, ordered descending by PriceValidFrom.
|
||||
/// Returns 200 with empty array if the product has no prices yet.
|
||||
/// Returns 404 if the product does not exist.
|
||||
/// </summary>
|
||||
[HttpGet("api/v1/products/{id:int}/prices")]
|
||||
[Authorize]
|
||||
[ProducesResponseType(typeof(IReadOnlyList<ProductPriceDto>), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
public async Task<IActionResult> GetProductPrices([FromRoute] int id)
|
||||
{
|
||||
var query = new GetProductPricesQuery(id);
|
||||
var result = await _dispatcher.Send<GetProductPricesQuery, IReadOnlyList<ProductPriceDto>>(query);
|
||||
return Ok(result);
|
||||
}
|
||||
|
||||
// ── WRITE endpoint ─────────────────────────────────────────────────────────
|
||||
|
||||
/// <summary>
|
||||
/// Adds a new price to a Product. Closes the current active price if one exists.
|
||||
/// PriceValidFrom must be >= today_AR and strictly greater than the active price's PriceValidFrom.
|
||||
/// Returns 201 Created with Location header pointing to GET /api/v1/products/{id}/prices.
|
||||
/// </summary>
|
||||
[HttpPost("api/v1/admin/products/{id:int}/prices")]
|
||||
[RequirePermission("catalogo:productos:gestionar")]
|
||||
[ProducesResponseType(typeof(AddProductPriceResponse), StatusCodes.Status201Created)]
|
||||
[ProducesResponseType(StatusCodes.Status400BadRequest)]
|
||||
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
|
||||
[ProducesResponseType(StatusCodes.Status403Forbidden)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
[ProducesResponseType(StatusCodes.Status409Conflict)]
|
||||
public async Task<IActionResult> AddProductPrice(
|
||||
[FromRoute] int id,
|
||||
[FromBody] AddProductPriceRequest request)
|
||||
{
|
||||
var command = new AddProductPriceCommand(
|
||||
ProductId: id,
|
||||
Price: request.Price,
|
||||
PriceValidFrom: request.PriceValidFrom);
|
||||
|
||||
var validation = await _addValidator.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<AddProductPriceCommand, AddProductPriceResponse>(command);
|
||||
return CreatedAtAction(nameof(GetProductPrices), new { id }, result);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Request body record ───────────────────────────────────────────────────────
|
||||
|
||||
/// <summary>PRD-003: Add ProductPrice request body.</summary>
|
||||
public sealed record AddProductPriceRequest(
|
||||
decimal Price,
|
||||
DateOnly PriceValidFrom);
|
||||
@@ -475,6 +475,45 @@ public sealed class ExceptionFilter : IExceptionFilter
|
||||
context.ExceptionHandled = true;
|
||||
break;
|
||||
|
||||
// PRD-003: ProductPrices exceptions
|
||||
case ProductPriceForwardOnlyException forwardOnlyEx:
|
||||
context.Result = new ObjectResult(new
|
||||
{
|
||||
error = "product_price_forward_only",
|
||||
message = forwardOnlyEx.Message,
|
||||
productId = forwardOnlyEx.ProductId
|
||||
})
|
||||
{
|
||||
StatusCode = StatusCodes.Status409Conflict
|
||||
};
|
||||
context.ExceptionHandled = true;
|
||||
break;
|
||||
|
||||
case ProductPriceInvalidException priceInvalidEx:
|
||||
context.Result = new ObjectResult(new
|
||||
{
|
||||
error = "product_price_invalid",
|
||||
message = priceInvalidEx.Message,
|
||||
field = priceInvalidEx.Field
|
||||
})
|
||||
{
|
||||
StatusCode = StatusCodes.Status400BadRequest
|
||||
};
|
||||
context.ExceptionHandled = true;
|
||||
break;
|
||||
|
||||
case ProductSinPrecioActivoException sinPrecioEx:
|
||||
context.Result = new ObjectResult(new
|
||||
{
|
||||
error = "product_sin_precio_activo",
|
||||
message = sinPrecioEx.Message
|
||||
})
|
||||
{
|
||||
StatusCode = StatusCodes.Status404NotFound
|
||||
};
|
||||
context.ExceptionHandled = true;
|
||||
break;
|
||||
|
||||
// PRD-002: Product exceptions
|
||||
case ProductNotFoundException productNotFoundEx:
|
||||
context.Result = new ObjectResult(new
|
||||
|
||||
@@ -44,30 +44,35 @@ public sealed class AddProductPriceCommandHandler
|
||||
|
||||
// 2. TX + SP + audit (fail-closed).
|
||||
// El audit.LogAsync enlista en el mismo TransactionScope — si falla, rollback total.
|
||||
using var tx = new TransactionScope(
|
||||
// GetByProductIdAsync se ejecuta FUERA del scope (post-commit) para evitar
|
||||
// "TransactionScope is already complete" al abrir una nueva conexión dentro del using.
|
||||
long newId;
|
||||
long? closedId;
|
||||
using (var tx = new TransactionScope(
|
||||
TransactionScopeOption.Required,
|
||||
new TransactionOptions { IsolationLevel = System.Transactions.IsolationLevel.ReadCommitted },
|
||||
TransactionScopeAsyncFlowOption.Enabled);
|
||||
TransactionScopeAsyncFlowOption.Enabled))
|
||||
{
|
||||
(newId, closedId) = await _pricesRepo.AddAsync(
|
||||
command.ProductId, command.Price, command.PriceValidFrom);
|
||||
|
||||
var (newId, closedId) = await _pricesRepo.AddAsync(
|
||||
command.ProductId, command.Price, command.PriceValidFrom);
|
||||
|
||||
await _audit.LogAsync(
|
||||
action: "product_price.created",
|
||||
targetType: "ProductPrice",
|
||||
targetId: newId.ToString(),
|
||||
metadata: new
|
||||
{
|
||||
after = new
|
||||
await _audit.LogAsync(
|
||||
action: "product_price.created",
|
||||
targetType: "ProductPrice",
|
||||
targetId: newId.ToString(),
|
||||
metadata: new
|
||||
{
|
||||
command.ProductId,
|
||||
command.Price,
|
||||
priceValidFrom = command.PriceValidFrom.ToString("yyyy-MM-dd"),
|
||||
},
|
||||
closedPriceId = closedId
|
||||
});
|
||||
after = new
|
||||
{
|
||||
command.ProductId,
|
||||
command.Price,
|
||||
priceValidFrom = command.PriceValidFrom.ToString("yyyy-MM-dd"),
|
||||
},
|
||||
closedPriceId = closedId
|
||||
});
|
||||
|
||||
tx.Complete();
|
||||
tx.Complete();
|
||||
} // TX disposed (committed) here — BEFORE the post-commit read below.
|
||||
|
||||
// 3. Compongo la respuesta post-commit con lectura de historial actualizado.
|
||||
var prices = await _pricesRepo.GetByProductIdAsync(command.ProductId);
|
||||
|
||||
Reference in New Issue
Block a user