ADM-008: Puntos de Venta (CRUD fundacional) #19

Merged
dmolinari merged 18 commits from feature/ADM-008 into main 2026-04-17 17:31:21 +00:00
2 changed files with 256 additions and 0 deletions
Showing only changes of commit 489359f0b8 - Show all commits

View File

@@ -34,6 +34,7 @@ public static class DependencyInjection
services.AddScoped<IRolPermisoRepository, RolPermisoRepository>(); services.AddScoped<IRolPermisoRepository, RolPermisoRepository>();
services.AddScoped<IMedioRepository, MedioRepository>(); services.AddScoped<IMedioRepository, MedioRepository>();
services.AddScoped<ISeccionRepository, SeccionRepository>(); services.AddScoped<ISeccionRepository, SeccionRepository>();
services.AddScoped<IPuntoDeVentaRepository, PuntoDeVentaRepository>();
// 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,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<int> 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<int>(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<PuntoDeVenta?> 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<PdvRow>(sql, new { Id = id });
return row is null ? null : MapRow(row);
}
public async Task<bool> 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<int>(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<PagedResult<PuntoDeVenta>> 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<PdvPagedRow>(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<PuntoDeVenta>(items, page, pageSize, total);
}
public async Task<int> 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<int>("NumeroReservado");
}
public async Task<int?> 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<int?>(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);
}