feat(application): CRUD handlers + validators + DI de ProductType (PRD-001)
Create/Update/Deactivate handlers con TransactionScope + audit; validators FluentValidation; DI wiring NullProductQueryRepository + 5 handlers; SqlTestFixture V017 + permiso count 25→26.
This commit is contained in:
@@ -69,6 +69,12 @@ using SIGCM2.Application.Rubros.GetById;
|
||||
using SIGCM2.Application.Rubros.Dtos;
|
||||
using SIGCM2.Application.Abstractions.Persistence;
|
||||
using SIGCM2.Application.Avisos;
|
||||
using SIGCM2.Application.Products;
|
||||
using SIGCM2.Application.ProductTypes.Create;
|
||||
using SIGCM2.Application.ProductTypes.Update;
|
||||
using SIGCM2.Application.ProductTypes.Deactivate;
|
||||
using SIGCM2.Application.ProductTypes.List;
|
||||
using SIGCM2.Application.ProductTypes.GetById;
|
||||
|
||||
namespace SIGCM2.Application;
|
||||
|
||||
@@ -165,6 +171,16 @@ public static class DependencyInjection
|
||||
services.AddScoped<ICommandHandler<GetRubroTreeQuery, IReadOnlyList<RubroTreeNodeDto>>, GetRubroTreeQueryHandler>();
|
||||
services.AddScoped<ICommandHandler<GetRubroByIdQuery, RubroDetailDto>, GetRubroByIdQueryHandler>();
|
||||
|
||||
// ProductTypes (PRD-001)
|
||||
// PRD-002 reemplaza IProductQueryRepository con implementación Dapper contra dbo.Product.
|
||||
services.AddScoped<IProductQueryRepository, NullProductQueryRepository>();
|
||||
|
||||
services.AddScoped<ICommandHandler<CreateProductTypeCommand, ProductTypeCreatedDto>, CreateProductTypeCommandHandler>();
|
||||
services.AddScoped<ICommandHandler<UpdateProductTypeCommand, ProductTypeUpdatedDto>, UpdateProductTypeCommandHandler>();
|
||||
services.AddScoped<ICommandHandler<DeactivateProductTypeCommand, ProductTypeStatusDto>, DeactivateProductTypeCommandHandler>();
|
||||
services.AddScoped<ICommandHandler<ListProductTypesQuery, PagedResult<ProductTypeListItemDto>>, ListProductTypesQueryHandler>();
|
||||
services.AddScoped<ICommandHandler<GetProductTypeByIdQuery, ProductTypeDetailDto>, GetProductTypeByIdQueryHandler>();
|
||||
|
||||
// FluentValidation validators (scans entire Application assembly)
|
||||
services.AddValidatorsFromAssemblyContaining<LoginCommandValidator>();
|
||||
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
namespace SIGCM2.Application.ProductTypes.Create;
|
||||
|
||||
public sealed record CreateProductTypeCommand(
|
||||
string Nombre,
|
||||
bool HasDuration,
|
||||
bool RequiresText,
|
||||
bool RequiresCategory,
|
||||
bool IsBundle,
|
||||
bool AllowImages,
|
||||
int? MaxImages,
|
||||
decimal? MaxImageSizeMB,
|
||||
int? MaxImageWidth,
|
||||
int? MaxImageHeight);
|
||||
@@ -0,0 +1,80 @@
|
||||
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.ProductTypes.Create;
|
||||
|
||||
public sealed class CreateProductTypeCommandHandler
|
||||
: ICommandHandler<CreateProductTypeCommand, ProductTypeCreatedDto>
|
||||
{
|
||||
private readonly IProductTypeRepository _repo;
|
||||
private readonly IAuditLogger _audit;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
|
||||
public CreateProductTypeCommandHandler(
|
||||
IProductTypeRepository repo,
|
||||
IAuditLogger audit,
|
||||
TimeProvider timeProvider)
|
||||
{
|
||||
_repo = repo;
|
||||
_audit = audit;
|
||||
_timeProvider = timeProvider;
|
||||
}
|
||||
|
||||
public async Task<ProductTypeCreatedDto> Handle(CreateProductTypeCommand command)
|
||||
{
|
||||
// 1. Duplicate name check (before factory — avoids wasting domain allocation on error)
|
||||
var exists = await _repo.ExistsByNombreAsync(command.Nombre, excludeId: null);
|
||||
if (exists)
|
||||
throw new ProductTypeNombreDuplicadoException(command.Nombre);
|
||||
|
||||
// 2. Build entity (factory normalizes multimedia if AllowImages=false)
|
||||
var entity = ProductType.ForCreation(
|
||||
command.Nombre,
|
||||
command.HasDuration, command.RequiresText, command.RequiresCategory, command.IsBundle,
|
||||
command.AllowImages,
|
||||
command.MaxImages, command.MaxImageSizeMB, command.MaxImageWidth, command.MaxImageHeight,
|
||||
_timeProvider);
|
||||
|
||||
// 3. Persist + audit (fail-closed: if audit throws, TX rolls back)
|
||||
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_tipo.created",
|
||||
targetType: "ProductType",
|
||||
targetId: newId.ToString(),
|
||||
metadata: new
|
||||
{
|
||||
after = new
|
||||
{
|
||||
entity.Nombre,
|
||||
entity.HasDuration,
|
||||
entity.RequiresText,
|
||||
entity.RequiresCategory,
|
||||
entity.IsBundle,
|
||||
entity.AllowImages,
|
||||
entity.MaxImages,
|
||||
entity.MaxImageSizeMB,
|
||||
entity.MaxImageWidth,
|
||||
entity.MaxImageHeight,
|
||||
}
|
||||
});
|
||||
|
||||
tx.Complete();
|
||||
|
||||
return new ProductTypeCreatedDto(
|
||||
newId, entity.Nombre,
|
||||
entity.HasDuration, entity.RequiresText, entity.RequiresCategory, entity.IsBundle,
|
||||
entity.AllowImages,
|
||||
entity.MaxImages, entity.MaxImageSizeMB, entity.MaxImageWidth, entity.MaxImageHeight,
|
||||
entity.IsActive);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
using FluentValidation;
|
||||
|
||||
namespace SIGCM2.Application.ProductTypes.Create;
|
||||
|
||||
public sealed class CreateProductTypeCommandValidator : AbstractValidator<CreateProductTypeCommand>
|
||||
{
|
||||
public CreateProductTypeCommandValidator()
|
||||
{
|
||||
RuleFor(x => x.Nombre)
|
||||
.NotEmpty().WithMessage("El nombre del tipo de producto es requerido.")
|
||||
.MaximumLength(200).WithMessage("El nombre no puede superar los 200 caracteres.");
|
||||
|
||||
RuleFor(x => x.MaxImages)
|
||||
.GreaterThan(0).When(x => x.MaxImages.HasValue)
|
||||
.WithMessage("MaxImages debe ser mayor que 0 (o null para sin límite).");
|
||||
|
||||
RuleFor(x => x.MaxImageSizeMB)
|
||||
.GreaterThan(0).When(x => x.MaxImageSizeMB.HasValue)
|
||||
.WithMessage("MaxImageSizeMB debe ser mayor que 0 (o null para sin límite).");
|
||||
|
||||
RuleFor(x => x.MaxImageWidth)
|
||||
.GreaterThan(0).When(x => x.MaxImageWidth.HasValue)
|
||||
.WithMessage("MaxImageWidth debe ser mayor que 0 (o null para sin límite).");
|
||||
|
||||
RuleFor(x => x.MaxImageHeight)
|
||||
.GreaterThan(0).When(x => x.MaxImageHeight.HasValue)
|
||||
.WithMessage("MaxImageHeight debe ser mayor que 0 (o null para sin límite).");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
namespace SIGCM2.Application.ProductTypes.Create;
|
||||
|
||||
public sealed record ProductTypeCreatedDto(
|
||||
int Id,
|
||||
string Nombre,
|
||||
bool HasDuration,
|
||||
bool RequiresText,
|
||||
bool RequiresCategory,
|
||||
bool IsBundle,
|
||||
bool AllowImages,
|
||||
int? MaxImages,
|
||||
decimal? MaxImageSizeMB,
|
||||
int? MaxImageWidth,
|
||||
int? MaxImageHeight,
|
||||
bool IsActive);
|
||||
@@ -0,0 +1,3 @@
|
||||
namespace SIGCM2.Application.ProductTypes.Deactivate;
|
||||
|
||||
public sealed record DeactivateProductTypeCommand(int Id);
|
||||
@@ -0,0 +1,72 @@
|
||||
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.ProductTypes.Deactivate;
|
||||
|
||||
public sealed class DeactivateProductTypeCommandHandler
|
||||
: ICommandHandler<DeactivateProductTypeCommand, ProductTypeStatusDto>
|
||||
{
|
||||
private readonly IProductTypeRepository _repo;
|
||||
private readonly IProductQueryRepository _productQuery;
|
||||
private readonly IAuditLogger _audit;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
|
||||
public DeactivateProductTypeCommandHandler(
|
||||
IProductTypeRepository repo,
|
||||
IProductQueryRepository productQuery,
|
||||
IAuditLogger audit,
|
||||
TimeProvider timeProvider)
|
||||
{
|
||||
_repo = repo;
|
||||
_productQuery = productQuery;
|
||||
_audit = audit;
|
||||
_timeProvider = timeProvider;
|
||||
}
|
||||
|
||||
public async Task<ProductTypeStatusDto> Handle(DeactivateProductTypeCommand command)
|
||||
{
|
||||
// 1. Load entity
|
||||
var target = await _repo.GetByIdAsync(command.Id)
|
||||
?? throw new ProductTypeNotFoundException(command.Id);
|
||||
|
||||
// 2. Idempotent: already inactive → return without side effects (I7)
|
||||
if (!target.IsActive)
|
||||
return new ProductTypeStatusDto(command.Id, false);
|
||||
|
||||
// 3. Guard: check if any active product uses this type (guard before audit — ordering matters)
|
||||
var inUse = await _productQuery.ExistsActiveByProductTypeAsync(command.Id);
|
||||
if (inUse)
|
||||
throw new ProductTypeEnUsoException(command.Id, productsActivos: -1);
|
||||
// Note: count=-1 sentinel because Products table doesn't exist in PRD-001.
|
||||
// PRD-002 will update this with the actual count.
|
||||
|
||||
// 4. Deactivate (immutable — returns new instance)
|
||||
var deactivated = target.WithDeactivated(_timeProvider);
|
||||
|
||||
// 5. 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_tipo.deactivated",
|
||||
targetType: "ProductType",
|
||||
targetId: command.Id.ToString(),
|
||||
metadata: new
|
||||
{
|
||||
productTypeId = command.Id,
|
||||
nombre = target.Nombre,
|
||||
});
|
||||
|
||||
tx.Complete();
|
||||
|
||||
return new ProductTypeStatusDto(deactivated.Id, deactivated.IsActive);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
namespace SIGCM2.Application.ProductTypes.Deactivate;
|
||||
|
||||
public sealed record ProductTypeStatusDto(int Id, bool IsActive);
|
||||
@@ -0,0 +1,16 @@
|
||||
namespace SIGCM2.Application.ProductTypes.Update;
|
||||
|
||||
public sealed record ProductTypeUpdatedDto(
|
||||
int Id,
|
||||
string Nombre,
|
||||
bool HasDuration,
|
||||
bool RequiresText,
|
||||
bool RequiresCategory,
|
||||
bool IsBundle,
|
||||
bool AllowImages,
|
||||
int? MaxImages,
|
||||
decimal? MaxImageSizeMB,
|
||||
int? MaxImageWidth,
|
||||
int? MaxImageHeight,
|
||||
bool IsActive,
|
||||
DateTime? FechaModificacion);
|
||||
@@ -0,0 +1,14 @@
|
||||
namespace SIGCM2.Application.ProductTypes.Update;
|
||||
|
||||
public sealed record UpdateProductTypeCommand(
|
||||
int Id,
|
||||
string Nombre,
|
||||
bool HasDuration,
|
||||
bool RequiresText,
|
||||
bool RequiresCategory,
|
||||
bool IsBundle,
|
||||
bool AllowImages,
|
||||
int? MaxImages,
|
||||
decimal? MaxImageSizeMB,
|
||||
int? MaxImageWidth,
|
||||
int? MaxImageHeight);
|
||||
@@ -0,0 +1,74 @@
|
||||
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.ProductTypes.Update;
|
||||
|
||||
public sealed class UpdateProductTypeCommandHandler
|
||||
: ICommandHandler<UpdateProductTypeCommand, ProductTypeUpdatedDto>
|
||||
{
|
||||
private readonly IProductTypeRepository _repo;
|
||||
private readonly IAuditLogger _audit;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
|
||||
public UpdateProductTypeCommandHandler(
|
||||
IProductTypeRepository repo,
|
||||
IAuditLogger audit,
|
||||
TimeProvider timeProvider)
|
||||
{
|
||||
_repo = repo;
|
||||
_audit = audit;
|
||||
_timeProvider = timeProvider;
|
||||
}
|
||||
|
||||
public async Task<ProductTypeUpdatedDto> Handle(UpdateProductTypeCommand command)
|
||||
{
|
||||
// 1. Load entity (throws if not found)
|
||||
var target = await _repo.GetByIdAsync(command.Id)
|
||||
?? throw new ProductTypeNotFoundException(command.Id);
|
||||
|
||||
// 2. If nombre changed, check for duplicate (skip call when same name — optimization)
|
||||
if (!string.Equals(command.Nombre, target.Nombre, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
var duplicateExists = await _repo.ExistsByNombreAsync(command.Nombre, excludeId: command.Id);
|
||||
if (duplicateExists)
|
||||
throw new ProductTypeNombreDuplicadoException(command.Nombre);
|
||||
}
|
||||
|
||||
// 3. Build updated entity via With* methods (immutable, each returns new instance)
|
||||
var updated = target
|
||||
.WithRenamed(command.Nombre, _timeProvider)
|
||||
.WithUpdatedFlags(command.HasDuration, command.RequiresText, command.RequiresCategory, command.IsBundle, _timeProvider)
|
||||
.WithUpdatedMultimedia(command.AllowImages, command.MaxImages, command.MaxImageSizeMB, command.MaxImageWidth, command.MaxImageHeight, _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(updated);
|
||||
|
||||
await _audit.LogAsync(
|
||||
action: "producto_tipo.updated",
|
||||
targetType: "ProductType",
|
||||
targetId: command.Id.ToString(),
|
||||
metadata: new
|
||||
{
|
||||
before = new { target.Nombre, target.HasDuration, target.RequiresText, target.RequiresCategory, target.IsBundle, target.AllowImages },
|
||||
after = new { updated.Nombre, updated.HasDuration, updated.RequiresText, updated.RequiresCategory, updated.IsBundle, updated.AllowImages }
|
||||
});
|
||||
|
||||
tx.Complete();
|
||||
|
||||
return new ProductTypeUpdatedDto(
|
||||
updated.Id, updated.Nombre,
|
||||
updated.HasDuration, updated.RequiresText, updated.RequiresCategory, updated.IsBundle,
|
||||
updated.AllowImages,
|
||||
updated.MaxImages, updated.MaxImageSizeMB, updated.MaxImageWidth, updated.MaxImageHeight,
|
||||
updated.IsActive, updated.FechaModificacion);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
using FluentValidation;
|
||||
|
||||
namespace SIGCM2.Application.ProductTypes.Update;
|
||||
|
||||
public sealed class UpdateProductTypeCommandValidator : AbstractValidator<UpdateProductTypeCommand>
|
||||
{
|
||||
public UpdateProductTypeCommandValidator()
|
||||
{
|
||||
RuleFor(x => x.Id)
|
||||
.GreaterThan(0).WithMessage("El Id debe ser un entero positivo.");
|
||||
|
||||
RuleFor(x => x.Nombre)
|
||||
.NotEmpty().WithMessage("El nombre del tipo de producto es requerido.")
|
||||
.MaximumLength(200).WithMessage("El nombre no puede superar los 200 caracteres.");
|
||||
|
||||
RuleFor(x => x.MaxImages)
|
||||
.GreaterThan(0).When(x => x.MaxImages.HasValue)
|
||||
.WithMessage("MaxImages debe ser mayor que 0 (o null para sin límite).");
|
||||
|
||||
RuleFor(x => x.MaxImageSizeMB)
|
||||
.GreaterThan(0).When(x => x.MaxImageSizeMB.HasValue)
|
||||
.WithMessage("MaxImageSizeMB debe ser mayor que 0 (o null para sin límite).");
|
||||
|
||||
RuleFor(x => x.MaxImageWidth)
|
||||
.GreaterThan(0).When(x => x.MaxImageWidth.HasValue)
|
||||
.WithMessage("MaxImageWidth debe ser mayor que 0 (o null para sin límite).");
|
||||
|
||||
RuleFor(x => x.MaxImageHeight)
|
||||
.GreaterThan(0).When(x => x.MaxImageHeight.HasValue)
|
||||
.WithMessage("MaxImageHeight debe ser mayor que 0 (o null para sin límite).");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user