From bef8977c5c7522d79a36d5006f9d6737d0e00d97 Mon Sep 17 00:00:00 2001 From: dmolinari Date: Fri, 17 Apr 2026 12:16:56 -0300 Subject: [PATCH] feat(db): migration V013 + SP usp_ReservarNumeroComprobante para ADM-008 - Tabla PuntoDeVenta con Temporal Tables + UNIQUE(MedioId, NumeroAFIP) - Tabla SecuenciaComprobante con Temporal Tables + UNIQUE(PdvId, TipoComprobante) - Permiso administracion:puntos_de_venta:gestionar (guion_bajo: CK_Permiso_Codigo_Format) - SP usp_ReservarNumeroComprobante con SERIALIZABLE + THROW 50001/50002/50003 - V013_ROLLBACK.sql incluido Smoke tests SIGCM2_Test: - TEST 1: primera reserva devuelve 1 (lazy init) OK - TEST 2: segunda reserva devuelve 2 OK - TEST 3: PdV inactivo -> SqlException 50001 'punto_de_venta_inactivo' OK - TEST 4: Medio inactivo -> SqlException 50002 'medio_inactivo' OK Covers: REQ-PDV-001/003/009, REQ-SEC-CMB-001/002/003/004 --- database/migrations/V013_ROLLBACK.sql | 131 ++++++++ .../V013__create_puntos_de_venta.sql | 294 ++++++++++++++++++ 2 files changed, 425 insertions(+) create mode 100644 database/migrations/V013_ROLLBACK.sql create mode 100644 database/migrations/V013__create_puntos_de_venta.sql diff --git a/database/migrations/V013_ROLLBACK.sql b/database/migrations/V013_ROLLBACK.sql new file mode 100644 index 0000000..8226e72 --- /dev/null +++ b/database/migrations/V013_ROLLBACK.sql @@ -0,0 +1,131 @@ +-- 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. +-- +-- 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. + +SET QUOTED_IDENTIFIER ON; +SET ANSI_NULLS ON; +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 +-- ═══════════════════════════════════════════════════════════════════════ + +IF EXISTS (SELECT 1 FROM sys.tables WHERE object_id = OBJECT_ID('dbo.PuntoDeVenta') AND temporal_type = 2) +BEGIN + ALTER TABLE dbo.PuntoDeVenta SET (SYSTEM_VERSIONING = OFF); + PRINT 'PuntoDeVenta: SYSTEM_VERSIONING OFF.'; +END +GO + +IF EXISTS (SELECT 1 FROM sys.periods WHERE object_id = OBJECT_ID('dbo.PuntoDeVenta')) +BEGIN + ALTER TABLE dbo.PuntoDeVenta DROP PERIOD FOR SYSTEM_TIME; + PRINT 'PuntoDeVenta: PERIOD FOR SYSTEM_TIME dropped.'; +END +GO + +IF COL_LENGTH('dbo.PuntoDeVenta', 'ValidFrom') IS NOT NULL +BEGIN + ALTER TABLE dbo.PuntoDeVenta DROP CONSTRAINT IF EXISTS DF_PuntoDeVenta_ValidFrom; + ALTER TABLE dbo.PuntoDeVenta DROP CONSTRAINT IF EXISTS DF_PuntoDeVenta_ValidTo; + ALTER TABLE dbo.PuntoDeVenta DROP COLUMN ValidFrom, ValidTo; + PRINT 'PuntoDeVenta: ValidFrom/ValidTo dropped.'; +END +GO + +IF OBJECT_ID(N'dbo.PuntoDeVenta_History', N'U') IS NOT NULL +BEGIN + DROP TABLE dbo.PuntoDeVenta_History; + PRINT 'PuntoDeVenta_History dropped.'; +END +GO + +-- ═══════════════════════════════════════════════════════════════════════ +-- 4. Drop tablas (SecuenciaComprobante primero por FK) +-- ═══════════════════════════════════════════════════════════════════════ + +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; + PRINT 'Table dbo.PuntoDeVenta dropped.'; +END +GO + +-- ═══════════════════════════════════════════════════════════════════════ +-- 5. Remover permiso 'administracion:puntos_de_venta:gestionar' + RolPermiso +-- ═══════════════════════════════════════════════════════════════════════ + +DELETE rp +FROM dbo.RolPermiso rp +JOIN dbo.Permiso p ON p.Id = rp.PermisoId +WHERE p.Codigo = 'administracion:puntos_de_venta:gestionar'; +GO + +DELETE FROM dbo.Permiso +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 'Permiso administracion:puntos_de_venta:gestionar removed.'; +GO diff --git a/database/migrations/V013__create_puntos_de_venta.sql b/database/migrations/V013__create_puntos_de_venta.sql new file mode 100644 index 0000000..0f0669a --- /dev/null +++ b/database/migrations/V013__create_puntos_de_venta.sql @@ -0,0 +1,294 @@ +-- V013__create_puntos_de_venta.sql +-- ADM-008 Puntos de Venta: DDL + SP de reserva atómica de número de comprobante. +-- +-- 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). +-- +-- Patrón: V011 (Temporal Tables + Permiso MERGE + PAGE compression en history). +-- Idempotente: seguro para re-ejecutar. +-- Reversa: V013_ROLLBACK.sql. +-- Run on: SIGCM2 (dev) y SIGCM2_Test (integration tests). +-- +-- NOTA: el código de permiso usa guion_bajo (_) según CK_Permiso_Codigo_Format del proyecto. +-- Código efectivo: 'administracion:puntos_de_venta:gestionar' +-- El spec menciona 'administracion:puntos-de-venta:gestionar' (guion) pero el CHECK constraint +-- 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 +-- Source of truth: Obsidian/02-ARQUITECTURA-y-TECH-STACK/2.10 ADM-008 +-- +-- NOTA T1.3 — Seeds: NO se seedean PuntoDeVenta. +-- Cada instalación configura sus propios PdVs con el NumeroAFIP real asignado por AFIP/ARCA. +-- Seedear con valores ficticios generaría confusión operativa. El admin los crea manualmente. + +SET QUOTED_IDENTIFIER ON; +SET ANSI_NULLS ON; +SET NOCOUNT ON; +GO + +-- ═══════════════════════════════════════════════════════════════════════ +-- 1. dbo.PuntoDeVenta +-- ═══════════════════════════════════════════════════════════════════════ + +IF OBJECT_ID(N'dbo.PuntoDeVenta', N'U') IS NULL +BEGIN + CREATE TABLE dbo.PuntoDeVenta ( + Id INT IDENTITY(1,1) NOT NULL CONSTRAINT PK_PuntoDeVenta PRIMARY KEY, + MedioId INT NOT NULL, + NumeroAFIP SMALLINT NOT NULL, + Nombre NVARCHAR(100) NOT NULL, + Descripcion NVARCHAR(255) NULL, + Activo BIT NOT NULL CONSTRAINT DF_PuntoDeVenta_Activo DEFAULT(1), + FechaCreacion DATETIME2(3) NOT NULL CONSTRAINT DF_PuntoDeVenta_FechaCreacion DEFAULT(SYSUTCDATETIME()), + FechaModificacion DATETIME2(3) NULL, + CONSTRAINT FK_PuntoDeVenta_Medio FOREIGN KEY (MedioId) REFERENCES dbo.Medio(Id) ON DELETE NO ACTION, + CONSTRAINT UQ_PuntoDeVenta_Medio_AFIP UNIQUE (MedioId, NumeroAFIP), + CONSTRAINT CK_PuntoDeVenta_NumeroAFIP CHECK (NumeroAFIP >= 1) + ); + PRINT 'Table dbo.PuntoDeVenta created.'; +END +ELSE + PRINT 'Table dbo.PuntoDeVenta already exists — skip.'; +GO + +IF NOT EXISTS (SELECT 1 FROM sys.indexes WHERE name = 'IX_PuntoDeVenta_MedioId_Activo' AND object_id = OBJECT_ID('dbo.PuntoDeVenta')) +BEGIN + CREATE INDEX IX_PuntoDeVenta_MedioId_Activo + ON dbo.PuntoDeVenta(MedioId, Activo) + INCLUDE (NumeroAFIP, Nombre); + PRINT 'Index IX_PuntoDeVenta_MedioId_Activo created.'; +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 +-- ═══════════════════════════════════════════════════════════════════════ + +IF COL_LENGTH('dbo.PuntoDeVenta', 'ValidFrom') IS NULL +BEGIN + ALTER TABLE dbo.PuntoDeVenta + ADD + ValidFrom DATETIME2(3) GENERATED ALWAYS AS ROW START HIDDEN NOT NULL + CONSTRAINT DF_PuntoDeVenta_ValidFrom DEFAULT(SYSUTCDATETIME()), + ValidTo DATETIME2(3) GENERATED ALWAYS AS ROW END HIDDEN NOT NULL + CONSTRAINT DF_PuntoDeVenta_ValidTo DEFAULT(CONVERT(DATETIME2(3), '9999-12-31 23:59:59.999')), + PERIOD FOR SYSTEM_TIME (ValidFrom, ValidTo); + PRINT 'PuntoDeVenta: PERIOD FOR SYSTEM_TIME added.'; +END +GO + +IF NOT EXISTS (SELECT 1 FROM sys.tables WHERE object_id = OBJECT_ID('dbo.PuntoDeVenta') AND temporal_type = 2) +BEGIN + ALTER TABLE dbo.PuntoDeVenta + SET (SYSTEM_VERSIONING = ON ( + HISTORY_TABLE = dbo.PuntoDeVenta_History, + HISTORY_RETENTION_PERIOD = 10 YEARS + )); + PRINT 'PuntoDeVenta: SYSTEM_VERSIONING = ON (history: dbo.PuntoDeVenta_History, retention: 10 years).'; +END +ELSE + PRINT 'PuntoDeVenta: SYSTEM_VERSIONING already ON — skip.'; +GO + +IF EXISTS (SELECT 1 FROM sys.tables WHERE name = 'PuntoDeVenta_History' AND schema_id = SCHEMA_ID('dbo')) + AND NOT EXISTS ( + SELECT 1 FROM sys.partitions p + JOIN sys.tables t ON t.object_id = p.object_id + WHERE t.name = 'PuntoDeVenta_History' AND p.data_compression = 2 + ) +BEGIN + ALTER TABLE dbo.PuntoDeVenta_History REBUILD WITH (DATA_COMPRESSION = PAGE); + PRINT 'PuntoDeVenta_History: rebuilt with PAGE compression.'; +END +GO + +-- ═══════════════════════════════════════════════════════════════════════ +-- 4. SYSTEM_VERSIONING — SecuenciaComprobante +-- ═══════════════════════════════════════════════════════════════════════ + +IF COL_LENGTH('dbo.SecuenciaComprobante', 'ValidFrom') IS NULL +BEGIN + ALTER TABLE dbo.SecuenciaComprobante + ADD + ValidFrom DATETIME2(3) GENERATED ALWAYS AS ROW START HIDDEN NOT NULL + CONSTRAINT DF_SecuenciaComprobante_ValidFrom DEFAULT(SYSUTCDATETIME()), + ValidTo DATETIME2(3) GENERATED ALWAYS AS ROW END HIDDEN NOT NULL + CONSTRAINT DF_SecuenciaComprobante_ValidTo DEFAULT(CONVERT(DATETIME2(3), '9999-12-31 23:59:59.999')), + PERIOD FOR SYSTEM_TIME (ValidFrom, ValidTo); + PRINT 'SecuenciaComprobante: PERIOD FOR SYSTEM_TIME added.'; +END +GO + +IF NOT 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 = ON ( + HISTORY_TABLE = dbo.SecuenciaComprobante_History, + HISTORY_RETENTION_PERIOD = 10 YEARS + )); + PRINT 'SecuenciaComprobante: SYSTEM_VERSIONING = ON (history: dbo.SecuenciaComprobante_History, retention: 10 years).'; +END +ELSE + PRINT 'SecuenciaComprobante: SYSTEM_VERSIONING already ON — skip.'; +GO + +IF EXISTS (SELECT 1 FROM sys.tables WHERE name = 'SecuenciaComprobante_History' AND schema_id = SCHEMA_ID('dbo')) + AND NOT EXISTS ( + SELECT 1 FROM sys.partitions p + JOIN sys.tables t ON t.object_id = p.object_id + WHERE t.name = 'SecuenciaComprobante_History' AND p.data_compression = 2 + ) +BEGIN + ALTER TABLE dbo.SecuenciaComprobante_History REBUILD WITH (DATA_COMPRESSION = PAGE); + PRINT 'SecuenciaComprobante_History: rebuilt with PAGE compression.'; +END +GO + +-- ═══════════════════════════════════════════════════════════════════════ +-- 5. Permiso: administracion:puntos-de-venta:gestionar +-- ═══════════════════════════════════════════════════════════════════════ + +MERGE dbo.Permiso AS t +USING (VALUES + ('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 + INSERT (Codigo, Nombre, Descripcion, Modulo) + VALUES (s.Codigo, s.Nombre, s.Descripcion, s.Modulo); +GO + +MERGE dbo.RolPermiso AS t +USING ( + SELECT r.Id AS RolId, p.Id AS PermisoId + FROM (VALUES + ('admin', 'administracion:puntos_de_venta:gestionar') + ) AS x (RolCodigo, PermisoCodigo) + JOIN dbo.Rol r ON r.Codigo = x.RolCodigo + JOIN dbo.Permiso p ON p.Codigo = x.PermisoCodigo +) AS s ON t.RolId = s.RolId AND t.PermisoId = s.PermisoId +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).'; +GO