From 83dd680fa33af5c32e962de0fb7f7f320b1c4515 Mon Sep 17 00:00:00 2001 From: dmolinari Date: Fri, 17 Apr 2026 18:23:10 -0300 Subject: [PATCH] feat(adm-009): TipoDeIvaRepository + IngresosBrutosRepository Dapper implementations + DI registration --- .../DependencyInjection.cs | 2 + .../Persistence/IngresosBrutosRepository.cs | 340 ++++++++++++++++++ .../Persistence/TipoDeIvaRepository.cs | 288 +++++++++++++++ .../IngresosBrutosRepositoryTests.cs | 12 +- .../TipoDeIvaRepositoryTests.cs | 19 +- 5 files changed, 655 insertions(+), 6 deletions(-) create mode 100644 src/api/SIGCM2.Infrastructure/Persistence/IngresosBrutosRepository.cs create mode 100644 src/api/SIGCM2.Infrastructure/Persistence/TipoDeIvaRepository.cs diff --git a/src/api/SIGCM2.Infrastructure/DependencyInjection.cs b/src/api/SIGCM2.Infrastructure/DependencyInjection.cs index 7cb03ba..a638f1f 100644 --- a/src/api/SIGCM2.Infrastructure/DependencyInjection.cs +++ b/src/api/SIGCM2.Infrastructure/DependencyInjection.cs @@ -35,6 +35,8 @@ public static class DependencyInjection services.AddScoped(); services.AddScoped(); services.AddScoped(); + services.AddScoped(); + services.AddScoped(); // JWT Options — bound lazily via IOptions so tests can override via ConfigureWebHost services.Configure(configuration.GetSection("Jwt")); diff --git a/src/api/SIGCM2.Infrastructure/Persistence/IngresosBrutosRepository.cs b/src/api/SIGCM2.Infrastructure/Persistence/IngresosBrutosRepository.cs new file mode 100644 index 0000000..f516f21 --- /dev/null +++ b/src/api/SIGCM2.Infrastructure/Persistence/IngresosBrutosRepository.cs @@ -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; + +/// +/// Dapper implementation of . +/// Provincia is persisted as the enum member name (PascalCase, e.g. "BuenosAires") via ToString(). +/// On read, it is parsed back via Enum.Parse<ProvinciaArgentina>. +/// Alicuota and Provincia are NEVER updated by cosmetic methods. +/// GetHistorialAsync uses a recursive CTE to walk the PredecesorId chain. +/// +public sealed class IngresosBrutosRepository : IIngresosBrutosRepository +{ + private readonly SqlConnectionFactory _connectionFactory; + + public IngresosBrutosRepository(SqlConnectionFactory connectionFactory) + { + _connectionFactory = connectionFactory; + } + + public async Task 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(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 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(sql, new { Id = id }); + return row is null ? null : MapRow(row); + } + + public async Task 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 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 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> 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(sql, parameters); + var list = rows.ToList(); + + var total = list.Count > 0 ? list[0].TotalCount : 0; + var items = list.Select(MapPagedRow).ToList(); + + return new PagedResult(items, page, pageSize, total); + } + + public async Task> 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(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); + + /// + /// 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. + /// + private static ProvinciaArgentina ParseProvincia(string value) + { + // Fast path: PascalCase written by this repo (e.g. "BuenosAires") + if (Enum.TryParse(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(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 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); +} diff --git a/src/api/SIGCM2.Infrastructure/Persistence/TipoDeIvaRepository.cs b/src/api/SIGCM2.Infrastructure/Persistence/TipoDeIvaRepository.cs new file mode 100644 index 0000000..907f384 --- /dev/null +++ b/src/api/SIGCM2.Infrastructure/Persistence/TipoDeIvaRepository.cs @@ -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; + +/// +/// Dapper implementation of . +/// All SQL is inline. Porcentaje and vigencia dates are NEVER updated by cosmetic methods. +/// GetHistorialAsync uses a recursive CTE to walk the PredecesorId chain. +/// +public sealed class TipoDeIvaRepository : ITipoDeIvaRepository +{ + private readonly SqlConnectionFactory _connectionFactory; + + public TipoDeIvaRepository(SqlConnectionFactory connectionFactory) + { + _connectionFactory = connectionFactory; + } + + public async Task 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(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 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(sql, new { Id = id }); + return row is null ? null : MapRow(row); + } + + public async Task 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 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 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> 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(sql, parameters); + var list = rows.ToList(); + + var total = list.Count > 0 ? list[0].TotalCount : 0; + var items = list.Select(MapPagedRow).ToList(); + + return new PagedResult(items, page, pageSize, total); + } + + public async Task> 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(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); +} diff --git a/tests/SIGCM2.Application.Tests/Infrastructure/IngresosBrutosRepositoryTests.cs b/tests/SIGCM2.Application.Tests/Infrastructure/IngresosBrutosRepositoryTests.cs index 0b8a7f2..642434d 100644 --- a/tests/SIGCM2.Application.Tests/Infrastructure/IngresosBrutosRepositoryTests.cs +++ b/tests/SIGCM2.Application.Tests/Infrastructure/IngresosBrutosRepositoryTests.cs @@ -24,13 +24,21 @@ public class IngresosBrutosRepositoryTests : IAsyncLifetime private SqlConnection _connection = 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 DateOnly NextUniqueDate() { var counter = Interlocked.Increment(ref _testCounter); - // Start at 2091-01-01 to avoid clashing with TipoDeIvaRepositoryTests (2090-01-01) - return new DateOnly(2091, 1, 1).AddDays(counter); + // Base year 2091 (different from TipoDeIvaRepositoryTests which uses 2090). + return new DateOnly(2091, 1, 1).AddDays(_runBase + counter); } public async Task InitializeAsync() diff --git a/tests/SIGCM2.Application.Tests/Infrastructure/TipoDeIvaRepositoryTests.cs b/tests/SIGCM2.Application.Tests/Infrastructure/TipoDeIvaRepositoryTests.cs index 7e8c684..75bc2f3 100644 --- a/tests/SIGCM2.Application.Tests/Infrastructure/TipoDeIvaRepositoryTests.cs +++ b/tests/SIGCM2.Application.Tests/Infrastructure/TipoDeIvaRepositoryTests.cs @@ -25,15 +25,26 @@ public class TipoDeIvaRepositoryTests : IAsyncLifetime private SqlConnection _connection = null!; private ITipoDeIvaRepository _repo = null!; - // Use a unique date range for this test class to avoid UQ constraint clashes with seed - // seed uses VigenciaDesde = '2020-01-01'; we use a far-future range per test. + // TipoDeIva rows are never Respawned (seed data). We need a globally unique VigenciaDesde + // 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 DateOnly NextUniqueDate() { 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(counter); + return new DateOnly(2090, 1, 1).AddDays(_runBase + counter); } public async Task InitializeAsync()