-- 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