Compare commits

..

5 Commits

Author SHA1 Message Date
fc77576427 chore(adm-008): limpiar import huerfano + comentario stale post-ciruigia
- PuntoDeVentaTests.cs: quitar using SIGCM2.Domain.Enums (quedo huerfano tras
  eliminar TipoComprobante).
- SqlTestFixture.cs: actualizar comentario de EnsureV013SchemaAsync para
  reflejar scope recortado (solo PdV + permiso, drops idempotentes de
  SecuenciaComprobante + SP).
2026-04-17 14:24:58 -03:00
6458ee0106 revert(tests): eliminar tests de reserva/concurrencia/secuencialidad ADM-008
Eliminar SecuenciaComprobanteTests, ReservarNumeroCommandHandlerTests,
GetProximoNumeroQueryHandlerTests y 7 tests de integración en
PuntosDeVentaControllerTests (reserva/proximo/concurrencia/secuencialidad).
SqlTestFixture ahora limpia SecuenciaComprobante+SP si existen (drops idempotentes)
y solo crea PuntoDeVenta + temporal table.
2026-04-17 14:16:21 -03:00
6be637b4cf revert(web): eliminar feature de reserva de numero en UI ADM-008
Eliminar secuencias.api.ts, useReservarNumero.ts, SecuenciasPanel.tsx,
TipoComprobante enum y tipos ReservarNumeroResponse/ProximoNumeroResponse.
Quitar SecuenciasPanel del PuntoDeVentaDetailPage.
2026-04-17 14:16:14 -03:00
7d432a949a revert(backend): eliminar handlers/endpoints/excepciones de reserva de numero ADM-008
Eliminar SecuenciaComprobante entity, TipoComprobante enum, DeadlockTransientException,
PuntoDeVentaInactivoException, carpetas Reservar/ y ProximoNumero/ de Application,
métodos ReservarNumeroAsync/GetUltimoNumeroAsync del repositorio, endpoints
POST /secuencias/.../reservar y GET /secuencias/.../proximo del controller,
y mapping PuntoDeVentaInactivoException del ExceptionFilter.
2026-04-17 14:16:09 -03:00
40482caf7b revert(db): eliminar SecuenciaComprobante + SP de V013 — IMAC asigna numeros AFIP
SecuenciaComprobante, usp_ReservarNumeroComprobante y TipoComprobante no tienen
propósito de negocio: IMAC/Infogestión asigna NumeroFactura+CAI externamente.
V013 ahora solo gestiona PuntoDeVenta + temporal table + permiso AFIP.
Sección 0 aplica drops idempotentes para limpiar SIGCM2_Test y reinstalaciones.
2026-04-17 14:16:01 -03:00
28 changed files with 101 additions and 1296 deletions

View File

@@ -1,12 +1,15 @@
-- V013_ROLLBACK.sql
-- Reversa de V013__create_puntos_de_venta.sql.
--
-- ADVERTENCIA: ejecutar ELIMINA PuntoDeVenta, SecuenciaComprobante, su historia temporal,
-- el permiso 'administracion:puntos_de_venta:gestionar', sus asignaciones y el SP.
-- ADVERTENCIA: ejecutar ELIMINA PuntoDeVenta, su historia temporal,
-- el permiso 'administracion:puntos_de_venta:gestionar' y sus asignaciones.
--
-- Uso intended: ROLLBACK en entornos NO-productivos.
-- Prerequisito: no deben existir FKs vivas apuntando a PuntoDeVenta (p.ej., comprobantes FAC-001).
-- Si FAC-001 ya está aplicado, este rollback fallará — usar backup.
--
-- NOTA: SecuenciaComprobante y SP usp_ReservarNumeroComprobante ya no forman parte
-- de V013 (eliminados en cirugía post-smoke Batch 9). Este rollback solo maneja PuntoDeVenta.
SET QUOTED_IDENTIFIER ON;
SET ANSI_NULLS ON;
@@ -14,52 +17,7 @@ SET NOCOUNT ON;
GO
-- ═══════════════════════════════════════════════════════════════════════
-- 1. Drop SP
-- ═══════════════════════════════════════════════════════════════════════
IF OBJECT_ID(N'dbo.usp_ReservarNumeroComprobante', N'P') IS NOT NULL
BEGIN
DROP PROCEDURE dbo.usp_ReservarNumeroComprobante;
PRINT 'SP dbo.usp_ReservarNumeroComprobante dropped.';
END
GO
-- ═══════════════════════════════════════════════════════════════════════
-- 2. Apagar SYSTEM_VERSIONING + remover PERIOD — SecuenciaComprobante primero (FK a PdV)
-- ═══════════════════════════════════════════════════════════════════════
IF EXISTS (SELECT 1 FROM sys.tables WHERE object_id = OBJECT_ID('dbo.SecuenciaComprobante') AND temporal_type = 2)
BEGIN
ALTER TABLE dbo.SecuenciaComprobante SET (SYSTEM_VERSIONING = OFF);
PRINT 'SecuenciaComprobante: SYSTEM_VERSIONING OFF.';
END
GO
IF EXISTS (SELECT 1 FROM sys.periods WHERE object_id = OBJECT_ID('dbo.SecuenciaComprobante'))
BEGIN
ALTER TABLE dbo.SecuenciaComprobante DROP PERIOD FOR SYSTEM_TIME;
PRINT 'SecuenciaComprobante: PERIOD FOR SYSTEM_TIME dropped.';
END
GO
IF COL_LENGTH('dbo.SecuenciaComprobante', 'ValidFrom') IS NOT NULL
BEGIN
ALTER TABLE dbo.SecuenciaComprobante DROP CONSTRAINT IF EXISTS DF_SecuenciaComprobante_ValidFrom;
ALTER TABLE dbo.SecuenciaComprobante DROP CONSTRAINT IF EXISTS DF_SecuenciaComprobante_ValidTo;
ALTER TABLE dbo.SecuenciaComprobante DROP COLUMN ValidFrom, ValidTo;
PRINT 'SecuenciaComprobante: ValidFrom/ValidTo dropped.';
END
GO
IF OBJECT_ID(N'dbo.SecuenciaComprobante_History', N'U') IS NOT NULL
BEGIN
DROP TABLE dbo.SecuenciaComprobante_History;
PRINT 'SecuenciaComprobante_History dropped.';
END
GO
-- ═══════════════════════════════════════════════════════════════════════
-- 3. Apagar SYSTEM_VERSIONING + remover PERIOD — PuntoDeVenta
-- 1. Apagar SYSTEM_VERSIONING + remover PERIOD — PuntoDeVenta
-- ═══════════════════════════════════════════════════════════════════════
IF EXISTS (SELECT 1 FROM sys.tables WHERE object_id = OBJECT_ID('dbo.PuntoDeVenta') AND temporal_type = 2)
@@ -93,16 +51,9 @@ END
GO
-- ═══════════════════════════════════════════════════════════════════════
-- 4. Drop tablas (SecuenciaComprobante primero por FK)
-- 2. Drop tabla PuntoDeVenta
-- ═══════════════════════════════════════════════════════════════════════
IF OBJECT_ID(N'dbo.SecuenciaComprobante', N'U') IS NOT NULL
BEGIN
DROP TABLE dbo.SecuenciaComprobante;
PRINT 'Table dbo.SecuenciaComprobante dropped.';
END
GO
IF OBJECT_ID(N'dbo.PuntoDeVenta', N'U') IS NOT NULL
BEGIN
DROP TABLE dbo.PuntoDeVenta;
@@ -111,7 +62,7 @@ END
GO
-- ═══════════════════════════════════════════════════════════════════════
-- 5. Remover permiso 'administracion:puntos_de_venta:gestionar' + RolPermiso
-- 3. Remover permiso 'administracion:puntos_de_venta:gestionar' + RolPermiso
-- ═══════════════════════════════════════════════════════════════════════
DELETE rp
@@ -125,7 +76,6 @@ WHERE Codigo = 'administracion:puntos_de_venta:gestionar';
GO
PRINT '';
PRINT 'V013 rolled back. dbo.PuntoDeVenta, dbo.SecuenciaComprobante and their history removed.';
PRINT 'SP dbo.usp_ReservarNumeroComprobante removed.';
PRINT 'V013 rolled back. dbo.PuntoDeVenta and its history removed.';
PRINT 'Permiso administracion:puntos_de_venta:gestionar removed.';
GO

View File

@@ -1,11 +1,16 @@
-- V013__create_puntos_de_venta.sql
-- ADM-008 Puntos de Venta: DDL + SP de reserva atómica de número de comprobante.
-- ADM-008 Puntos de Venta: DDL para dbo.PuntoDeVenta + permiso AFIP.
--
-- NOTA POST-SMOKE (Batch 9): SecuenciaComprobante, SP usp_ReservarNumeroComprobante
-- y TipoComprobante fueron eliminados. SIG-CM2.0 NO genera números AFIP — IMAC
-- (Plataforma Infogestión) los asigna externamente. Un worker futuro (INT-001)
-- polleará la vista de Infogestión para asociar NumeroOrdenInterno ↔ NumeroFacturaAFIP + CAI.
-- PuntoDeVenta.NumeroAFIP es config fija que se manda en el payload a IMAC.
--
-- Cambios:
-- 1. dbo.PuntoDeVenta (FK→Medio, UNIQUE(MedioId,NumeroAFIP), SYSTEM_VERSIONING ON, retention 10Y).
-- 2. dbo.SecuenciaComprobante (FK→PuntoDeVenta, PK(PuntoDeVentaId,TipoComprobante), SYSTEM_VERSIONING ON, retention 10Y).
-- 3. Permiso 'administracion:puntos-de-venta:gestionar' + asignación a rol 'admin'.
-- 4. SP dbo.usp_ReservarNumeroComprobante (SERIALIZABLE, UPDATE+OUTPUT, THROW 50001/50002/50003).
-- 2. Drops idempotentes de artefactos de versión previa (SecuenciaComprobante + SP).
-- 3. Permiso 'administracion:puntos_de_venta:gestionar' + asignación a rol 'admin'.
--
-- Patrón: V011 (Temporal Tables + Permiso MERGE + PAGE compression en history).
-- Idempotente: seguro para re-ejecutar.
@@ -18,7 +23,7 @@
-- solo permite [a-z0-9_:] — se usa guion_bajo para cumplir la constraint existente.
-- El backend y frontend deben usar el código con guion_bajo.
--
-- Covers: REQ-PDV-001, -003, -009, REQ-SEC-CMB-001, -002, -003, -004
-- Covers: REQ-PDV-001, -003, -009
-- Source of truth: Obsidian/02-ARQUITECTURA-y-TECH-STACK/2.10 ADM-008
--
-- NOTA T1.3 — Seeds: NO se seedean PuntoDeVenta.
@@ -30,6 +35,39 @@ SET ANSI_NULLS ON;
SET NOCOUNT ON;
GO
-- ═══════════════════════════════════════════════════════════════════════
-- 0. Drops idempotentes de artefactos de versión previa
-- (SecuenciaComprobante + SP — eliminados en cirugía post-smoke Batch 9)
-- ═══════════════════════════════════════════════════════════════════════
IF OBJECT_ID(N'dbo.usp_ReservarNumeroComprobante', N'P') IS NOT NULL
BEGIN
DROP PROCEDURE dbo.usp_ReservarNumeroComprobante;
PRINT 'SP dbo.usp_ReservarNumeroComprobante dropped (cleanup).';
END
GO
IF EXISTS (SELECT 1 FROM sys.tables WHERE object_id = OBJECT_ID('dbo.SecuenciaComprobante') AND temporal_type = 2)
BEGIN
ALTER TABLE dbo.SecuenciaComprobante SET (SYSTEM_VERSIONING = OFF);
PRINT 'SecuenciaComprobante: SYSTEM_VERSIONING = OFF (cleanup).';
END
GO
IF OBJECT_ID(N'dbo.SecuenciaComprobante_History', N'U') IS NOT NULL
BEGIN
DROP TABLE dbo.SecuenciaComprobante_History;
PRINT 'SecuenciaComprobante_History dropped (cleanup).';
END
GO
IF OBJECT_ID(N'dbo.SecuenciaComprobante', N'U') IS NOT NULL
BEGIN
DROP TABLE dbo.SecuenciaComprobante;
PRINT 'Table dbo.SecuenciaComprobante dropped (cleanup).';
END
GO
-- ═══════════════════════════════════════════════════════════════════════
-- 1. dbo.PuntoDeVenta
-- ═══════════════════════════════════════════════════════════════════════
@@ -65,30 +103,7 @@ END
GO
-- ═══════════════════════════════════════════════════════════════════════
-- 2. dbo.SecuenciaComprobante
-- ═══════════════════════════════════════════════════════════════════════
IF OBJECT_ID(N'dbo.SecuenciaComprobante', N'U') IS NULL
BEGIN
CREATE TABLE dbo.SecuenciaComprobante (
PuntoDeVentaId INT NOT NULL,
TipoComprobante TINYINT NOT NULL,
UltimoNumero INT NOT NULL CONSTRAINT DF_SecuenciaComprobante_UltimoNumero DEFAULT(0),
FechaCreacion DATETIME2(3) NOT NULL CONSTRAINT DF_SecuenciaComprobante_FechaCreacion DEFAULT(SYSUTCDATETIME()),
FechaModificacion DATETIME2(3) NULL,
CONSTRAINT PK_SecuenciaComprobante PRIMARY KEY (PuntoDeVentaId, TipoComprobante),
CONSTRAINT FK_SecuenciaComprobante_PuntoDeVenta FOREIGN KEY (PuntoDeVentaId) REFERENCES dbo.PuntoDeVenta(Id) ON DELETE NO ACTION,
CONSTRAINT CK_SecuenciaComprobante_TipoComprobante CHECK (TipoComprobante BETWEEN 1 AND 6),
CONSTRAINT CK_SecuenciaComprobante_UltimoNumero CHECK (UltimoNumero >= 0)
);
PRINT 'Table dbo.SecuenciaComprobante created.';
END
ELSE
PRINT 'Table dbo.SecuenciaComprobante already exists — skip.';
GO
-- ═══════════════════════════════════════════════════════════════════════
-- 3. SYSTEM_VERSIONING — PuntoDeVenta
-- 2. SYSTEM_VERSIONING — PuntoDeVenta
-- ═══════════════════════════════════════════════════════════════════════
IF COL_LENGTH('dbo.PuntoDeVenta', 'ValidFrom') IS NULL
@@ -130,55 +145,7 @@ END
GO
-- ═══════════════════════════════════════════════════════════════════════
-- 4. SecuenciaComprobante — SIN SYSTEM_VERSIONING (decisión AD8 revisitada)
-- ═══════════════════════════════════════════════════════════════════════
-- Razón: bajo reservas concurrentes (reportado 50 threads paralelos) el engine
-- arroja "Data modification failed on system-versioned table because transaction
-- time was earlier than period start" — UPDATEs repetidos sobre la misma fila
-- en transacciones SERIALIZABLE concurrentes violan la invariante temporal.
-- Ya anticipado en el design (AD8): la reserva es operacional, no configuracion.
-- La auditoria del numero vigente no tiene valor de negocio: el estado actual
-- (UltimoNumero) es la unica informacion relevante; cada comprobante emitido
-- deja su propio rastro en AvisosCdo/CtaCte (modulos FAC-*).
--
-- Esta seccion es idempotente: si una version previa de la migracion dejo
-- SYSTEM_VERSIONING = ON, lo desactiva y drop la history. En instalacion nueva
-- no hace nada porque nunca se activo.
IF EXISTS (SELECT 1 FROM sys.tables WHERE object_id = OBJECT_ID('dbo.SecuenciaComprobante') AND temporal_type = 2)
BEGIN
ALTER TABLE dbo.SecuenciaComprobante SET (SYSTEM_VERSIONING = OFF);
PRINT 'SecuenciaComprobante: SYSTEM_VERSIONING = OFF (revisited AD8).';
END
GO
IF OBJECT_ID(N'dbo.SecuenciaComprobante_History', N'U') IS NOT NULL
BEGIN
DROP TABLE dbo.SecuenciaComprobante_History;
PRINT 'SecuenciaComprobante_History: dropped.';
END
GO
IF EXISTS (SELECT 1 FROM sys.periods WHERE object_id = OBJECT_ID('dbo.SecuenciaComprobante'))
BEGIN
ALTER TABLE dbo.SecuenciaComprobante DROP PERIOD FOR SYSTEM_TIME;
PRINT 'SecuenciaComprobante: PERIOD FOR SYSTEM_TIME dropped.';
END
GO
IF COL_LENGTH('dbo.SecuenciaComprobante', 'ValidFrom') IS NOT NULL
BEGIN
IF EXISTS (SELECT 1 FROM sys.default_constraints WHERE name = 'DF_SecuenciaComprobante_ValidFrom' AND parent_object_id = OBJECT_ID('dbo.SecuenciaComprobante'))
ALTER TABLE dbo.SecuenciaComprobante DROP CONSTRAINT DF_SecuenciaComprobante_ValidFrom;
IF EXISTS (SELECT 1 FROM sys.default_constraints WHERE name = 'DF_SecuenciaComprobante_ValidTo' AND parent_object_id = OBJECT_ID('dbo.SecuenciaComprobante'))
ALTER TABLE dbo.SecuenciaComprobante DROP CONSTRAINT DF_SecuenciaComprobante_ValidTo;
ALTER TABLE dbo.SecuenciaComprobante DROP COLUMN ValidFrom, ValidTo;
PRINT 'SecuenciaComprobante: ValidFrom/ValidTo + default constraints dropped.';
END
GO
-- ═══════════════════════════════════════════════════════════════════════
-- 5. Permiso: administracion:puntos-de-venta:gestionar
-- 3. Permiso: administracion:puntos_de_venta:gestionar
-- ═══════════════════════════════════════════════════════════════════════
MERGE dbo.Permiso AS t
@@ -204,97 +171,9 @@ WHEN NOT MATCHED BY TARGET THEN
INSERT (RolId, PermisoId) VALUES (s.RolId, s.PermisoId);
GO
-- ═══════════════════════════════════════════════════════════════════════
-- 6. SP: dbo.usp_ReservarNumeroComprobante
-- ═══════════════════════════════════════════════════════════════════════
-- Decisión AD1: SP con UPDATE+OUTPUT bajo SERIALIZABLE (lógica crítica en SP;
-- permite validar PdV/Medio activos en DB; atómico sin race).
-- Decisión AD2: patrón "UPDATE si existe, INSERT si @@ROWCOUNT=0" (lazy init;
-- sin trigger; simple; no genera filas huérfanas).
-- El retry ante deadlock (SqlException 1205) vive en el Application handler
-- (ReservarNumeroCommandHandler), NO en este SP. Máx 3 intentos, 50/150/450ms.
-- NO envolver la llamada en TransactionScope externo: el SP ya es atómico.
-- Un TransactionScope externo con otra conexión escalaría a MSDTC innecesariamente.
IF OBJECT_ID(N'dbo.usp_ReservarNumeroComprobante', N'P') IS NOT NULL
DROP PROCEDURE dbo.usp_ReservarNumeroComprobante;
GO
CREATE PROCEDURE dbo.usp_ReservarNumeroComprobante
@PuntoDeVentaId INT,
@TipoComprobante TINYINT,
@NumeroReservado INT OUTPUT
AS
BEGIN
SET NOCOUNT ON;
SET XACT_ABORT ON;
SET TRANSACTION ISOLATION LEVEL SERIALIZABLE;
BEGIN TRAN;
-- Validar existencia y estado de PdV + Medio padre en una sola lectura.
-- Con SERIALIZABLE, el lock de rango protege contra inserciones concurrentes
-- de la misma fila de SecuenciaComprobante entre el SELECT y el UPDATE/INSERT.
DECLARE @PdvActivo BIT;
DECLARE @MedioActivo BIT;
SELECT
@PdvActivo = p.Activo,
@MedioActivo = m.Activo
FROM dbo.PuntoDeVenta p
JOIN dbo.Medio m ON m.Id = p.MedioId
WHERE p.Id = @PuntoDeVentaId;
IF @PdvActivo IS NULL
BEGIN
ROLLBACK;
THROW 50003, 'punto_de_venta_not_found', 1;
END
IF @PdvActivo = 0
BEGIN
ROLLBACK;
THROW 50001, 'punto_de_venta_inactivo', 1;
END
IF @MedioActivo = 0
BEGIN
ROLLBACK;
THROW 50002, 'medio_inactivo', 1;
END
-- Intentar actualizar la fila existente y capturar el nuevo número.
DECLARE @_out TABLE (n INT NOT NULL);
UPDATE dbo.SecuenciaComprobante
SET
UltimoNumero = UltimoNumero + 1,
FechaModificacion = SYSUTCDATETIME()
OUTPUT inserted.UltimoNumero INTO @_out(n)
WHERE PuntoDeVentaId = @PuntoDeVentaId
AND TipoComprobante = @TipoComprobante;
IF @@ROWCOUNT = 0
BEGIN
-- Primera reserva para este par (PdvId, TipoComprobante): inicialización lazy.
INSERT INTO dbo.SecuenciaComprobante (PuntoDeVentaId, TipoComprobante, UltimoNumero)
VALUES (@PuntoDeVentaId, @TipoComprobante, 1);
SET @NumeroReservado = 1;
END
ELSE
BEGIN
SELECT @NumeroReservado = n FROM @_out;
END
COMMIT;
END
GO
PRINT '';
PRINT 'V013 applied successfully.';
PRINT ' - dbo.PuntoDeVenta (temporal, retention 10y, PAGE compression)';
PRINT ' - dbo.SecuenciaComprobante (temporal, retention 10y, PAGE compression)';
PRINT ' - Permiso administracion:puntos-de-venta:gestionar (asignado a admin)';
PRINT ' - SP dbo.usp_ReservarNumeroComprobante';
PRINT 'Next: Batch 2 — Domain (PuntoDeVenta entity + exceptions + TipoComprobante enum).';
PRINT ' - Permiso administracion:puntos_de_venta:gestionar (asignado a admin)';
PRINT ' - Artefactos de version previa (SecuenciaComprobante + SP) eliminados si existian';
GO

View File

@@ -7,11 +7,8 @@ using SIGCM2.Application.PuntosDeVenta.Create;
using SIGCM2.Application.PuntosDeVenta.Deactivate;
using SIGCM2.Application.PuntosDeVenta.GetById;
using SIGCM2.Application.PuntosDeVenta.List;
using SIGCM2.Application.PuntosDeVenta.ProximoNumero;
using SIGCM2.Application.PuntosDeVenta.Reactivate;
using SIGCM2.Application.PuntosDeVenta.Reservar;
using SIGCM2.Application.PuntosDeVenta.Update;
using SIGCM2.Domain.Enums;
namespace SIGCM2.Api.Controllers;
@@ -160,34 +157,6 @@ public sealed class PuntosDeVentaController : ControllerBase
return NoContent();
}
/// <summary>Reserves the next sequential number for a given PdV and TipoComprobante.</summary>
[HttpPost("{id:int}/secuencias/{tipoComprobante}/reservar")]
[RequirePermission("administracion:puntos_de_venta:gestionar")]
[ProducesResponseType(typeof(ReservaNumeroDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
[ProducesResponseType(StatusCodes.Status403Forbidden)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
[ProducesResponseType(StatusCodes.Status409Conflict)]
public async Task<IActionResult> ReservarNumero([FromRoute] int id, [FromRoute] TipoComprobante tipoComprobante)
{
var command = new ReservarNumeroCommand(id, tipoComprobante);
var result = await _dispatcher.Send<ReservarNumeroCommand, ReservaNumeroDto>(command);
return Ok(result);
}
/// <summary>Returns the next available number (read-only, no reservation).</summary>
[HttpGet("{id:int}/secuencias/{tipoComprobante}/proximo")]
[RequirePermission("administracion:puntos_de_venta:gestionar")]
[ProducesResponseType(typeof(ProximoNumeroDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
[ProducesResponseType(StatusCodes.Status403Forbidden)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<IActionResult> GetProximoNumero([FromRoute] int id, [FromRoute] TipoComprobante tipoComprobante)
{
var query = new GetProximoNumeroQuery(id, tipoComprobante);
var result = await _dispatcher.Send<GetProximoNumeroQuery, ProximoNumeroDto>(query);
return Ok(result);
}
}
// ── Request body records ──────────────────────────────────────────────────────

View File

@@ -244,18 +244,6 @@ public sealed class ExceptionFilter : IExceptionFilter
context.ExceptionHandled = true;
break;
case PuntoDeVentaInactivoException puntoDeVentaInactivoEx:
context.Result = new ObjectResult(new
{
error = "punto_de_venta_inactivo",
message = puntoDeVentaInactivoEx.Message
})
{
StatusCode = StatusCodes.Status409Conflict
};
context.ExceptionHandled = true;
break;
case NumeroAFIPDuplicadoException numeroAFIPDupEx:
context.Result = new ObjectResult(new
{

View File

@@ -1,6 +1,5 @@
using SIGCM2.Application.Common;
using SIGCM2.Domain.Entities;
using SIGCM2.Domain.Enums;
namespace SIGCM2.Application.Abstractions.Persistence;
@@ -11,6 +10,4 @@ public interface IPuntoDeVentaRepository
Task<bool> ExistsByNumeroAFIPInMedioAsync(int medioId, short numeroAFIP, int? excludeId = null, CancellationToken ct = default);
Task UpdateAsync(PuntoDeVenta pdv, CancellationToken ct = default);
Task<PagedResult<PuntoDeVenta>> GetPagedAsync(PuntosDeVentaQuery q, CancellationToken ct = default);
Task<int> ReservarNumeroAsync(int puntoDeVentaId, TipoComprobante tipo, CancellationToken ct = default);
Task<int?> GetUltimoNumeroAsync(int puntoDeVentaId, TipoComprobante tipo, CancellationToken ct = default);
}

View File

@@ -25,9 +25,7 @@ using SIGCM2.Application.PuntosDeVenta.Create;
using SIGCM2.Application.PuntosDeVenta.Deactivate;
using SIGCM2.Application.PuntosDeVenta.GetById;
using SIGCM2.Application.PuntosDeVenta.List;
using SIGCM2.Application.PuntosDeVenta.ProximoNumero;
using SIGCM2.Application.PuntosDeVenta.Reactivate;
using SIGCM2.Application.PuntosDeVenta.Reservar;
using SIGCM2.Application.PuntosDeVenta.Update;
using SIGCM2.Application.Secciones.Create;
using SIGCM2.Application.Secciones.Deactivate;
@@ -105,8 +103,6 @@ public static class DependencyInjection
services.AddScoped<ICommandHandler<ReactivatePuntoDeVentaCommand, PuntoDeVentaStatusDto>, ReactivatePuntoDeVentaCommandHandler>();
services.AddScoped<ICommandHandler<ListPuntosDeVentaQuery, PagedResult<PuntoDeVentaListItemDto>>, ListPuntosDeVentaQueryHandler>();
services.AddScoped<ICommandHandler<GetPuntoDeVentaByIdQuery, PuntoDeVentaDetailDto>, GetPuntoDeVentaByIdQueryHandler>();
services.AddScoped<ICommandHandler<ReservarNumeroCommand, ReservaNumeroDto>, ReservarNumeroCommandHandler>();
services.AddScoped<ICommandHandler<GetProximoNumeroQuery, ProximoNumeroDto>, GetProximoNumeroQueryHandler>();
// FluentValidation validators (scans entire Application assembly)
services.AddValidatorsFromAssemblyContaining<LoginCommandValidator>();

View File

@@ -1,5 +0,0 @@
using SIGCM2.Domain.Enums;
namespace SIGCM2.Application.PuntosDeVenta.ProximoNumero;
public sealed record GetProximoNumeroQuery(int PuntoDeVentaId, TipoComprobante TipoComprobante);

View File

@@ -1,27 +0,0 @@
using SIGCM2.Application.Abstractions;
using SIGCM2.Application.Abstractions.Persistence;
namespace SIGCM2.Application.PuntosDeVenta.ProximoNumero;
/// <summary>
/// Consulta el próximo número disponible sin reservarlo (read-only, REQ-SEC-CMB-005).
/// Retorna UltimoNumero+1; si no existe fila devuelve 1.
/// </summary>
public sealed class GetProximoNumeroQueryHandler : ICommandHandler<GetProximoNumeroQuery, ProximoNumeroDto>
{
private readonly IPuntoDeVentaRepository _repo;
public GetProximoNumeroQueryHandler(IPuntoDeVentaRepository repo)
{
_repo = repo;
}
public async Task<ProximoNumeroDto> Handle(GetProximoNumeroQuery query)
{
var ultimoNumero = await _repo.GetUltimoNumeroAsync(query.PuntoDeVentaId, query.TipoComprobante);
var proximo = ultimoNumero.HasValue ? ultimoNumero.Value + 1 : 1;
return new ProximoNumeroDto(query.TipoComprobante, proximo);
}
}

View File

@@ -1,5 +0,0 @@
using SIGCM2.Domain.Enums;
namespace SIGCM2.Application.PuntosDeVenta.ProximoNumero;
public sealed record ProximoNumeroDto(TipoComprobante TipoComprobante, int ProximoNumero);

View File

@@ -1,5 +0,0 @@
using SIGCM2.Domain.Enums;
namespace SIGCM2.Application.PuntosDeVenta.Reservar;
public sealed record ReservaNumeroDto(TipoComprobante TipoComprobante, int NumeroReservado);

View File

@@ -1,5 +0,0 @@
using SIGCM2.Domain.Enums;
namespace SIGCM2.Application.PuntosDeVenta.Reservar;
public sealed record ReservarNumeroCommand(int PuntoDeVentaId, TipoComprobante TipoComprobante);

View File

@@ -1,53 +0,0 @@
using SIGCM2.Application.Abstractions;
using SIGCM2.Application.Abstractions.Persistence;
using SIGCM2.Domain.Exceptions;
namespace SIGCM2.Application.PuntosDeVenta.Reservar;
/// <summary>
/// Reserva el próximo número correlativo para (PdvId × TipoComprobante) ejecutando
/// usp_ReservarNumeroComprobante vía el repositorio.
///
/// NOTAS DE DISEÑO (AD4, AD9):
/// - NO se envuelve en TransactionScope: el SP ya es atómico bajo SERIALIZABLE.
/// Un TransactionScope ambiente aquí escalaría a DTC → innecesario.
/// - NO usa Polly: no está en el proyecto. Retry deadlock con bucle simple.
/// - Infrastructure traduce SqlException 1205 → DeadlockTransientException.
/// - Backoff en ms: [25, 75, 200, 500, 1200] — 5 retries máximo (6 intentos totales).
/// - La auditoría de reservas corre solo vía Temporal Tables (AD8).
/// </summary>
public sealed class ReservarNumeroCommandHandler : ICommandHandler<ReservarNumeroCommand, ReservaNumeroDto>
{
private readonly IPuntoDeVentaRepository _repo;
private readonly int[] _deadlockBackoffMs;
private static readonly int[] DefaultBackoffMs = [25, 75, 200, 500, 1200];
public ReservarNumeroCommandHandler(IPuntoDeVentaRepository repo)
: this(repo, DefaultBackoffMs) { }
/// <summary>Constructor with custom backoff for testing (e.g., [0,0,0] for fast tests).</summary>
public ReservarNumeroCommandHandler(IPuntoDeVentaRepository repo, int[] deadlockBackoffMs)
{
_repo = repo;
_deadlockBackoffMs = deadlockBackoffMs;
}
public async Task<ReservaNumeroDto> Handle(ReservarNumeroCommand command)
{
for (var i = 0; ; i++)
{
try
{
var numero = await _repo.ReservarNumeroAsync(command.PuntoDeVentaId, command.TipoComprobante);
return new ReservaNumeroDto(command.TipoComprobante, numero);
}
catch (DeadlockTransientException) when (i < _deadlockBackoffMs.Length)
{
// Deadlock — retry with backoff
await Task.Delay(_deadlockBackoffMs[i]);
}
// All other exceptions bubble up immediately
}
}
}

View File

@@ -1,34 +0,0 @@
using SIGCM2.Domain.Enums;
namespace SIGCM2.Domain.Entities;
/// <summary>
/// Lleva el correlativo de números de comprobante por (PuntoDeVentaId × TipoComprobante).
/// La reserva atómica la ejecuta usp_ReservarNumeroComprobante directamente en BD.
/// Este objeto es un helper de lectura/proyección.
/// </summary>
public sealed class SecuenciaComprobante
{
public int PuntoDeVentaId { get; }
public TipoComprobante TipoComprobante { get; }
public int UltimoNumero { get; }
public DateTime FechaCreacion { get; }
public DateTime? FechaModificacion { get; }
/// <summary>El próximo número disponible (read-only, sin modificar el estado).</summary>
public int ProximoNumero => UltimoNumero + 1;
public SecuenciaComprobante(
int puntoDeVentaId,
TipoComprobante tipoComprobante,
int ultimoNumero,
DateTime fechaCreacion,
DateTime? fechaModificacion)
{
PuntoDeVentaId = puntoDeVentaId;
TipoComprobante = tipoComprobante;
UltimoNumero = ultimoNumero;
FechaCreacion = fechaCreacion;
FechaModificacion = fechaModificacion;
}
}

View File

@@ -1,16 +0,0 @@
namespace SIGCM2.Domain.Enums;
/// <summary>
/// Tipos de comprobante AFIP soportados por ADM-008.
/// Valor TINYINT persistido en BD (CHECK TipoComprobante BETWEEN 1 AND 6).
/// Migración a tabla maestra diferida a FAC-001.
/// </summary>
public enum TipoComprobante : byte
{
FacturaA = 1,
FacturaB = 2,
FacturaC = 3,
NotaCreditoA = 4,
NotaCreditoB = 5,
NotaCreditoC = 6,
}

View File

@@ -1,14 +0,0 @@
namespace SIGCM2.Domain.Exceptions;
/// <summary>
/// Thrown by Infrastructure when a database deadlock (SQL 1205) is detected.
/// Allows Application handlers to retry without referencing SqlClient.
/// </summary>
public sealed class DeadlockTransientException : DomainException
{
public DeadlockTransientException()
: base("Se detectó un deadlock en la base de datos. Reintentando operación.") { }
public DeadlockTransientException(Exception innerException)
: base("Se detectó un deadlock en la base de datos. Reintentando operación.", innerException) { }
}

View File

@@ -1,15 +0,0 @@
namespace SIGCM2.Domain.Exceptions;
/// <summary>
/// Thrown when a mutation (reserva) is attempted on an inactive PuntoDeVenta.
/// </summary>
public sealed class PuntoDeVentaInactivoException : DomainException
{
public int PuntoDeVentaId { get; }
public PuntoDeVentaInactivoException(int puntoDeVentaId)
: base($"El punto de venta {puntoDeVentaId} está inactivo. No se pueden realizar operaciones hasta reactivarlo.")
{
PuntoDeVentaId = puntoDeVentaId;
}
}

View File

@@ -1,11 +1,9 @@
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;
@@ -153,56 +151,6 @@ public sealed class PuntoDeVentaRepository : IPuntoDeVentaRepository
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)

View File

@@ -1,22 +0,0 @@
import { axiosClient } from '@/api/axiosClient'
import type { TipoComprobante, ReservarNumeroResponse, ProximoNumeroResponse } from '../types'
export async function reservarNumero(
puntoDeVentaId: number,
tipoComprobante: TipoComprobante,
): Promise<ReservarNumeroResponse> {
const response = await axiosClient.post<ReservarNumeroResponse>(
`/api/v1/admin/puntos-de-venta/${puntoDeVentaId}/secuencias/${tipoComprobante}/reservar`,
)
return response.data
}
export async function getProximoNumero(
puntoDeVentaId: number,
tipoComprobante: TipoComprobante,
): Promise<ProximoNumeroResponse> {
const response = await axiosClient.get<ProximoNumeroResponse>(
`/api/v1/admin/puntos-de-venta/${puntoDeVentaId}/secuencias/${tipoComprobante}/proximo`,
)
return response.data
}

View File

@@ -1,96 +0,0 @@
import { toast } from 'sonner'
import { Button } from '@/components/ui/button'
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table'
import { TipoComprobante } from '../types'
import { useReservarNumero, useProximoNumero } from '../hooks/useReservarNumero'
const TIPOS: Array<{ value: TipoComprobante; label: string }> = [
{ value: TipoComprobante.FacturaA, label: 'Factura A' },
{ value: TipoComprobante.FacturaB, label: 'Factura B' },
{ value: TipoComprobante.FacturaC, label: 'Factura C' },
{ value: TipoComprobante.NotaCreditoA, label: 'Nota Crédito A' },
{ value: TipoComprobante.NotaCreditoB, label: 'Nota Crédito B' },
{ value: TipoComprobante.NotaCreditoC, label: 'Nota Crédito C' },
]
interface Props {
puntoDeVentaId: number
disabled: boolean
}
export function SecuenciasPanel({ puntoDeVentaId, disabled }: Props) {
return (
<div className="rounded-md border border-border">
<div className="border-b border-border px-4 py-3">
<h2 className="text-sm font-semibold">Reserva de números de comprobante</h2>
<p className="text-xs text-muted-foreground">
Cada reserva incrementa el correlativo y devuelve el número asignado.
</p>
</div>
<Table>
<TableHeader>
<TableRow>
<TableHead>Tipo</TableHead>
<TableHead className="text-right">Próximo número</TableHead>
<TableHead className="text-right">Acción</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{TIPOS.map((tipo) => (
<SecuenciaRow
key={tipo.value}
puntoDeVentaId={puntoDeVentaId}
tipoValue={tipo.value}
tipoLabel={tipo.label}
disabled={disabled}
/>
))}
</TableBody>
</Table>
</div>
)
}
interface RowProps {
puntoDeVentaId: number
tipoValue: TipoComprobante
tipoLabel: string
disabled: boolean
}
function SecuenciaRow({ puntoDeVentaId, tipoValue, tipoLabel, disabled }: RowProps) {
const proximo = useProximoNumero(puntoDeVentaId, tipoValue)
const reservar = useReservarNumero(puntoDeVentaId)
const handleReservar = () => {
reservar.mutate(tipoValue, {
onSuccess: (data) => {
toast.success(`${tipoLabel}: número ${data.numeroReservado} reservado`)
},
onError: (err: unknown) => {
const apiError = err as { response?: { data?: { error?: string } } }
const code = apiError.response?.data?.error ?? 'error'
toast.error(`No se pudo reservar: ${code}`)
},
})
}
return (
<TableRow>
<TableCell>{tipoLabel}</TableCell>
<TableCell className="text-right font-mono">
{proximo.isLoading ? '…' : proximo.data?.proximoNumero ?? '—'}
</TableCell>
<TableCell className="text-right">
<Button
size="sm"
variant="outline"
disabled={disabled || reservar.isPending}
onClick={handleReservar}
>
{reservar.isPending ? 'Reservando…' : 'Reservar'}
</Button>
</TableCell>
</TableRow>
)
}

View File

@@ -1,30 +0,0 @@
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { reservarNumero, getProximoNumero } from '../api/secuencias.api'
import type { TipoComprobante } from '../types'
// ─── Reservar ────────────────────────────────────────────────────────────────
export function useReservarNumero(puntoDeVentaId: number) {
const queryClient = useQueryClient()
return useMutation({
mutationFn: (tipoComprobante: TipoComprobante) =>
reservarNumero(puntoDeVentaId, tipoComprobante),
onSuccess: (_data, tipoComprobante) => {
// Invalidate the proximo query for this pdv+tipo so it refetches
queryClient.invalidateQueries({
queryKey: ['puntos-de-venta', 'proximo', puntoDeVentaId, tipoComprobante],
})
},
})
}
// ─── Próximo número (read-only) ──────────────────────────────────────────────
export function useProximoNumero(puntoDeVentaId: number, tipoComprobante: TipoComprobante) {
return useQuery({
queryKey: ['puntos-de-venta', 'proximo', puntoDeVentaId, tipoComprobante],
queryFn: () => getProximoNumero(puntoDeVentaId, tipoComprobante),
enabled: !!puntoDeVentaId,
staleTime: 5_000,
})
}

View File

@@ -7,7 +7,6 @@ import { useMedio } from '../../medios/hooks/useMedio'
import { DeactivatePuntoDeVentaModal } from '../components/DeactivatePuntoDeVentaModal'
import { MedioInactivoBanner } from '../components/MedioInactivoBanner'
import { PdvInactivoBanner } from '../components/PdvInactivoBanner'
import { SecuenciasPanel } from '../components/SecuenciasPanel'
function formatDate(iso: string | null): string {
if (!iso) return '—'
@@ -105,12 +104,6 @@ export function PuntoDeVentaDetailPage() {
</div>
</CanPerform>
<CanPerform permission="administracion:puntos_de_venta:gestionar">
<SecuenciasPanel
puntoDeVentaId={puntoDeVentaId}
disabled={pdvInactivo || medioInactivo}
/>
</CanPerform>
</div>
)
}

View File

@@ -1,13 +1,7 @@
// ADM-008 — shared types for puntos-de-venta feature
export enum TipoComprobante {
FacturaA = 1,
FacturaB = 2,
FacturaC = 3,
NotaCreditoA = 4,
NotaCreditoB = 5,
NotaCreditoC = 6,
}
// NOTE: numeración AFIP (NumeroFactura, CAI) es asignada externamente por IMAC/Infogestión.
// Un worker futuro (INT-001) polleará la vista de Infogestión para asociar
// NumeroOrdenInterno ↔ NumeroFacturaAFIP + CAI. No se generan números aquí.
export interface PuntoDeVentaListItem {
id: number
@@ -53,16 +47,6 @@ export interface PuntosDeVentaQuery {
activo?: boolean
}
export interface ReservarNumeroResponse {
tipoComprobante: TipoComprobante
numeroReservado: number
}
export interface ProximoNumeroResponse {
tipoComprobante: TipoComprobante
proximoNumero: number
}
export interface PagedResult<T> {
items: T[]
page: number

View File

@@ -125,13 +125,6 @@ public sealed class PuntosDeVentaControllerTests : IAsyncLifetime
"SELECT Id FROM dbo.Medio WHERE Codigo = @Codigo", new { Codigo = codigo });
if (id is null) return;
// Delete SecuenciaComprobante for PuntosDeVenta of this Medio (no versioning)
await conn.ExecuteAsync("""
DELETE sc FROM dbo.SecuenciaComprobante sc
INNER JOIN dbo.PuntoDeVenta pdv ON pdv.Id = sc.PuntoDeVentaId
WHERE pdv.MedioId = @id
""", new { id });
// Delete dependent PuntosDeVenta (disable versioning to also clear history)
await conn.ExecuteAsync("ALTER TABLE dbo.PuntoDeVenta SET (SYSTEM_VERSIONING = OFF)");
await conn.ExecuteAsync("DELETE FROM dbo.PuntoDeVenta_History WHERE MedioId = @id", new { id });
@@ -604,238 +597,4 @@ public sealed class PuntosDeVentaControllerTests : IAsyncLifetime
}
}
// ── SECUENCIAS: RESERVAR ──────────────────────────────────────────────────
/// <summary>T5.3 — Primera reserva inicializa en 1.</summary>
[Fact]
public async Task ReservarNumero_FirstReservation_Returns1()
{
const string medioCodigo = "ADMS08_MED_RSV1";
var token = await GetAdminTokenAsync();
try
{
var medioId = await CreateMedioAsync(medioCodigo, "Medio PDV Reservar 1", token);
var pdvId = await CreatePdvAsync(medioId, 1, "PdV Reservar First", token);
using var req = BuildRequest(HttpMethod.Post, $"{Endpoint}/{pdvId}/secuencias/FacturaA/reservar", bearerToken: token);
var resp = await _client.SendAsync(req);
Assert.Equal(HttpStatusCode.OK, resp.StatusCode);
var json = await resp.Content.ReadFromJsonAsync<JsonElement>();
Assert.Equal(1, json.GetProperty("numeroReservado").GetInt32());
}
finally
{
await DeleteMedioIfExistsAsync(medioCodigo);
}
}
/// <summary>T5.3 — 409 punto_de_venta_inactivo al reservar en PdV inactivo.</summary>
[Fact]
public async Task ReservarNumero_WhenPdvInactive_Returns409PdvInactivo()
{
const string medioCodigo = "ADMS08_MED_RSVI";
var token = await GetAdminTokenAsync();
try
{
var medioId = await CreateMedioAsync(medioCodigo, "Medio PDV Reservar Inactivo", token);
var pdvId = await CreatePdvAsync(medioId, 1, "PdV Reservar Inactivo", token);
// Deactivate PdV
using var deactReq = BuildRequest(HttpMethod.Post, $"{Endpoint}/{pdvId}/deactivate", bearerToken: token);
await _client.SendAsync(deactReq);
using var req = BuildRequest(HttpMethod.Post, $"{Endpoint}/{pdvId}/secuencias/FacturaA/reservar", bearerToken: token);
var resp = await _client.SendAsync(req);
Assert.Equal(HttpStatusCode.Conflict, resp.StatusCode);
var json = await resp.Content.ReadFromJsonAsync<JsonElement>();
Assert.Equal("punto_de_venta_inactivo", json.GetProperty("error").GetString());
}
finally
{
await DeleteMedioIfExistsAsync(medioCodigo);
}
}
/// <summary>T5.3 — 409 medio_inactivo al reservar con Medio inactivo.</summary>
[Fact]
public async Task ReservarNumero_WhenMedioInactive_Returns409MedioInactivo()
{
const string medioCodigo = "ADMS08_MED_RSVMI";
var token = await GetAdminTokenAsync();
try
{
var medioId = await CreateMedioAsync(medioCodigo, "Medio PDV Reservar MedioInactivo", token);
var pdvId = await CreatePdvAsync(medioId, 1, "PdV Reservar MedioInact", token);
// Deactivate medio
using var deactMedioReq = BuildRequest(HttpMethod.Post, $"{MediosEndpoint}/{medioId}/deactivate", bearerToken: token);
await _client.SendAsync(deactMedioReq);
using var req = BuildRequest(HttpMethod.Post, $"{Endpoint}/{pdvId}/secuencias/FacturaA/reservar", bearerToken: token);
var resp = await _client.SendAsync(req);
Assert.Equal(HttpStatusCode.Conflict, resp.StatusCode);
var json = await resp.Content.ReadFromJsonAsync<JsonElement>();
Assert.Equal("medio_inactivo", json.GetProperty("error").GetString());
}
finally
{
await DeleteMedioIfExistsAsync(medioCodigo);
}
}
// ── SECUENCIAS: PROXIMO ───────────────────────────────────────────────────
/// <summary>T5.3 — GetProximo es read-only: no modifica UltimoNumero.</summary>
[Fact]
public async Task GetProximoNumero_DoesNotChangeState()
{
const string medioCodigo = "ADMS08_MED_PROX";
var token = await GetAdminTokenAsync();
try
{
var medioId = await CreateMedioAsync(medioCodigo, "Medio PDV Proximo", token);
var pdvId = await CreatePdvAsync(medioId, 1, "PdV Proximo", token);
// Reserve once to establish state
using var rsv = BuildRequest(HttpMethod.Post, $"{Endpoint}/{pdvId}/secuencias/FacturaA/reservar", bearerToken: token);
await _client.SendAsync(rsv);
// GetProximo twice — should return 2 both times
using var req1 = BuildRequest(HttpMethod.Get, $"{Endpoint}/{pdvId}/secuencias/FacturaA/proximo", bearerToken: token);
var resp1 = await _client.SendAsync(req1);
Assert.Equal(HttpStatusCode.OK, resp1.StatusCode);
var json1 = await resp1.Content.ReadFromJsonAsync<JsonElement>();
Assert.Equal(2, json1.GetProperty("proximoNumero").GetInt32());
using var req2 = BuildRequest(HttpMethod.Get, $"{Endpoint}/{pdvId}/secuencias/FacturaA/proximo", bearerToken: token);
var resp2 = await _client.SendAsync(req2);
var json2 = await resp2.Content.ReadFromJsonAsync<JsonElement>();
Assert.Equal(2, json2.GetProperty("proximoNumero").GetInt32());
}
finally
{
await DeleteMedioIfExistsAsync(medioCodigo);
}
}
/// <summary>T5.3 — GetProximo para fila inexistente devuelve 1.</summary>
[Fact]
public async Task GetProximoNumero_WhenNoSequenceExists_Returns1()
{
const string medioCodigo = "ADMS08_MED_PROX1";
var token = await GetAdminTokenAsync();
try
{
var medioId = await CreateMedioAsync(medioCodigo, "Medio PDV Proximo 1", token);
var pdvId = await CreatePdvAsync(medioId, 1, "PdV Proximo First", token);
using var req = BuildRequest(HttpMethod.Get, $"{Endpoint}/{pdvId}/secuencias/FacturaB/proximo", bearerToken: token);
var resp = await _client.SendAsync(req);
Assert.Equal(HttpStatusCode.OK, resp.StatusCode);
var json = await resp.Content.ReadFromJsonAsync<JsonElement>();
Assert.Equal(1, json.GetProperty("proximoNumero").GetInt32());
}
finally
{
await DeleteMedioIfExistsAsync(medioCodigo);
}
}
// ── T5.4 — Concurrencia ───────────────────────────────────────────────────
/// <summary>
/// T5.4 — 50 tasks paralelas reservando para mismo PdV + TipoComprobante
/// deben producir 50 números distintos cubriendo {1..50}.
/// </summary>
[Fact]
public async Task ReservarNumero_50ConcurrentReservations_ProducesNoDuplicates()
{
const string medioCodigo = "ADMS08_CONC50";
var token = await GetAdminTokenAsync();
try
{
var medioId = await CreateMedioAsync(medioCodigo, "Medio PDV Concurrencia 50", token);
var pdvId = await CreatePdvAsync(medioId, 1, "PdV Concurrencia 50", token);
const int taskCount = 50;
var tasks = Enumerable.Range(0, taskCount)
.Select(_ => Task.Run(async () =>
{
// Each task creates its own HttpClient to avoid sharing
// the shared _client which is not thread-safe for concurrent requests.
// Use BuildRequest on a shared client IS safe since HttpClient is thread-safe
// for concurrent operations as long as each message is distinct.
using var req = new HttpRequestMessage(HttpMethod.Post, $"{Endpoint}/{pdvId}/secuencias/FacturaA/reservar");
req.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token);
var resp = await _client.SendAsync(req);
resp.EnsureSuccessStatusCode();
var json = await resp.Content.ReadFromJsonAsync<JsonElement>();
return json.GetProperty("numeroReservado").GetInt32();
}))
.ToList();
var results = await Task.WhenAll(tasks);
// All 50 numbers must be present exactly once
Assert.Equal(taskCount, results.Length);
Assert.Equal(taskCount, results.Distinct().Count());
var expected = Enumerable.Range(1, taskCount).ToHashSet();
var actual = results.ToHashSet();
Assert.Equal(expected, actual);
}
finally
{
await DeleteMedioIfExistsAsync(medioCodigo);
}
}
// ── T5.5 — Secuencialidad ─────────────────────────────────────────────────
/// <summary>
/// T5.5 — 100 reservas en serie para mismo PdV + TipoComprobante
/// deben devolver {1, 2, 3, ..., 100} en orden.
/// </summary>
[Fact]
public async Task ReservarNumero_100SerialReservations_ProducesSequentialNumbers()
{
const string medioCodigo = "ADMS08_SEQ100";
var token = await GetAdminTokenAsync();
try
{
var medioId = await CreateMedioAsync(medioCodigo, "Medio PDV Secuencial 100", token);
var pdvId = await CreatePdvAsync(medioId, 1, "PdV Secuencial 100", token);
const int count = 100;
var results = new List<int>(count);
for (int i = 0; i < count; i++)
{
using var req = BuildRequest(HttpMethod.Post, $"{Endpoint}/{pdvId}/secuencias/FacturaA/reservar", bearerToken: token);
var resp = await _client.SendAsync(req);
resp.EnsureSuccessStatusCode();
var json = await resp.Content.ReadFromJsonAsync<JsonElement>();
results.Add(json.GetProperty("numeroReservado").GetInt32());
}
// Verify sequential: {1, 2, 3, ..., 100}
var expected = Enumerable.Range(1, count).ToList();
Assert.Equal(expected, results);
}
finally
{
await DeleteMedioIfExistsAsync(medioCodigo);
}
}
}

View File

@@ -1,5 +1,4 @@
using SIGCM2.Domain.Entities;
using SIGCM2.Domain.Enums;
namespace SIGCM2.Application.Tests.Domain;

View File

@@ -1,57 +0,0 @@
using SIGCM2.Domain.Entities;
using SIGCM2.Domain.Enums;
namespace SIGCM2.Application.Tests.Domain;
public class SecuenciaComprobanteTests
{
private static SecuenciaComprobante Make(
int puntoDeVentaId = 1,
TipoComprobante tipo = TipoComprobante.FacturaA,
int ultimoNumero = 0)
=> new(puntoDeVentaId, tipo, ultimoNumero, DateTime.UtcNow, null);
[Fact]
public void Constructor_SetsAllProperties()
{
var now = DateTime.UtcNow;
var seq = new SecuenciaComprobante(
puntoDeVentaId: 3,
tipoComprobante: TipoComprobante.FacturaB,
ultimoNumero: 42,
fechaCreacion: now,
fechaModificacion: null);
Assert.Equal(3, seq.PuntoDeVentaId);
Assert.Equal(TipoComprobante.FacturaB, seq.TipoComprobante);
Assert.Equal(42, seq.UltimoNumero);
Assert.Equal(now, seq.FechaCreacion);
Assert.Null(seq.FechaModificacion);
}
[Fact]
public void ProximoNumero_WhenUltimoNumeroZero_ReturnsOne()
{
var seq = Make(ultimoNumero: 0);
Assert.Equal(1, seq.ProximoNumero);
}
[Fact]
public void ProximoNumero_WhenUltimoNumeroN_ReturnsNPlusOne()
{
var seq = Make(ultimoNumero: 7);
Assert.Equal(8, seq.ProximoNumero);
}
[Fact]
public void AllTipoComprobanteValues_CanBeUsedInConstructor()
{
foreach (TipoComprobante tipo in Enum.GetValues<TipoComprobante>())
{
var seq = Make(tipo: tipo);
Assert.Equal(tipo, seq.TipoComprobante);
}
}
}

View File

@@ -1,52 +0,0 @@
using NSubstitute;
using SIGCM2.Application.Abstractions.Persistence;
using SIGCM2.Application.PuntosDeVenta.ProximoNumero;
using SIGCM2.Domain.Enums;
namespace SIGCM2.Application.Tests.PuntosDeVenta.ProximoNumero;
public class GetProximoNumeroQueryHandlerTests
{
private readonly IPuntoDeVentaRepository _repo = Substitute.For<IPuntoDeVentaRepository>();
private readonly GetProximoNumeroQueryHandler _handler;
public GetProximoNumeroQueryHandlerTests()
{
_handler = new GetProximoNumeroQueryHandler(_repo);
}
[Fact]
public async Task Handle_ExistingSequence_ReturnsUltimoNumeroMasUno()
{
_repo.GetUltimoNumeroAsync(10, TipoComprobante.FacturaA, Arg.Any<CancellationToken>())
.Returns(7);
var result = await _handler.Handle(new GetProximoNumeroQuery(10, TipoComprobante.FacturaA));
Assert.Equal(TipoComprobante.FacturaA, result.TipoComprobante);
Assert.Equal(8, result.ProximoNumero);
}
[Fact]
public async Task Handle_NoExistingSequence_ReturnsOne()
{
_repo.GetUltimoNumeroAsync(10, TipoComprobante.FacturaB, Arg.Any<CancellationToken>())
.Returns((int?)null);
var result = await _handler.Handle(new GetProximoNumeroQuery(10, TipoComprobante.FacturaB));
Assert.Equal(1, result.ProximoNumero);
}
[Fact]
public async Task Handle_DoesNotCallReservarNumero()
{
_repo.GetUltimoNumeroAsync(10, TipoComprobante.FacturaA, Arg.Any<CancellationToken>())
.Returns(5);
await _handler.Handle(new GetProximoNumeroQuery(10, TipoComprobante.FacturaA));
await _repo.DidNotReceive().ReservarNumeroAsync(
Arg.Any<int>(), Arg.Any<TipoComprobante>(), Arg.Any<CancellationToken>());
}
}

View File

@@ -1,126 +0,0 @@
using NSubstitute;
using NSubstitute.ExceptionExtensions;
using SIGCM2.Application.Abstractions.Persistence;
using SIGCM2.Application.PuntosDeVenta.Reservar;
using SIGCM2.Domain.Enums;
using SIGCM2.Domain.Exceptions;
namespace SIGCM2.Application.Tests.PuntosDeVenta.Reservar;
public class ReservarNumeroCommandHandlerTests
{
private readonly IPuntoDeVentaRepository _repo = Substitute.For<IPuntoDeVentaRepository>();
private readonly ReservarNumeroCommandHandler _handler;
private static readonly ReservarNumeroCommand ValidCommand =
new(PuntoDeVentaId: 10, TipoComprobante: TipoComprobante.FacturaA);
public ReservarNumeroCommandHandlerTests()
{
// Use delay = 0 for fast tests
_handler = new ReservarNumeroCommandHandler(_repo, deadlockBackoffMs: [0, 0, 0]);
}
// ── happy path ───────────────────────────────────────────────────────────
[Fact]
public async Task Handle_HappyPath_ReturnsNumeroReservado()
{
_repo.ReservarNumeroAsync(10, TipoComprobante.FacturaA, Arg.Any<CancellationToken>())
.Returns(7);
var result = await _handler.Handle(ValidCommand);
Assert.Equal(TipoComprobante.FacturaA, result.TipoComprobante);
Assert.Equal(7, result.NumeroReservado);
}
// ── retry deadlock ────────────────────────────────────────────────────────
[Fact]
public async Task Handle_DeadlockTwiceThenSucceeds_ReturnsResult()
{
var deadlock = new DeadlockTransientException();
_repo.ReservarNumeroAsync(10, TipoComprobante.FacturaA, Arg.Any<CancellationToken>())
.Returns(
_ => Task.FromException<int>(deadlock),
_ => Task.FromException<int>(deadlock),
_ => Task.FromResult(3));
var result = await _handler.Handle(ValidCommand);
Assert.Equal(3, result.NumeroReservado);
}
[Fact]
public async Task Handle_DeadlockThreeTimes_BubblesUpDeadlockException()
{
var deadlock = new DeadlockTransientException();
_repo.ReservarNumeroAsync(10, TipoComprobante.FacturaA, Arg.Any<CancellationToken>())
.Returns(
_ => Task.FromException<int>(deadlock),
_ => Task.FromException<int>(deadlock),
_ => Task.FromException<int>(deadlock));
await Assert.ThrowsAsync<DeadlockTransientException>(
() => _handler.Handle(ValidCommand));
}
[Fact]
public async Task Handle_DeadlockExhaustsBackoff_TriedFourTimesTotal()
{
// backoff = [0,0,0] → 3 retries → 4 total attempts (1 initial + 3 retries)
var deadlock = new DeadlockTransientException();
_repo.ReservarNumeroAsync(10, TipoComprobante.FacturaA, Arg.Any<CancellationToken>())
.Returns(_ => Task.FromException<int>(deadlock));
try { await _handler.Handle(ValidCommand); } catch (DeadlockTransientException) { }
await _repo.Received(4).ReservarNumeroAsync(
Arg.Any<int>(), Arg.Any<TipoComprobante>(), Arg.Any<CancellationToken>());
}
// ── domain exceptions bubble up without retry ─────────────────────────────
[Fact]
public async Task Handle_PuntoDeVentaInactivo_BubblesUpImmediately()
{
_repo.ReservarNumeroAsync(10, TipoComprobante.FacturaA, Arg.Any<CancellationToken>())
.Throws(new PuntoDeVentaInactivoException(10));
await Assert.ThrowsAsync<PuntoDeVentaInactivoException>(
() => _handler.Handle(ValidCommand));
await _repo.Received(1).ReservarNumeroAsync(
Arg.Any<int>(), Arg.Any<TipoComprobante>(), Arg.Any<CancellationToken>());
}
[Fact]
public async Task Handle_MedioInactivo_BubblesUpImmediately()
{
_repo.ReservarNumeroAsync(10, TipoComprobante.FacturaA, Arg.Any<CancellationToken>())
.Throws(new MedioInactivoException(5));
await Assert.ThrowsAsync<MedioInactivoException>(
() => _handler.Handle(ValidCommand));
await _repo.Received(1).ReservarNumeroAsync(
Arg.Any<int>(), Arg.Any<TipoComprobante>(), Arg.Any<CancellationToken>());
}
[Fact]
public async Task Handle_PdvNotFound_BubblesUpImmediately()
{
_repo.ReservarNumeroAsync(10, TipoComprobante.FacturaA, Arg.Any<CancellationToken>())
.Throws(new PuntoDeVentaNotFoundException(10));
await Assert.ThrowsAsync<PuntoDeVentaNotFoundException>(
() => _handler.Handle(ValidCommand));
await _repo.Received(1).ReservarNumeroAsync(
Arg.Any<int>(), Arg.Any<TipoComprobante>(), Arg.Any<CancellationToken>());
}
}

View File

@@ -39,7 +39,9 @@ public sealed class SqlTestFixture : IAsyncLifetime
// V011 (ADM-001): ensure dbo.Medio, dbo.Seccion + temporal tables + permiso 'administracion:secciones:gestionar'.
await EnsureV011SchemaAsync();
// V013 (ADM-008): ensure dbo.PuntoDeVenta, dbo.SecuenciaComprobante + temporal + SP usp_ReservarNumeroComprobante.
// V013 (ADM-008): ensure dbo.PuntoDeVenta + temporal + permiso. Drops idempotentes
// de SecuenciaComprobante + SP usp_ReservarNumeroComprobante (cirugía post-smoke:
// IMAC asigna numeros AFIP, no nosotros — ver memoria architecture/facturacion-imac-numeracion).
await EnsureV013SchemaAsync();
_respawner = await Respawner.CreateAsync(_connection, new RespawnerOptions
@@ -171,7 +173,7 @@ public sealed class SqlTestFixture : IAsyncLifetime
-- V011 (ADM-001): permiso para CRUD de Secciones
('administracion:secciones:gestionar', N'Gestionar secciones por medio', N'Crear, editar y desactivar secciones de un medio','administracion'),
-- V013 (ADM-008): permiso para CRUD de Puntos de Venta
('administracion:puntos_de_venta:gestionar', N'Gestionar puntos de venta', N'Crear, editar y desactivar puntos de venta y reservar numeros','administracion')
('administracion:puntos_de_venta:gestionar', N'Gestionar puntos de venta', N'Crear, editar y desactivar puntos de venta AFIP','administracion')
) AS s (Codigo, Nombre, Descripcion, Modulo)
ON t.Codigo = s.Codigo
WHEN NOT MATCHED BY TARGET THEN
@@ -382,13 +384,38 @@ public sealed class SqlTestFixture : IAsyncLifetime
}
/// <summary>
/// ADM-008 (V013): applies PuntoDeVenta / SecuenciaComprobante schema + temporal tables +
/// permiso 'administracion:puntos_de_venta:gestionar' + SP usp_ReservarNumeroComprobante.
/// Idempotent — mirrors V013__create_puntos_de_venta.sql. Permiso y asignación se siembran
/// desde SeedPermisosCanonicalAsync / SeedRolPermisosCanonicalAsync (post-respawn).
/// ADM-008 (V013): applies dbo.PuntoDeVenta schema + temporal table.
/// NOTE: SecuenciaComprobante y SP usp_ReservarNumeroComprobante fueron eliminados
/// post-smoke (Batch 9) — IMAC/Infogestión asigna los números AFIP externamente.
/// Este método también hace DROP idempotente de esos artefactos en caso de que
/// SIGCM2_Test los tuviera de una versión previa de la migración V013.
/// Permiso y asignación se siembran desde SeedPermisosCanonicalAsync / SeedRolPermisosCanonicalAsync.
/// </summary>
private async Task EnsureV013SchemaAsync()
{
// ── Drops idempotentes de artefactos eliminados (cirugía post-smoke) ──
// Si SIGCM2_Test tiene SecuenciaComprobante o el SP de una versión previa, se limpian.
const string dropSp = """
IF OBJECT_ID(N'dbo.usp_ReservarNumeroComprobante', N'P') IS NOT NULL
DROP PROCEDURE dbo.usp_ReservarNumeroComprobante;
""";
const string disableSecuenciaVersioning = """
IF EXISTS (SELECT 1 FROM sys.tables WHERE object_id = OBJECT_ID('dbo.SecuenciaComprobante') AND temporal_type = 2)
ALTER TABLE dbo.SecuenciaComprobante SET (SYSTEM_VERSIONING = OFF);
""";
const string dropSecuenciaHistory = """
IF OBJECT_ID(N'dbo.SecuenciaComprobante_History', N'U') IS NOT NULL
DROP TABLE dbo.SecuenciaComprobante_History;
""";
const string dropSecuencia = """
IF OBJECT_ID(N'dbo.SecuenciaComprobante', N'U') IS NOT NULL
DROP TABLE dbo.SecuenciaComprobante;
""";
// ── PuntoDeVenta: crear si no existe ──────────────────────────────────
const string createPdv = """
IF OBJECT_ID(N'dbo.PuntoDeVenta', N'U') IS NULL
BEGIN
@@ -417,23 +444,6 @@ public sealed class SqlTestFixture : IAsyncLifetime
END
""";
const string createSecuencia = """
IF OBJECT_ID(N'dbo.SecuenciaComprobante', N'U') IS NULL
BEGIN
CREATE TABLE dbo.SecuenciaComprobante (
PuntoDeVentaId INT NOT NULL,
TipoComprobante TINYINT NOT NULL,
UltimoNumero INT NOT NULL CONSTRAINT DF_SecuenciaComprobante_UltimoNumero DEFAULT(0),
FechaCreacion DATETIME2(3) NOT NULL CONSTRAINT DF_SecuenciaComprobante_FechaCreacion DEFAULT(SYSUTCDATETIME()),
FechaModificacion DATETIME2(3) NULL,
CONSTRAINT PK_SecuenciaComprobante PRIMARY KEY (PuntoDeVentaId, TipoComprobante),
CONSTRAINT FK_SecuenciaComprobante_PuntoDeVenta FOREIGN KEY (PuntoDeVentaId) REFERENCES dbo.PuntoDeVenta(Id) ON DELETE NO ACTION,
CONSTRAINT CK_SecuenciaComprobante_TipoComprobante CHECK (TipoComprobante BETWEEN 1 AND 6),
CONSTRAINT CK_SecuenciaComprobante_UltimoNumero CHECK (UltimoNumero >= 0)
);
END
""";
const string addPdvPeriod = """
IF COL_LENGTH('dbo.PuntoDeVenta', 'ValidFrom') IS NULL
BEGIN
@@ -458,122 +468,17 @@ public sealed class SqlTestFixture : IAsyncLifetime
END
""";
// SecuenciaComprobante: sin SYSTEM_VERSIONING (AD8 revisitado — ver comentario en V013).
// Si una version previa del fixture activo SYSTEM_VERSIONING, lo desactiva + drop history.
const string disableSecuenciaVersioning = """
IF EXISTS (SELECT 1 FROM sys.tables WHERE object_id = OBJECT_ID('dbo.SecuenciaComprobante') AND temporal_type = 2)
BEGIN
ALTER TABLE dbo.SecuenciaComprobante SET (SYSTEM_VERSIONING = OFF);
END
""";
const string dropSecuenciaHistory = """
IF OBJECT_ID(N'dbo.SecuenciaComprobante_History', N'U') IS NOT NULL
BEGIN
DROP TABLE dbo.SecuenciaComprobante_History;
END
""";
const string dropSecuenciaPeriod = """
IF EXISTS (SELECT 1 FROM sys.periods WHERE object_id = OBJECT_ID('dbo.SecuenciaComprobante'))
BEGIN
ALTER TABLE dbo.SecuenciaComprobante DROP PERIOD FOR SYSTEM_TIME;
END
""";
const string dropSecuenciaValidCols = """
IF COL_LENGTH('dbo.SecuenciaComprobante', 'ValidFrom') IS NOT NULL
BEGIN
IF EXISTS (SELECT 1 FROM sys.default_constraints WHERE name = 'DF_SecuenciaComprobante_ValidFrom' AND parent_object_id = OBJECT_ID('dbo.SecuenciaComprobante'))
ALTER TABLE dbo.SecuenciaComprobante DROP CONSTRAINT DF_SecuenciaComprobante_ValidFrom;
IF EXISTS (SELECT 1 FROM sys.default_constraints WHERE name = 'DF_SecuenciaComprobante_ValidTo' AND parent_object_id = OBJECT_ID('dbo.SecuenciaComprobante'))
ALTER TABLE dbo.SecuenciaComprobante DROP CONSTRAINT DF_SecuenciaComprobante_ValidTo;
ALTER TABLE dbo.SecuenciaComprobante DROP COLUMN ValidFrom, ValidTo;
END
""";
const string dropSp = """
IF OBJECT_ID(N'dbo.usp_ReservarNumeroComprobante', N'P') IS NOT NULL
DROP PROCEDURE dbo.usp_ReservarNumeroComprobante;
""";
const string createSp = """
CREATE PROCEDURE dbo.usp_ReservarNumeroComprobante
@PuntoDeVentaId INT,
@TipoComprobante TINYINT,
@NumeroReservado INT OUTPUT
AS
BEGIN
SET NOCOUNT ON;
SET XACT_ABORT ON;
SET TRANSACTION ISOLATION LEVEL SERIALIZABLE;
BEGIN TRAN;
DECLARE @PdvActivo BIT;
DECLARE @MedioActivo BIT;
SELECT
@PdvActivo = p.Activo,
@MedioActivo = m.Activo
FROM dbo.PuntoDeVenta p
JOIN dbo.Medio m ON m.Id = p.MedioId
WHERE p.Id = @PuntoDeVentaId;
IF @PdvActivo IS NULL
BEGIN
ROLLBACK;
THROW 50003, 'punto_de_venta_not_found', 1;
END
IF @PdvActivo = 0
BEGIN
ROLLBACK;
THROW 50001, 'punto_de_venta_inactivo', 1;
END
IF @MedioActivo = 0
BEGIN
ROLLBACK;
THROW 50002, 'medio_inactivo', 1;
END
DECLARE @_out TABLE (n INT NOT NULL);
UPDATE dbo.SecuenciaComprobante
SET
UltimoNumero = UltimoNumero + 1,
FechaModificacion = SYSUTCDATETIME()
OUTPUT inserted.UltimoNumero INTO @_out(n)
WHERE PuntoDeVentaId = @PuntoDeVentaId
AND TipoComprobante = @TipoComprobante;
IF @@ROWCOUNT = 0
BEGIN
INSERT INTO dbo.SecuenciaComprobante (PuntoDeVentaId, TipoComprobante, UltimoNumero)
VALUES (@PuntoDeVentaId, @TipoComprobante, 1);
SET @NumeroReservado = 1;
END
ELSE
BEGIN
SELECT @NumeroReservado = n FROM @_out;
END
COMMIT;
END
""";
await _connection.ExecuteAsync(createPdv);
await _connection.ExecuteAsync(createPdvIndex);
await _connection.ExecuteAsync(createSecuencia);
await _connection.ExecuteAsync(addPdvPeriod);
await _connection.ExecuteAsync(setPdvVersioning);
// Drops primero (limpieza de versión previa)
await _connection.ExecuteAsync(dropSp);
await _connection.ExecuteAsync(disableSecuenciaVersioning);
await _connection.ExecuteAsync(dropSecuenciaHistory);
await _connection.ExecuteAsync(dropSecuenciaPeriod);
await _connection.ExecuteAsync(dropSecuenciaValidCols);
await _connection.ExecuteAsync(dropSp);
await _connection.ExecuteAsync(createSp);
await _connection.ExecuteAsync(dropSecuencia);
// Luego crear PuntoDeVenta + Temporal Table
await _connection.ExecuteAsync(createPdv);
await _connection.ExecuteAsync(createPdvIndex);
await _connection.ExecuteAsync(addPdvPeriod);
await _connection.ExecuteAsync(setPdvVersioning);
}
/// <summary>