feat(infrastructure): ProductRepository + ProductQueryRepository, DI swap activates guard (PRD-002)
This commit is contained in:
@@ -69,7 +69,6 @@ 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.Products.Create;
|
||||
using SIGCM2.Application.Products.Update;
|
||||
using SIGCM2.Application.Products.Deactivate;
|
||||
@@ -184,8 +183,7 @@ public static class DependencyInjection
|
||||
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>();
|
||||
// IProductQueryRepository is now bound in Infrastructure DI (PRD-002) against dbo.Product.
|
||||
|
||||
services.AddScoped<ICommandHandler<CreateProductTypeCommand, ProductTypeCreatedDto>, CreateProductTypeCommandHandler>();
|
||||
services.AddScoped<ICommandHandler<UpdateProductTypeCommand, ProductTypeUpdatedDto>, UpdateProductTypeCommandHandler>();
|
||||
|
||||
@@ -37,12 +37,10 @@ public sealed class DeactivateProductTypeCommandHandler
|
||||
if (!target.IsActive)
|
||||
return new ProductTypeStatusDto(command.Id, false);
|
||||
|
||||
// 3. Guard: check if any active product uses this type (guard before audit — ordering matters)
|
||||
// 3. Guard: check if any active product uses this type
|
||||
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.
|
||||
throw new ProductTypeEnUsoException(command.Id, productsActivos: 1);
|
||||
|
||||
// 4. Deactivate (immutable — returns new instance)
|
||||
var deactivated = target.WithDeactivated(_timeProvider);
|
||||
|
||||
@@ -40,6 +40,9 @@ public static class DependencyInjection
|
||||
services.AddScoped<IIngresosBrutosRepository, IngresosBrutosRepository>();
|
||||
services.AddScoped<IRubroRepository, RubroRepository>();
|
||||
services.AddScoped<IProductTypeRepository, ProductTypeRepository>();
|
||||
services.AddScoped<IProductRepository, ProductRepository>();
|
||||
// PRD-002: replaces NullProductQueryRepository from Application DI
|
||||
services.AddScoped<IProductQueryRepository, ProductQueryRepository>();
|
||||
|
||||
// JWT Options — bound lazily via IOptions so tests can override via ConfigureWebHost
|
||||
services.Configure<JwtOptions>(configuration.GetSection("Jwt"));
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
using Dapper;
|
||||
using SIGCM2.Application.Abstractions.Persistence;
|
||||
|
||||
namespace SIGCM2.Infrastructure.Persistence;
|
||||
|
||||
/// <summary>
|
||||
/// PRD-002 — Real Dapper implementation of IProductQueryRepository against dbo.Product.
|
||||
/// Replaces NullProductQueryRepository which was bound during PRD-001.
|
||||
/// </summary>
|
||||
public sealed class ProductQueryRepository : IProductQueryRepository
|
||||
{
|
||||
private readonly SqlConnectionFactory _factory;
|
||||
|
||||
public ProductQueryRepository(SqlConnectionFactory factory)
|
||||
{
|
||||
_factory = factory;
|
||||
}
|
||||
|
||||
public async Task<bool> ExistsActiveByProductTypeAsync(int productTypeId, CancellationToken ct = default)
|
||||
{
|
||||
const string sql = """
|
||||
SELECT COUNT(1)
|
||||
FROM dbo.Product
|
||||
WHERE ProductTypeId = @ProductTypeId
|
||||
AND IsActive = 1
|
||||
""";
|
||||
|
||||
await using var connection = _factory.CreateConnection();
|
||||
await connection.OpenAsync(ct);
|
||||
|
||||
var count = await connection.ExecuteScalarAsync<int>(sql, new { ProductTypeId = productTypeId });
|
||||
return count > 0;
|
||||
}
|
||||
}
|
||||
201
src/api/SIGCM2.Infrastructure/Persistence/ProductRepository.cs
Normal file
201
src/api/SIGCM2.Infrastructure/Persistence/ProductRepository.cs
Normal file
@@ -0,0 +1,201 @@
|
||||
using Dapper;
|
||||
using SIGCM2.Application.Abstractions.Persistence;
|
||||
using SIGCM2.Application.Common;
|
||||
using SIGCM2.Domain.Entities;
|
||||
|
||||
namespace SIGCM2.Infrastructure.Persistence;
|
||||
|
||||
/// <summary>
|
||||
/// PRD-002 — Dapper implementation of IProductRepository against dbo.Product.
|
||||
/// Full implementation in Batch 6.
|
||||
/// </summary>
|
||||
public sealed class ProductRepository : IProductRepository
|
||||
{
|
||||
private readonly SqlConnectionFactory _factory;
|
||||
|
||||
public ProductRepository(SqlConnectionFactory factory)
|
||||
{
|
||||
_factory = factory;
|
||||
}
|
||||
|
||||
public async Task<int> AddAsync(Product product, CancellationToken ct = default)
|
||||
{
|
||||
const string sql = """
|
||||
INSERT INTO dbo.Product (
|
||||
Nombre, MedioId, ProductTypeId, RubroId, BasePrice, PriceDurationDays,
|
||||
IsActive, FechaCreacion
|
||||
)
|
||||
OUTPUT INSERTED.Id
|
||||
VALUES (
|
||||
@Nombre, @MedioId, @ProductTypeId, @RubroId, @BasePrice, @PriceDurationDays,
|
||||
1, @FechaCreacion
|
||||
)
|
||||
""";
|
||||
|
||||
await using var connection = _factory.CreateConnection();
|
||||
await connection.OpenAsync(ct);
|
||||
|
||||
return await connection.ExecuteScalarAsync<int>(sql, new
|
||||
{
|
||||
product.Nombre,
|
||||
product.MedioId,
|
||||
product.ProductTypeId,
|
||||
product.RubroId,
|
||||
product.BasePrice,
|
||||
product.PriceDurationDays,
|
||||
product.FechaCreacion,
|
||||
});
|
||||
}
|
||||
|
||||
public async Task<Product?> GetByIdAsync(int id, CancellationToken ct = default)
|
||||
{
|
||||
const string sql = """
|
||||
SELECT Id, Nombre, MedioId, ProductTypeId, RubroId,
|
||||
BasePrice, PriceDurationDays, IsActive, FechaCreacion, FechaModificacion
|
||||
FROM dbo.Product
|
||||
WHERE Id = @Id
|
||||
""";
|
||||
|
||||
await using var connection = _factory.CreateConnection();
|
||||
await connection.OpenAsync(ct);
|
||||
|
||||
var row = await connection.QuerySingleOrDefaultAsync<ProductRow>(sql, new { Id = id });
|
||||
return row is null ? null : MapRow(row);
|
||||
}
|
||||
|
||||
public async Task<PagedResult<Product>> GetPagedAsync(ProductsQuery query, CancellationToken ct = default)
|
||||
{
|
||||
var conditions = new List<string>();
|
||||
if (query.Activo.HasValue)
|
||||
conditions.Add("IsActive = @Activo");
|
||||
if (!string.IsNullOrWhiteSpace(query.Search))
|
||||
conditions.Add("Nombre LIKE '%' + @Search + '%'");
|
||||
if (query.MedioId.HasValue)
|
||||
conditions.Add("MedioId = @MedioId");
|
||||
if (query.ProductTypeId.HasValue)
|
||||
conditions.Add("ProductTypeId = @ProductTypeId");
|
||||
if (query.RubroId.HasValue)
|
||||
conditions.Add("RubroId = @RubroId");
|
||||
|
||||
var where = conditions.Count > 0
|
||||
? "WHERE " + string.Join(" AND ", conditions)
|
||||
: string.Empty;
|
||||
|
||||
var countSql = $"SELECT COUNT(1) FROM dbo.Product {where}";
|
||||
var dataSql = $"""
|
||||
SELECT Id, Nombre, MedioId, ProductTypeId, RubroId,
|
||||
BasePrice, PriceDurationDays, IsActive, FechaCreacion, FechaModificacion
|
||||
FROM dbo.Product
|
||||
{where}
|
||||
ORDER BY Nombre
|
||||
OFFSET @Offset ROWS FETCH NEXT @PageSize ROWS ONLY
|
||||
""";
|
||||
|
||||
var offset = (query.Page - 1) * query.PageSize;
|
||||
var parameters = new
|
||||
{
|
||||
Activo = query.Activo.HasValue ? (object)(query.Activo.Value ? 1 : 0) : null,
|
||||
Search = string.IsNullOrWhiteSpace(query.Search) ? null : query.Search,
|
||||
query.MedioId,
|
||||
query.ProductTypeId,
|
||||
query.RubroId,
|
||||
Offset = offset,
|
||||
PageSize = query.PageSize,
|
||||
};
|
||||
|
||||
await using var connection = _factory.CreateConnection();
|
||||
await connection.OpenAsync(ct);
|
||||
|
||||
var total = await connection.ExecuteScalarAsync<int>(countSql, parameters);
|
||||
var rows = await connection.QueryAsync<ProductRow>(dataSql, parameters);
|
||||
var items = rows.Select(MapRow).ToList();
|
||||
|
||||
return new PagedResult<Product>(items, query.Page, query.PageSize, total);
|
||||
}
|
||||
|
||||
public async Task UpdateAsync(Product product, CancellationToken ct = default)
|
||||
{
|
||||
const string sql = """
|
||||
UPDATE dbo.Product
|
||||
SET Nombre = @Nombre,
|
||||
RubroId = @RubroId,
|
||||
BasePrice = @BasePrice,
|
||||
PriceDurationDays = @PriceDurationDays,
|
||||
IsActive = @IsActive,
|
||||
FechaModificacion = @FechaModificacion
|
||||
WHERE Id = @Id
|
||||
""";
|
||||
|
||||
await using var connection = _factory.CreateConnection();
|
||||
await connection.OpenAsync(ct);
|
||||
|
||||
await connection.ExecuteAsync(sql, new
|
||||
{
|
||||
product.Nombre,
|
||||
product.RubroId,
|
||||
product.BasePrice,
|
||||
product.PriceDurationDays,
|
||||
IsActive = product.IsActive ? 1 : 0,
|
||||
product.FechaModificacion,
|
||||
product.Id,
|
||||
});
|
||||
}
|
||||
|
||||
public async Task<bool> ExistsByNombreAsync(
|
||||
string nombre,
|
||||
int medioId,
|
||||
int productTypeId,
|
||||
int? excludeId = null,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
const string sql = """
|
||||
SELECT COUNT(1)
|
||||
FROM dbo.Product
|
||||
WHERE Nombre = @Nombre
|
||||
AND MedioId = @MedioId
|
||||
AND ProductTypeId = @ProductTypeId
|
||||
AND IsActive = 1
|
||||
AND (@ExcludeId IS NULL OR Id <> @ExcludeId)
|
||||
""";
|
||||
|
||||
await using var connection = _factory.CreateConnection();
|
||||
await connection.OpenAsync(ct);
|
||||
|
||||
var count = await connection.ExecuteScalarAsync<int>(sql, new
|
||||
{
|
||||
Nombre = nombre,
|
||||
MedioId = medioId,
|
||||
ProductTypeId = productTypeId,
|
||||
ExcludeId = excludeId,
|
||||
});
|
||||
|
||||
return count > 0;
|
||||
}
|
||||
|
||||
// ── Mapping ───────────────────────────────────────────────────────────────
|
||||
|
||||
private static Product MapRow(ProductRow r)
|
||||
=> new(
|
||||
id: r.Id,
|
||||
nombre: r.Nombre,
|
||||
medioId: r.MedioId,
|
||||
productTypeId: r.ProductTypeId,
|
||||
rubroId: r.RubroId,
|
||||
basePrice: r.BasePrice,
|
||||
priceDurationDays: r.PriceDurationDays,
|
||||
isActive: r.IsActive,
|
||||
fechaCreacion: r.FechaCreacion,
|
||||
fechaModificacion: r.FechaModificacion);
|
||||
|
||||
private sealed record ProductRow(
|
||||
int Id,
|
||||
string Nombre,
|
||||
int MedioId,
|
||||
int ProductTypeId,
|
||||
int? RubroId,
|
||||
decimal BasePrice,
|
||||
int? PriceDurationDays,
|
||||
bool IsActive,
|
||||
DateTime FechaCreacion,
|
||||
DateTime? FechaModificacion);
|
||||
}
|
||||
Reference in New Issue
Block a user