feat(application): Product handlers + DI registration, fix permiso count to 27 (PRD-002)
This commit is contained in:
@@ -70,6 +70,11 @@ using SIGCM2.Application.Rubros.Dtos;
|
||||
using SIGCM2.Application.Abstractions.Persistence;
|
||||
using SIGCM2.Application.Avisos;
|
||||
using SIGCM2.Application.Products;
|
||||
using SIGCM2.Application.Products.Create;
|
||||
using SIGCM2.Application.Products.Update;
|
||||
using SIGCM2.Application.Products.Deactivate;
|
||||
using SIGCM2.Application.Products.GetById;
|
||||
using SIGCM2.Application.Products.List;
|
||||
using SIGCM2.Application.ProductTypes.Create;
|
||||
using SIGCM2.Application.ProductTypes.Update;
|
||||
using SIGCM2.Application.ProductTypes.Deactivate;
|
||||
@@ -171,6 +176,13 @@ public static class DependencyInjection
|
||||
services.AddScoped<ICommandHandler<GetRubroTreeQuery, IReadOnlyList<RubroTreeNodeDto>>, GetRubroTreeQueryHandler>();
|
||||
services.AddScoped<ICommandHandler<GetRubroByIdQuery, RubroDetailDto>, GetRubroByIdQueryHandler>();
|
||||
|
||||
// Products (PRD-002)
|
||||
services.AddScoped<ICommandHandler<CreateProductCommand, ProductCreatedDto>, CreateProductCommandHandler>();
|
||||
services.AddScoped<ICommandHandler<UpdateProductCommand, ProductUpdatedDto>, UpdateProductCommandHandler>();
|
||||
services.AddScoped<ICommandHandler<DeactivateProductCommand, ProductStatusDto>, DeactivateProductCommandHandler>();
|
||||
services.AddScoped<ICommandHandler<GetProductByIdQuery, ProductDetailDto>, GetProductByIdQueryHandler>();
|
||||
services.AddScoped<ICommandHandler<ListProductsQuery, PagedResult<ProductListItemDto>>, ListProductsQueryHandler>();
|
||||
|
||||
// ProductTypes (PRD-001)
|
||||
// PRD-002 reemplaza IProductQueryRepository con implementación Dapper contra dbo.Product.
|
||||
services.AddScoped<IProductQueryRepository, NullProductQueryRepository>();
|
||||
|
||||
@@ -0,0 +1,112 @@
|
||||
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.Create;
|
||||
|
||||
public sealed class CreateProductCommandHandler
|
||||
: ICommandHandler<CreateProductCommand, ProductCreatedDto>
|
||||
{
|
||||
private readonly IProductRepository _repo;
|
||||
private readonly IProductTypeRepository _ptRepo;
|
||||
private readonly IMedioRepository _medioRepo;
|
||||
private readonly IRubroRepository _rubroRepo;
|
||||
private readonly IAuditLogger _audit;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
|
||||
public CreateProductCommandHandler(
|
||||
IProductRepository repo,
|
||||
IProductTypeRepository ptRepo,
|
||||
IMedioRepository medioRepo,
|
||||
IRubroRepository rubroRepo,
|
||||
IAuditLogger audit,
|
||||
TimeProvider timeProvider)
|
||||
{
|
||||
_repo = repo;
|
||||
_ptRepo = ptRepo;
|
||||
_medioRepo = medioRepo;
|
||||
_rubroRepo = rubroRepo;
|
||||
_audit = audit;
|
||||
_timeProvider = timeProvider;
|
||||
}
|
||||
|
||||
public async Task<ProductCreatedDto> Handle(CreateProductCommand command)
|
||||
{
|
||||
// 1. Validate Medio exists and is active
|
||||
var medio = await _medioRepo.GetByIdAsync(command.MedioId)
|
||||
?? throw new MedioNotFoundException(command.MedioId);
|
||||
if (!medio.Activo)
|
||||
throw new MedioInactivoException(command.MedioId);
|
||||
|
||||
// 2. Validate ProductType exists and is active
|
||||
var productType = await _ptRepo.GetByIdAsync(command.ProductTypeId)
|
||||
?? throw new ProductTypeNotFoundException(command.ProductTypeId);
|
||||
if (!productType.IsActive)
|
||||
throw new ProductTypeInactivoException(command.ProductTypeId);
|
||||
|
||||
// 3. Flags coherence: RequiresCategory → RubroId required
|
||||
if (productType.RequiresCategory && !command.RubroId.HasValue)
|
||||
throw new ProductTipoFlagsIncoherentesException(
|
||||
$"El tipo '{productType.Nombre}' requiere RubroId (RequiresCategory=true)", "rubroId");
|
||||
|
||||
// 4. Flags coherence: HasDuration → PriceDurationDays required
|
||||
if (productType.HasDuration && !command.PriceDurationDays.HasValue)
|
||||
throw new ProductTipoFlagsIncoherentesException(
|
||||
$"El tipo '{productType.Nombre}' requiere PriceDurationDays (HasDuration=true)", "priceDurationDays");
|
||||
|
||||
// 5. Validate Rubro if provided: must be active
|
||||
if (command.RubroId.HasValue)
|
||||
{
|
||||
var rubro = await _rubroRepo.GetByIdAsync(command.RubroId.Value);
|
||||
if (rubro == null || !rubro.Activo)
|
||||
throw new RubroInactivoException(command.RubroId.Value);
|
||||
}
|
||||
|
||||
// 6. Duplicate nombre check (filtered on IsActive=1 — allows reuse after soft-delete)
|
||||
var exists = await _repo.ExistsByNombreAsync(command.Nombre, command.MedioId, command.ProductTypeId, excludeId: null);
|
||||
if (exists)
|
||||
throw new ProductNombreDuplicadoEnMedioTipoException(command.MedioId, command.ProductTypeId, command.Nombre);
|
||||
|
||||
// 7. Build entity
|
||||
var entity = Product.ForCreation(
|
||||
command.Nombre, command.MedioId, command.ProductTypeId,
|
||||
command.RubroId, command.BasePrice, command.PriceDurationDays,
|
||||
_timeProvider);
|
||||
|
||||
// 8. Persist + audit (fail-closed)
|
||||
using var tx = new TransactionScope(
|
||||
TransactionScopeOption.Required,
|
||||
new TransactionOptions { IsolationLevel = System.Transactions.IsolationLevel.ReadCommitted },
|
||||
TransactionScopeAsyncFlowOption.Enabled);
|
||||
|
||||
var newId = await _repo.AddAsync(entity);
|
||||
|
||||
await _audit.LogAsync(
|
||||
action: "producto.created",
|
||||
targetType: "Product",
|
||||
targetId: newId.ToString(),
|
||||
metadata: new
|
||||
{
|
||||
after = new
|
||||
{
|
||||
entity.Nombre,
|
||||
entity.MedioId,
|
||||
entity.ProductTypeId,
|
||||
entity.RubroId,
|
||||
entity.BasePrice,
|
||||
entity.PriceDurationDays,
|
||||
}
|
||||
});
|
||||
|
||||
tx.Complete();
|
||||
|
||||
return new ProductCreatedDto(
|
||||
newId, entity.Nombre,
|
||||
entity.MedioId, entity.ProductTypeId, entity.RubroId,
|
||||
entity.BasePrice, entity.PriceDurationDays,
|
||||
entity.IsActive, entity.FechaCreacion);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
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.Deactivate;
|
||||
|
||||
public sealed class DeactivateProductCommandHandler
|
||||
: ICommandHandler<DeactivateProductCommand, ProductStatusDto>
|
||||
{
|
||||
private readonly IProductRepository _repo;
|
||||
private readonly IAuditLogger _audit;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
|
||||
public DeactivateProductCommandHandler(
|
||||
IProductRepository repo,
|
||||
IAuditLogger audit,
|
||||
TimeProvider timeProvider)
|
||||
{
|
||||
_repo = repo;
|
||||
_audit = audit;
|
||||
_timeProvider = timeProvider;
|
||||
}
|
||||
|
||||
public async Task<ProductStatusDto> Handle(DeactivateProductCommand command)
|
||||
{
|
||||
// 1. Load entity
|
||||
var target = await _repo.GetByIdAsync(command.Id)
|
||||
?? throw new ProductNotFoundException(command.Id);
|
||||
|
||||
// 2. Idempotent: already inactive → return without side effects
|
||||
if (!target.IsActive)
|
||||
return new ProductStatusDto(command.Id, false, target.FechaModificacion);
|
||||
|
||||
// 3. Deactivate (immutable)
|
||||
var deactivated = target.WithDeactivated(_timeProvider);
|
||||
|
||||
// 4. Persist + audit (fail-closed)
|
||||
using var tx = new TransactionScope(
|
||||
TransactionScopeOption.Required,
|
||||
new TransactionOptions { IsolationLevel = System.Transactions.IsolationLevel.ReadCommitted },
|
||||
TransactionScopeAsyncFlowOption.Enabled);
|
||||
|
||||
await _repo.UpdateAsync(deactivated);
|
||||
|
||||
await _audit.LogAsync(
|
||||
action: "producto.deactivated",
|
||||
targetType: "Product",
|
||||
targetId: command.Id.ToString(),
|
||||
metadata: new
|
||||
{
|
||||
productId = command.Id,
|
||||
nombre = target.Nombre,
|
||||
});
|
||||
|
||||
tx.Complete();
|
||||
|
||||
return new ProductStatusDto(deactivated.Id, deactivated.IsActive, deactivated.FechaModificacion);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
using SIGCM2.Application.Abstractions;
|
||||
using SIGCM2.Application.Abstractions.Persistence;
|
||||
using SIGCM2.Domain.Exceptions;
|
||||
|
||||
namespace SIGCM2.Application.Products.GetById;
|
||||
|
||||
public sealed class GetProductByIdQueryHandler
|
||||
: ICommandHandler<GetProductByIdQuery, ProductDetailDto>
|
||||
{
|
||||
private readonly IProductRepository _repo;
|
||||
|
||||
public GetProductByIdQueryHandler(IProductRepository repo)
|
||||
{
|
||||
_repo = repo;
|
||||
}
|
||||
|
||||
public async Task<ProductDetailDto> Handle(GetProductByIdQuery query)
|
||||
{
|
||||
var product = await _repo.GetByIdAsync(query.Id)
|
||||
?? throw new ProductNotFoundException(query.Id);
|
||||
|
||||
return new ProductDetailDto(
|
||||
product.Id, product.Nombre,
|
||||
product.MedioId, product.ProductTypeId, product.RubroId,
|
||||
product.BasePrice, product.PriceDurationDays,
|
||||
product.IsActive,
|
||||
product.FechaCreacion, product.FechaModificacion);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
using SIGCM2.Application.Abstractions;
|
||||
using SIGCM2.Application.Abstractions.Persistence;
|
||||
using SIGCM2.Application.Common;
|
||||
|
||||
namespace SIGCM2.Application.Products.List;
|
||||
|
||||
public sealed class ListProductsQueryHandler
|
||||
: ICommandHandler<ListProductsQuery, PagedResult<ProductListItemDto>>
|
||||
{
|
||||
private readonly IProductRepository _repo;
|
||||
|
||||
public ListProductsQueryHandler(IProductRepository repo)
|
||||
{
|
||||
_repo = repo;
|
||||
}
|
||||
|
||||
public async Task<PagedResult<ProductListItemDto>> Handle(ListProductsQuery query)
|
||||
{
|
||||
var page = Math.Max(1, query.Page);
|
||||
var pageSize = Math.Clamp(query.PageSize, 1, 100);
|
||||
|
||||
var repoQuery = new ProductsQuery(
|
||||
page, pageSize, query.Activo, query.Search,
|
||||
query.MedioId, query.ProductTypeId, query.RubroId);
|
||||
var paged = await _repo.GetPagedAsync(repoQuery);
|
||||
|
||||
var items = paged.Items.Select(p => new ProductListItemDto(
|
||||
p.Id, p.Nombre,
|
||||
p.MedioId, p.ProductTypeId, p.RubroId,
|
||||
p.BasePrice, p.PriceDurationDays,
|
||||
p.IsActive)).ToList();
|
||||
|
||||
return new PagedResult<ProductListItemDto>(items, paged.Page, paged.PageSize, paged.Total);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
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.Update;
|
||||
|
||||
public sealed class UpdateProductCommandHandler
|
||||
: ICommandHandler<UpdateProductCommand, ProductUpdatedDto>
|
||||
{
|
||||
private readonly IProductRepository _repo;
|
||||
private readonly IProductTypeRepository _ptRepo;
|
||||
private readonly IRubroRepository _rubroRepo;
|
||||
private readonly IAuditLogger _audit;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
|
||||
public UpdateProductCommandHandler(
|
||||
IProductRepository repo,
|
||||
IProductTypeRepository ptRepo,
|
||||
IRubroRepository rubroRepo,
|
||||
IAuditLogger audit,
|
||||
TimeProvider timeProvider)
|
||||
{
|
||||
_repo = repo;
|
||||
_ptRepo = ptRepo;
|
||||
_rubroRepo = rubroRepo;
|
||||
_audit = audit;
|
||||
_timeProvider = timeProvider;
|
||||
}
|
||||
|
||||
public async Task<ProductUpdatedDto> Handle(UpdateProductCommand command)
|
||||
{
|
||||
// 1. Load entity
|
||||
var target = await _repo.GetByIdAsync(command.Id)
|
||||
?? throw new ProductNotFoundException(command.Id);
|
||||
|
||||
// 2. Load ProductType (MedioId + ProductTypeId are immutable post-creation)
|
||||
var productType = await _ptRepo.GetByIdAsync(target.ProductTypeId)
|
||||
?? throw new ProductTypeNotFoundException(target.ProductTypeId);
|
||||
|
||||
// 3. Flags coherence
|
||||
if (productType.RequiresCategory && !command.RubroId.HasValue)
|
||||
throw new ProductTipoFlagsIncoherentesException(
|
||||
$"El tipo '{productType.Nombre}' requiere RubroId (RequiresCategory=true)", "rubroId");
|
||||
|
||||
if (productType.HasDuration && !command.PriceDurationDays.HasValue)
|
||||
throw new ProductTipoFlagsIncoherentesException(
|
||||
$"El tipo '{productType.Nombre}' requiere PriceDurationDays (HasDuration=true)", "priceDurationDays");
|
||||
|
||||
// 4. Validate Rubro if provided: must be active
|
||||
if (command.RubroId.HasValue)
|
||||
{
|
||||
var rubro = await _rubroRepo.GetByIdAsync(command.RubroId.Value);
|
||||
if (rubro == null || !rubro.Activo)
|
||||
throw new RubroInactivoException(command.RubroId.Value);
|
||||
}
|
||||
|
||||
// 5. Duplicate nombre check (skip if name unchanged — optimization)
|
||||
if (!string.Equals(command.Nombre, target.Nombre, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
var exists = await _repo.ExistsByNombreAsync(command.Nombre, target.MedioId, target.ProductTypeId, excludeId: command.Id);
|
||||
if (exists)
|
||||
throw new ProductNombreDuplicadoEnMedioTipoException(target.MedioId, target.ProductTypeId, command.Nombre);
|
||||
}
|
||||
|
||||
// 6. Apply mutation (immutable)
|
||||
var updated = target.WithUpdated(command.Nombre, command.RubroId, command.BasePrice, command.PriceDurationDays, _timeProvider);
|
||||
|
||||
// 7. Persist + audit
|
||||
using var tx = new TransactionScope(
|
||||
TransactionScopeOption.Required,
|
||||
new TransactionOptions { IsolationLevel = System.Transactions.IsolationLevel.ReadCommitted },
|
||||
TransactionScopeAsyncFlowOption.Enabled);
|
||||
|
||||
await _repo.UpdateAsync(updated);
|
||||
|
||||
await _audit.LogAsync(
|
||||
action: "producto.updated",
|
||||
targetType: "Product",
|
||||
targetId: command.Id.ToString(),
|
||||
metadata: new
|
||||
{
|
||||
before = new { target.Nombre, target.RubroId, target.BasePrice, target.PriceDurationDays },
|
||||
after = new { updated.Nombre, updated.RubroId, updated.BasePrice, updated.PriceDurationDays }
|
||||
});
|
||||
|
||||
tx.Complete();
|
||||
|
||||
return new ProductUpdatedDto(
|
||||
updated.Id, updated.Nombre,
|
||||
updated.MedioId, updated.ProductTypeId, updated.RubroId,
|
||||
updated.BasePrice, updated.PriceDurationDays,
|
||||
updated.IsActive, updated.FechaCreacion, updated.FechaModificacion);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user