feat(infrastructure): ChargeableCharConfigRepository Dapper + SP invocation (PRC-001)

- ChargeableCharConfigRepository implements IChargeableCharConfigRepository via Dapper
- InsertWithCloseAsync calls usp_ChargeableCharConfig_InsertWithClose with OUTPUT params;
  maps SqlException 50409 → ChargeableCharConfigForwardOnlyException, 50404 → ChargeableCharConfigInvalidException
- GetActiveForMedioAsync calls usp_ChargeableCharConfig_GetActiveForMedio; returns all rows
  (global + per-medio) — Application service handles priority resolution
- ListAsync / CountAsync use parameterized SQL with OFFSET/FETCH and NULL-aware MedioId filter
- GetByIdAsync / DeactivateAsync cover single-entity read and idempotent deactivation
- DateOnly mapping: DateTime → DateOnly.FromDateTime() pattern, same as ProductPriceRepository
- Registered IChargeableCharConfigRepository → ChargeableCharConfigRepository in DI
- 14 integration tests against SIGCM2_Test_App (all GREEN); 1571/1571 total tests pass
This commit is contained in:
2026-04-20 12:32:17 -03:00
parent f1b38cd9ce
commit 3b1edfd696
3 changed files with 700 additions and 0 deletions

View File

@@ -45,6 +45,8 @@ public static class DependencyInjection
services.AddScoped<IProductQueryRepository, ProductQueryRepository>();
// PRD-003: ProductPrices históricos
services.AddScoped<IProductPriceRepository, ProductPriceRepository>();
// PRC-001: ChargeableCharConfig — caracteres especiales tasables
services.AddScoped<IChargeableCharConfigRepository, ChargeableCharConfigRepository>();
// JWT Options — bound lazily via IOptions so tests can override via ConfigureWebHost
services.Configure<JwtOptions>(configuration.GetSection("Jwt"));

View File

@@ -0,0 +1,248 @@
using System.Data;
using Dapper;
using Microsoft.Data.SqlClient;
using SIGCM2.Application.Abstractions.Persistence;
using SIGCM2.Domain.Pricing.ChargeableChars;
using SIGCM2.Domain.Pricing.Exceptions;
namespace SIGCM2.Infrastructure.Persistence;
/// <summary>
/// PRC-001 — Dapper implementation of IChargeableCharConfigRepository against dbo.ChargeableCharConfig.
///
/// InsertWithCloseAsync: invokes usp_ChargeableCharConfig_InsertWithClose and maps:
/// - SqlException 50404 → ChargeableCharConfigInvalidException (Medio not found)
/// - SqlException 50409 → ChargeableCharConfigForwardOnlyException
///
/// GetActiveForMedioAsync: invokes usp_ChargeableCharConfig_GetActiveForMedio.
/// Returns all rows (global + per-medio) — the Application service applies priority.
///
/// DateOnly mapping: SQL DATE columns are received as DateTime by Dapper; converted via
/// DateOnly.FromDateTime() in the row mapper — same pattern as ProductPriceRepository.
///
/// MedioId: the SP accepts INT NULL; int? cast from long? is performed in this layer.
/// </summary>
public sealed class ChargeableCharConfigRepository : IChargeableCharConfigRepository
{
private readonly SqlConnectionFactory _factory;
public ChargeableCharConfigRepository(SqlConnectionFactory factory)
{
_factory = factory;
}
/// <inheritdoc/>
public async Task<long> InsertWithCloseAsync(
long? medioId,
string symbol,
string category,
decimal price,
DateOnly validFrom,
CancellationToken ct = default)
{
var p = new DynamicParameters();
// SP parameter is INT NULL — cast long? → int? here; DB uses INT for MedioId (V021)
p.Add("@MedioId", medioId.HasValue ? (int?)checked((int)medioId.Value) : null, DbType.Int32);
p.Add("@Symbol", symbol, DbType.String, size: 4);
p.Add("@Category", category, DbType.String, size: 32);
p.Add("@PricePerUnit", price, DbType.Decimal, precision: 18, scale: 4);
p.Add("@ValidFrom", validFrom.ToDateTime(TimeOnly.MinValue), DbType.Date);
p.Add("@NewId", dbType: DbType.Int64, direction: ParameterDirection.Output);
p.Add("@ClosedId", dbType: DbType.Int64, direction: ParameterDirection.Output);
await using var connection = _factory.CreateConnection();
await connection.OpenAsync(ct);
try
{
await connection.ExecuteAsync(
new CommandDefinition(
"dbo.usp_ChargeableCharConfig_InsertWithClose",
p,
commandType: CommandType.StoredProcedure,
cancellationToken: ct));
}
catch (SqlException ex) when (ex.Number == 50404)
{
// Medio not found (SP validates MedioId when not null)
throw new ChargeableCharConfigInvalidException(
nameof(medioId),
$"Medio with Id={medioId} not found.");
}
catch (SqlException ex) when (ex.Number == 50409)
{
// Forward-only violation: new ValidFrom <= active.ValidFrom
throw new ChargeableCharConfigForwardOnlyException(
medioId.HasValue ? (int?)checked((int)medioId.Value) : null,
symbol,
validFrom,
DateOnly.MinValue); // active.ValidFrom not returned by SP; safe placeholder
}
return p.Get<long>("@NewId");
}
/// <inheritdoc/>
public async Task<IReadOnlyList<ChargeableCharConfig>> GetActiveForMedioAsync(
long medioId,
DateOnly asOfDate,
CancellationToken ct = default)
{
var p = new DynamicParameters();
// SP @MedioId is INT
p.Add("@MedioId", checked((int)medioId), DbType.Int32);
p.Add("@AsOfDate", asOfDate.ToDateTime(TimeOnly.MinValue), DbType.Date);
await using var connection = _factory.CreateConnection();
await connection.OpenAsync(ct);
var rows = await connection.QueryAsync<ChargeableCharConfigRow>(
new CommandDefinition(
"dbo.usp_ChargeableCharConfig_GetActiveForMedio",
p,
commandType: CommandType.StoredProcedure,
cancellationToken: ct));
return rows.Select(MapRow).ToList();
}
/// <inheritdoc/>
public async Task<IReadOnlyList<ChargeableCharConfig>> ListAsync(
long? medioId,
bool activeOnly,
int skip,
int take,
CancellationToken ct = default)
{
// NULL-aware MedioId filter:
// - medioId provided → filter to that medio only
// - medioId null → return all rows regardless of medio
// activeOnly filters by IsActive = 1.
const string sql = """
SELECT Id, MedioId, Symbol, Category, PricePerUnit, ValidFrom, ValidTo, IsActive
FROM dbo.ChargeableCharConfig
WHERE (@MedioId IS NULL OR MedioId = @MedioId)
AND (@ActiveOnly = 0 OR IsActive = 1)
ORDER BY ValidFrom DESC, Id DESC
OFFSET @Skip ROWS FETCH NEXT @Take ROWS ONLY
""";
await using var connection = _factory.CreateConnection();
await connection.OpenAsync(ct);
var rows = await connection.QueryAsync<ChargeableCharConfigRow>(
new CommandDefinition(
sql,
new
{
MedioId = medioId.HasValue ? (int?)checked((int)medioId.Value) : (int?)null,
ActiveOnly = activeOnly ? 1 : 0,
Skip = skip,
Take = take
},
cancellationToken: ct));
return rows.Select(MapRow).ToList();
}
/// <inheritdoc/>
public async Task<int> CountAsync(
long? medioId,
bool activeOnly,
CancellationToken ct = default)
{
const string sql = """
SELECT COUNT(1)
FROM dbo.ChargeableCharConfig
WHERE (@MedioId IS NULL OR MedioId = @MedioId)
AND (@ActiveOnly = 0 OR IsActive = 1)
""";
await using var connection = _factory.CreateConnection();
await connection.OpenAsync(ct);
return await connection.ExecuteScalarAsync<int>(
new CommandDefinition(
sql,
new
{
MedioId = medioId.HasValue ? (int?)checked((int)medioId.Value) : (int?)null,
ActiveOnly = activeOnly ? 1 : 0
},
cancellationToken: ct));
}
/// <inheritdoc/>
public async Task<ChargeableCharConfig?> GetByIdAsync(
long id,
CancellationToken ct = default)
{
const string sql = """
SELECT Id, MedioId, Symbol, Category, PricePerUnit, ValidFrom, ValidTo, IsActive
FROM dbo.ChargeableCharConfig
WHERE Id = @Id
""";
await using var connection = _factory.CreateConnection();
await connection.OpenAsync(ct);
var row = await connection.QuerySingleOrDefaultAsync<ChargeableCharConfigRow>(
new CommandDefinition(sql, new { Id = id }, cancellationToken: ct));
return row is null ? null : MapRow(row);
}
/// <inheritdoc/>
public async Task DeactivateAsync(
long id,
DateOnly today,
CancellationToken ct = default)
{
// Idempotent: WHERE ... AND IsActive = 1 — no-op if already inactive.
const string sql = """
UPDATE dbo.ChargeableCharConfig
SET IsActive = 0,
ValidTo = @Today
WHERE Id = @Id
AND IsActive = 1
""";
await using var connection = _factory.CreateConnection();
await connection.OpenAsync(ct);
await connection.ExecuteAsync(
new CommandDefinition(
sql,
new
{
Id = id,
Today = today.ToDateTime(TimeOnly.MinValue)
},
cancellationToken: ct));
}
// ── Row mapper ────────────────────────────────────────────────────────────
// Dapper maps SQL DATE columns to DateTime; we convert to DateOnly here.
// Same pattern as ProductPriceRepository.
private static ChargeableCharConfig MapRow(ChargeableCharConfigRow r)
=> ChargeableCharConfig.Rehydrate(
id: r.Id,
medioId: r.MedioId,
symbol: r.Symbol,
category: r.Category,
price: r.PricePerUnit,
validFrom: DateOnly.FromDateTime(r.ValidFrom),
validTo: r.ValidTo.HasValue ? DateOnly.FromDateTime(r.ValidTo.Value) : (DateOnly?)null,
isActive: r.IsActive);
private sealed record ChargeableCharConfigRow(
long Id,
int? MedioId,
string Symbol,
string Category,
decimal PricePerUnit,
DateTime ValidFrom,
DateTime? ValidTo,
bool IsActive);
}