feat(adm-009): TipoDeIvaRepository + IngresosBrutosRepository Dapper implementations + DI registration
This commit is contained in:
@@ -35,6 +35,8 @@ public static class DependencyInjection
|
||||
services.AddScoped<IMedioRepository, MedioRepository>();
|
||||
services.AddScoped<ISeccionRepository, SeccionRepository>();
|
||||
services.AddScoped<IPuntoDeVentaRepository, PuntoDeVentaRepository>();
|
||||
services.AddScoped<ITipoDeIvaRepository, TipoDeIvaRepository>();
|
||||
services.AddScoped<IIngresosBrutosRepository, IngresosBrutosRepository>();
|
||||
|
||||
// JWT Options — bound lazily via IOptions so tests can override via ConfigureWebHost
|
||||
services.Configure<JwtOptions>(configuration.GetSection("Jwt"));
|
||||
|
||||
@@ -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<ProvinciaArgentina>.
|
||||
/// 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);
|
||||
}
|
||||
288
src/api/SIGCM2.Infrastructure/Persistence/TipoDeIvaRepository.cs
Normal file
288
src/api/SIGCM2.Infrastructure/Persistence/TipoDeIvaRepository.cs
Normal 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);
|
||||
}
|
||||
@@ -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()
|
||||
|
||||
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user