diff --git a/src/api/SIGCM2.Infrastructure/DependencyInjection.cs b/src/api/SIGCM2.Infrastructure/DependencyInjection.cs index 7887203..7cb03ba 100644 --- a/src/api/SIGCM2.Infrastructure/DependencyInjection.cs +++ b/src/api/SIGCM2.Infrastructure/DependencyInjection.cs @@ -34,6 +34,7 @@ public static class DependencyInjection 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/PuntoDeVentaRepository.cs b/src/api/SIGCM2.Infrastructure/Persistence/PuntoDeVentaRepository.cs new file mode 100644 index 0000000..9d5ab66 --- /dev/null +++ b/src/api/SIGCM2.Infrastructure/Persistence/PuntoDeVentaRepository.cs @@ -0,0 +1,255 @@ +using System.Data; +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.Enums; +using SIGCM2.Domain.Exceptions; + +namespace SIGCM2.Infrastructure.Persistence; + +public sealed class PuntoDeVentaRepository : IPuntoDeVentaRepository +{ + private readonly SqlConnectionFactory _connectionFactory; + + public PuntoDeVentaRepository(SqlConnectionFactory connectionFactory) + { + _connectionFactory = connectionFactory; + } + + public async Task AddAsync(PuntoDeVenta pdv, CancellationToken ct = default) + { + // DF handles: Activo (1), FechaCreacion (SYSUTCDATETIME()). + const string sql = """ + INSERT INTO dbo.PuntoDeVenta (MedioId, NumeroAFIP, Nombre, Descripcion) + OUTPUT INSERTED.Id + VALUES (@MedioId, @NumeroAFIP, @Nombre, @Descripcion) + """; + + await using var connection = _connectionFactory.CreateConnection(); + await connection.OpenAsync(ct); + + try + { + return await connection.ExecuteScalarAsync(sql, new + { + pdv.MedioId, + pdv.NumeroAFIP, + pdv.Nombre, + pdv.Descripcion, + }); + } + catch (SqlException ex) when (IsUniqueViolation(ex) && ex.Message.Contains("UQ_PuntoDeVenta_MedioId_NumeroAFIP")) + { + throw new NumeroAFIPDuplicadoException(pdv.MedioId, pdv.NumeroAFIP); + } + } + + public async Task GetByIdAsync(int id, CancellationToken ct = default) + { + const string sql = """ + SELECT Id, MedioId, NumeroAFIP, Nombre, Descripcion, Activo, FechaCreacion, FechaModificacion + FROM dbo.PuntoDeVenta + 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 ExistsByNumeroAFIPInMedioAsync(int medioId, short numeroAFIP, int? excludeId = null, CancellationToken ct = default) + { + var sql = excludeId.HasValue + ? "SELECT COUNT(1) FROM dbo.PuntoDeVenta WHERE MedioId = @MedioId AND NumeroAFIP = @NumeroAFIP AND Id <> @ExcludeId" + : "SELECT COUNT(1) FROM dbo.PuntoDeVenta WHERE MedioId = @MedioId AND NumeroAFIP = @NumeroAFIP"; + + await using var connection = _connectionFactory.CreateConnection(); + await connection.OpenAsync(ct); + + var count = await connection.ExecuteScalarAsync(sql, new { MedioId = medioId, NumeroAFIP = numeroAFIP, ExcludeId = excludeId }); + return count > 0; + } + + public async Task UpdateAsync(PuntoDeVenta pdv, CancellationToken ct = default) + { + const string sql = """ + UPDATE dbo.PuntoDeVenta + SET NumeroAFIP = @NumeroAFIP, + Nombre = @Nombre, + Descripcion = @Descripcion, + Activo = @Activo, + FechaModificacion = @FechaModificacion + WHERE Id = @Id + """; + + await using var connection = _connectionFactory.CreateConnection(); + await connection.OpenAsync(ct); + + try + { + await connection.ExecuteAsync(sql, new + { + pdv.NumeroAFIP, + pdv.Nombre, + pdv.Descripcion, + pdv.Activo, + FechaModificacion = pdv.FechaModificacion ?? DateTime.UtcNow, + pdv.Id, + }); + } + catch (SqlException ex) when (IsUniqueViolation(ex) && ex.Message.Contains("UQ_PuntoDeVenta_MedioId_NumeroAFIP")) + { + throw new NumeroAFIPDuplicadoException(pdv.MedioId, pdv.NumeroAFIP); + } + } + + public async Task> GetPagedAsync(PuntosDeVentaQuery q, CancellationToken ct = default) + { + var page = Math.Max(1, q.Page); + var pageSize = Math.Clamp(q.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 (q.MedioId.HasValue) + { + where.Append(" AND MedioId = @MedioId"); + parameters.Add("MedioId", q.MedioId.Value); + } + + if (q.Activo.HasValue) + { + where.Append(" AND Activo = @Activo"); + parameters.Add("Activo", q.Activo.Value ? 1 : 0); + } + + var sql = $""" + SELECT + Id, MedioId, NumeroAFIP, Nombre, Descripcion, Activo, FechaCreacion, FechaModificacion, + COUNT(*) OVER() AS TotalCount + FROM dbo.PuntoDeVenta + {where} + ORDER BY MedioId, NumeroAFIP + 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(r => MapRow(r)).ToList(); + + return new PagedResult(items, page, pageSize, total); + } + + public async Task ReservarNumeroAsync(int puntoDeVentaId, TipoComprobante tipo, CancellationToken ct = default) + { + var parameters = new DynamicParameters(); + parameters.Add("PuntoDeVentaId", puntoDeVentaId, DbType.Int32); + parameters.Add("TipoComprobante", (byte)tipo, DbType.Byte); + parameters.Add("NumeroReservado", dbType: DbType.Int32, direction: ParameterDirection.Output); + + await using var connection = _connectionFactory.CreateConnection(); + await connection.OpenAsync(ct); + + try + { + await connection.ExecuteAsync( + "dbo.usp_ReservarNumeroComprobante", + parameters, + commandType: CommandType.StoredProcedure); + } + catch (SqlException ex) + { + throw ex.Number switch + { + 50001 => new PuntoDeVentaInactivoException(puntoDeVentaId), + 50002 => new MedioInactivoException(puntoDeVentaId), + 50003 => new PuntoDeVentaNotFoundException(puntoDeVentaId), + 1205 => new DeadlockTransientException(ex), + _ => ex, + }; + } + + return parameters.Get("NumeroReservado"); + } + + public async Task GetUltimoNumeroAsync(int puntoDeVentaId, TipoComprobante tipo, CancellationToken ct = default) + { + const string sql = """ + SELECT UltimoNumero + FROM dbo.SecuenciaComprobante + WHERE PuntoDeVentaId = @PuntoDeVentaId AND TipoComprobante = @TipoComprobante + """; + + await using var connection = _connectionFactory.CreateConnection(); + await connection.OpenAsync(ct); + + return await connection.QuerySingleOrDefaultAsync(sql, new + { + PuntoDeVentaId = puntoDeVentaId, + TipoComprobante = (byte)tipo, + }); + } + + // ── mapping ─────────────────────────────────────────────────────────────── + + private static PuntoDeVenta MapRow(PdvRow r) + => new( + id: r.Id, + medioId: r.MedioId, + numeroAFIP: r.NumeroAFIP, + nombre: r.Nombre, + descripcion: r.Descripcion, + activo: r.Activo, + fechaCreacion: r.FechaCreacion, + fechaModificacion: r.FechaModificacion); + + private static PuntoDeVenta MapRow(PdvPagedRow r) + => new( + id: r.Id, + medioId: r.MedioId, + numeroAFIP: r.NumeroAFIP, + nombre: r.Nombre, + descripcion: r.Descripcion, + activo: r.Activo, + fechaCreacion: r.FechaCreacion, + fechaModificacion: r.FechaModificacion); + + private static bool IsUniqueViolation(SqlException ex) + => ex.Number is 2627 or 2601; + + // ── private rows ────────────────────────────────────────────────────────── + + private sealed record PdvRow( + int Id, + int MedioId, + short NumeroAFIP, + string Nombre, + string? Descripcion, + bool Activo, + DateTime FechaCreacion, + DateTime? FechaModificacion); + + private sealed record PdvPagedRow( + int Id, + int MedioId, + short NumeroAFIP, + string Nombre, + string? Descripcion, + bool Activo, + DateTime FechaCreacion, + DateTime? FechaModificacion, + int TotalCount); +}