feat(adm-009): TipoDeIvaRepository + IngresosBrutosRepository Dapper implementations + DI registration

This commit is contained in:
2026-04-17 18:23:10 -03:00
parent 8e2d6bfb14
commit 83dd680fa3
5 changed files with 655 additions and 6 deletions

View File

@@ -35,6 +35,8 @@ public static class DependencyInjection
services.AddScoped<IMedioRepository, MedioRepository>(); services.AddScoped<IMedioRepository, MedioRepository>();
services.AddScoped<ISeccionRepository, SeccionRepository>(); services.AddScoped<ISeccionRepository, SeccionRepository>();
services.AddScoped<IPuntoDeVentaRepository, PuntoDeVentaRepository>(); services.AddScoped<IPuntoDeVentaRepository, PuntoDeVentaRepository>();
services.AddScoped<ITipoDeIvaRepository, TipoDeIvaRepository>();
services.AddScoped<IIngresosBrutosRepository, IngresosBrutosRepository>();
// JWT Options — bound lazily via IOptions so tests can override via ConfigureWebHost // JWT Options — bound lazily via IOptions so tests can override via ConfigureWebHost
services.Configure<JwtOptions>(configuration.GetSection("Jwt")); services.Configure<JwtOptions>(configuration.GetSection("Jwt"));

View File

@@ -0,0 +1,340 @@
using System.Text;
using Dapper;
using Microsoft.Data.SqlClient;
using SIGCM2.Application.Abstractions.Persistence;
using SIGCM2.Application.Common;
using SIGCM2.Domain.Exceptions;
using SIGCM2.Domain.Fiscal;
using IibbEntity = SIGCM2.Domain.Entities.IngresosBrutos;
namespace SIGCM2.Infrastructure.Persistence;
/// <summary>
/// Dapper implementation of <see cref="IIngresosBrutosRepository"/>.
/// Provincia is persisted as the enum member name (PascalCase, e.g. "BuenosAires") via ToString().
/// On read, it is parsed back via Enum.Parse&lt;ProvinciaArgentina&gt;.
/// Alicuota and Provincia are NEVER updated by cosmetic methods.
/// GetHistorialAsync uses a recursive CTE to walk the PredecesorId chain.
/// </summary>
public sealed class IngresosBrutosRepository : IIngresosBrutosRepository
{
private readonly SqlConnectionFactory _connectionFactory;
public IngresosBrutosRepository(SqlConnectionFactory connectionFactory)
{
_connectionFactory = connectionFactory;
}
public async Task<int> InsertAsync(IibbEntity entity, CancellationToken ct = default)
{
const string sql = """
INSERT INTO dbo.IngresosBrutos
(Provincia, Descripcion, Alicuota, Activo, VigenciaDesde, VigenciaHasta, PredecesorId, FechaCreacion, FechaModificacion)
OUTPUT INSERTED.Id
VALUES
(@Provincia, @Descripcion, @Alicuota, @Activo, @VigenciaDesde, @VigenciaHasta, @PredecesorId, SYSUTCDATETIME(), SYSUTCDATETIME())
""";
await using var connection = _connectionFactory.CreateConnection();
await connection.OpenAsync(ct);
try
{
return await connection.ExecuteScalarAsync<int>(sql, new
{
Provincia = entity.Provincia.ToString(),
entity.Descripcion,
entity.Alicuota,
entity.Activo,
VigenciaDesde = entity.VigenciaDesde.ToDateTime(TimeOnly.MinValue),
VigenciaHasta = entity.VigenciaHasta.HasValue
? (object)entity.VigenciaHasta.Value.ToDateTime(TimeOnly.MinValue)
: DBNull.Value,
PredecesorId = entity.PredecesorId.HasValue ? (object)entity.PredecesorId.Value : DBNull.Value,
});
}
catch (SqlException ex) when (IsUniqueViolation(ex))
{
throw new DuplicateProvinciaException(entity.Provincia);
}
}
public async Task<IibbEntity?> GetByIdAsync(int id, CancellationToken ct = default)
{
const string sql = """
SELECT
Id, Provincia, Descripcion, Alicuota, Activo,
VigenciaDesde, VigenciaHasta, PredecesorId, FechaCreacion, FechaModificacion
FROM dbo.IngresosBrutos
WHERE Id = @Id
""";
await using var connection = _connectionFactory.CreateConnection();
await connection.OpenAsync(ct);
var row = await connection.QuerySingleOrDefaultAsync<IibbRow>(sql, new { Id = id });
return row is null ? null : MapRow(row);
}
public async Task<bool> UpdateCosmeticoAsync(
int id, string descripcion, bool activo,
CancellationToken ct = default)
{
// NOTE: Alicuota and Provincia are intentionally EXCLUDED — they are IMMUTABLE.
const string sql = """
UPDATE dbo.IngresosBrutos
SET
Descripcion = @Descripcion,
Activo = @Activo,
FechaModificacion = SYSUTCDATETIME()
WHERE Id = @Id
""";
await using var connection = _connectionFactory.CreateConnection();
await connection.OpenAsync(ct);
var rows = await connection.ExecuteAsync(sql, new { Id = id, Descripcion = descripcion, Activo = activo });
return rows > 0;
}
public async Task<bool> UpdateCierreVigenciaAsync(
int id, DateOnly vigenciaHasta,
CancellationToken ct = default)
{
// Optimistic guard: only update if row is still open (VigenciaHasta IS NULL AND Activo = 1).
// Returns false when 0 rows affected (already closed — race condition detected).
const string sql = """
UPDATE dbo.IngresosBrutos
SET
VigenciaHasta = @VigenciaHasta,
FechaModificacion = SYSUTCDATETIME()
WHERE Id = @Id
AND VigenciaHasta IS NULL
AND Activo = 1
""";
await using var connection = _connectionFactory.CreateConnection();
await connection.OpenAsync(ct);
var rows = await connection.ExecuteAsync(sql, new
{
Id = id,
VigenciaHasta = vigenciaHasta.ToDateTime(TimeOnly.MinValue),
});
return rows > 0;
}
public async Task<bool> SetActivoAsync(int id, bool activo, CancellationToken ct = default)
{
const string sql = """
UPDATE dbo.IngresosBrutos
SET
Activo = @Activo,
FechaModificacion = SYSUTCDATETIME()
WHERE Id = @Id
""";
await using var connection = _connectionFactory.CreateConnection();
await connection.OpenAsync(ct);
var rows = await connection.ExecuteAsync(sql, new { Id = id, Activo = activo });
return rows > 0;
}
public async Task<PagedResult<IibbEntity>> ListAsync(IngresosBrutosQuery query, CancellationToken ct = default)
{
var page = Math.Max(1, query.Page);
var pageSize = Math.Clamp(query.PageSize, 1, 100);
var offset = (page - 1) * pageSize;
var where = new StringBuilder("WHERE 1=1");
var parameters = new DynamicParameters();
parameters.Add("PageSize", pageSize);
parameters.Add("Offset", offset);
if (query.Activo.HasValue)
{
where.Append(" AND Activo = @Activo");
parameters.Add("Activo", query.Activo.Value ? 1 : 0);
}
if (query.Provincia.HasValue)
{
where.Append(" AND Provincia = @Provincia");
parameters.Add("Provincia", query.Provincia.Value.ToString());
}
var sql = $"""
SELECT
Id, Provincia, Descripcion, Alicuota, Activo,
VigenciaDesde, VigenciaHasta, PredecesorId, FechaCreacion, FechaModificacion,
COUNT(*) OVER() AS TotalCount
FROM dbo.IngresosBrutos
{where}
ORDER BY Id DESC
OFFSET @Offset ROWS FETCH NEXT @PageSize ROWS ONLY
""";
await using var connection = _connectionFactory.CreateConnection();
await connection.OpenAsync(ct);
var rows = await connection.QueryAsync<IibbPagedRow>(sql, parameters);
var list = rows.ToList();
var total = list.Count > 0 ? list[0].TotalCount : 0;
var items = list.Select(MapPagedRow).ToList();
return new PagedResult<IibbEntity>(items, page, pageSize, total);
}
public async Task<IReadOnlyList<IibbEntity>> GetHistorialAsync(int id, CancellationToken ct = default)
{
// Recursive CTE: starts at @Id and walks PredecesorId upward to root,
// then orders by VigenciaDesde ASC so root comes first.
const string sql = """
WITH Cadena AS (
SELECT
Id, Provincia, Descripcion, Alicuota, Activo,
VigenciaDesde, VigenciaHasta, PredecesorId, FechaCreacion, FechaModificacion,
0 AS NivelDesdeActual
FROM dbo.IngresosBrutos
WHERE Id = @Id
UNION ALL
SELECT
t.Id, t.Provincia, t.Descripcion, t.Alicuota, t.Activo,
t.VigenciaDesde, t.VigenciaHasta, t.PredecesorId, t.FechaCreacion, t.FechaModificacion,
c.NivelDesdeActual + 1
FROM dbo.IngresosBrutos t
INNER JOIN Cadena c ON t.Id = c.PredecesorId
)
SELECT
Id, Provincia, Descripcion, Alicuota, Activo,
VigenciaDesde, VigenciaHasta, PredecesorId, FechaCreacion, FechaModificacion
FROM Cadena
ORDER BY VigenciaDesde ASC
OPTION (MAXRECURSION 100)
""";
await using var connection = _connectionFactory.CreateConnection();
await connection.OpenAsync(ct);
var rows = await connection.QueryAsync<IibbRow>(sql, new { Id = id });
return rows.Select(MapRow).ToList().AsReadOnly();
}
// ── Private helpers ───────────────────────────────────────────────────────
private static IibbEntity MapRow(IibbRow r)
=> IibbEntity.FromDb(
id: r.Id,
provincia: ParseProvincia(r.Provincia),
descripcion: r.Descripcion,
alicuota: r.Alicuota,
activo: r.Activo,
vigenciaDesde: DateOnly.FromDateTime(r.VigenciaDesde),
vigenciaHasta: r.VigenciaHasta.HasValue ? DateOnly.FromDateTime(r.VigenciaHasta.Value) : null,
predecesorId: r.PredecesorId,
fechaCreacion: r.FechaCreacion,
fechaModificacion: r.FechaModificacion);
private static IibbEntity MapPagedRow(IibbPagedRow r)
=> IibbEntity.FromDb(
id: r.Id,
provincia: ParseProvincia(r.Provincia),
descripcion: r.Descripcion,
alicuota: r.Alicuota,
activo: r.Activo,
vigenciaDesde: DateOnly.FromDateTime(r.VigenciaDesde),
vigenciaHasta: r.VigenciaHasta.HasValue ? DateOnly.FromDateTime(r.VigenciaHasta.Value) : null,
predecesorId: r.PredecesorId,
fechaCreacion: r.FechaCreacion,
fechaModificacion: r.FechaModificacion);
/// <summary>
/// Parses a Provincia string from DB to ProvinciaArgentina enum.
/// Handles both PascalCase (e.g. "BuenosAires" — written by this repo) and
/// UPPER_SNAKE_CASE legacy seed values (e.g. "BUENOS_AIRES" — written by V014 seed).
/// Strategy: try direct Enum.Parse first, then normalize UPPER_SNAKE_CASE → PascalCase.
/// </summary>
private static ProvinciaArgentina ParseProvincia(string value)
{
// Fast path: PascalCase written by this repo (e.g. "BuenosAires")
if (Enum.TryParse<ProvinciaArgentina>(value, ignoreCase: false, out var result))
return result;
// Slow path: UPPER_SNAKE_CASE from V014 seed (e.g. "BUENOS_AIRES" → "BuenosAires")
// Also handles CABA → CiudadAutonomaDeBuenosAires via explicit mapping
var normalized = NormalizeUpperSnakeToPascal(value);
if (Enum.TryParse<ProvinciaArgentina>(normalized, ignoreCase: false, out result))
return result;
throw new ArgumentException(
$"Cannot parse '{value}' as ProvinciaArgentina. " +
$"Expected PascalCase enum name (e.g. 'BuenosAires') or UPPER_SNAKE_CASE seed name (e.g. 'BUENOS_AIRES').");
}
// Maps UPPER_SNAKE_CASE seed values to PascalCase enum names.
// Explicit mappings for non-trivial conversions (CABA, multi-word with articles).
private static readonly Dictionary<string, string> LegacySeedMap = new(StringComparer.Ordinal)
{
["BUENOS_AIRES"] = nameof(ProvinciaArgentina.BuenosAires),
["CABA"] = nameof(ProvinciaArgentina.CiudadAutonomaDeBuenosAires),
["CATAMARCA"] = nameof(ProvinciaArgentina.Catamarca),
["CHACO"] = nameof(ProvinciaArgentina.Chaco),
["CHUBUT"] = nameof(ProvinciaArgentina.Chubut),
["CORDOBA"] = nameof(ProvinciaArgentina.Cordoba),
["CORRIENTES"] = nameof(ProvinciaArgentina.Corrientes),
["ENTRE_RIOS"] = nameof(ProvinciaArgentina.EntreRios),
["FORMOSA"] = nameof(ProvinciaArgentina.Formosa),
["JUJUY"] = nameof(ProvinciaArgentina.Jujuy),
["LA_PAMPA"] = nameof(ProvinciaArgentina.LaPampa),
["LA_RIOJA"] = nameof(ProvinciaArgentina.LaRioja),
["MENDOZA"] = nameof(ProvinciaArgentina.Mendoza),
["MISIONES"] = nameof(ProvinciaArgentina.Misiones),
["NEUQUEN"] = nameof(ProvinciaArgentina.Neuquen),
["RIO_NEGRO"] = nameof(ProvinciaArgentina.RioNegro),
["SALTA"] = nameof(ProvinciaArgentina.Salta),
["SAN_JUAN"] = nameof(ProvinciaArgentina.SanJuan),
["SAN_LUIS"] = nameof(ProvinciaArgentina.SanLuis),
["SANTA_CRUZ"] = nameof(ProvinciaArgentina.SantaCruz),
["SANTA_FE"] = nameof(ProvinciaArgentina.SantaFe),
["SANTIAGO_DEL_ESTERO"] = nameof(ProvinciaArgentina.SantiagoDelEstero),
["TIERRA_DEL_FUEGO"] = nameof(ProvinciaArgentina.TierraDelFuego),
["TUCUMAN"] = nameof(ProvinciaArgentina.Tucuman),
};
private static string NormalizeUpperSnakeToPascal(string value)
=> LegacySeedMap.TryGetValue(value, out var pascal) ? pascal : value;
private static bool IsUniqueViolation(SqlException ex)
=> ex.Number is 2627 or 2601;
// ── Private row records ───────────────────────────────────────────────────
private sealed record IibbRow(
int Id,
string Provincia,
string Descripcion,
decimal Alicuota,
bool Activo,
DateTime VigenciaDesde,
DateTime? VigenciaHasta,
int? PredecesorId,
DateTime FechaCreacion,
DateTime? FechaModificacion);
private sealed record IibbPagedRow(
int Id,
string Provincia,
string Descripcion,
decimal Alicuota,
bool Activo,
DateTime VigenciaDesde,
DateTime? VigenciaHasta,
int? PredecesorId,
DateTime FechaCreacion,
DateTime? FechaModificacion,
int TotalCount);
}

View File

@@ -0,0 +1,288 @@
using System.Text;
using Dapper;
using Microsoft.Data.SqlClient;
using SIGCM2.Application.Abstractions.Persistence;
using SIGCM2.Application.Common;
using SIGCM2.Domain.Entities;
using SIGCM2.Domain.Exceptions;
namespace SIGCM2.Infrastructure.Persistence;
/// <summary>
/// Dapper implementation of <see cref="ITipoDeIvaRepository"/>.
/// All SQL is inline. Porcentaje and vigencia dates are NEVER updated by cosmetic methods.
/// GetHistorialAsync uses a recursive CTE to walk the PredecesorId chain.
/// </summary>
public sealed class TipoDeIvaRepository : ITipoDeIvaRepository
{
private readonly SqlConnectionFactory _connectionFactory;
public TipoDeIvaRepository(SqlConnectionFactory connectionFactory)
{
_connectionFactory = connectionFactory;
}
public async Task<int> InsertAsync(TipoDeIva entity, CancellationToken ct = default)
{
const string sql = """
INSERT INTO dbo.TipoDeIva
(Codigo, Descripcion, Porcentaje, AplicaIVA, Activo, VigenciaDesde, VigenciaHasta, PredecesorId, FechaCreacion, FechaModificacion)
OUTPUT INSERTED.Id
VALUES
(@Codigo, @Descripcion, @Porcentaje, @AplicaIVA, @Activo, @VigenciaDesde, @VigenciaHasta, @PredecesorId, SYSUTCDATETIME(), SYSUTCDATETIME())
""";
await using var connection = _connectionFactory.CreateConnection();
await connection.OpenAsync(ct);
try
{
return await connection.ExecuteScalarAsync<int>(sql, new
{
entity.Codigo,
entity.Descripcion,
entity.Porcentaje,
entity.AplicaIVA,
entity.Activo,
VigenciaDesde = entity.VigenciaDesde.ToDateTime(TimeOnly.MinValue),
VigenciaHasta = entity.VigenciaHasta.HasValue
? (object)entity.VigenciaHasta.Value.ToDateTime(TimeOnly.MinValue)
: DBNull.Value,
PredecesorId = entity.PredecesorId.HasValue ? (object)entity.PredecesorId.Value : DBNull.Value,
});
}
catch (SqlException ex) when (IsUniqueViolation(ex))
{
throw new DuplicateCodigoException(entity.Codigo);
}
}
public async Task<TipoDeIva?> GetByIdAsync(int id, CancellationToken ct = default)
{
const string sql = """
SELECT
Id, Codigo, Descripcion, Porcentaje, AplicaIVA, Activo,
VigenciaDesde, VigenciaHasta, PredecesorId, FechaCreacion, FechaModificacion
FROM dbo.TipoDeIva
WHERE Id = @Id
""";
await using var connection = _connectionFactory.CreateConnection();
await connection.OpenAsync(ct);
var row = await connection.QuerySingleOrDefaultAsync<TipoDeIvaRow>(sql, new { Id = id });
return row is null ? null : MapRow(row);
}
public async Task<bool> UpdateCosmeticoAsync(
int id, string codigo, string descripcion, bool aplicaIVA, bool activo,
CancellationToken ct = default)
{
// NOTE: Porcentaje, VigenciaDesde, VigenciaHasta, PredecesorId are intentionally EXCLUDED.
const string sql = """
UPDATE dbo.TipoDeIva
SET
Codigo = @Codigo,
Descripcion = @Descripcion,
AplicaIVA = @AplicaIVA,
Activo = @Activo,
FechaModificacion = SYSUTCDATETIME()
WHERE Id = @Id
""";
await using var connection = _connectionFactory.CreateConnection();
await connection.OpenAsync(ct);
var rows = await connection.ExecuteAsync(sql, new { Id = id, Codigo = codigo, Descripcion = descripcion, AplicaIVA = aplicaIVA, Activo = activo });
return rows > 0;
}
public async Task<bool> UpdateCierreVigenciaAsync(
int id, DateOnly vigenciaHasta,
CancellationToken ct = default)
{
// Optimistic guard: only update if row is still open (VigenciaHasta IS NULL AND Activo = 1).
// Returns false when 0 rows affected (already closed — race condition detected).
const string sql = """
UPDATE dbo.TipoDeIva
SET
VigenciaHasta = @VigenciaHasta,
FechaModificacion = SYSUTCDATETIME()
WHERE Id = @Id
AND VigenciaHasta IS NULL
AND Activo = 1
""";
await using var connection = _connectionFactory.CreateConnection();
await connection.OpenAsync(ct);
var rows = await connection.ExecuteAsync(sql, new
{
Id = id,
VigenciaHasta = vigenciaHasta.ToDateTime(TimeOnly.MinValue),
});
return rows > 0;
}
public async Task<bool> SetActivoAsync(int id, bool activo, CancellationToken ct = default)
{
const string sql = """
UPDATE dbo.TipoDeIva
SET
Activo = @Activo,
FechaModificacion = SYSUTCDATETIME()
WHERE Id = @Id
""";
await using var connection = _connectionFactory.CreateConnection();
await connection.OpenAsync(ct);
var rows = await connection.ExecuteAsync(sql, new { Id = id, Activo = activo });
return rows > 0;
}
public async Task<PagedResult<TipoDeIva>> ListAsync(TiposDeIvaQuery query, CancellationToken ct = default)
{
var page = Math.Max(1, query.Page);
var pageSize = Math.Clamp(query.PageSize, 1, 100);
var offset = (page - 1) * pageSize;
var where = new StringBuilder("WHERE 1=1");
var parameters = new DynamicParameters();
parameters.Add("PageSize", pageSize);
parameters.Add("Offset", offset);
if (query.Activo.HasValue)
{
where.Append(" AND Activo = @Activo");
parameters.Add("Activo", query.Activo.Value ? 1 : 0);
}
if (!string.IsNullOrWhiteSpace(query.Codigo))
{
where.Append(" AND Codigo LIKE @Codigo + '%'");
parameters.Add("Codigo", query.Codigo);
}
var sql = $"""
SELECT
Id, Codigo, Descripcion, Porcentaje, AplicaIVA, Activo,
VigenciaDesde, VigenciaHasta, PredecesorId, FechaCreacion, FechaModificacion,
COUNT(*) OVER() AS TotalCount
FROM dbo.TipoDeIva
{where}
ORDER BY Id DESC
OFFSET @Offset ROWS FETCH NEXT @PageSize ROWS ONLY
""";
await using var connection = _connectionFactory.CreateConnection();
await connection.OpenAsync(ct);
var rows = await connection.QueryAsync<TipoDeIvaPagedRow>(sql, parameters);
var list = rows.ToList();
var total = list.Count > 0 ? list[0].TotalCount : 0;
var items = list.Select(MapPagedRow).ToList();
return new PagedResult<TipoDeIva>(items, page, pageSize, total);
}
public async Task<IReadOnlyList<TipoDeIva>> GetHistorialAsync(int id, CancellationToken ct = default)
{
// Recursive CTE: starts at @Id and walks PredecesorId upward to root,
// then orders by VigenciaDesde ASC so root comes first.
const string sql = """
WITH Cadena AS (
SELECT
Id, Codigo, Descripcion, Porcentaje, AplicaIVA, Activo,
VigenciaDesde, VigenciaHasta, PredecesorId, FechaCreacion, FechaModificacion,
0 AS NivelDesdeActual
FROM dbo.TipoDeIva
WHERE Id = @Id
UNION ALL
SELECT
t.Id, t.Codigo, t.Descripcion, t.Porcentaje, t.AplicaIVA, t.Activo,
t.VigenciaDesde, t.VigenciaHasta, t.PredecesorId, t.FechaCreacion, t.FechaModificacion,
c.NivelDesdeActual + 1
FROM dbo.TipoDeIva t
INNER JOIN Cadena c ON t.Id = c.PredecesorId
)
SELECT
Id, Codigo, Descripcion, Porcentaje, AplicaIVA, Activo,
VigenciaDesde, VigenciaHasta, PredecesorId, FechaCreacion, FechaModificacion
FROM Cadena
ORDER BY VigenciaDesde ASC
OPTION (MAXRECURSION 100)
""";
await using var connection = _connectionFactory.CreateConnection();
await connection.OpenAsync(ct);
var rows = await connection.QueryAsync<TipoDeIvaRow>(sql, new { Id = id });
return rows.Select(MapRow).ToList().AsReadOnly();
}
// ── Private helpers ───────────────────────────────────────────────────────
private static TipoDeIva MapRow(TipoDeIvaRow r)
=> TipoDeIva.FromDb(
id: r.Id,
codigo: r.Codigo,
descripcion: r.Descripcion,
porcentaje: r.Porcentaje,
aplicaIVA: r.AplicaIVA,
activo: r.Activo,
vigenciaDesde: DateOnly.FromDateTime(r.VigenciaDesde),
vigenciaHasta: r.VigenciaHasta.HasValue ? DateOnly.FromDateTime(r.VigenciaHasta.Value) : null,
predecesorId: r.PredecesorId,
fechaCreacion: r.FechaCreacion,
fechaModificacion: r.FechaModificacion);
private static TipoDeIva MapPagedRow(TipoDeIvaPagedRow r)
=> TipoDeIva.FromDb(
id: r.Id,
codigo: r.Codigo,
descripcion: r.Descripcion,
porcentaje: r.Porcentaje,
aplicaIVA: r.AplicaIVA,
activo: r.Activo,
vigenciaDesde: DateOnly.FromDateTime(r.VigenciaDesde),
vigenciaHasta: r.VigenciaHasta.HasValue ? DateOnly.FromDateTime(r.VigenciaHasta.Value) : null,
predecesorId: r.PredecesorId,
fechaCreacion: r.FechaCreacion,
fechaModificacion: r.FechaModificacion);
private static bool IsUniqueViolation(SqlException ex)
=> ex.Number is 2627 or 2601;
// ── Private row records ───────────────────────────────────────────────────
private sealed record TipoDeIvaRow(
int Id,
string Codigo,
string Descripcion,
decimal Porcentaje,
bool AplicaIVA,
bool Activo,
DateTime VigenciaDesde,
DateTime? VigenciaHasta,
int? PredecesorId,
DateTime FechaCreacion,
DateTime? FechaModificacion);
private sealed record TipoDeIvaPagedRow(
int Id,
string Codigo,
string Descripcion,
decimal Porcentaje,
bool AplicaIVA,
bool Activo,
DateTime VigenciaDesde,
DateTime? VigenciaHasta,
int? PredecesorId,
DateTime FechaCreacion,
DateTime? FechaModificacion,
int TotalCount);
}

View File

@@ -24,13 +24,21 @@ public class IngresosBrutosRepositoryTests : IAsyncLifetime
private SqlConnection _connection = null!; private SqlConnection _connection = null!;
private IIngresosBrutosRepository _repo = null!; private IIngresosBrutosRepository _repo = null!;
private static readonly int _runBase;
static IngresosBrutosRepositoryTests()
{
var bytes = Guid.NewGuid().ToByteArray();
_runBase = (int)(BitConverter.ToUInt32(bytes, 0) % 500_000u);
}
private static int _testCounter = 0; private static int _testCounter = 0;
private static DateOnly NextUniqueDate() private static DateOnly NextUniqueDate()
{ {
var counter = Interlocked.Increment(ref _testCounter); var counter = Interlocked.Increment(ref _testCounter);
// Start at 2091-01-01 to avoid clashing with TipoDeIvaRepositoryTests (2090-01-01) // Base year 2091 (different from TipoDeIvaRepositoryTests which uses 2090).
return new DateOnly(2091, 1, 1).AddDays(counter); return new DateOnly(2091, 1, 1).AddDays(_runBase + counter);
} }
public async Task InitializeAsync() public async Task InitializeAsync()

View File

@@ -25,15 +25,26 @@ public class TipoDeIvaRepositoryTests : IAsyncLifetime
private SqlConnection _connection = null!; private SqlConnection _connection = null!;
private ITipoDeIvaRepository _repo = null!; private ITipoDeIvaRepository _repo = null!;
// Use a unique date range for this test class to avoid UQ constraint clashes with seed // TipoDeIva rows are never Respawned (seed data). We need a globally unique VigenciaDesde
// seed uses VigenciaDesde = '2020-01-01'; we use a far-future range per test. // per (Codigo, VigenciaDesde) pair across all test runs.
// Strategy: derive a large pseudo-random day offset from a Guid generated once per
// process + an Interlocked counter for intra-run uniqueness.
// This makes the probability of collision between runs astronomically small.
private static readonly int _runBase;
static TipoDeIvaRepositoryTests()
{
// Take the first 4 bytes of a fresh Guid as a pseudo-random int in [0, 500_000)
var bytes = Guid.NewGuid().ToByteArray();
_runBase = (int)(BitConverter.ToUInt32(bytes, 0) % 500_000u);
}
private static int _testCounter = 0; private static int _testCounter = 0;
private static DateOnly NextUniqueDate() private static DateOnly NextUniqueDate()
{ {
var counter = Interlocked.Increment(ref _testCounter); var counter = Interlocked.Increment(ref _testCounter);
// Start at 2090-01-01 + counter days so each test gets its own unique date return new DateOnly(2090, 1, 1).AddDays(_runBase + counter);
return new DateOnly(2090, 1, 1).AddDays(counter);
} }
public async Task InitializeAsync() public async Task InitializeAsync()