feat(bd): V019 crea dbo.ProductPrices + SP + índices (PRD-003)
This commit is contained in:
71
database/migrations/V019_ROLLBACK.sql
Normal file
71
database/migrations/V019_ROLLBACK.sql
Normal file
@@ -0,0 +1,71 @@
|
|||||||
|
-- V019_ROLLBACK.sql
|
||||||
|
-- PRD-003: Reversa de V019__create_product_prices.sql.
|
||||||
|
--
|
||||||
|
-- Pasos:
|
||||||
|
-- 1. Deshabilita SYSTEM_VERSIONING en dbo.ProductPrices (requerido antes de DROP TABLE).
|
||||||
|
-- 2. Elimina el PERIOD FOR SYSTEM_TIME y las columnas hidden SysStartTime/SysEndTime.
|
||||||
|
-- 3. Drop de dbo.ProductPrices_History.
|
||||||
|
-- 4. Drop de dbo.ProductPrices (y sus constraints + índices en cascada).
|
||||||
|
-- 5. Drop de dbo.usp_AddProductPrice.
|
||||||
|
--
|
||||||
|
-- ADVERTENCIA: destruye todo el historial de precios. Ejecutar sólo en DEV o TEST.
|
||||||
|
-- Run on: SIGCM2 (dev), SIGCM2_Test_App, SIGCM2_Test_Api.
|
||||||
|
|
||||||
|
SET QUOTED_IDENTIFIER ON;
|
||||||
|
SET ANSI_NULLS ON;
|
||||||
|
SET NOCOUNT ON;
|
||||||
|
GO
|
||||||
|
|
||||||
|
-- 1. Deshabilita SYSTEM_VERSIONING (imprescindible antes de DROP TABLE temporal).
|
||||||
|
IF EXISTS (SELECT 1 FROM sys.tables WHERE object_id = OBJECT_ID('dbo.ProductPrices') AND temporal_type = 2)
|
||||||
|
BEGIN
|
||||||
|
ALTER TABLE dbo.ProductPrices SET (SYSTEM_VERSIONING = OFF);
|
||||||
|
PRINT 'ProductPrices: SYSTEM_VERSIONING = OFF.';
|
||||||
|
END
|
||||||
|
GO
|
||||||
|
|
||||||
|
-- 2. Elimina el PERIOD y las hidden cols (si existen, independientemente del versioning).
|
||||||
|
IF COL_LENGTH('dbo.ProductPrices', 'SysStartTime') IS NOT NULL
|
||||||
|
BEGIN
|
||||||
|
ALTER TABLE dbo.ProductPrices
|
||||||
|
DROP PERIOD FOR SYSTEM_TIME;
|
||||||
|
|
||||||
|
-- Drop default constraints antes de drop de columnas.
|
||||||
|
IF EXISTS (SELECT 1 FROM sys.default_constraints WHERE name = 'DF_ProductPrices_SysStartTime')
|
||||||
|
ALTER TABLE dbo.ProductPrices DROP CONSTRAINT DF_ProductPrices_SysStartTime;
|
||||||
|
IF EXISTS (SELECT 1 FROM sys.default_constraints WHERE name = 'DF_ProductPrices_SysEndTime')
|
||||||
|
ALTER TABLE dbo.ProductPrices DROP CONSTRAINT DF_ProductPrices_SysEndTime;
|
||||||
|
|
||||||
|
ALTER TABLE dbo.ProductPrices DROP COLUMN SysStartTime;
|
||||||
|
ALTER TABLE dbo.ProductPrices DROP COLUMN SysEndTime;
|
||||||
|
PRINT 'ProductPrices: PERIOD + hidden cols dropped.';
|
||||||
|
END
|
||||||
|
GO
|
||||||
|
|
||||||
|
-- 3. Drop de la history table.
|
||||||
|
IF OBJECT_ID(N'dbo.ProductPrices_History', N'U') IS NOT NULL
|
||||||
|
BEGIN
|
||||||
|
DROP TABLE dbo.ProductPrices_History;
|
||||||
|
PRINT 'Table dbo.ProductPrices_History dropped.';
|
||||||
|
END
|
||||||
|
GO
|
||||||
|
|
||||||
|
-- 4. Drop de la tabla principal (constraints + índices en cascada).
|
||||||
|
IF OBJECT_ID(N'dbo.ProductPrices', N'U') IS NOT NULL
|
||||||
|
BEGIN
|
||||||
|
DROP TABLE dbo.ProductPrices;
|
||||||
|
PRINT 'Table dbo.ProductPrices dropped.';
|
||||||
|
END
|
||||||
|
GO
|
||||||
|
|
||||||
|
-- 5. Drop del SP.
|
||||||
|
IF OBJECT_ID(N'dbo.usp_AddProductPrice', N'P') IS NOT NULL
|
||||||
|
BEGIN
|
||||||
|
DROP PROCEDURE dbo.usp_AddProductPrice;
|
||||||
|
PRINT 'Procedure dbo.usp_AddProductPrice dropped.';
|
||||||
|
END
|
||||||
|
GO
|
||||||
|
|
||||||
|
PRINT '';
|
||||||
|
PRINT 'V019 rollback complete — dbo.ProductPrices, dbo.ProductPrices_History, dbo.usp_AddProductPrice removed.';
|
||||||
|
GO
|
||||||
196
database/migrations/V019__create_product_prices.sql
Normal file
196
database/migrations/V019__create_product_prices.sql
Normal file
@@ -0,0 +1,196 @@
|
|||||||
|
-- V019__create_product_prices.sql
|
||||||
|
-- PRD-003: ProductPrices — historial de precios por Producto con vigencia civil (Cat2).
|
||||||
|
--
|
||||||
|
-- Cambios:
|
||||||
|
-- 1. dbo.ProductPrices (FK Product, SYSTEM_VERSIONING ON, retention 10 años).
|
||||||
|
-- 2. Índices: filtered UQ un único activo; cover compuesto para GetPriceAt.
|
||||||
|
-- 3. SP dbo.usp_AddProductPrice (SERIALIZABLE + UPDLOCK, cierre atómico forward-only).
|
||||||
|
--
|
||||||
|
-- Patrón: V018 (SYSTEM_VERSIONING + PAGE compression).
|
||||||
|
-- Idempotente: seguro para re-ejecutar.
|
||||||
|
-- Reversa: V019_ROLLBACK.sql.
|
||||||
|
-- Run on: SIGCM2 (dev), SIGCM2_Test_App, SIGCM2_Test_Api.
|
||||||
|
--
|
||||||
|
-- Notas:
|
||||||
|
-- - SysStartTime/SysEndTime como nombres de cols HIDDEN (no ValidFrom/ValidTo):
|
||||||
|
-- evita colisión con las business cols PriceValidFrom/PriceValidTo (D1).
|
||||||
|
-- - DECIMAL(12,2) para Price (distinto de Product.BasePrice DECIMAL(18,4)) — precios retail
|
||||||
|
-- en pesos con 2 decimales; la diferencia es intencional (D6).
|
||||||
|
-- - Sin seed inicial — Product.BasePrice queda ortogonal como fallback (OQ-B, D8).
|
||||||
|
-- - Forward-only estricto en SP: THROW 50409 si new PVF <= active PVF (no solo <).
|
||||||
|
--
|
||||||
|
-- SDD Design: engram sdd/prd-003-product-prices-historicos/design
|
||||||
|
|
||||||
|
SET QUOTED_IDENTIFIER ON;
|
||||||
|
SET ANSI_NULLS ON;
|
||||||
|
SET NOCOUNT ON;
|
||||||
|
GO
|
||||||
|
|
||||||
|
-- ═══════════════════════════════════════════════════════════════════════
|
||||||
|
-- 1. dbo.ProductPrices
|
||||||
|
-- ═══════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
IF OBJECT_ID(N'dbo.ProductPrices', N'U') IS NULL
|
||||||
|
BEGIN
|
||||||
|
CREATE TABLE dbo.ProductPrices (
|
||||||
|
Id BIGINT IDENTITY(1,1) NOT NULL
|
||||||
|
CONSTRAINT PK_ProductPrices PRIMARY KEY,
|
||||||
|
ProductId INT NOT NULL,
|
||||||
|
Price DECIMAL(12,2) NOT NULL,
|
||||||
|
PriceValidFrom DATE NOT NULL,
|
||||||
|
PriceValidTo DATE NULL,
|
||||||
|
FechaCreacion DATETIME2(3) NOT NULL
|
||||||
|
CONSTRAINT DF_ProductPrices_FechaCreacion DEFAULT(SYSUTCDATETIME()),
|
||||||
|
CONSTRAINT FK_ProductPrices_Product
|
||||||
|
FOREIGN KEY (ProductId) REFERENCES dbo.Product(Id) ON DELETE NO ACTION,
|
||||||
|
CONSTRAINT CK_ProductPrices_Price_Positive
|
||||||
|
CHECK (Price > 0),
|
||||||
|
CONSTRAINT CK_ProductPrices_ValidRange
|
||||||
|
CHECK (PriceValidTo IS NULL OR PriceValidTo >= PriceValidFrom)
|
||||||
|
);
|
||||||
|
PRINT 'Table dbo.ProductPrices created.';
|
||||||
|
END
|
||||||
|
ELSE
|
||||||
|
PRINT 'Table dbo.ProductPrices already exists — skip.';
|
||||||
|
GO
|
||||||
|
|
||||||
|
-- ═══════════════════════════════════════════════════════════════════════
|
||||||
|
-- 2. SYSTEM_VERSIONING — ProductPrices
|
||||||
|
-- Las hidden cols se llaman SysStartTime/SysEndTime para evitar
|
||||||
|
-- colisión con las business cols PriceValidFrom/PriceValidTo (D1).
|
||||||
|
-- ═══════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
IF COL_LENGTH('dbo.ProductPrices', 'SysStartTime') IS NULL
|
||||||
|
BEGIN
|
||||||
|
ALTER TABLE dbo.ProductPrices
|
||||||
|
ADD
|
||||||
|
SysStartTime DATETIME2(3) GENERATED ALWAYS AS ROW START HIDDEN NOT NULL
|
||||||
|
CONSTRAINT DF_ProductPrices_SysStartTime DEFAULT(SYSUTCDATETIME()),
|
||||||
|
SysEndTime DATETIME2(3) GENERATED ALWAYS AS ROW END HIDDEN NOT NULL
|
||||||
|
CONSTRAINT DF_ProductPrices_SysEndTime DEFAULT(CONVERT(DATETIME2(3),'9999-12-31 23:59:59.999')),
|
||||||
|
PERIOD FOR SYSTEM_TIME (SysStartTime, SysEndTime);
|
||||||
|
PRINT 'ProductPrices: PERIOD FOR SYSTEM_TIME added (SysStartTime/SysEndTime).';
|
||||||
|
END
|
||||||
|
GO
|
||||||
|
|
||||||
|
IF NOT EXISTS (SELECT 1 FROM sys.tables WHERE object_id = OBJECT_ID('dbo.ProductPrices') AND temporal_type = 2)
|
||||||
|
BEGIN
|
||||||
|
ALTER TABLE dbo.ProductPrices
|
||||||
|
SET (SYSTEM_VERSIONING = ON (
|
||||||
|
HISTORY_TABLE = dbo.ProductPrices_History,
|
||||||
|
HISTORY_RETENTION_PERIOD = 10 YEARS
|
||||||
|
));
|
||||||
|
PRINT 'ProductPrices: SYSTEM_VERSIONING = ON (history: dbo.ProductPrices_History, retention: 10 years).';
|
||||||
|
END
|
||||||
|
ELSE
|
||||||
|
PRINT 'ProductPrices: SYSTEM_VERSIONING already ON — skip.';
|
||||||
|
GO
|
||||||
|
|
||||||
|
IF EXISTS (SELECT 1 FROM sys.tables WHERE name = 'ProductPrices_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 = 'ProductPrices_History' AND p.data_compression = 2
|
||||||
|
)
|
||||||
|
BEGIN
|
||||||
|
ALTER TABLE dbo.ProductPrices_History REBUILD WITH (DATA_COMPRESSION = PAGE);
|
||||||
|
PRINT 'ProductPrices_History: rebuilt with PAGE compression.';
|
||||||
|
END
|
||||||
|
GO
|
||||||
|
|
||||||
|
-- ═══════════════════════════════════════════════════════════════════════
|
||||||
|
-- 3. Índices
|
||||||
|
-- ═══════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
-- Un único activo por producto (imposibilita violar a nivel BD).
|
||||||
|
IF NOT EXISTS (SELECT 1 FROM sys.indexes WHERE name = 'UX_ProductPrices_Active' AND object_id = OBJECT_ID('dbo.ProductPrices'))
|
||||||
|
BEGIN
|
||||||
|
CREATE UNIQUE INDEX UX_ProductPrices_Active
|
||||||
|
ON dbo.ProductPrices (ProductId)
|
||||||
|
WHERE PriceValidTo IS NULL;
|
||||||
|
PRINT 'Index UX_ProductPrices_Active created.';
|
||||||
|
END
|
||||||
|
GO
|
||||||
|
|
||||||
|
-- Cover para GetPriceAt / GetByProductIdAsync (ProductId + PriceValidFrom con INCLUDEs).
|
||||||
|
IF NOT EXISTS (SELECT 1 FROM sys.indexes WHERE name = 'IX_ProductPrices_Lookup' AND object_id = OBJECT_ID('dbo.ProductPrices'))
|
||||||
|
BEGIN
|
||||||
|
CREATE INDEX IX_ProductPrices_Lookup
|
||||||
|
ON dbo.ProductPrices (ProductId, PriceValidFrom DESC)
|
||||||
|
INCLUDE (Price, PriceValidTo);
|
||||||
|
PRINT 'Index IX_ProductPrices_Lookup created.';
|
||||||
|
END
|
||||||
|
GO
|
||||||
|
|
||||||
|
-- ═══════════════════════════════════════════════════════════════════════
|
||||||
|
-- 4. SP — dbo.usp_AddProductPrice
|
||||||
|
-- Cierre atómico forward-only: SERIALIZABLE + UPDLOCK + HOLDLOCK.
|
||||||
|
-- Params de salida: @NewId (BIGINT), @ClosedId (BIGINT — NULL si primer precio).
|
||||||
|
-- ═══════════════════════════════════════════════════════════════════════
|
||||||
|
GO
|
||||||
|
CREATE OR ALTER PROCEDURE dbo.usp_AddProductPrice
|
||||||
|
@ProductId INT,
|
||||||
|
@Price DECIMAL(12,2),
|
||||||
|
@PriceValidFrom DATE,
|
||||||
|
@NewId BIGINT OUTPUT,
|
||||||
|
@ClosedId BIGINT OUTPUT
|
||||||
|
AS
|
||||||
|
BEGIN
|
||||||
|
SET NOCOUNT ON;
|
||||||
|
SET XACT_ABORT ON;
|
||||||
|
SET TRANSACTION ISOLATION LEVEL SERIALIZABLE;
|
||||||
|
|
||||||
|
BEGIN TRY
|
||||||
|
BEGIN TRANSACTION;
|
||||||
|
|
||||||
|
-- Validación: producto debe existir y estar activo.
|
||||||
|
IF NOT EXISTS (SELECT 1 FROM dbo.Product WITH (NOLOCK) WHERE Id = @ProductId AND IsActive = 1)
|
||||||
|
BEGIN
|
||||||
|
ROLLBACK;
|
||||||
|
THROW 50404, 'Product not found or inactive', 1;
|
||||||
|
END
|
||||||
|
|
||||||
|
-- Lee activo con UPDLOCK + HOLDLOCK — bloquea el range key del filtered index.
|
||||||
|
DECLARE @ActiveId BIGINT, @ActivePVF DATE;
|
||||||
|
SELECT TOP 1
|
||||||
|
@ActiveId = Id,
|
||||||
|
@ActivePVF = PriceValidFrom
|
||||||
|
FROM dbo.ProductPrices WITH (UPDLOCK, HOLDLOCK, ROWLOCK)
|
||||||
|
WHERE ProductId = @ProductId AND PriceValidTo IS NULL;
|
||||||
|
|
||||||
|
-- Forward-only estricto: el nuevo PVF debe ser ESTRICTAMENTE mayor al activo.
|
||||||
|
IF @ActiveId IS NOT NULL AND @PriceValidFrom <= @ActivePVF
|
||||||
|
BEGIN
|
||||||
|
ROLLBACK;
|
||||||
|
THROW 50409, 'ProductPriceForwardOnly: new PriceValidFrom must be > active.PriceValidFrom', 1;
|
||||||
|
END
|
||||||
|
|
||||||
|
-- Cierra el activo previo: PVT = PVF(nuevo) - 1 día.
|
||||||
|
IF @ActiveId IS NOT NULL
|
||||||
|
BEGIN
|
||||||
|
UPDATE dbo.ProductPrices
|
||||||
|
SET PriceValidTo = DATEADD(DAY, -1, @PriceValidFrom)
|
||||||
|
WHERE Id = @ActiveId;
|
||||||
|
SET @ClosedId = @ActiveId;
|
||||||
|
END
|
||||||
|
ELSE
|
||||||
|
SET @ClosedId = NULL;
|
||||||
|
|
||||||
|
-- Inserta el nuevo activo.
|
||||||
|
INSERT INTO dbo.ProductPrices (ProductId, Price, PriceValidFrom, PriceValidTo)
|
||||||
|
VALUES (@ProductId, @Price, @PriceValidFrom, NULL);
|
||||||
|
SET @NewId = SCOPE_IDENTITY();
|
||||||
|
|
||||||
|
COMMIT TRANSACTION;
|
||||||
|
END TRY
|
||||||
|
BEGIN CATCH
|
||||||
|
IF XACT_STATE() <> 0 ROLLBACK TRANSACTION;
|
||||||
|
THROW;
|
||||||
|
END CATCH
|
||||||
|
END
|
||||||
|
GO
|
||||||
|
|
||||||
|
PRINT '';
|
||||||
|
PRINT 'V019 applied — dbo.ProductPrices (temporal, retention 10y) + UX_ProductPrices_Active + IX_ProductPrices_Lookup + usp_AddProductPrice.';
|
||||||
|
PRINT 'Next migration: V020 (TBD).';
|
||||||
|
GO
|
||||||
@@ -0,0 +1,360 @@
|
|||||||
|
using Dapper;
|
||||||
|
using FluentAssertions;
|
||||||
|
using Microsoft.Data.SqlClient;
|
||||||
|
using SIGCM2.TestSupport;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace SIGCM2.Application.Tests.Products.Repository;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// PRD-003 — RED integration tests for dbo.ProductPrices + dbo.usp_AddProductPrice.
|
||||||
|
/// These tests run directly against SIGCM2_Test_App via SqlTestFixture (Respawn).
|
||||||
|
/// They are intentionally RED until V019 migration is applied and ProductPriceRepository is implemented.
|
||||||
|
///
|
||||||
|
/// Spec coverage:
|
||||||
|
/// §REQ-1.1 — Happy path: first price, no active to close
|
||||||
|
/// §REQ-1.1 — Happy path: closes previous active on new price
|
||||||
|
/// §REQ-1.2 — Concurrency: only one winner, loser gets 409 or unique violation
|
||||||
|
/// §REQ-2.2 — ForwardOnly: retroactive date → SQL 50409
|
||||||
|
/// §REQ-2.3 — ForwardOnly: equal PriceValidFrom → SQL 50409
|
||||||
|
/// §REQ-3.3 — Inactive product → SQL 50404
|
||||||
|
/// SYSTEM_VERSIONING: UPDATE to close active produces history row
|
||||||
|
/// Filtered unique index: duplicate active → SQL error 2601/2627
|
||||||
|
/// </summary>
|
||||||
|
[Collection("Database")]
|
||||||
|
public class ProductPriceRepositoryIntegrationTests : IAsyncLifetime
|
||||||
|
{
|
||||||
|
private readonly SqlTestFixture _db;
|
||||||
|
private int _defaultProductId;
|
||||||
|
private int _defaultMedioId;
|
||||||
|
private int _defaultProductTypeId;
|
||||||
|
|
||||||
|
public ProductPriceRepositoryIntegrationTests(SqlTestFixture db)
|
||||||
|
{
|
||||||
|
_db = db;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task InitializeAsync()
|
||||||
|
{
|
||||||
|
await _db.ResetAndSeedAsync();
|
||||||
|
|
||||||
|
await using var conn = new SqlConnection(TestConnectionStrings.AppTestDb);
|
||||||
|
await conn.OpenAsync();
|
||||||
|
|
||||||
|
// Ensure V019 schema is present (table + SP + indexes).
|
||||||
|
await EnsureV019SchemaAsync(conn);
|
||||||
|
|
||||||
|
// Create a Medio, ProductType, and an active Product for use in all tests.
|
||||||
|
_defaultMedioId = await conn.ExecuteScalarAsync<int>("""
|
||||||
|
INSERT INTO dbo.Medio (Codigo, Nombre, Tipo, Activo)
|
||||||
|
OUTPUT INSERTED.Id
|
||||||
|
VALUES ('PP', 'Medio ProductPrices', 1, 1)
|
||||||
|
""");
|
||||||
|
|
||||||
|
_defaultProductTypeId = await conn.ExecuteScalarAsync<int>("""
|
||||||
|
INSERT INTO dbo.ProductType (Nombre, HasDuration, RequiresText, RequiresCategory, IsBundle, AllowImages)
|
||||||
|
OUTPUT INSERTED.Id
|
||||||
|
VALUES ('Tipo PP', 0, 0, 0, 0, 0)
|
||||||
|
""");
|
||||||
|
|
||||||
|
_defaultProductId = await conn.ExecuteScalarAsync<int>("""
|
||||||
|
INSERT INTO dbo.Product (Nombre, MedioId, ProductTypeId, RubroId, BasePrice, PriceDurationDays, IsActive)
|
||||||
|
OUTPUT INSERTED.Id
|
||||||
|
VALUES ('Producto Precios Test', @MedioId, @ProductTypeId, NULL, 100.00, NULL, 1)
|
||||||
|
""", new { MedioId = _defaultMedioId, ProductTypeId = _defaultProductTypeId });
|
||||||
|
}
|
||||||
|
|
||||||
|
public Task DisposeAsync() => Task.CompletedTask;
|
||||||
|
|
||||||
|
// ── Helper: invoke SP directly ────────────────────────────────────────────
|
||||||
|
|
||||||
|
private static async Task<(long NewId, long? ClosedId)> ExecAddPriceSpAsync(
|
||||||
|
SqlConnection conn,
|
||||||
|
int productId,
|
||||||
|
decimal price,
|
||||||
|
DateOnly priceValidFrom)
|
||||||
|
{
|
||||||
|
var p = new DynamicParameters();
|
||||||
|
p.Add("@ProductId", productId);
|
||||||
|
p.Add("@Price", price);
|
||||||
|
p.Add("@PriceValidFrom", priceValidFrom.ToDateTime(TimeOnly.MinValue), System.Data.DbType.Date);
|
||||||
|
p.Add("@NewId", dbType: System.Data.DbType.Int64, direction: System.Data.ParameterDirection.Output);
|
||||||
|
p.Add("@ClosedId", dbType: System.Data.DbType.Int64, direction: System.Data.ParameterDirection.Output);
|
||||||
|
|
||||||
|
await conn.ExecuteAsync("dbo.usp_AddProductPrice", p,
|
||||||
|
commandType: System.Data.CommandType.StoredProcedure);
|
||||||
|
|
||||||
|
var newId = p.Get<long>("@NewId");
|
||||||
|
var closedId = p.Get<long?>("@ClosedId");
|
||||||
|
return (newId, closedId);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────
|
||||||
|
// §REQ-1.1 — Primer precio: ClosedId es null
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task FirstPrice_NoActiveToClose_ClosedIdIsNull()
|
||||||
|
{
|
||||||
|
await using var conn = new SqlConnection(TestConnectionStrings.AppTestDb);
|
||||||
|
await conn.OpenAsync();
|
||||||
|
|
||||||
|
var pvf = new DateOnly(2026, 4, 19);
|
||||||
|
var (newId, closedId) = await ExecAddPriceSpAsync(conn, _defaultProductId, 150.00m, pvf);
|
||||||
|
|
||||||
|
newId.Should().BeGreaterThan(0);
|
||||||
|
closedId.Should().BeNull();
|
||||||
|
|
||||||
|
var row = await conn.QuerySingleOrDefaultAsync<dynamic>(
|
||||||
|
"SELECT Id, ProductId, Price, PriceValidFrom, PriceValidTo FROM dbo.ProductPrices WHERE Id = @Id",
|
||||||
|
new { Id = newId });
|
||||||
|
|
||||||
|
((object?)row).Should().NotBeNull();
|
||||||
|
((int)row!.ProductId).Should().Be(_defaultProductId);
|
||||||
|
((decimal)row.Price).Should().Be(150.00m);
|
||||||
|
((DateTime)row.PriceValidFrom).Should().Be(new DateTime(2026, 4, 19));
|
||||||
|
((object?)row.PriceValidTo).Should().BeNull();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────
|
||||||
|
// §REQ-1.1 — Happy path: cierra activo, inserta nuevo
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task HappyPath_ClosesActivePriceAndInsertsNew()
|
||||||
|
{
|
||||||
|
await using var conn = new SqlConnection(TestConnectionStrings.AppTestDb);
|
||||||
|
await conn.OpenAsync();
|
||||||
|
|
||||||
|
// Primera inserción
|
||||||
|
var (firstId, _) = await ExecAddPriceSpAsync(conn, _defaultProductId, 100.00m, new DateOnly(2026, 4, 1));
|
||||||
|
|
||||||
|
// Segunda inserción (forward)
|
||||||
|
var pvf2 = new DateOnly(2026, 4, 20);
|
||||||
|
var (newId, closedId) = await ExecAddPriceSpAsync(conn, _defaultProductId, 200.00m, pvf2);
|
||||||
|
|
||||||
|
// El nuevo es activo (PVT = NULL)
|
||||||
|
var newRow = await conn.QuerySingleAsync<dynamic>(
|
||||||
|
"SELECT PriceValidTo FROM dbo.ProductPrices WHERE Id = @Id", new { Id = newId });
|
||||||
|
((object?)newRow.PriceValidTo).Should().BeNull();
|
||||||
|
|
||||||
|
// El anterior está cerrado con PVT = pvf2 - 1 día
|
||||||
|
closedId.Should().Be(firstId);
|
||||||
|
var closedRow = await conn.QuerySingleAsync<dynamic>(
|
||||||
|
"SELECT PriceValidTo FROM dbo.ProductPrices WHERE Id = @Id", new { Id = firstId });
|
||||||
|
((DateTime)closedRow.PriceValidTo).Should().Be(new DateTime(2026, 4, 19));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────
|
||||||
|
// §REQ-2.2 — ForwardOnly: fecha retroactiva → THROW 50409
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task ForwardOnly_RetroactiveDate_ThrowsSql50409()
|
||||||
|
{
|
||||||
|
await using var conn = new SqlConnection(TestConnectionStrings.AppTestDb);
|
||||||
|
await conn.OpenAsync();
|
||||||
|
|
||||||
|
// Precio activo desde 2026-04-01
|
||||||
|
await ExecAddPriceSpAsync(conn, _defaultProductId, 100.00m, new DateOnly(2026, 4, 1));
|
||||||
|
|
||||||
|
// Intento retroactivo
|
||||||
|
var act = async () => await ExecAddPriceSpAsync(conn, _defaultProductId, 90.00m, new DateOnly(2026, 3, 15));
|
||||||
|
|
||||||
|
await act.Should().ThrowAsync<SqlException>()
|
||||||
|
.Where(ex => ex.Number == 50409);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────
|
||||||
|
// §REQ-2.3 — ForwardOnly: misma fecha → THROW 50409
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task ForwardOnly_EqualPriceValidFrom_ThrowsSql50409()
|
||||||
|
{
|
||||||
|
await using var conn = new SqlConnection(TestConnectionStrings.AppTestDb);
|
||||||
|
await conn.OpenAsync();
|
||||||
|
|
||||||
|
var pvf = new DateOnly(2026, 4, 19);
|
||||||
|
await ExecAddPriceSpAsync(conn, _defaultProductId, 100.00m, pvf);
|
||||||
|
|
||||||
|
var act = async () => await ExecAddPriceSpAsync(conn, _defaultProductId, 120.00m, pvf);
|
||||||
|
|
||||||
|
await act.Should().ThrowAsync<SqlException>()
|
||||||
|
.Where(ex => ex.Number == 50409);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────
|
||||||
|
// §REQ-3.3 — Producto inactivo → THROW 50404
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task InactiveProduct_ThrowsSql50404()
|
||||||
|
{
|
||||||
|
await using var conn = new SqlConnection(TestConnectionStrings.AppTestDb);
|
||||||
|
await conn.OpenAsync();
|
||||||
|
|
||||||
|
// Desactivar el producto
|
||||||
|
await conn.ExecuteAsync(
|
||||||
|
"UPDATE dbo.Product SET IsActive = 0 WHERE Id = @Id",
|
||||||
|
new { Id = _defaultProductId });
|
||||||
|
|
||||||
|
var act = async () => await ExecAddPriceSpAsync(conn, _defaultProductId, 100.00m, new DateOnly(2026, 4, 19));
|
||||||
|
|
||||||
|
await act.Should().ThrowAsync<SqlException>()
|
||||||
|
.Where(ex => ex.Number == 50404);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────
|
||||||
|
// §REQ-3.3 — Producto inexistente → THROW 50404
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task NonExistentProduct_ThrowsSql50404()
|
||||||
|
{
|
||||||
|
await using var conn = new SqlConnection(TestConnectionStrings.AppTestDb);
|
||||||
|
await conn.OpenAsync();
|
||||||
|
|
||||||
|
var act = async () => await ExecAddPriceSpAsync(conn, 999999, 100.00m, new DateOnly(2026, 4, 19));
|
||||||
|
|
||||||
|
await act.Should().ThrowAsync<SqlException>()
|
||||||
|
.Where(ex => ex.Number == 50404);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────
|
||||||
|
// §REQ-1.2 — Concurrencia: solo un ganador, el perdedor lanza excepción
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Concurrency_OnlyOneSucceeds_OneThrows()
|
||||||
|
{
|
||||||
|
// Ambas conexiones intentan agregar precio al mismo producto simultáneamente.
|
||||||
|
// Con SERIALIZABLE + UPDLOCK, exactamente una debe tener éxito.
|
||||||
|
var pvf = new DateOnly(2026, 5, 1);
|
||||||
|
|
||||||
|
var task1 = Task.Run(async () =>
|
||||||
|
{
|
||||||
|
await using var conn1 = new SqlConnection(TestConnectionStrings.AppTestDb);
|
||||||
|
await conn1.OpenAsync();
|
||||||
|
return await ExecAddPriceSpAsync(conn1, _defaultProductId, 111.00m, pvf);
|
||||||
|
});
|
||||||
|
|
||||||
|
var task2 = Task.Run(async () =>
|
||||||
|
{
|
||||||
|
await using var conn2 = new SqlConnection(TestConnectionStrings.AppTestDb);
|
||||||
|
await conn2.OpenAsync();
|
||||||
|
return await ExecAddPriceSpAsync(conn2, _defaultProductId, 222.00m, pvf);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Exactamente una debe lanzar (50409, 2601, 2627, o deadlock 1205)
|
||||||
|
Exception? caughtEx = null;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await Task.WhenAll(task1, task2);
|
||||||
|
}
|
||||||
|
catch (SqlException ex)
|
||||||
|
{
|
||||||
|
caughtEx = ex;
|
||||||
|
}
|
||||||
|
catch (AggregateException aex) when (aex.InnerExceptions.All(e => e is SqlException))
|
||||||
|
{
|
||||||
|
caughtEx = aex;
|
||||||
|
}
|
||||||
|
|
||||||
|
caughtEx.Should().NotBeNull("one concurrent insert must fail");
|
||||||
|
|
||||||
|
// Exactamente 1 activo debe existir (PriceValidTo IS NULL)
|
||||||
|
await using var verifyConn = new SqlConnection(TestConnectionStrings.AppTestDb);
|
||||||
|
await verifyConn.OpenAsync();
|
||||||
|
var activeCount = await verifyConn.ExecuteScalarAsync<int>(
|
||||||
|
"SELECT COUNT(1) FROM dbo.ProductPrices WHERE ProductId = @ProductId AND PriceValidTo IS NULL",
|
||||||
|
new { ProductId = _defaultProductId });
|
||||||
|
activeCount.Should().Be(1, "only one active price must survive concurrency");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────
|
||||||
|
// SYSTEM_VERSIONING: UPDATE del activo produce row en history
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task SystemVersioning_UpdateOnClose_ProducesHistoryRow()
|
||||||
|
{
|
||||||
|
await using var conn = new SqlConnection(TestConnectionStrings.AppTestDb);
|
||||||
|
await conn.OpenAsync();
|
||||||
|
|
||||||
|
var (firstId, _) = await ExecAddPriceSpAsync(conn, _defaultProductId, 100.00m, new DateOnly(2026, 4, 1));
|
||||||
|
|
||||||
|
// Cierra el activo con una segunda inserción
|
||||||
|
await ExecAddPriceSpAsync(conn, _defaultProductId, 200.00m, new DateOnly(2026, 4, 20));
|
||||||
|
|
||||||
|
// dbo.ProductPrices_History debe tener al menos 1 row para el ID cerrado
|
||||||
|
var histCount = await conn.ExecuteScalarAsync<int>(
|
||||||
|
"SELECT COUNT(1) FROM dbo.ProductPrices_History WHERE Id = @Id",
|
||||||
|
new { Id = firstId });
|
||||||
|
|
||||||
|
histCount.Should().BeGreaterThanOrEqualTo(1,
|
||||||
|
"SYSTEM_VERSIONING must produce a history row when the active price is closed");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────
|
||||||
|
// Filtered unique index: two simultaneous direct INSERTs → 2601/2627
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task FilteredUniqueIndex_DirectDuplicateActiveInsert_ThrowsUniqueViolation()
|
||||||
|
{
|
||||||
|
await using var conn = new SqlConnection(TestConnectionStrings.AppTestDb);
|
||||||
|
await conn.OpenAsync();
|
||||||
|
|
||||||
|
// Insert an active price directly (bypassing SP to force a duplicate)
|
||||||
|
const string insertSql = """
|
||||||
|
INSERT INTO dbo.ProductPrices (ProductId, Price, PriceValidFrom, PriceValidTo)
|
||||||
|
VALUES (@ProductId, @Price, @PriceValidFrom, NULL)
|
||||||
|
""";
|
||||||
|
|
||||||
|
await conn.ExecuteAsync(insertSql, new
|
||||||
|
{
|
||||||
|
ProductId = _defaultProductId,
|
||||||
|
Price = 100m,
|
||||||
|
PriceValidFrom = new DateTime(2026, 4, 1)
|
||||||
|
});
|
||||||
|
|
||||||
|
var act = async () => await conn.ExecuteAsync(insertSql, new
|
||||||
|
{
|
||||||
|
ProductId = _defaultProductId,
|
||||||
|
Price = 150m,
|
||||||
|
PriceValidFrom = new DateTime(2026, 4, 10)
|
||||||
|
});
|
||||||
|
|
||||||
|
await act.Should().ThrowAsync<SqlException>()
|
||||||
|
.Where(ex => ex.Number == 2601 || ex.Number == 2627,
|
||||||
|
"filtered unique index UX_ProductPrices_Active must prevent two NULL PriceValidTo for same ProductId");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────
|
||||||
|
// Schema setup: ensures V019 objects exist in SIGCM2_Test_App
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
private static async Task EnsureV019SchemaAsync(SqlConnection conn)
|
||||||
|
{
|
||||||
|
// This will fail (RED) until V019__create_product_prices.sql is applied.
|
||||||
|
// Once the migration file is created and applied, all tests above become GREEN.
|
||||||
|
var tableExists = await conn.ExecuteScalarAsync<int>("""
|
||||||
|
SELECT COUNT(1) FROM sys.tables
|
||||||
|
WHERE object_id = OBJECT_ID(N'dbo.ProductPrices', N'U')
|
||||||
|
""");
|
||||||
|
|
||||||
|
if (tableExists == 0)
|
||||||
|
throw new InvalidOperationException(
|
||||||
|
"dbo.ProductPrices does not exist. Apply V019__create_product_prices.sql to SIGCM2_Test_App first.");
|
||||||
|
|
||||||
|
var spExists = await conn.ExecuteScalarAsync<int>("""
|
||||||
|
SELECT COUNT(1) FROM sys.objects
|
||||||
|
WHERE object_id = OBJECT_ID(N'dbo.usp_AddProductPrice', N'P')
|
||||||
|
""");
|
||||||
|
|
||||||
|
if (spExists == 0)
|
||||||
|
throw new InvalidOperationException(
|
||||||
|
"dbo.usp_AddProductPrice does not exist. Apply V019__create_product_prices.sql to SIGCM2_Test_App first.");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -66,6 +66,9 @@ public sealed class SqlTestFixture : IAsyncLifetime
|
|||||||
// V018 (PRD-002): ensure dbo.Product + temporal + permiso 'catalogo:productos:gestionar'.
|
// V018 (PRD-002): ensure dbo.Product + temporal + permiso 'catalogo:productos:gestionar'.
|
||||||
await EnsureV018SchemaAsync();
|
await EnsureV018SchemaAsync();
|
||||||
|
|
||||||
|
// V019 (PRD-003): ensure dbo.ProductPrices + temporal + SP usp_AddProductPrice.
|
||||||
|
await EnsureV019SchemaAsync();
|
||||||
|
|
||||||
_respawner = await Respawner.CreateAsync(_connection, new RespawnerOptions
|
_respawner = await Respawner.CreateAsync(_connection, new RespawnerOptions
|
||||||
{
|
{
|
||||||
DbAdapter = DbAdapter.SqlServer,
|
DbAdapter = DbAdapter.SqlServer,
|
||||||
@@ -96,6 +99,8 @@ public sealed class SqlTestFixture : IAsyncLifetime
|
|||||||
new Respawn.Graph.Table("dbo", "ProductType_History"),
|
new Respawn.Graph.Table("dbo", "ProductType_History"),
|
||||||
// PRD-002 (V018): Product es temporal — history no puede deletearse directo.
|
// PRD-002 (V018): Product es temporal — history no puede deletearse directo.
|
||||||
new Respawn.Graph.Table("dbo", "Product_History"),
|
new Respawn.Graph.Table("dbo", "Product_History"),
|
||||||
|
// PRD-003 (V019): ProductPrices es temporal — history protegida por SYSTEM_VERSIONING.
|
||||||
|
new Respawn.Graph.Table("dbo", "ProductPrices_History"),
|
||||||
]
|
]
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -1122,4 +1127,144 @@ public sealed class SqlTestFixture : IAsyncLifetime
|
|||||||
await _connection.ExecuteAsync(createMedioIdx);
|
await _connection.ExecuteAsync(createMedioIdx);
|
||||||
await _connection.ExecuteAsync(createRubroIdx);
|
await _connection.ExecuteAsync(createRubroIdx);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// PRD-003 (V019): applies dbo.ProductPrices + SYSTEM_VERSIONING + indexes + SP usp_AddProductPrice
|
||||||
|
/// idempotently to the test database. Mirrors V019__create_product_prices.sql.
|
||||||
|
/// </summary>
|
||||||
|
public async Task EnsureV019SchemaAsync()
|
||||||
|
{
|
||||||
|
const string createTable = """
|
||||||
|
IF OBJECT_ID(N'dbo.ProductPrices', N'U') IS NULL
|
||||||
|
BEGIN
|
||||||
|
CREATE TABLE dbo.ProductPrices (
|
||||||
|
Id BIGINT IDENTITY(1,1) NOT NULL
|
||||||
|
CONSTRAINT PK_ProductPrices PRIMARY KEY,
|
||||||
|
ProductId INT NOT NULL,
|
||||||
|
Price DECIMAL(12,2) NOT NULL,
|
||||||
|
PriceValidFrom DATE NOT NULL,
|
||||||
|
PriceValidTo DATE NULL,
|
||||||
|
FechaCreacion DATETIME2(3) NOT NULL
|
||||||
|
CONSTRAINT DF_ProductPrices_FechaCreacion DEFAULT(SYSUTCDATETIME()),
|
||||||
|
CONSTRAINT FK_ProductPrices_Product
|
||||||
|
FOREIGN KEY (ProductId) REFERENCES dbo.Product(Id) ON DELETE NO ACTION,
|
||||||
|
CONSTRAINT CK_ProductPrices_Price_Positive
|
||||||
|
CHECK (Price > 0),
|
||||||
|
CONSTRAINT CK_ProductPrices_ValidRange
|
||||||
|
CHECK (PriceValidTo IS NULL OR PriceValidTo >= PriceValidFrom)
|
||||||
|
);
|
||||||
|
END
|
||||||
|
""";
|
||||||
|
|
||||||
|
const string addPeriod = """
|
||||||
|
IF COL_LENGTH('dbo.ProductPrices', 'SysStartTime') IS NULL
|
||||||
|
BEGIN
|
||||||
|
ALTER TABLE dbo.ProductPrices
|
||||||
|
ADD
|
||||||
|
SysStartTime DATETIME2(3) GENERATED ALWAYS AS ROW START HIDDEN NOT NULL
|
||||||
|
CONSTRAINT DF_ProductPrices_SysStartTime DEFAULT(SYSUTCDATETIME()),
|
||||||
|
SysEndTime DATETIME2(3) GENERATED ALWAYS AS ROW END HIDDEN NOT NULL
|
||||||
|
CONSTRAINT DF_ProductPrices_SysEndTime DEFAULT(CONVERT(DATETIME2(3),'9999-12-31 23:59:59.999')),
|
||||||
|
PERIOD FOR SYSTEM_TIME (SysStartTime, SysEndTime);
|
||||||
|
END
|
||||||
|
""";
|
||||||
|
|
||||||
|
const string setVersioning = """
|
||||||
|
IF NOT EXISTS (SELECT 1 FROM sys.tables WHERE object_id = OBJECT_ID('dbo.ProductPrices') AND temporal_type = 2)
|
||||||
|
BEGIN
|
||||||
|
ALTER TABLE dbo.ProductPrices
|
||||||
|
SET (SYSTEM_VERSIONING = ON (
|
||||||
|
HISTORY_TABLE = dbo.ProductPrices_History,
|
||||||
|
HISTORY_RETENTION_PERIOD = 10 YEARS
|
||||||
|
));
|
||||||
|
END
|
||||||
|
""";
|
||||||
|
|
||||||
|
const string createActiveIndex = """
|
||||||
|
IF NOT EXISTS (SELECT 1 FROM sys.indexes WHERE name = 'UX_ProductPrices_Active' AND object_id = OBJECT_ID('dbo.ProductPrices'))
|
||||||
|
BEGIN
|
||||||
|
CREATE UNIQUE INDEX UX_ProductPrices_Active
|
||||||
|
ON dbo.ProductPrices (ProductId)
|
||||||
|
WHERE PriceValidTo IS NULL;
|
||||||
|
END
|
||||||
|
""";
|
||||||
|
|
||||||
|
const string createLookupIndex = """
|
||||||
|
IF NOT EXISTS (SELECT 1 FROM sys.indexes WHERE name = 'IX_ProductPrices_Lookup' AND object_id = OBJECT_ID('dbo.ProductPrices'))
|
||||||
|
BEGIN
|
||||||
|
CREATE INDEX IX_ProductPrices_Lookup
|
||||||
|
ON dbo.ProductPrices (ProductId, PriceValidFrom DESC)
|
||||||
|
INCLUDE (Price, PriceValidTo);
|
||||||
|
END
|
||||||
|
""";
|
||||||
|
|
||||||
|
const string createSp = """
|
||||||
|
IF OBJECT_ID(N'dbo.usp_AddProductPrice', N'P') IS NULL
|
||||||
|
EXEC('CREATE PROCEDURE dbo.usp_AddProductPrice AS RETURN 0');
|
||||||
|
""";
|
||||||
|
|
||||||
|
const string alterSp = """
|
||||||
|
ALTER PROCEDURE dbo.usp_AddProductPrice
|
||||||
|
@ProductId INT,
|
||||||
|
@Price DECIMAL(12,2),
|
||||||
|
@PriceValidFrom DATE,
|
||||||
|
@NewId BIGINT OUTPUT,
|
||||||
|
@ClosedId BIGINT OUTPUT
|
||||||
|
AS
|
||||||
|
BEGIN
|
||||||
|
SET NOCOUNT ON;
|
||||||
|
SET XACT_ABORT ON;
|
||||||
|
SET TRANSACTION ISOLATION LEVEL SERIALIZABLE;
|
||||||
|
|
||||||
|
BEGIN TRY
|
||||||
|
BEGIN TRANSACTION;
|
||||||
|
|
||||||
|
IF NOT EXISTS (SELECT 1 FROM dbo.Product WITH (NOLOCK) WHERE Id=@ProductId AND IsActive=1)
|
||||||
|
BEGIN
|
||||||
|
ROLLBACK;
|
||||||
|
THROW 50404, 'Product not found or inactive', 1;
|
||||||
|
END
|
||||||
|
|
||||||
|
DECLARE @ActiveId BIGINT, @ActivePVF DATE;
|
||||||
|
SELECT TOP 1 @ActiveId = Id, @ActivePVF = PriceValidFrom
|
||||||
|
FROM dbo.ProductPrices WITH (UPDLOCK, HOLDLOCK, ROWLOCK)
|
||||||
|
WHERE ProductId = @ProductId AND PriceValidTo IS NULL;
|
||||||
|
|
||||||
|
IF @ActiveId IS NOT NULL AND @PriceValidFrom <= @ActivePVF
|
||||||
|
BEGIN
|
||||||
|
ROLLBACK;
|
||||||
|
THROW 50409, 'ProductPriceForwardOnly: new PriceValidFrom must be > active.PriceValidFrom', 1;
|
||||||
|
END
|
||||||
|
|
||||||
|
IF @ActiveId IS NOT NULL
|
||||||
|
BEGIN
|
||||||
|
UPDATE dbo.ProductPrices
|
||||||
|
SET PriceValidTo = DATEADD(DAY, -1, @PriceValidFrom)
|
||||||
|
WHERE Id = @ActiveId;
|
||||||
|
SET @ClosedId = @ActiveId;
|
||||||
|
END
|
||||||
|
ELSE
|
||||||
|
SET @ClosedId = NULL;
|
||||||
|
|
||||||
|
INSERT INTO dbo.ProductPrices (ProductId, Price, PriceValidFrom, PriceValidTo)
|
||||||
|
VALUES (@ProductId, @Price, @PriceValidFrom, NULL);
|
||||||
|
SET @NewId = SCOPE_IDENTITY();
|
||||||
|
|
||||||
|
COMMIT TRANSACTION;
|
||||||
|
END TRY
|
||||||
|
BEGIN CATCH
|
||||||
|
IF XACT_STATE() <> 0 ROLLBACK TRANSACTION;
|
||||||
|
THROW;
|
||||||
|
END CATCH
|
||||||
|
END
|
||||||
|
""";
|
||||||
|
|
||||||
|
await _connection.ExecuteAsync(createTable);
|
||||||
|
await _connection.ExecuteAsync(addPeriod);
|
||||||
|
await _connection.ExecuteAsync(setVersioning);
|
||||||
|
await _connection.ExecuteAsync(createActiveIndex);
|
||||||
|
await _connection.ExecuteAsync(createLookupIndex);
|
||||||
|
await _connection.ExecuteAsync(createSp);
|
||||||
|
await _connection.ExecuteAsync(alterSp);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user