feat(application): Product commands, DTOs, IProductRepository, validators (PRD-002)

This commit is contained in:
2026-04-19 13:02:42 -03:00
parent 16197cf242
commit 8b555e1f8b
15 changed files with 311 additions and 0 deletions

View File

@@ -0,0 +1,29 @@
using SIGCM2.Application.Common;
using SIGCM2.Domain.Entities;
namespace SIGCM2.Application.Abstractions.Persistence;
/// <summary>
/// Write-side repository for Product.
/// All reads needed by write handlers are included here.
/// </summary>
public interface IProductRepository
{
/// <summary>Inserts a new Product and returns the DB-assigned Id.</summary>
Task<int> AddAsync(Product product, CancellationToken ct = default);
/// <summary>Returns the Product with the given Id, or null if not found.</summary>
Task<Product?> GetByIdAsync(int id, CancellationToken ct = default);
/// <summary>Returns a paged result of Products matching the query.</summary>
Task<PagedResult<Product>> GetPagedAsync(ProductsQuery query, CancellationToken ct = default);
/// <summary>Persists all changes to an existing Product row.</summary>
Task UpdateAsync(Product product, CancellationToken ct = default);
/// <summary>
/// Returns true if an active Product with the same Nombre exists for the given MedioId+ProductTypeId combination.
/// Pass excludeId to skip the self-comparison during rename (update scenario).
/// </summary>
Task<bool> ExistsByNombreAsync(string nombre, int medioId, int productTypeId, int? excludeId = null, CancellationToken ct = default);
}

View File

@@ -0,0 +1,13 @@
namespace SIGCM2.Application.Common;
/// <summary>
/// Query parameters for listing Products (used by IProductRepository.GetPagedAsync).
/// </summary>
public sealed record ProductsQuery(
int Page = 1,
int PageSize = 20,
bool? Activo = true,
string? Search = null,
int? MedioId = null,
int? ProductTypeId = null,
int? RubroId = null);

View File

@@ -0,0 +1,9 @@
namespace SIGCM2.Application.Products.Create;
public sealed record CreateProductCommand(
string Nombre,
int MedioId,
int ProductTypeId,
int? RubroId,
decimal BasePrice,
int? PriceDurationDays);

View File

@@ -0,0 +1,26 @@
using FluentValidation;
namespace SIGCM2.Application.Products.Create;
public sealed class CreateProductCommandValidator : AbstractValidator<CreateProductCommand>
{
public CreateProductCommandValidator()
{
RuleFor(x => x.Nombre)
.NotEmpty().WithMessage("El nombre del producto es requerido.")
.MaximumLength(300).WithMessage("El nombre no puede superar los 300 caracteres.");
RuleFor(x => x.MedioId)
.GreaterThan(0).WithMessage("MedioId debe ser un entero positivo.");
RuleFor(x => x.ProductTypeId)
.GreaterThan(0).WithMessage("ProductTypeId debe ser un entero positivo.");
RuleFor(x => x.BasePrice)
.GreaterThanOrEqualTo(0m).WithMessage("El precio base no puede ser negativo.");
RuleFor(x => x.PriceDurationDays)
.GreaterThan(0).When(x => x.PriceDurationDays.HasValue)
.WithMessage("PriceDurationDays debe ser >= 1 cuando se provee.");
}
}

View File

@@ -0,0 +1,12 @@
namespace SIGCM2.Application.Products.Create;
public sealed record ProductCreatedDto(
int Id,
string Nombre,
int MedioId,
int ProductTypeId,
int? RubroId,
decimal BasePrice,
int? PriceDurationDays,
bool IsActive,
DateTime FechaCreacion);

View File

@@ -0,0 +1,3 @@
namespace SIGCM2.Application.Products.Deactivate;
public sealed record DeactivateProductCommand(int Id);

View File

@@ -0,0 +1,6 @@
namespace SIGCM2.Application.Products.Deactivate;
public sealed record ProductStatusDto(
int Id,
bool IsActive,
DateTime? FechaModificacion);

View File

@@ -0,0 +1,3 @@
namespace SIGCM2.Application.Products.GetById;
public sealed record GetProductByIdQuery(int Id);

View File

@@ -0,0 +1,13 @@
namespace SIGCM2.Application.Products.GetById;
public sealed record ProductDetailDto(
int Id,
string Nombre,
int MedioId,
int ProductTypeId,
int? RubroId,
decimal BasePrice,
int? PriceDurationDays,
bool IsActive,
DateTime FechaCreacion,
DateTime? FechaModificacion);

View File

@@ -0,0 +1,10 @@
namespace SIGCM2.Application.Products.List;
public sealed record ListProductsQuery(
int Page = 1,
int PageSize = 20,
bool? Activo = true,
string? Search = null,
int? MedioId = null,
int? ProductTypeId = null,
int? RubroId = null);

View File

@@ -0,0 +1,11 @@
namespace SIGCM2.Application.Products.List;
public sealed record ProductListItemDto(
int Id,
string Nombre,
int MedioId,
int ProductTypeId,
int? RubroId,
decimal BasePrice,
int? PriceDurationDays,
bool IsActive);

View File

@@ -0,0 +1,13 @@
namespace SIGCM2.Application.Products.Update;
public sealed record ProductUpdatedDto(
int Id,
string Nombre,
int MedioId,
int ProductTypeId,
int? RubroId,
decimal BasePrice,
int? PriceDurationDays,
bool IsActive,
DateTime FechaCreacion,
DateTime? FechaModificacion);

View File

@@ -0,0 +1,8 @@
namespace SIGCM2.Application.Products.Update;
public sealed record UpdateProductCommand(
int Id,
string Nombre,
int? RubroId,
decimal BasePrice,
int? PriceDurationDays);

View File

@@ -0,0 +1,23 @@
using FluentValidation;
namespace SIGCM2.Application.Products.Update;
public sealed class UpdateProductCommandValidator : AbstractValidator<UpdateProductCommand>
{
public UpdateProductCommandValidator()
{
RuleFor(x => x.Id)
.GreaterThan(0).WithMessage("Id debe ser un entero positivo.");
RuleFor(x => x.Nombre)
.NotEmpty().WithMessage("El nombre del producto es requerido.")
.MaximumLength(300).WithMessage("El nombre no puede superar los 300 caracteres.");
RuleFor(x => x.BasePrice)
.GreaterThanOrEqualTo(0m).WithMessage("El precio base no puede ser negativo.");
RuleFor(x => x.PriceDurationDays)
.GreaterThan(0).When(x => x.PriceDurationDays.HasValue)
.WithMessage("PriceDurationDays debe ser >= 1 cuando se provee.");
}
}