feat(application): commands/queries + IProductPricingService (PRD-003)
- IProductPriceRepository (AddAsync/GetByProductIdAsync/GetActiveAsync) - ProductPriceDto, AddProductPriceCommand/Response, GetProductPricesQuery - AddProductPriceCommandValidator (FluentValidation + TimeProvider, fecha >= hoy_AR) - AddProductPriceCommandHandler (TransactionScope AsyncFlow, audit fail-closed) - GetProductPricesQueryHandler (verifica producto existe, lista vacía válida) - IProductPricingService + ProductPricingService (GetPriceAtAsync → decimal?) - DI wiring en DependencyInjection.cs - 29 tests NSubstitute + FakeTimeProvider, 1081 Application.Tests GREEN
This commit is contained in:
@@ -0,0 +1,40 @@
|
||||
using SIGCM2.Domain.Entities;
|
||||
|
||||
namespace SIGCM2.Application.Abstractions.Persistence;
|
||||
|
||||
/// <summary>
|
||||
/// PRD-003 — Write + query access to dbo.ProductPrices.
|
||||
/// Implemented by ProductPriceRepository (Dapper) in Infrastructure.
|
||||
/// </summary>
|
||||
public interface IProductPriceRepository
|
||||
{
|
||||
/// <summary>
|
||||
/// Invokes dbo.usp_AddProductPrice inside the ambient TransactionScope.
|
||||
/// Returns (newId, closedId?). Throws:
|
||||
/// - ProductPriceForwardOnlyException on SQL THROW 50409 or unique index violation (2601/2627).
|
||||
/// - ProductNotFoundException on SQL THROW 50404.
|
||||
/// </summary>
|
||||
Task<(long NewId, long? ClosedId)> AddAsync(
|
||||
int productId,
|
||||
decimal price,
|
||||
DateOnly priceValidFrom,
|
||||
CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// Returns all price rows for the product, ordered descending by PriceValidFrom (active first).
|
||||
/// Returns empty list when the product has no price history.
|
||||
/// </summary>
|
||||
Task<IReadOnlyList<ProductPrice>> GetByProductIdAsync(
|
||||
int productId,
|
||||
CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// Returns the ProductPrice row whose window [PriceValidFrom, PriceValidTo] covers the given
|
||||
/// civil date, or null if no row matches (no history, or date is before any recorded price).
|
||||
/// Used by ProductPricingService.GetPriceAtAsync.
|
||||
/// </summary>
|
||||
Task<ProductPrice?> GetActiveAsync(
|
||||
int productId,
|
||||
DateOnly date,
|
||||
CancellationToken ct = default);
|
||||
}
|
||||
@@ -74,6 +74,10 @@ using SIGCM2.Application.Products.Update;
|
||||
using SIGCM2.Application.Products.Deactivate;
|
||||
using SIGCM2.Application.Products.GetById;
|
||||
using SIGCM2.Application.Products.List;
|
||||
using SIGCM2.Application.Products.Prices;
|
||||
using SIGCM2.Application.Products.Prices.AddPrice;
|
||||
using SIGCM2.Application.Products.Prices.GetHistory;
|
||||
using SIGCM2.Application.Products.Pricing;
|
||||
using SIGCM2.Application.ProductTypes.Create;
|
||||
using SIGCM2.Application.ProductTypes.Update;
|
||||
using SIGCM2.Application.ProductTypes.Deactivate;
|
||||
@@ -182,6 +186,11 @@ public static class DependencyInjection
|
||||
services.AddScoped<ICommandHandler<GetProductByIdQuery, ProductDetailDto>, GetProductByIdQueryHandler>();
|
||||
services.AddScoped<ICommandHandler<ListProductsQuery, PagedResult<ProductListItemDto>>, ListProductsQueryHandler>();
|
||||
|
||||
// ProductPrices (PRD-003)
|
||||
services.AddScoped<ICommandHandler<AddProductPriceCommand, AddProductPriceResponse>, AddProductPriceCommandHandler>();
|
||||
services.AddScoped<ICommandHandler<GetProductPricesQuery, IReadOnlyList<ProductPriceDto>>, GetProductPricesQueryHandler>();
|
||||
services.AddScoped<IProductPricingService, ProductPricingService>();
|
||||
|
||||
// ProductTypes (PRD-001)
|
||||
// IProductQueryRepository is now bound in Infrastructure DI (PRD-002) against dbo.Product.
|
||||
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
namespace SIGCM2.Application.Products.Prices.AddPrice;
|
||||
|
||||
/// <summary>
|
||||
/// PRD-003 — Comando para registrar un nuevo precio histórico para un Product.
|
||||
/// Price debe ser > 0. PriceValidFrom debe ser >= hoy_AR (Cat2, TimeProvider).
|
||||
/// </summary>
|
||||
public sealed record AddProductPriceCommand(
|
||||
int ProductId,
|
||||
decimal Price,
|
||||
DateOnly PriceValidFrom);
|
||||
@@ -0,0 +1,84 @@
|
||||
using System.Transactions;
|
||||
using SIGCM2.Application.Abstractions;
|
||||
using SIGCM2.Application.Abstractions.Persistence;
|
||||
using SIGCM2.Application.Audit;
|
||||
using SIGCM2.Domain.Entities;
|
||||
using SIGCM2.Domain.Exceptions;
|
||||
|
||||
namespace SIGCM2.Application.Products.Prices.AddPrice;
|
||||
|
||||
/// <summary>
|
||||
/// PRD-003 — Handler del comando AddProductPrice.
|
||||
/// Flujo: verifica producto activo → abre TransactionScope (AsyncFlow) →
|
||||
/// AddAsync (SP usp_AddProductPrice) → IAuditLogger.LogAsync (fail-closed) →
|
||||
/// tx.Complete() → construye response con GetByProductIdAsync.
|
||||
/// </summary>
|
||||
public sealed class AddProductPriceCommandHandler
|
||||
: ICommandHandler<AddProductPriceCommand, AddProductPriceResponse>
|
||||
{
|
||||
private readonly IProductPriceRepository _pricesRepo;
|
||||
private readonly IProductRepository _productsRepo;
|
||||
private readonly IAuditLogger _audit;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
|
||||
public AddProductPriceCommandHandler(
|
||||
IProductPriceRepository pricesRepo,
|
||||
IProductRepository productsRepo,
|
||||
IAuditLogger audit,
|
||||
TimeProvider timeProvider)
|
||||
{
|
||||
_pricesRepo = pricesRepo;
|
||||
_productsRepo = productsRepo;
|
||||
_audit = audit;
|
||||
_timeProvider = timeProvider;
|
||||
}
|
||||
|
||||
public async Task<AddProductPriceResponse> Handle(AddProductPriceCommand command)
|
||||
{
|
||||
// 1. Producto debe existir Y estar activo (defensa Application — el SP también valida en BD).
|
||||
var product = await _productsRepo.GetByIdAsync(command.ProductId)
|
||||
?? throw new ProductNotFoundException(command.ProductId);
|
||||
|
||||
if (!product.IsActive)
|
||||
throw new ProductNotFoundException(command.ProductId); // inactivo = invisible para clientes
|
||||
|
||||
// 2. TX + SP + audit (fail-closed).
|
||||
// El audit.LogAsync enlista en el mismo TransactionScope — si falla, rollback total.
|
||||
using var tx = new TransactionScope(
|
||||
TransactionScopeOption.Required,
|
||||
new TransactionOptions { IsolationLevel = System.Transactions.IsolationLevel.ReadCommitted },
|
||||
TransactionScopeAsyncFlowOption.Enabled);
|
||||
|
||||
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
|
||||
{
|
||||
command.ProductId,
|
||||
command.Price,
|
||||
priceValidFrom = command.PriceValidFrom.ToString("yyyy-MM-dd"),
|
||||
},
|
||||
closedPriceId = closedId
|
||||
});
|
||||
|
||||
tx.Complete();
|
||||
|
||||
// 3. Compongo la respuesta post-commit con lectura de historial actualizado.
|
||||
var prices = await _pricesRepo.GetByProductIdAsync(command.ProductId);
|
||||
var created = prices.Single(p => p.Id == newId);
|
||||
var closed = closedId.HasValue
|
||||
? prices.SingleOrDefault(p => p.Id == closedId.Value)
|
||||
: null;
|
||||
|
||||
return new AddProductPriceResponse(ToDto(created), closed is null ? null : ToDto(closed));
|
||||
}
|
||||
|
||||
private static ProductPriceDto ToDto(ProductPrice p)
|
||||
=> new(p.Id, p.ProductId, p.Price, p.PriceValidFrom, p.PriceValidTo, p.IsActive);
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
using FluentValidation;
|
||||
using SIGCM2.Application.Common;
|
||||
|
||||
namespace SIGCM2.Application.Products.Prices.AddPrice;
|
||||
|
||||
/// <summary>
|
||||
/// PRD-003 — FluentValidation validator para AddProductPriceCommand.
|
||||
/// Inyecta TimeProvider para obtener hoy_AR (Cat2, nunca DateTime.Now).
|
||||
/// FakeTimeProvider en tests garantiza determinismo.
|
||||
/// </summary>
|
||||
public sealed class AddProductPriceCommandValidator : AbstractValidator<AddProductPriceCommand>
|
||||
{
|
||||
public AddProductPriceCommandValidator(TimeProvider timeProvider)
|
||||
{
|
||||
var today = timeProvider.GetArgentinaToday();
|
||||
|
||||
RuleFor(x => x.ProductId)
|
||||
.GreaterThan(0)
|
||||
.WithMessage("ProductId debe ser un entero positivo.");
|
||||
|
||||
RuleFor(x => x.Price)
|
||||
.GreaterThan(0m)
|
||||
.WithMessage("El precio debe ser mayor a cero.");
|
||||
|
||||
RuleFor(x => x.PriceValidFrom)
|
||||
.GreaterThanOrEqualTo(today)
|
||||
.WithMessage($"PriceValidFrom debe ser >= hoy ({today:yyyy-MM-dd} ART). No se permiten precios con fecha retroactiva.");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
namespace SIGCM2.Application.Products.Prices.AddPrice;
|
||||
|
||||
/// <summary>
|
||||
/// PRD-003 — Respuesta del comando AddProductPrice.
|
||||
/// Closed es null si era el primer precio registrado para el producto.
|
||||
/// </summary>
|
||||
public sealed record AddProductPriceResponse(
|
||||
ProductPriceDto Created,
|
||||
ProductPriceDto? Closed);
|
||||
@@ -0,0 +1,8 @@
|
||||
namespace SIGCM2.Application.Products.Prices.GetHistory;
|
||||
|
||||
/// <summary>
|
||||
/// PRD-003 — Query para obtener el historial de precios de un Product.
|
||||
/// Devuelve lista ordenada descending por PriceValidFrom (activo primero).
|
||||
/// Lanza ProductNotFoundException si el producto no existe.
|
||||
/// </summary>
|
||||
public sealed record GetProductPricesQuery(int ProductId);
|
||||
@@ -0,0 +1,42 @@
|
||||
using SIGCM2.Application.Abstractions;
|
||||
using SIGCM2.Application.Abstractions.Persistence;
|
||||
using SIGCM2.Application.Products.Prices;
|
||||
using SIGCM2.Domain.Exceptions;
|
||||
|
||||
namespace SIGCM2.Application.Products.Prices.GetHistory;
|
||||
|
||||
/// <summary>
|
||||
/// PRD-003 — Handler de GetProductPricesQuery.
|
||||
/// Verifica que el producto exista (404 si no), luego retorna historial de precios
|
||||
/// ordenado descending por PriceValidFrom (responsabilidad del repo — SQL ORDER BY).
|
||||
/// Lista vacía es válida (nuevo producto sin precios registrados aún).
|
||||
/// </summary>
|
||||
public sealed class GetProductPricesQueryHandler
|
||||
: ICommandHandler<GetProductPricesQuery, IReadOnlyList<ProductPriceDto>>
|
||||
{
|
||||
private readonly IProductPriceRepository _pricesRepo;
|
||||
private readonly IProductRepository _productsRepo;
|
||||
|
||||
public GetProductPricesQueryHandler(
|
||||
IProductPriceRepository pricesRepo,
|
||||
IProductRepository productsRepo)
|
||||
{
|
||||
_pricesRepo = pricesRepo;
|
||||
_productsRepo = productsRepo;
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<ProductPriceDto>> Handle(GetProductPricesQuery query)
|
||||
{
|
||||
// Verifica existencia del producto (lanza 404 si no existe).
|
||||
_ = await _productsRepo.GetByIdAsync(query.ProductId)
|
||||
?? throw new ProductNotFoundException(query.ProductId);
|
||||
|
||||
var prices = await _pricesRepo.GetByProductIdAsync(query.ProductId);
|
||||
|
||||
return prices
|
||||
.Select(p => new ProductPriceDto(
|
||||
p.Id, p.ProductId, p.Price,
|
||||
p.PriceValidFrom, p.PriceValidTo, p.IsActive))
|
||||
.ToList();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
namespace SIGCM2.Application.Products.Prices;
|
||||
|
||||
/// <summary>
|
||||
/// PRD-003 — DTO de lectura para un registro de precio histórico de Product.
|
||||
/// IsActive = true cuando PriceValidTo is null (precio vigente en curso).
|
||||
/// </summary>
|
||||
public sealed record ProductPriceDto(
|
||||
long Id,
|
||||
int ProductId,
|
||||
decimal Price,
|
||||
DateOnly PriceValidFrom,
|
||||
DateOnly? PriceValidTo,
|
||||
bool IsActive);
|
||||
@@ -0,0 +1,18 @@
|
||||
namespace SIGCM2.Application.Products.Pricing;
|
||||
|
||||
/// <summary>
|
||||
/// PRD-003 — Servicio de consulta de precio vigente de un Product para una fecha civil (Cat2).
|
||||
/// Contrato forward para PRC-001 (tasación).
|
||||
///
|
||||
/// Retorna null si no existe historial de precios para el producto en la fecha indicada.
|
||||
/// La política de fallback (usar Product.BasePrice o lanzar ProductSinPrecioActivoException)
|
||||
/// queda en el consumidor (OQ-B: Product.BasePrice es ortogonal a ProductPrices).
|
||||
/// </summary>
|
||||
public interface IProductPricingService
|
||||
{
|
||||
/// <summary>
|
||||
/// Devuelve el precio cuya ventana [PriceValidFrom, PriceValidTo] cubre la fecha civil dada,
|
||||
/// o null si ningún registro de precio cubre esa fecha.
|
||||
/// </summary>
|
||||
Task<decimal?> GetPriceAtAsync(int productId, DateOnly date, CancellationToken ct = default);
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
using SIGCM2.Application.Abstractions.Persistence;
|
||||
|
||||
namespace SIGCM2.Application.Products.Pricing;
|
||||
|
||||
/// <summary>
|
||||
/// PRD-003 — Implementación de IProductPricingService.
|
||||
/// Delega en IProductPriceRepository.GetActiveAsync para el lookup de ventana civil.
|
||||
/// </summary>
|
||||
public sealed class ProductPricingService : IProductPricingService
|
||||
{
|
||||
private readonly IProductPriceRepository _repo;
|
||||
|
||||
public ProductPricingService(IProductPriceRepository repo)
|
||||
{
|
||||
_repo = repo;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<decimal?> GetPriceAtAsync(int productId, DateOnly date, CancellationToken ct = default)
|
||||
{
|
||||
var price = await _repo.GetActiveAsync(productId, date, ct);
|
||||
return price?.Price;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user