Compare commits
12 Commits
main
...
f5ed9c4b3c
| Author | SHA1 | Date | |
|---|---|---|---|
| f5ed9c4b3c | |||
| d49d2f7536 | |||
| 443380d1d1 | |||
| f8d861a25a | |||
| f6733acfbb | |||
| ff7c28789e | |||
| cc3108dfdb | |||
| b1be4a5573 | |||
| d4c05cc364 | |||
| 4c9b7eabaf | |||
| 4a88cb4319 | |||
| d3ed8300f0 |
21
README.md
21
README.md
@@ -73,27 +73,6 @@ dotnet test tests/SIGCM2.Api.Tests # integration (requiere SIGCM2_
|
|||||||
cd src/web && npx vitest run
|
cd src/web && npx vitest run
|
||||||
```
|
```
|
||||||
|
|
||||||
### Coverage (backend)
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Generar reporte de coverage en formato Cobertura
|
|
||||||
dotnet test --collect:"XPlat Code Coverage" --settings coverlet.runsettings --results-directory ./TestResults
|
|
||||||
```
|
|
||||||
|
|
||||||
El comando genera un `coverage.cobertura.xml` por cada proyecto de test en `./TestResults/`.
|
|
||||||
|
|
||||||
Para convertirlo a HTML:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Instalar ReportGenerator (solo la primera vez)
|
|
||||||
dotnet tool install -g dotnet-reportgenerator-globaltool
|
|
||||||
|
|
||||||
# Generar reporte HTML
|
|
||||||
reportgenerator -reports:"./TestResults/**/coverage.cobertura.xml" -targetdir:"./coverage-report" -reporttypes:Html
|
|
||||||
```
|
|
||||||
|
|
||||||
Abrí `./coverage-report/index.html` en el browser para ver el detalle por archivo.
|
|
||||||
|
|
||||||
## Convenciones
|
## Convenciones
|
||||||
|
|
||||||
- Ramas: `feature/UDT-XXX` desde `main`.
|
- Ramas: `feature/UDT-XXX` desde `main`.
|
||||||
|
|||||||
@@ -1,48 +0,0 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
|
||||||
<RunSettings>
|
|
||||||
<!--
|
|
||||||
Configuracion de coverage con coverlet.collector.
|
|
||||||
Uso: dotnet test /collect:"XPlat Code Coverage" /settings:coverlet.runsettings /results-directory:./TestResults
|
|
||||||
-->
|
|
||||||
<RunConfiguration>
|
|
||||||
<!-- Mantener ejecución secuencial (hereda política de tests.runsettings) -->
|
|
||||||
<MaxCpuCount>1</MaxCpuCount>
|
|
||||||
</RunConfiguration>
|
|
||||||
|
|
||||||
<DataCollectionRunSettings>
|
|
||||||
<DataCollectors>
|
|
||||||
<DataCollector friendlyName="XPlat Code Coverage">
|
|
||||||
<Configuration>
|
|
||||||
<!-- Formato de salida: cobertura (compatible con ReportGenerator y CI/CD) -->
|
|
||||||
<Format>cobertura</Format>
|
|
||||||
|
|
||||||
<!-- Exclusiones por atributo generado -->
|
|
||||||
<ExcludeByAttribute>GeneratedCodeAttribute,ExcludeFromCodeCoverageAttribute</ExcludeByAttribute>
|
|
||||||
|
|
||||||
<!-- Exclusiones por tipo/namespace -->
|
|
||||||
<Exclude>
|
|
||||||
<!-- Migrations embebidas (SQL scripts, no lógica de negocio) -->
|
|
||||||
[*.Migrations]*,
|
|
||||||
<!-- Los proyectos de test no se miden a sí mismos -->
|
|
||||||
[*.Tests]*,
|
|
||||||
[SIGCM2.TestSupport]*,
|
|
||||||
<!-- Program.cs: host wiring, no testeable unitariamente -->
|
|
||||||
[SIGCM2.Api]Program,
|
|
||||||
<!-- Extension methods de DI: una línea por registro, ruido sin valor -->
|
|
||||||
[*]*.Extensions.*Extensions,
|
|
||||||
[*]*.DependencyInjection
|
|
||||||
</Exclude>
|
|
||||||
|
|
||||||
<!-- No medir las propiedades auto-implementadas -->
|
|
||||||
<SkipAutoProps>true</SkipAutoProps>
|
|
||||||
|
|
||||||
<!-- No incluir el assembly de tests en el reporte -->
|
|
||||||
<IncludeTestAssembly>false</IncludeTestAssembly>
|
|
||||||
|
|
||||||
<!-- Permitir timestamps reales en el reporte (no forzar determinismo) -->
|
|
||||||
<DeterministicReport>false</DeterministicReport>
|
|
||||||
</Configuration>
|
|
||||||
</DataCollector>
|
|
||||||
</DataCollectors>
|
|
||||||
</DataCollectionRunSettings>
|
|
||||||
</RunSettings>
|
|
||||||
@@ -33,15 +33,6 @@ database/
|
|||||||
| V014 | `V014__create_tablas_fiscales.sql` | ADM-009 | TiposDeIva + IngresosBrutos (versioning por cadena) + permisos fiscales |
|
| V014 | `V014__create_tablas_fiscales.sql` | ADM-009 | TiposDeIva + IngresosBrutos (versioning por cadena) + permisos fiscales |
|
||||||
| V015 | `V015__create_local_timezone_views.sql` | UDT-011 | Vistas admin con OccurredAt convertido a hora Argentina |
|
| V015 | `V015__create_local_timezone_views.sql` | UDT-011 | Vistas admin con OccurredAt convertido a hora Argentina |
|
||||||
| **V016** | **`V016__create_rubro.sql`** | **CAT-001** | **Rubro (adjacency list, temporal 10y) + permiso `catalogo:rubros:gestionar`** |
|
| **V016** | **`V016__create_rubro.sql`** | **CAT-001** | **Rubro (adjacency list, temporal 10y) + permiso `catalogo:rubros:gestionar`** |
|
||||||
| **V017** | **`V017__create_product_type.sql`** | **PRD-001** | **ProductType (flags + multimedia limits, temporal 10y) + permiso `catalogo:tipos:gestionar`** |
|
|
||||||
| V018 | `V018__create_product.sql` | PRD-002 | Product (temporal 10y) + permiso `catalogo:productos:gestionar` + índices |
|
|
||||||
| V019 | `V019__create_product_prices.sql` | PRD-003 | ProductPrices (temporal 10y, forward-only) + SP `sp_ProductPrices_InsertWithClose` + permiso implícito |
|
|
||||||
| V020 | `V020__add_chargeable_chars_permission.sql` | PRC-001 | Permiso `tasacion:caracteres_especiales:gestionar` + asignación a admin |
|
|
||||||
| V021 | `V021__create_chargeable_char_config.sql` | PRC-001 | ChargeableCharConfig + ChargeableCharConfig_History (temporal 10y) + 2 SPs (`InsertWithClose`, `GetActiveForProductType`) + 2 índices |
|
|
||||||
| V022 | `V022__seed_chargeable_char_config.sql` | PRC-001 | Seed 4 filas globales (`$`, `%`, `!`, `¡`) con PricePerUnit=1.0000 |
|
|
||||||
| V023 | `V023__refactor_chargeable_char_config_to_product_type.sql` | PRC-001 (scope delta) | Refactor MedioId→ProductTypeId + nuevo SP `ReactivateWithGuard` + CK_Price_NonNegative (>= 0) |
|
|
||||||
| V024 | `V024__reseed_global_with_zero_price.sql` | PRC-001 (scope delta) | Reseed 4 globales a PricePerUnit=0.0000 (opt-in billing) |
|
|
||||||
| V025 | `V025__seed_chargeable_char_overrides_demo.sql` | PRC-001 (followup #54) | Seed demo de overrides ficticios per-ProductType (Clasificado/Notables/Fúnebres). Idempotente: no-op si los tipos no existen |
|
|
||||||
|
|
||||||
## Convenciones
|
## Convenciones
|
||||||
|
|
||||||
@@ -49,24 +40,23 @@ database/
|
|||||||
- **Idempotentes**: cada migración usa `IF NOT EXISTS` / `MERGE` / `IF NOT EXISTS (SELECT 1 FROM sys....)`. Re-ejecutarlas es seguro.
|
- **Idempotentes**: cada migración usa `IF NOT EXISTS` / `MERGE` / `IF NOT EXISTS (SELECT 1 FROM sys....)`. Re-ejecutarlas es seguro.
|
||||||
- **Boilerplate** inicial: `SET QUOTED_IDENTIFIER ON; SET ANSI_NULLS ON; SET NOCOUNT ON; GO`.
|
- **Boilerplate** inicial: `SET QUOTED_IDENTIFIER ON; SET ANSI_NULLS ON; SET NOCOUNT ON; GO`.
|
||||||
- **Naming**: `PK_*`, `UQ_*`, `FK_*`, `DF_*`, `CK_*`, `IX_*`.
|
- **Naming**: `PK_*`, `UQ_*`, `FK_*`, `DF_*`, `CK_*`, `IX_*`.
|
||||||
- **Se aplican a TRES bases**: `SIGCM2` (dev), `SIGCM2_Test_App` (Application.Tests) y `SIGCM2_Test_Api` (Api.Tests). El orden debe ser idéntico en las tres.
|
- **Se aplican a AMBAS bases**: `SIGCM2` (dev) y `SIGCM2_Test` (integration tests). El orden debe ser idéntico.
|
||||||
|
|
||||||
## Cómo aplicar migraciones
|
## Cómo aplicar migraciones
|
||||||
|
|
||||||
### En dev (manual)
|
### En dev (manual)
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Con sqlcmd (aplicar a las tres bases en orden):
|
# Con sqlcmd:
|
||||||
sqlcmd -S TECNICA3 -d SIGCM2 -U desarrollo -P desarrollo2026 -i database/migrations/V010__audit_infrastructure.sql
|
sqlcmd -S TECNICA3 -d SIGCM2 -U desarrollo -P desarrollo2026 -i database/migrations/V010__audit_infrastructure.sql
|
||||||
sqlcmd -S TECNICA3 -d SIGCM2_Test_App -U desarrollo -P desarrollo2026 -i database/migrations/V010__audit_infrastructure.sql
|
sqlcmd -S TECNICA3 -d SIGCM2_Test -U desarrollo -P desarrollo2026 -i database/migrations/V010__audit_infrastructure.sql
|
||||||
sqlcmd -S TECNICA3 -d SIGCM2_Test_Api -U desarrollo -P desarrollo2026 -i database/migrations/V010__audit_infrastructure.sql
|
|
||||||
```
|
```
|
||||||
|
|
||||||
O desde SSMS: abrir el archivo, conectar a cada base, F5.
|
O desde SSMS: abrir el archivo, conectar a cada base, F5.
|
||||||
|
|
||||||
### En integration tests
|
### En integration tests
|
||||||
|
|
||||||
`tests/SIGCM2.TestSupport/SqlTestFixture.cs` aplica automáticamente el schema necesario en `SIGCM2_Test_App` al inicializar el fixture (`EnsureV008SchemaAsync`, `EnsureV009SchemaAsync`, `EnsureV010SchemaAsync`…). `TestWebAppFactory` hace lo mismo contra `SIGCM2_Test_Api`. **NO** hace falta correr los scripts manualmente si el fixture ya lo cubre.
|
`tests/SIGCM2.TestSupport/SqlTestFixture.cs` aplica automáticamente el schema necesario en `SIGCM2_Test` al inicializar el fixture (`EnsureV008SchemaAsync`, `EnsureV009SchemaAsync`, `EnsureV010SchemaAsync`…). **NO** hace falta correr el script manualmente.
|
||||||
|
|
||||||
### En producción (roadmap futuro)
|
### En producción (roadmap futuro)
|
||||||
|
|
||||||
@@ -104,22 +94,6 @@ O desde SSMS: abrir el archivo, conectar a cada base, F5.
|
|||||||
- `Seccion.Tipo` acepta `'clasificados' | 'notables' | 'suplementos'` (CHECK constraint). Config avanzada (par/impar, suplementos, cupos) se introduce en ADM-003.
|
- `Seccion.Tipo` acepta `'clasificados' | 'notables' | 'suplementos'` (CHECK constraint). Config avanzada (par/impar, suplementos, cupos) se introduce en ADM-003.
|
||||||
- Rollback: `V012_ROLLBACK.sql` (falla si hay Secciones vivas) → `V011_ROLLBACK.sql`. Rollback en prod NO soportado si ADM-008/009 o PRC-* ya aplicados.
|
- Rollback: `V012_ROLLBACK.sql` (falla si hay Secciones vivas) → `V011_ROLLBACK.sql`. Rollback en prod NO soportado si ADM-008/009 o PRC-* ya aplicados.
|
||||||
|
|
||||||
## Bases de datos de integration tests
|
|
||||||
|
|
||||||
| Base | Propósito | Usada por |
|
|
||||||
|---|---|---|
|
|
||||||
| `SIGCM2_Test_App` | Tests de repositorios y Application layer | `SIGCM2.Application.Tests` vía `SqlTestFixture` (parameterless ctor) |
|
|
||||||
| `SIGCM2_Test_Api` | Tests de endpoints HTTP / WebApplicationFactory | `SIGCM2.Api.Tests` vía `TestWebAppFactory` |
|
|
||||||
|
|
||||||
**Script de creación inicial** (idempotente): `database/init/create-test-api-db.sql`
|
|
||||||
|
|
||||||
Ambas bases deben tener **todas las migraciones V001–V015** aplicadas en orden. Al crear una base nueva o al agregar un desarrollador:
|
|
||||||
1. Crear las bases con `create-test-api-db.sql`
|
|
||||||
2. Aplicar V001–V015 en orden (ver tabla de arriba) contra cada base de test
|
|
||||||
3. Las `EnsureV0XX` del fixture validan presencia; no aplican migraciones pesadas
|
|
||||||
|
|
||||||
Fuente única de connection strings: `tests/SIGCM2.TestSupport/TestConnectionStrings.cs`
|
|
||||||
|
|
||||||
## Recursos
|
## Recursos
|
||||||
|
|
||||||
- Design autoritativo: `Obsidian/02-ARQUITECTURA-y-TECH-STACK/2.5 📋 Auditoría.md`
|
- Design autoritativo: `Obsidian/02-ARQUITECTURA-y-TECH-STACK/2.5 📋 Auditoría.md`
|
||||||
|
|||||||
@@ -1,30 +0,0 @@
|
|||||||
-- create-test-api-db.sql
|
|
||||||
-- Creates test databases for integration tests (idempotent).
|
|
||||||
-- Run once per environment on TECNICA3 before executing integration tests.
|
|
||||||
--
|
|
||||||
-- SIGCM2_Test_App -> used by SIGCM2.Application.Tests
|
|
||||||
-- SIGCM2_Test_Api -> used by SIGCM2.Api.Tests
|
|
||||||
-- SIGCM2_Test -> legacy (kept for old branches e.g. pre-merge CAT-001)
|
|
||||||
--
|
|
||||||
-- After creating the DBs, apply V010 to both new DBs:
|
|
||||||
-- See database/README.md > "Test DBs" section for the PowerShell runbook.
|
|
||||||
|
|
||||||
IF DB_ID(N'SIGCM2_Test_App') IS NULL
|
|
||||||
BEGIN
|
|
||||||
CREATE DATABASE [SIGCM2_Test_App]
|
|
||||||
COLLATE Modern_Spanish_CI_AS;
|
|
||||||
PRINT 'Database SIGCM2_Test_App created.';
|
|
||||||
END
|
|
||||||
ELSE
|
|
||||||
PRINT 'Database SIGCM2_Test_App already exists -- skip.';
|
|
||||||
GO
|
|
||||||
|
|
||||||
IF DB_ID(N'SIGCM2_Test_Api') IS NULL
|
|
||||||
BEGIN
|
|
||||||
CREATE DATABASE [SIGCM2_Test_Api]
|
|
||||||
COLLATE Modern_Spanish_CI_AS;
|
|
||||||
PRINT 'Database SIGCM2_Test_Api created.';
|
|
||||||
END
|
|
||||||
ELSE
|
|
||||||
PRINT 'Database SIGCM2_Test_Api already exists -- skip.';
|
|
||||||
GO
|
|
||||||
@@ -1,71 +0,0 @@
|
|||||||
-- V017_ROLLBACK.sql
|
|
||||||
-- Reversa de V017__create_product_type.sql.
|
|
||||||
-- PRD-001: ProductType rollback.
|
|
||||||
--
|
|
||||||
-- ADVERTENCIA: Si PRD-002 ya fue mergeado (IProductQueryRepository real), hacer rollback
|
|
||||||
-- de PRD-002 primero (la interfaz es removida por esta rollback).
|
|
||||||
--
|
|
||||||
-- Idempotente: cada paso usa IF EXISTS guards.
|
|
||||||
|
|
||||||
SET QUOTED_IDENTIFIER ON;
|
|
||||||
SET ANSI_NULLS ON;
|
|
||||||
SET NOCOUNT ON;
|
|
||||||
GO
|
|
||||||
|
|
||||||
-- 1. Desactivar SYSTEM_VERSIONING
|
|
||||||
IF EXISTS (SELECT 1 FROM sys.tables WHERE object_id = OBJECT_ID('dbo.ProductType') AND temporal_type = 2)
|
|
||||||
BEGIN
|
|
||||||
ALTER TABLE dbo.ProductType SET (SYSTEM_VERSIONING = OFF);
|
|
||||||
PRINT 'ProductType: SYSTEM_VERSIONING = OFF.';
|
|
||||||
END
|
|
||||||
GO
|
|
||||||
|
|
||||||
-- 2. Remover PERIOD FOR SYSTEM_TIME
|
|
||||||
IF EXISTS (SELECT 1 FROM sys.periods WHERE object_id = OBJECT_ID('dbo.ProductType'))
|
|
||||||
BEGIN
|
|
||||||
ALTER TABLE dbo.ProductType DROP PERIOD FOR SYSTEM_TIME;
|
|
||||||
PRINT 'ProductType: PERIOD FOR SYSTEM_TIME dropped.';
|
|
||||||
END
|
|
||||||
GO
|
|
||||||
|
|
||||||
-- 3. Remover columnas HIDDEN + default constraints
|
|
||||||
IF COL_LENGTH('dbo.ProductType', 'ValidFrom') IS NOT NULL
|
|
||||||
BEGIN
|
|
||||||
ALTER TABLE dbo.ProductType DROP CONSTRAINT IF EXISTS DF_ProductType_ValidFrom;
|
|
||||||
ALTER TABLE dbo.ProductType DROP CONSTRAINT IF EXISTS DF_ProductType_ValidTo;
|
|
||||||
ALTER TABLE dbo.ProductType DROP COLUMN ValidFrom, ValidTo;
|
|
||||||
PRINT 'ProductType: ValidFrom/ValidTo columns dropped.';
|
|
||||||
END
|
|
||||||
GO
|
|
||||||
|
|
||||||
-- 4. Drop history table
|
|
||||||
IF OBJECT_ID(N'dbo.ProductType_History', N'U') IS NOT NULL
|
|
||||||
BEGIN
|
|
||||||
DROP TABLE dbo.ProductType_History;
|
|
||||||
PRINT 'Table dbo.ProductType_History dropped.';
|
|
||||||
END
|
|
||||||
GO
|
|
||||||
|
|
||||||
-- 5. Drop main table
|
|
||||||
IF OBJECT_ID(N'dbo.ProductType', N'U') IS NOT NULL
|
|
||||||
BEGIN
|
|
||||||
DROP TABLE dbo.ProductType;
|
|
||||||
PRINT 'Table dbo.ProductType dropped.';
|
|
||||||
END
|
|
||||||
GO
|
|
||||||
|
|
||||||
-- 6. Remover RolPermiso para catalogo:tipos:gestionar
|
|
||||||
DELETE rp FROM dbo.RolPermiso rp
|
|
||||||
JOIN dbo.Permiso p ON p.Id = rp.PermisoId
|
|
||||||
WHERE p.Codigo = 'catalogo:tipos:gestionar';
|
|
||||||
PRINT 'RolPermiso rows for catalogo:tipos:gestionar deleted.';
|
|
||||||
GO
|
|
||||||
|
|
||||||
-- 7. Remover Permiso
|
|
||||||
DELETE FROM dbo.Permiso WHERE Codigo = 'catalogo:tipos:gestionar';
|
|
||||||
PRINT 'Permiso catalogo:tipos:gestionar deleted.';
|
|
||||||
GO
|
|
||||||
|
|
||||||
PRINT '';
|
|
||||||
PRINT 'V017 rolled back successfully.';
|
|
||||||
GO
|
|
||||||
@@ -1,158 +0,0 @@
|
|||||||
-- V017__create_product_type.sql
|
|
||||||
-- PRD-001: ProductType — tipología dinámica de productos con flags de comportamiento + límites multimedia.
|
|
||||||
--
|
|
||||||
-- Cambios:
|
|
||||||
-- 1. dbo.ProductType (flags + multimedia limits, SYSTEM_VERSIONING ON, retention 10 años).
|
|
||||||
-- 2. Índice filtrado unique UQ_ProductType_Nombre_Activo (unicidad CI entre activos).
|
|
||||||
-- 3. Índice cubriente IX_ProductType_IsActive_Cover.
|
|
||||||
-- 4. Permiso 'catalogo:tipos:gestionar' + asignación a rol 'admin'.
|
|
||||||
--
|
|
||||||
-- Patrón: V016 (dbo.Rubro con SYSTEM_VERSIONING + PAGE compression + MERGE permisos).
|
|
||||||
-- Idempotente: seguro para re-ejecutar.
|
|
||||||
-- Reversa: V017_ROLLBACK.sql.
|
|
||||||
-- Run on: SIGCM2 (dev) y SIGCM2_Test (integration tests).
|
|
||||||
--
|
|
||||||
-- Notas:
|
|
||||||
-- - SIN seed de datos — PRD-008 (V018) seedea los 12 tipos legacy.
|
|
||||||
-- - SIN FK desde dbo.Product — PRD-002 agrega ALTER TABLE con FK.
|
|
||||||
-- - Invariante aplicada en Application: si AllowImages=0, los 4 campos multimedia son NULL (handler normaliza).
|
|
||||||
-- - MaxImages/MaxImageSizeMB/MaxImageWidth/MaxImageHeight: NULL = sin límite; >=1 = tope (validator rechaza <=0).
|
|
||||||
-- - Desviación del UDT: "0 = ilimitado" → usamos NULL (convención canónica). Ver PRD-001 archive-report.
|
|
||||||
--
|
|
||||||
-- SDD Design: engram sdd/prd-001-product-type-flags-multimedia/design
|
|
||||||
|
|
||||||
SET QUOTED_IDENTIFIER ON;
|
|
||||||
SET ANSI_NULLS ON;
|
|
||||||
SET NOCOUNT ON;
|
|
||||||
GO
|
|
||||||
|
|
||||||
-- ═══════════════════════════════════════════════════════════════════════
|
|
||||||
-- 1. dbo.ProductType
|
|
||||||
-- ═══════════════════════════════════════════════════════════════════════
|
|
||||||
|
|
||||||
IF OBJECT_ID(N'dbo.ProductType', N'U') IS NULL
|
|
||||||
BEGIN
|
|
||||||
CREATE TABLE dbo.ProductType (
|
|
||||||
Id INT IDENTITY(1,1) NOT NULL CONSTRAINT PK_ProductType PRIMARY KEY,
|
|
||||||
Nombre NVARCHAR(200) COLLATE SQL_Latin1_General_CP1_CI_AI NOT NULL,
|
|
||||||
|
|
||||||
-- Flags de comportamiento
|
|
||||||
HasDuration BIT NOT NULL CONSTRAINT DF_ProductType_HasDuration DEFAULT(0),
|
|
||||||
RequiresText BIT NOT NULL CONSTRAINT DF_ProductType_RequiresText DEFAULT(0),
|
|
||||||
RequiresCategory BIT NOT NULL CONSTRAINT DF_ProductType_RequiresCategory DEFAULT(0),
|
|
||||||
IsBundle BIT NOT NULL CONSTRAINT DF_ProductType_IsBundle DEFAULT(0),
|
|
||||||
|
|
||||||
-- Multimedia (AllowImages=0 => handler normaliza los 4 siguientes a NULL)
|
|
||||||
AllowImages BIT NOT NULL CONSTRAINT DF_ProductType_AllowImages DEFAULT(0),
|
|
||||||
MaxImages INT NULL, -- NULL = sin límite; >=1 tope (validator rechaza <=0)
|
|
||||||
MaxImageSizeMB DECIMAL(10,2) NULL, -- NULL = sin límite; DECIMAL(10,2) permite 0.5 MB, 2.75 MB
|
|
||||||
MaxImageWidth INT NULL, -- NULL = sin límite; >=1 px
|
|
||||||
MaxImageHeight INT NULL, -- NULL = sin límite; >=1 px
|
|
||||||
|
|
||||||
-- Lifecycle
|
|
||||||
IsActive BIT NOT NULL CONSTRAINT DF_ProductType_IsActive DEFAULT(1),
|
|
||||||
FechaCreacion DATETIME2(3) NOT NULL CONSTRAINT DF_ProductType_FechaCreacion DEFAULT(SYSUTCDATETIME()),
|
|
||||||
FechaModificacion DATETIME2(3) NULL
|
|
||||||
);
|
|
||||||
PRINT 'Table dbo.ProductType created.';
|
|
||||||
END
|
|
||||||
ELSE
|
|
||||||
PRINT 'Table dbo.ProductType already exists — skip.';
|
|
||||||
GO
|
|
||||||
|
|
||||||
-- ═══════════════════════════════════════════════════════════════════════
|
|
||||||
-- 2. SYSTEM_VERSIONING — ProductType
|
|
||||||
-- ═══════════════════════════════════════════════════════════════════════
|
|
||||||
|
|
||||||
IF COL_LENGTH('dbo.ProductType', 'ValidFrom') IS NULL
|
|
||||||
BEGIN
|
|
||||||
ALTER TABLE dbo.ProductType
|
|
||||||
ADD
|
|
||||||
ValidFrom DATETIME2(3) GENERATED ALWAYS AS ROW START HIDDEN NOT NULL
|
|
||||||
CONSTRAINT DF_ProductType_ValidFrom DEFAULT(SYSUTCDATETIME()),
|
|
||||||
ValidTo DATETIME2(3) GENERATED ALWAYS AS ROW END HIDDEN NOT NULL
|
|
||||||
CONSTRAINT DF_ProductType_ValidTo DEFAULT(CONVERT(DATETIME2(3), '9999-12-31 23:59:59.999')),
|
|
||||||
PERIOD FOR SYSTEM_TIME (ValidFrom, ValidTo);
|
|
||||||
PRINT 'ProductType: PERIOD FOR SYSTEM_TIME added.';
|
|
||||||
END
|
|
||||||
GO
|
|
||||||
|
|
||||||
IF NOT EXISTS (SELECT 1 FROM sys.tables WHERE object_id = OBJECT_ID('dbo.ProductType') AND temporal_type = 2)
|
|
||||||
BEGIN
|
|
||||||
ALTER TABLE dbo.ProductType
|
|
||||||
SET (SYSTEM_VERSIONING = ON (
|
|
||||||
HISTORY_TABLE = dbo.ProductType_History,
|
|
||||||
HISTORY_RETENTION_PERIOD = 10 YEARS
|
|
||||||
));
|
|
||||||
PRINT 'ProductType: SYSTEM_VERSIONING = ON (history: dbo.ProductType_History, retention: 10 years).';
|
|
||||||
END
|
|
||||||
ELSE
|
|
||||||
PRINT 'ProductType: SYSTEM_VERSIONING already ON — skip.';
|
|
||||||
GO
|
|
||||||
|
|
||||||
IF EXISTS (SELECT 1 FROM sys.tables WHERE name = 'ProductType_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 = 'ProductType_History' AND p.data_compression = 2
|
|
||||||
)
|
|
||||||
BEGIN
|
|
||||||
ALTER TABLE dbo.ProductType_History REBUILD WITH (DATA_COMPRESSION = PAGE);
|
|
||||||
PRINT 'ProductType_History: rebuilt with PAGE compression.';
|
|
||||||
END
|
|
||||||
GO
|
|
||||||
|
|
||||||
-- ═══════════════════════════════════════════════════════════════════════
|
|
||||||
-- 3. Índices
|
|
||||||
-- ═══════════════════════════════════════════════════════════════════════
|
|
||||||
|
|
||||||
IF NOT EXISTS (SELECT 1 FROM sys.indexes WHERE name = 'UQ_ProductType_Nombre_Activo' AND object_id = OBJECT_ID('dbo.ProductType'))
|
|
||||||
BEGIN
|
|
||||||
CREATE UNIQUE INDEX UQ_ProductType_Nombre_Activo
|
|
||||||
ON dbo.ProductType(Nombre)
|
|
||||||
WHERE IsActive = 1;
|
|
||||||
PRINT 'Index UQ_ProductType_Nombre_Activo created.';
|
|
||||||
END
|
|
||||||
GO
|
|
||||||
|
|
||||||
IF NOT EXISTS (SELECT 1 FROM sys.indexes WHERE name = 'IX_ProductType_IsActive_Cover' AND object_id = OBJECT_ID('dbo.ProductType'))
|
|
||||||
BEGIN
|
|
||||||
CREATE INDEX IX_ProductType_IsActive_Cover
|
|
||||||
ON dbo.ProductType(IsActive)
|
|
||||||
INCLUDE (Nombre, HasDuration, RequiresText, RequiresCategory, IsBundle, AllowImages);
|
|
||||||
PRINT 'Index IX_ProductType_IsActive_Cover created.';
|
|
||||||
END
|
|
||||||
GO
|
|
||||||
|
|
||||||
-- ═══════════════════════════════════════════════════════════════════════
|
|
||||||
-- 4. Permiso: catalogo:tipos:gestionar + asignación a rol 'admin'
|
|
||||||
-- ═══════════════════════════════════════════════════════════════════════
|
|
||||||
|
|
||||||
MERGE dbo.Permiso AS t
|
|
||||||
USING (VALUES
|
|
||||||
('catalogo:tipos:gestionar',
|
|
||||||
N'Gestionar tipos de producto',
|
|
||||||
N'Crear, editar y desactivar ProductTypes del catálogo (flags + límites multimedia)',
|
|
||||||
'catalogo')
|
|
||||||
) 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', 'catalogo:tipos: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
|
|
||||||
|
|
||||||
PRINT '';
|
|
||||||
PRINT 'V017 applied — dbo.ProductType (temporal, retention 10y) + permiso catalogo:tipos:gestionar.';
|
|
||||||
PRINT 'Next: V018 (PRD-008 — seed 12 tipos legacy).';
|
|
||||||
GO
|
|
||||||
@@ -1,67 +0,0 @@
|
|||||||
-- V018_ROLLBACK.sql
|
|
||||||
-- Reversa de V018__create_product.sql — PRD-002.
|
|
||||||
--
|
|
||||||
-- Idempotente: cada paso usa IF EXISTS guards.
|
|
||||||
-- ADVERTENCIA: Ejecutar antes de V017_ROLLBACK (FK desde Product hacia ProductType).
|
|
||||||
|
|
||||||
SET QUOTED_IDENTIFIER ON;
|
|
||||||
SET ANSI_NULLS ON;
|
|
||||||
SET NOCOUNT ON;
|
|
||||||
GO
|
|
||||||
|
|
||||||
-- 1. SYSTEM_VERSIONING OFF
|
|
||||||
IF EXISTS (SELECT 1 FROM sys.tables WHERE object_id = OBJECT_ID('dbo.Product') AND temporal_type = 2)
|
|
||||||
BEGIN
|
|
||||||
ALTER TABLE dbo.Product SET (SYSTEM_VERSIONING = OFF);
|
|
||||||
PRINT 'Product: SYSTEM_VERSIONING = OFF.';
|
|
||||||
END
|
|
||||||
GO
|
|
||||||
|
|
||||||
-- 2. DROP PERIOD
|
|
||||||
IF EXISTS (SELECT 1 FROM sys.periods WHERE object_id = OBJECT_ID('dbo.Product'))
|
|
||||||
BEGIN
|
|
||||||
ALTER TABLE dbo.Product DROP PERIOD FOR SYSTEM_TIME;
|
|
||||||
PRINT 'Product: PERIOD FOR SYSTEM_TIME dropped.';
|
|
||||||
END
|
|
||||||
GO
|
|
||||||
|
|
||||||
-- 3. Drop HIDDEN columns + default constraints
|
|
||||||
IF COL_LENGTH('dbo.Product', 'ValidFrom') IS NOT NULL
|
|
||||||
BEGIN
|
|
||||||
ALTER TABLE dbo.Product DROP CONSTRAINT IF EXISTS DF_Product_ValidFrom;
|
|
||||||
ALTER TABLE dbo.Product DROP CONSTRAINT IF EXISTS DF_Product_ValidTo;
|
|
||||||
ALTER TABLE dbo.Product DROP COLUMN ValidFrom, ValidTo;
|
|
||||||
PRINT 'Product: ValidFrom/ValidTo columns dropped.';
|
|
||||||
END
|
|
||||||
GO
|
|
||||||
|
|
||||||
-- 4. Drop history
|
|
||||||
IF OBJECT_ID(N'dbo.Product_History', N'U') IS NOT NULL
|
|
||||||
BEGIN
|
|
||||||
DROP TABLE dbo.Product_History;
|
|
||||||
PRINT 'Table dbo.Product_History dropped.';
|
|
||||||
END
|
|
||||||
GO
|
|
||||||
|
|
||||||
-- 5. Drop main
|
|
||||||
IF OBJECT_ID(N'dbo.Product', N'U') IS NOT NULL
|
|
||||||
BEGIN
|
|
||||||
DROP TABLE dbo.Product;
|
|
||||||
PRINT 'Table dbo.Product dropped.';
|
|
||||||
END
|
|
||||||
GO
|
|
||||||
|
|
||||||
-- 6. Remove RolPermiso / Permiso
|
|
||||||
DELETE rp FROM dbo.RolPermiso rp
|
|
||||||
JOIN dbo.Permiso p ON p.Id = rp.PermisoId
|
|
||||||
WHERE p.Codigo = 'catalogo:productos:gestionar';
|
|
||||||
PRINT 'RolPermiso rows for catalogo:productos:gestionar deleted.';
|
|
||||||
GO
|
|
||||||
|
|
||||||
DELETE FROM dbo.Permiso WHERE Codigo = 'catalogo:productos:gestionar';
|
|
||||||
PRINT 'Permiso catalogo:productos:gestionar deleted.';
|
|
||||||
GO
|
|
||||||
|
|
||||||
PRINT '';
|
|
||||||
PRINT 'V018 rolled back successfully.';
|
|
||||||
GO
|
|
||||||
@@ -1,172 +0,0 @@
|
|||||||
-- V018__create_product.sql
|
|
||||||
-- PRD-002: Product — entidad vendible concreta del catálogo comercial.
|
|
||||||
--
|
|
||||||
-- Cambios:
|
|
||||||
-- 1. dbo.Product (FK Medio/ProductType/Rubro, SYSTEM_VERSIONING ON, retention 10 años).
|
|
||||||
-- 2. Índices: filtered UQ por (MedioId, ProductTypeId, Nombre) activos; cover por ProductTypeId
|
|
||||||
-- (para IProductQueryRepository); cover por MedioId; cover filtrado por RubroId.
|
|
||||||
-- 3. Permiso 'catalogo:productos:gestionar' + asignación a rol 'admin'.
|
|
||||||
--
|
|
||||||
-- Patrón: V017 (dbo.ProductType con SYSTEM_VERSIONING + PAGE compression + MERGE permisos).
|
|
||||||
-- Idempotente: seguro para re-ejecutar.
|
|
||||||
-- Reversa: V018_ROLLBACK.sql.
|
|
||||||
-- Run on: SIGCM2 (dev) y SIGCM2_Test (integration tests).
|
|
||||||
--
|
|
||||||
-- Notas:
|
|
||||||
-- - SIN seed de datos — PRD-008 (V019) seedea los 12 productos legacy.
|
|
||||||
-- - Validación de flags (RequiresCategory, HasDuration) vive en Application layer:
|
|
||||||
-- un ProductType puede cambiar flags; la Product queda en estado snapshot.
|
|
||||||
-- - UQ filtered WHERE IsActive=1: permite reusar nombres tras soft-delete.
|
|
||||||
--
|
|
||||||
-- SDD Design: engram sdd/prd-002-product-crud/design
|
|
||||||
|
|
||||||
SET QUOTED_IDENTIFIER ON;
|
|
||||||
SET ANSI_NULLS ON;
|
|
||||||
SET NOCOUNT ON;
|
|
||||||
GO
|
|
||||||
|
|
||||||
-- ═══════════════════════════════════════════════════════════════════════
|
|
||||||
-- 1. dbo.Product
|
|
||||||
-- ═══════════════════════════════════════════════════════════════════════
|
|
||||||
|
|
||||||
IF OBJECT_ID(N'dbo.Product', N'U') IS NULL
|
|
||||||
BEGIN
|
|
||||||
CREATE TABLE dbo.Product (
|
|
||||||
Id INT IDENTITY(1,1) NOT NULL CONSTRAINT PK_Product PRIMARY KEY,
|
|
||||||
Nombre NVARCHAR(300) COLLATE SQL_Latin1_General_CP1_CI_AI NOT NULL,
|
|
||||||
MedioId INT NOT NULL,
|
|
||||||
ProductTypeId INT NOT NULL,
|
|
||||||
RubroId INT NULL,
|
|
||||||
BasePrice DECIMAL(18,4) NOT NULL,
|
|
||||||
PriceDurationDays INT NULL,
|
|
||||||
IsActive BIT NOT NULL CONSTRAINT DF_Product_IsActive DEFAULT(1),
|
|
||||||
FechaCreacion DATETIME2(3) NOT NULL CONSTRAINT DF_Product_FechaCreacion DEFAULT(SYSUTCDATETIME()),
|
|
||||||
FechaModificacion DATETIME2(3) NULL,
|
|
||||||
CONSTRAINT FK_Product_Medio FOREIGN KEY (MedioId) REFERENCES dbo.Medio(Id) ON DELETE NO ACTION,
|
|
||||||
CONSTRAINT FK_Product_ProductType FOREIGN KEY (ProductTypeId) REFERENCES dbo.ProductType(Id) ON DELETE NO ACTION,
|
|
||||||
CONSTRAINT FK_Product_Rubro FOREIGN KEY (RubroId) REFERENCES dbo.Rubro(Id) ON DELETE NO ACTION,
|
|
||||||
CONSTRAINT CK_Product_BasePrice_NonNegative CHECK (BasePrice >= 0),
|
|
||||||
CONSTRAINT CK_Product_PriceDurationDays_Positive CHECK (PriceDurationDays IS NULL OR PriceDurationDays >= 1)
|
|
||||||
);
|
|
||||||
PRINT 'Table dbo.Product created.';
|
|
||||||
END
|
|
||||||
ELSE
|
|
||||||
PRINT 'Table dbo.Product already exists — skip.';
|
|
||||||
GO
|
|
||||||
|
|
||||||
-- ═══════════════════════════════════════════════════════════════════════
|
|
||||||
-- 2. SYSTEM_VERSIONING — Product
|
|
||||||
-- ═══════════════════════════════════════════════════════════════════════
|
|
||||||
|
|
||||||
IF COL_LENGTH('dbo.Product', 'ValidFrom') IS NULL
|
|
||||||
BEGIN
|
|
||||||
ALTER TABLE dbo.Product
|
|
||||||
ADD
|
|
||||||
ValidFrom DATETIME2(3) GENERATED ALWAYS AS ROW START HIDDEN NOT NULL
|
|
||||||
CONSTRAINT DF_Product_ValidFrom DEFAULT(SYSUTCDATETIME()),
|
|
||||||
ValidTo DATETIME2(3) GENERATED ALWAYS AS ROW END HIDDEN NOT NULL
|
|
||||||
CONSTRAINT DF_Product_ValidTo DEFAULT(CONVERT(DATETIME2(3), '9999-12-31 23:59:59.999')),
|
|
||||||
PERIOD FOR SYSTEM_TIME (ValidFrom, ValidTo);
|
|
||||||
PRINT 'Product: PERIOD FOR SYSTEM_TIME added.';
|
|
||||||
END
|
|
||||||
GO
|
|
||||||
|
|
||||||
IF NOT EXISTS (SELECT 1 FROM sys.tables WHERE object_id = OBJECT_ID('dbo.Product') AND temporal_type = 2)
|
|
||||||
BEGIN
|
|
||||||
ALTER TABLE dbo.Product
|
|
||||||
SET (SYSTEM_VERSIONING = ON (
|
|
||||||
HISTORY_TABLE = dbo.Product_History,
|
|
||||||
HISTORY_RETENTION_PERIOD = 10 YEARS
|
|
||||||
));
|
|
||||||
PRINT 'Product: SYSTEM_VERSIONING = ON (history: dbo.Product_History, retention: 10 years).';
|
|
||||||
END
|
|
||||||
ELSE
|
|
||||||
PRINT 'Product: SYSTEM_VERSIONING already ON — skip.';
|
|
||||||
GO
|
|
||||||
|
|
||||||
IF EXISTS (SELECT 1 FROM sys.tables WHERE name = 'Product_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 = 'Product_History' AND p.data_compression = 2
|
|
||||||
)
|
|
||||||
BEGIN
|
|
||||||
ALTER TABLE dbo.Product_History REBUILD WITH (DATA_COMPRESSION = PAGE);
|
|
||||||
PRINT 'Product_History: rebuilt with PAGE compression.';
|
|
||||||
END
|
|
||||||
GO
|
|
||||||
|
|
||||||
-- ═══════════════════════════════════════════════════════════════════════
|
|
||||||
-- 3. Índices
|
|
||||||
-- ═══════════════════════════════════════════════════════════════════════
|
|
||||||
|
|
||||||
-- Filtered UQ: unicidad activa por (Medio, Tipo, Nombre). Permite reusar nombres tras soft-delete.
|
|
||||||
IF NOT EXISTS (SELECT 1 FROM sys.indexes WHERE name = 'UQ_Product_MedioId_ProductTypeId_Nombre_Active' AND object_id = OBJECT_ID('dbo.Product'))
|
|
||||||
BEGIN
|
|
||||||
CREATE UNIQUE INDEX UQ_Product_MedioId_ProductTypeId_Nombre_Active
|
|
||||||
ON dbo.Product (MedioId, ProductTypeId, Nombre)
|
|
||||||
WHERE IsActive = 1;
|
|
||||||
PRINT 'Index UQ_Product_MedioId_ProductTypeId_Nombre_Active created.';
|
|
||||||
END
|
|
||||||
GO
|
|
||||||
|
|
||||||
-- Cover para IProductQueryRepository.ExistsActiveByProductTypeAsync
|
|
||||||
IF NOT EXISTS (SELECT 1 FROM sys.indexes WHERE name = 'IX_Product_ProductTypeId_IsActive' AND object_id = OBJECT_ID('dbo.Product'))
|
|
||||||
BEGIN
|
|
||||||
CREATE INDEX IX_Product_ProductTypeId_IsActive
|
|
||||||
ON dbo.Product (ProductTypeId, IsActive);
|
|
||||||
PRINT 'Index IX_Product_ProductTypeId_IsActive created.';
|
|
||||||
END
|
|
||||||
GO
|
|
||||||
|
|
||||||
-- Cover para list filtered by MedioId
|
|
||||||
IF NOT EXISTS (SELECT 1 FROM sys.indexes WHERE name = 'IX_Product_MedioId_IsActive' AND object_id = OBJECT_ID('dbo.Product'))
|
|
||||||
BEGIN
|
|
||||||
CREATE INDEX IX_Product_MedioId_IsActive
|
|
||||||
ON dbo.Product (MedioId, IsActive);
|
|
||||||
PRINT 'Index IX_Product_MedioId_IsActive created.';
|
|
||||||
END
|
|
||||||
GO
|
|
||||||
|
|
||||||
-- Cover para list filtered by RubroId
|
|
||||||
IF NOT EXISTS (SELECT 1 FROM sys.indexes WHERE name = 'IX_Product_RubroId_IsActive' AND object_id = OBJECT_ID('dbo.Product'))
|
|
||||||
BEGIN
|
|
||||||
CREATE INDEX IX_Product_RubroId_IsActive
|
|
||||||
ON dbo.Product (RubroId, IsActive)
|
|
||||||
WHERE RubroId IS NOT NULL;
|
|
||||||
PRINT 'Index IX_Product_RubroId_IsActive created.';
|
|
||||||
END
|
|
||||||
GO
|
|
||||||
|
|
||||||
-- ═══════════════════════════════════════════════════════════════════════
|
|
||||||
-- 4. Permiso: catalogo:productos:gestionar + asignación a rol 'admin'
|
|
||||||
-- ═══════════════════════════════════════════════════════════════════════
|
|
||||||
|
|
||||||
MERGE dbo.Permiso AS t
|
|
||||||
USING (VALUES
|
|
||||||
('catalogo:productos:gestionar',
|
|
||||||
N'Gestionar productos del catálogo',
|
|
||||||
N'Crear, editar y desactivar productos del catálogo comercial',
|
|
||||||
'catalogo')
|
|
||||||
) 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', 'catalogo:productos: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
|
|
||||||
|
|
||||||
PRINT '';
|
|
||||||
PRINT 'V018 applied — dbo.Product (temporal, retention 10y) + permiso catalogo:productos:gestionar.';
|
|
||||||
PRINT 'Next: V019 (PRD-008 — seed 12 productos legacy).';
|
|
||||||
GO
|
|
||||||
@@ -1,71 +0,0 @@
|
|||||||
-- 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
|
|
||||||
@@ -1,196 +0,0 @@
|
|||||||
-- 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
|
|
||||||
@@ -1,33 +0,0 @@
|
|||||||
-- V020_ROLLBACK.sql
|
|
||||||
-- PRC-001: Reversa de V020__add_chargeable_chars_permission.sql.
|
|
||||||
--
|
|
||||||
-- Pasos:
|
|
||||||
-- 1. Elimina la asignación del permiso al rol 'admin'.
|
|
||||||
-- 2. Elimina el permiso del catálogo.
|
|
||||||
--
|
|
||||||
-- ADVERTENCIA: si algún usuario o rol tiene este permiso asignado explícitamente,
|
|
||||||
-- la FK de RolPermiso causará error. Limpiar RolPermiso primero.
|
|
||||||
-- Run on: SIGCM2 (dev), SIGCM2_Test_App, SIGCM2_Test_Api.
|
|
||||||
|
|
||||||
SET QUOTED_IDENTIFIER ON;
|
|
||||||
SET ANSI_NULLS ON;
|
|
||||||
SET NOCOUNT ON;
|
|
||||||
GO
|
|
||||||
|
|
||||||
-- 1. Eliminar asignaciones del permiso a cualquier rol.
|
|
||||||
DELETE rp
|
|
||||||
FROM dbo.RolPermiso rp
|
|
||||||
JOIN dbo.Permiso p ON p.Id = rp.PermisoId
|
|
||||||
WHERE p.Codigo = 'tasacion:caracteres_especiales:gestionar';
|
|
||||||
PRINT 'V020 rollback: RolPermiso entries for tasacion:caracteres_especiales:gestionar removed.';
|
|
||||||
GO
|
|
||||||
|
|
||||||
-- 2. Eliminar el permiso del catálogo.
|
|
||||||
DELETE FROM dbo.Permiso
|
|
||||||
WHERE Codigo = 'tasacion:caracteres_especiales:gestionar';
|
|
||||||
PRINT 'V020 rollback: Permiso tasacion:caracteres_especiales:gestionar removed.';
|
|
||||||
GO
|
|
||||||
|
|
||||||
PRINT '';
|
|
||||||
PRINT 'V020 rollback complete.';
|
|
||||||
GO
|
|
||||||
@@ -1,54 +0,0 @@
|
|||||||
-- V020__add_chargeable_chars_permission.sql
|
|
||||||
-- PRC-001: permiso RBAC para ABM de caracteres tasables.
|
|
||||||
--
|
|
||||||
-- Cambios:
|
|
||||||
-- 1. Agrega permiso 'tasacion:caracteres_especiales:gestionar' al catálogo.
|
|
||||||
-- 2. Asigna el permiso al rol 'admin'.
|
|
||||||
--
|
|
||||||
-- Convención RBAC: modulo:recurso:accion.
|
|
||||||
-- Patrón: V007 (MERGE idempotente).
|
|
||||||
-- Idempotente: seguro para re-ejecutar.
|
|
||||||
-- Reversa: V020_ROLLBACK.sql.
|
|
||||||
-- Run on: SIGCM2 (dev), SIGCM2_Test_App, SIGCM2_Test_Api.
|
|
||||||
--
|
|
||||||
-- NOTA: V020 se ejecuta ANTES de V021 (tabla) porque el permiso debe existir
|
|
||||||
-- antes de que la API arranque con [RequirePermission(...)].
|
|
||||||
-- V021 crea la tabla dbo.ChargeableCharConfig.
|
|
||||||
-- V022 siembra las 4 filas globales por defecto.
|
|
||||||
--
|
|
||||||
-- SDD Design: engram sdd/prc-001-word-counter-spike/design (D16/D17)
|
|
||||||
|
|
||||||
SET QUOTED_IDENTIFIER ON;
|
|
||||||
SET ANSI_NULLS ON;
|
|
||||||
SET NOCOUNT ON;
|
|
||||||
GO
|
|
||||||
|
|
||||||
-- Agregar permiso al catálogo (idempotente via MERGE).
|
|
||||||
MERGE dbo.Permiso AS t
|
|
||||||
USING (VALUES
|
|
||||||
('tasacion:caracteres_especiales:gestionar',
|
|
||||||
N'Gestionar caracteres tasables',
|
|
||||||
N'Crear, editar precio y desactivar la configuración de caracteres especiales para tasación.',
|
|
||||||
'tasacion')
|
|
||||||
) 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
|
|
||||||
|
|
||||||
-- Asignar a rol 'admin' (idempotente via MERGE).
|
|
||||||
MERGE dbo.RolPermiso AS t
|
|
||||||
USING (
|
|
||||||
SELECT r.Id AS RolId, p.Id AS PermisoId
|
|
||||||
FROM dbo.Rol r
|
|
||||||
CROSS JOIN dbo.Permiso p
|
|
||||||
WHERE r.Codigo = 'admin'
|
|
||||||
AND p.Codigo = 'tasacion:caracteres_especiales:gestionar'
|
|
||||||
) 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
|
|
||||||
|
|
||||||
PRINT 'V020 applied — tasacion:caracteres_especiales:gestionar added to catalog and assigned to admin.';
|
|
||||||
GO
|
|
||||||
@@ -1,79 +0,0 @@
|
|||||||
-- V021_ROLLBACK.sql
|
|
||||||
-- PRC-001: Reversa de V021__create_chargeable_char_config.sql.
|
|
||||||
--
|
|
||||||
-- Pasos:
|
|
||||||
-- 1. Deshabilita SYSTEM_VERSIONING en dbo.ChargeableCharConfig (requerido antes de DROP TABLE).
|
|
||||||
-- 2. Elimina el PERIOD FOR SYSTEM_TIME y las columnas hidden SysStartTime/SysEndTime.
|
|
||||||
-- 3. Drop de dbo.ChargeableCharConfig_History.
|
|
||||||
-- 4. Drop de dbo.ChargeableCharConfig (constraints + índices en cascada).
|
|
||||||
-- 5. Drop de dbo.usp_ChargeableCharConfig_InsertWithClose.
|
|
||||||
-- 6. Drop de dbo.usp_ChargeableCharConfig_GetActiveForMedio.
|
|
||||||
--
|
|
||||||
-- ADVERTENCIA: destruye toda la configuración de caracteres tasables. Solo DEV/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.ChargeableCharConfig') AND temporal_type = 2)
|
|
||||||
BEGIN
|
|
||||||
ALTER TABLE dbo.ChargeableCharConfig SET (SYSTEM_VERSIONING = OFF);
|
|
||||||
PRINT 'ChargeableCharConfig: SYSTEM_VERSIONING = OFF.';
|
|
||||||
END
|
|
||||||
GO
|
|
||||||
|
|
||||||
-- 2. Elimina el PERIOD y las hidden cols.
|
|
||||||
IF COL_LENGTH('dbo.ChargeableCharConfig', 'SysStartTime') IS NOT NULL
|
|
||||||
BEGIN
|
|
||||||
ALTER TABLE dbo.ChargeableCharConfig
|
|
||||||
DROP PERIOD FOR SYSTEM_TIME;
|
|
||||||
|
|
||||||
IF EXISTS (SELECT 1 FROM sys.default_constraints WHERE name = 'DF_ChargeableCharConfig_SysStartTime')
|
|
||||||
ALTER TABLE dbo.ChargeableCharConfig DROP CONSTRAINT DF_ChargeableCharConfig_SysStartTime;
|
|
||||||
IF EXISTS (SELECT 1 FROM sys.default_constraints WHERE name = 'DF_ChargeableCharConfig_SysEndTime')
|
|
||||||
ALTER TABLE dbo.ChargeableCharConfig DROP CONSTRAINT DF_ChargeableCharConfig_SysEndTime;
|
|
||||||
|
|
||||||
ALTER TABLE dbo.ChargeableCharConfig DROP COLUMN SysStartTime;
|
|
||||||
ALTER TABLE dbo.ChargeableCharConfig DROP COLUMN SysEndTime;
|
|
||||||
PRINT 'ChargeableCharConfig: PERIOD + hidden cols dropped.';
|
|
||||||
END
|
|
||||||
GO
|
|
||||||
|
|
||||||
-- 3. Drop de la history table.
|
|
||||||
IF OBJECT_ID(N'dbo.ChargeableCharConfig_History', N'U') IS NOT NULL
|
|
||||||
BEGIN
|
|
||||||
DROP TABLE dbo.ChargeableCharConfig_History;
|
|
||||||
PRINT 'Table dbo.ChargeableCharConfig_History dropped.';
|
|
||||||
END
|
|
||||||
GO
|
|
||||||
|
|
||||||
-- 4. Drop de la tabla principal.
|
|
||||||
IF OBJECT_ID(N'dbo.ChargeableCharConfig', N'U') IS NOT NULL
|
|
||||||
BEGIN
|
|
||||||
DROP TABLE dbo.ChargeableCharConfig;
|
|
||||||
PRINT 'Table dbo.ChargeableCharConfig dropped.';
|
|
||||||
END
|
|
||||||
GO
|
|
||||||
|
|
||||||
-- 5. Drop del SP InsertWithClose.
|
|
||||||
IF OBJECT_ID(N'dbo.usp_ChargeableCharConfig_InsertWithClose', N'P') IS NOT NULL
|
|
||||||
BEGIN
|
|
||||||
DROP PROCEDURE dbo.usp_ChargeableCharConfig_InsertWithClose;
|
|
||||||
PRINT 'Procedure dbo.usp_ChargeableCharConfig_InsertWithClose dropped.';
|
|
||||||
END
|
|
||||||
GO
|
|
||||||
|
|
||||||
-- 6. Drop del SP GetActiveForMedio.
|
|
||||||
IF OBJECT_ID(N'dbo.usp_ChargeableCharConfig_GetActiveForMedio', N'P') IS NOT NULL
|
|
||||||
BEGIN
|
|
||||||
DROP PROCEDURE dbo.usp_ChargeableCharConfig_GetActiveForMedio;
|
|
||||||
PRINT 'Procedure dbo.usp_ChargeableCharConfig_GetActiveForMedio dropped.';
|
|
||||||
END
|
|
||||||
GO
|
|
||||||
|
|
||||||
PRINT '';
|
|
||||||
PRINT 'V021 rollback complete — dbo.ChargeableCharConfig, dbo.ChargeableCharConfig_History, usp_ChargeableCharConfig_InsertWithClose, usp_ChargeableCharConfig_GetActiveForMedio removed.';
|
|
||||||
GO
|
|
||||||
@@ -1,256 +0,0 @@
|
|||||||
-- V021__create_chargeable_char_config.sql
|
|
||||||
-- PRC-001: ChargeableCharConfig — configuración de caracteres especiales tasables con vigencia civil.
|
|
||||||
--
|
|
||||||
-- Cambios:
|
|
||||||
-- 1. dbo.ChargeableCharConfig (FK Medios NULL=global, SYSTEM_VERSIONING ON, retention 10 años).
|
|
||||||
-- 2. Índices: filtered UX vigente por (MedioId,Symbol); cover IX para GetActiveForMedio.
|
|
||||||
-- 3. SP dbo.usp_ChargeableCharConfig_InsertWithClose (SERIALIZABLE + UPDLOCK, forward-only).
|
|
||||||
-- 4. SP dbo.usp_ChargeableCharConfig_GetActiveForMedio (CTE + ROW_NUMBER per-medio/global).
|
|
||||||
--
|
|
||||||
-- Patrón: V019 (SYSTEM_VERSIONING + PAGE compression + SERIALIZABLE SP).
|
|
||||||
-- Idempotente: seguro para re-ejecutar.
|
|
||||||
-- Reversa: V021_ROLLBACK.sql.
|
|
||||||
-- Run on: SIGCM2 (dev), SIGCM2_Test_App, SIGCM2_Test_Api.
|
|
||||||
--
|
|
||||||
-- Notas:
|
|
||||||
-- - SysStartTime/SysEndTime como hidden cols: evita colisión con business cols ValidFrom/ValidTo (D4).
|
|
||||||
-- - DECIMAL(18,4) para PricePerUnit (mayor granularidad que ProductPrices) (D8).
|
|
||||||
-- - MedioId NULL = global fallback; per-medio overrides global in GetActiveForMedio (D2/D6).
|
|
||||||
-- - Forward-only estricto: THROW 50409 si new ValidFrom <= activo.ValidFrom (D9).
|
|
||||||
-- - UX filtered WHERE ValidTo IS NULL: SQL Server trata (NULL,'$') como valor igual → enforza 1 vigente global (D7).
|
|
||||||
-- - dbo.ChargeableCharConfig_History debe agregarse a TablesToIgnore en SqlTestFixture.cs (Respawn).
|
|
||||||
--
|
|
||||||
-- SDD Design: engram sdd/prc-001-word-counter-spike/design
|
|
||||||
|
|
||||||
SET QUOTED_IDENTIFIER ON;
|
|
||||||
SET ANSI_NULLS ON;
|
|
||||||
SET NOCOUNT ON;
|
|
||||||
GO
|
|
||||||
|
|
||||||
-- ═══════════════════════════════════════════════════════════════════════
|
|
||||||
-- 1. dbo.ChargeableCharConfig
|
|
||||||
-- ═══════════════════════════════════════════════════════════════════════
|
|
||||||
|
|
||||||
IF OBJECT_ID(N'dbo.ChargeableCharConfig', N'U') IS NULL
|
|
||||||
BEGIN
|
|
||||||
CREATE TABLE dbo.ChargeableCharConfig (
|
|
||||||
Id BIGINT IDENTITY(1,1) NOT NULL
|
|
||||||
CONSTRAINT PK_ChargeableCharConfig PRIMARY KEY,
|
|
||||||
MedioId INT NULL, -- NULL = global fallback
|
|
||||||
Symbol NVARCHAR(4) NOT NULL,
|
|
||||||
Category NVARCHAR(32) NOT NULL, -- enum-as-string: Currency/Percentage/Exclamation/Question/Other
|
|
||||||
PricePerUnit DECIMAL(18,4) NOT NULL,
|
|
||||||
ValidFrom DATE NOT NULL,
|
|
||||||
ValidTo DATE NULL,
|
|
||||||
IsActive BIT NOT NULL
|
|
||||||
CONSTRAINT DF_ChargeableCharConfig_IsActive DEFAULT(1),
|
|
||||||
FechaCreacion DATETIME2(3) NOT NULL
|
|
||||||
CONSTRAINT DF_ChargeableCharConfig_FechaCreacion DEFAULT(SYSUTCDATETIME()),
|
|
||||||
CONSTRAINT FK_ChargeableCharConfig_Medio
|
|
||||||
FOREIGN KEY (MedioId) REFERENCES dbo.Medio(Id) ON DELETE NO ACTION,
|
|
||||||
CONSTRAINT CK_ChargeableCharConfig_Price_Positive
|
|
||||||
CHECK (PricePerUnit > 0),
|
|
||||||
CONSTRAINT CK_ChargeableCharConfig_Symbol_NotEmpty
|
|
||||||
CHECK (LEN(Symbol) > 0),
|
|
||||||
CONSTRAINT CK_ChargeableCharConfig_ValidRange
|
|
||||||
CHECK (ValidTo IS NULL OR ValidTo >= ValidFrom)
|
|
||||||
);
|
|
||||||
PRINT 'Table dbo.ChargeableCharConfig created.';
|
|
||||||
END
|
|
||||||
ELSE
|
|
||||||
PRINT 'Table dbo.ChargeableCharConfig already exists — skip.';
|
|
||||||
GO
|
|
||||||
|
|
||||||
-- ═══════════════════════════════════════════════════════════════════════
|
|
||||||
-- 2. SYSTEM_VERSIONING — ChargeableCharConfig
|
|
||||||
-- SysStartTime/SysEndTime para no colisionar con business cols ValidFrom/ValidTo (D4).
|
|
||||||
-- ═══════════════════════════════════════════════════════════════════════
|
|
||||||
|
|
||||||
IF COL_LENGTH('dbo.ChargeableCharConfig', 'SysStartTime') IS NULL
|
|
||||||
BEGIN
|
|
||||||
ALTER TABLE dbo.ChargeableCharConfig
|
|
||||||
ADD
|
|
||||||
SysStartTime DATETIME2(3) GENERATED ALWAYS AS ROW START HIDDEN NOT NULL
|
|
||||||
CONSTRAINT DF_ChargeableCharConfig_SysStartTime DEFAULT(SYSUTCDATETIME()),
|
|
||||||
SysEndTime DATETIME2(3) GENERATED ALWAYS AS ROW END HIDDEN NOT NULL
|
|
||||||
CONSTRAINT DF_ChargeableCharConfig_SysEndTime DEFAULT(CONVERT(DATETIME2(3),'9999-12-31 23:59:59.999')),
|
|
||||||
PERIOD FOR SYSTEM_TIME (SysStartTime, SysEndTime);
|
|
||||||
PRINT 'ChargeableCharConfig: PERIOD FOR SYSTEM_TIME added (SysStartTime/SysEndTime).';
|
|
||||||
END
|
|
||||||
GO
|
|
||||||
|
|
||||||
IF NOT EXISTS (SELECT 1 FROM sys.tables WHERE object_id = OBJECT_ID('dbo.ChargeableCharConfig') AND temporal_type = 2)
|
|
||||||
BEGIN
|
|
||||||
ALTER TABLE dbo.ChargeableCharConfig
|
|
||||||
SET (SYSTEM_VERSIONING = ON (
|
|
||||||
HISTORY_TABLE = dbo.ChargeableCharConfig_History,
|
|
||||||
HISTORY_RETENTION_PERIOD = 10 YEARS
|
|
||||||
));
|
|
||||||
PRINT 'ChargeableCharConfig: SYSTEM_VERSIONING = ON (history: dbo.ChargeableCharConfig_History, retention: 10 years).';
|
|
||||||
END
|
|
||||||
ELSE
|
|
||||||
PRINT 'ChargeableCharConfig: SYSTEM_VERSIONING already ON — skip.';
|
|
||||||
GO
|
|
||||||
|
|
||||||
IF EXISTS (SELECT 1 FROM sys.tables WHERE name = 'ChargeableCharConfig_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 = 'ChargeableCharConfig_History' AND p.data_compression = 2
|
|
||||||
)
|
|
||||||
BEGIN
|
|
||||||
ALTER TABLE dbo.ChargeableCharConfig_History REBUILD WITH (DATA_COMPRESSION = PAGE);
|
|
||||||
PRINT 'ChargeableCharConfig_History: rebuilt with PAGE compression.';
|
|
||||||
END
|
|
||||||
GO
|
|
||||||
|
|
||||||
-- ═══════════════════════════════════════════════════════════════════════
|
|
||||||
-- 3. Índices
|
|
||||||
-- ═══════════════════════════════════════════════════════════════════════
|
|
||||||
|
|
||||||
-- Un único vigente por (MedioId, Symbol).
|
|
||||||
-- SQL Server trata NULL como "distinto" en índices únicos: (NULL,'$') colisiona consigo mismo
|
|
||||||
-- → enforza exactamente 1 vigente global por símbolo (D7).
|
|
||||||
IF NOT EXISTS (
|
|
||||||
SELECT 1 FROM sys.indexes
|
|
||||||
WHERE name = 'UX_ChargeableCharConfig_Vigente'
|
|
||||||
AND object_id = OBJECT_ID('dbo.ChargeableCharConfig')
|
|
||||||
)
|
|
||||||
BEGIN
|
|
||||||
CREATE UNIQUE INDEX UX_ChargeableCharConfig_Vigente
|
|
||||||
ON dbo.ChargeableCharConfig (MedioId, Symbol)
|
|
||||||
WHERE ValidTo IS NULL;
|
|
||||||
PRINT 'Index UX_ChargeableCharConfig_Vigente created.';
|
|
||||||
END
|
|
||||||
GO
|
|
||||||
|
|
||||||
-- Cover para GetActiveForMedio y List.
|
|
||||||
IF NOT EXISTS (
|
|
||||||
SELECT 1 FROM sys.indexes
|
|
||||||
WHERE name = 'IX_ChargeableCharConfig_Query'
|
|
||||||
AND object_id = OBJECT_ID('dbo.ChargeableCharConfig')
|
|
||||||
)
|
|
||||||
BEGIN
|
|
||||||
CREATE INDEX IX_ChargeableCharConfig_Query
|
|
||||||
ON dbo.ChargeableCharConfig (MedioId, Symbol, ValidFrom, ValidTo)
|
|
||||||
INCLUDE (PricePerUnit, IsActive, Category);
|
|
||||||
PRINT 'Index IX_ChargeableCharConfig_Query created.';
|
|
||||||
END
|
|
||||||
GO
|
|
||||||
|
|
||||||
-- ═══════════════════════════════════════════════════════════════════════
|
|
||||||
-- 4. SP — dbo.usp_ChargeableCharConfig_InsertWithClose
|
|
||||||
-- Cierre atómico forward-only: SERIALIZABLE + UPDLOCK + HOLDLOCK.
|
|
||||||
-- @MedioId NULL = global; existencia validada sólo cuando NOT NULL.
|
|
||||||
-- THROW 50404: Medio not found.
|
|
||||||
-- THROW 50409: ForwardOnly — new ValidFrom must be > active.ValidFrom.
|
|
||||||
-- Params de salida: @NewId (BIGINT), @ClosedId (BIGINT — NULL si primer precio).
|
|
||||||
-- ═══════════════════════════════════════════════════════════════════════
|
|
||||||
GO
|
|
||||||
CREATE OR ALTER PROCEDURE dbo.usp_ChargeableCharConfig_InsertWithClose
|
|
||||||
@MedioId INT = NULL,
|
|
||||||
@Symbol NVARCHAR(4),
|
|
||||||
@Category NVARCHAR(32),
|
|
||||||
@PricePerUnit DECIMAL(18,4),
|
|
||||||
@ValidFrom 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;
|
|
||||||
|
|
||||||
-- Validar MedioId sólo cuando se proporciona (NULL = global fallback siempre válido).
|
|
||||||
IF @MedioId IS NOT NULL
|
|
||||||
AND NOT EXISTS (SELECT 1 FROM dbo.Medio WITH (NOLOCK) WHERE Id = @MedioId)
|
|
||||||
BEGIN
|
|
||||||
ROLLBACK;
|
|
||||||
THROW 50404, 'Medio not found', 1;
|
|
||||||
END
|
|
||||||
|
|
||||||
-- Lee el vigente actual con bloqueo de rango para serialización.
|
|
||||||
DECLARE @ActiveId BIGINT, @ActiveValidFrom DATE;
|
|
||||||
SELECT TOP 1
|
|
||||||
@ActiveId = Id,
|
|
||||||
@ActiveValidFrom = ValidFrom
|
|
||||||
FROM dbo.ChargeableCharConfig WITH (UPDLOCK, HOLDLOCK, ROWLOCK)
|
|
||||||
WHERE ((@MedioId IS NULL AND MedioId IS NULL)
|
|
||||||
OR (@MedioId IS NOT NULL AND MedioId = @MedioId))
|
|
||||||
AND Symbol = @Symbol
|
|
||||||
AND ValidTo IS NULL;
|
|
||||||
|
|
||||||
-- Forward-only estricto: new ValidFrom debe ser ESTRICTAMENTE mayor al activo.
|
|
||||||
IF @ActiveId IS NOT NULL AND @ValidFrom <= @ActiveValidFrom
|
|
||||||
BEGIN
|
|
||||||
ROLLBACK;
|
|
||||||
THROW 50409, 'ChargeableCharConfigForwardOnly: new ValidFrom must be > active.ValidFrom', 1;
|
|
||||||
END
|
|
||||||
|
|
||||||
-- Cierra el vigente previo: ValidTo = ValidFrom(nuevo) - 1 día.
|
|
||||||
IF @ActiveId IS NOT NULL
|
|
||||||
BEGIN
|
|
||||||
UPDATE dbo.ChargeableCharConfig
|
|
||||||
SET ValidTo = DATEADD(DAY, -1, @ValidFrom)
|
|
||||||
WHERE Id = @ActiveId;
|
|
||||||
SET @ClosedId = @ActiveId;
|
|
||||||
END
|
|
||||||
ELSE
|
|
||||||
SET @ClosedId = NULL;
|
|
||||||
|
|
||||||
-- Inserta el nuevo vigente.
|
|
||||||
INSERT INTO dbo.ChargeableCharConfig
|
|
||||||
(MedioId, Symbol, Category, PricePerUnit, ValidFrom, ValidTo, IsActive)
|
|
||||||
VALUES
|
|
||||||
(@MedioId, @Symbol, @Category, @PricePerUnit, @ValidFrom, NULL, 1);
|
|
||||||
SET @NewId = SCOPE_IDENTITY();
|
|
||||||
|
|
||||||
COMMIT TRANSACTION;
|
|
||||||
END TRY
|
|
||||||
BEGIN CATCH
|
|
||||||
IF XACT_STATE() <> 0 ROLLBACK TRANSACTION;
|
|
||||||
THROW;
|
|
||||||
END CATCH
|
|
||||||
END
|
|
||||||
GO
|
|
||||||
|
|
||||||
-- ═══════════════════════════════════════════════════════════════════════
|
|
||||||
-- 5. SP — dbo.usp_ChargeableCharConfig_GetActiveForMedio
|
|
||||||
-- Resolución per-medio + global fallback: 1 fila por Symbol.
|
|
||||||
-- CTE + ROW_NUMBER PARTITION BY Symbol ORDER BY per-medio(0) vs global(1).
|
|
||||||
-- ═══════════════════════════════════════════════════════════════════════
|
|
||||||
GO
|
|
||||||
CREATE OR ALTER PROCEDURE dbo.usp_ChargeableCharConfig_GetActiveForMedio
|
|
||||||
@MedioId INT,
|
|
||||||
@AsOfDate DATE
|
|
||||||
AS
|
|
||||||
BEGIN
|
|
||||||
SET NOCOUNT ON;
|
|
||||||
WITH Candidates AS (
|
|
||||||
SELECT
|
|
||||||
Id, MedioId, Symbol, Category, PricePerUnit, ValidFrom, ValidTo, IsActive,
|
|
||||||
ROW_NUMBER() OVER (
|
|
||||||
PARTITION BY Symbol
|
|
||||||
ORDER BY
|
|
||||||
CASE WHEN MedioId = @MedioId THEN 0 ELSE 1 END, -- prefer specific over global
|
|
||||||
ValidFrom DESC
|
|
||||||
) AS rn
|
|
||||||
FROM dbo.ChargeableCharConfig
|
|
||||||
WHERE IsActive = 1
|
|
||||||
AND ValidFrom <= @AsOfDate
|
|
||||||
AND (ValidTo IS NULL OR ValidTo >= @AsOfDate)
|
|
||||||
AND (MedioId = @MedioId OR MedioId IS NULL)
|
|
||||||
)
|
|
||||||
SELECT Id, MedioId, Symbol, Category, PricePerUnit, ValidFrom, ValidTo, IsActive
|
|
||||||
FROM Candidates
|
|
||||||
WHERE rn = 1;
|
|
||||||
END
|
|
||||||
GO
|
|
||||||
|
|
||||||
PRINT '';
|
|
||||||
PRINT 'V021 applied — dbo.ChargeableCharConfig (temporal, retention 10y) + UX_ChargeableCharConfig_Vigente + IX_ChargeableCharConfig_Query + usp_ChargeableCharConfig_InsertWithClose + usp_ChargeableCharConfig_GetActiveForMedio.';
|
|
||||||
PRINT 'Next migration: V022 (seed ChargeableCharConfig).';
|
|
||||||
GO
|
|
||||||
@@ -1,23 +0,0 @@
|
|||||||
-- V022_ROLLBACK.sql
|
|
||||||
-- PRC-001: Reversa de V022__seed_chargeable_char_config.sql.
|
|
||||||
--
|
|
||||||
-- Elimina las 4 filas globales de seed (MedioId NULL, símbolos $/%/!/¡, ValidTo NULL).
|
|
||||||
-- Solo elimina las filas vigentes (ValidTo IS NULL) para no romper el historial temporal.
|
|
||||||
--
|
|
||||||
-- ADVERTENCIA: si alguna de estas filas fue cerrada (ValidTo SET), el rollback las ignora
|
|
||||||
-- (ya no son vigentes). La historia temporal queda intacta.
|
|
||||||
-- Run on: SIGCM2 (dev), SIGCM2_Test_App, SIGCM2_Test_Api.
|
|
||||||
|
|
||||||
SET QUOTED_IDENTIFIER ON;
|
|
||||||
SET ANSI_NULLS ON;
|
|
||||||
SET NOCOUNT ON;
|
|
||||||
GO
|
|
||||||
|
|
||||||
DELETE FROM dbo.ChargeableCharConfig
|
|
||||||
WHERE MedioId IS NULL
|
|
||||||
AND Symbol IN (N'$', N'%', N'!', N'¡')
|
|
||||||
AND ValidTo IS NULL;
|
|
||||||
GO
|
|
||||||
|
|
||||||
PRINT 'V022 rollback complete — global seed rows ($, %, !, ¡) removed.';
|
|
||||||
GO
|
|
||||||
@@ -1,44 +0,0 @@
|
|||||||
-- V022__seed_chargeable_char_config.sql
|
|
||||||
-- PRC-001: seed de las 4 configuraciones globales de caracteres tasables por defecto.
|
|
||||||
--
|
|
||||||
-- Cambios:
|
|
||||||
-- 1. Inserta 4 filas globales (MedioId NULL): $, %, !, ¡ — precios placeholder 1.0000.
|
|
||||||
-- El equipo de negocio seteará los valores reales desde el CMS.
|
|
||||||
--
|
|
||||||
-- Patrón: MERGE idempotente ON (MedioId IS NULL AND Symbol AND ValidTo IS NULL).
|
|
||||||
-- Idempotente: seguro para re-ejecutar.
|
|
||||||
-- Reversa: V022_ROLLBACK.sql.
|
|
||||||
-- Run on: SIGCM2 (dev), SIGCM2_Test_App, SIGCM2_Test_Api.
|
|
||||||
--
|
|
||||||
-- Depends on: V021 (dbo.ChargeableCharConfig must exist).
|
|
||||||
--
|
|
||||||
-- Notas:
|
|
||||||
-- - MedioId NULL = global fallback; aplica a todos los medios a menos que exista
|
|
||||||
-- una fila per-medio más específica (resolución en usp_ChargeableCharConfig_GetActiveForMedio).
|
|
||||||
-- - ValidFrom = 2026-01-01: retroactivo al inicio del año fiscal 2026.
|
|
||||||
-- - ValidTo NULL = vigente (sin fecha de cierre).
|
|
||||||
-- - PricePerUnit 1.0000 son placeholders — CONFIRMAR con el área de tasación.
|
|
||||||
--
|
|
||||||
-- SDD Design: engram sdd/prc-001-word-counter-spike/design (§3.3)
|
|
||||||
|
|
||||||
SET QUOTED_IDENTIFIER ON;
|
|
||||||
SET ANSI_NULLS ON;
|
|
||||||
SET NOCOUNT ON;
|
|
||||||
GO
|
|
||||||
|
|
||||||
MERGE dbo.ChargeableCharConfig AS t
|
|
||||||
USING (VALUES
|
|
||||||
(NULL, N'$', N'Currency', CAST(1.0000 AS DECIMAL(18,4)), CAST('2026-01-01' AS DATE)),
|
|
||||||
(NULL, N'%', N'Percentage', CAST(1.0000 AS DECIMAL(18,4)), CAST('2026-01-01' AS DATE)),
|
|
||||||
(NULL, N'!', N'Exclamation', CAST(1.0000 AS DECIMAL(18,4)), CAST('2026-01-01' AS DATE)),
|
|
||||||
(NULL, N'¡', N'Exclamation', CAST(1.0000 AS DECIMAL(18,4)), CAST('2026-01-01' AS DATE))
|
|
||||||
) AS s (MedioId, Symbol, Category, PricePerUnit, ValidFrom)
|
|
||||||
ON (t.MedioId IS NULL AND s.MedioId IS NULL AND t.Symbol = s.Symbol AND t.ValidTo IS NULL)
|
|
||||||
WHEN NOT MATCHED THEN
|
|
||||||
INSERT (MedioId, Symbol, Category, PricePerUnit, ValidFrom, ValidTo, IsActive)
|
|
||||||
VALUES (s.MedioId, s.Symbol, s.Category, s.PricePerUnit, s.ValidFrom, NULL, 1);
|
|
||||||
GO
|
|
||||||
|
|
||||||
PRINT 'V022 applied — 4 global ChargeableCharConfig defaults seeded ($, %, !, ¡).';
|
|
||||||
PRINT 'NOTE: PricePerUnit values are placeholders (1.0000). Update via CMS before going live.';
|
|
||||||
GO
|
|
||||||
@@ -1,246 +0,0 @@
|
|||||||
-- V023_ROLLBACK.sql
|
|
||||||
-- PRC-001: Reversa de V023__refactor_chargeable_char_config_to_product_type.sql.
|
|
||||||
--
|
|
||||||
-- ADVERTENCIA: rollback destructivo — elimina ProductTypeId y restaura MedioId.
|
|
||||||
-- - Todos los datos de ProductTypeId se pierden.
|
|
||||||
-- - Las filas globales (ProductTypeId NULL) se preservan como globales (MedioId NULL).
|
|
||||||
-- - El historial temporal puede quedar inconsistente si la tabla fue modificada después.
|
|
||||||
--
|
|
||||||
-- Solo para uso en DEV/TEST. No ejecutar en producción si hay datos de ProductTypeId.
|
|
||||||
-- Run on: SIGCM2 (dev), SIGCM2_Test_App, SIGCM2_Test_Api.
|
|
||||||
|
|
||||||
SET QUOTED_IDENTIFIER ON;
|
|
||||||
SET ANSI_NULLS ON;
|
|
||||||
SET NOCOUNT ON;
|
|
||||||
GO
|
|
||||||
|
|
||||||
-- ─── 1. Drop new SPs ────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
IF OBJECT_ID('dbo.usp_ChargeableCharConfig_ReactivateWithGuard', 'P') IS NOT NULL
|
|
||||||
BEGIN
|
|
||||||
DROP PROCEDURE dbo.usp_ChargeableCharConfig_ReactivateWithGuard;
|
|
||||||
PRINT 'V023 ROLLBACK: usp_ChargeableCharConfig_ReactivateWithGuard dropped.';
|
|
||||||
END
|
|
||||||
GO
|
|
||||||
|
|
||||||
IF OBJECT_ID('dbo.usp_ChargeableCharConfig_GetActiveForProductType', 'P') IS NOT NULL
|
|
||||||
BEGIN
|
|
||||||
DROP PROCEDURE dbo.usp_ChargeableCharConfig_GetActiveForProductType;
|
|
||||||
PRINT 'V023 ROLLBACK: usp_ChargeableCharConfig_GetActiveForProductType dropped.';
|
|
||||||
END
|
|
||||||
GO
|
|
||||||
|
|
||||||
IF OBJECT_ID('dbo.usp_ChargeableCharConfig_InsertWithClose', 'P') IS NOT NULL
|
|
||||||
BEGIN
|
|
||||||
DROP PROCEDURE dbo.usp_ChargeableCharConfig_InsertWithClose;
|
|
||||||
PRINT 'V023 ROLLBACK: usp_ChargeableCharConfig_InsertWithClose dropped.';
|
|
||||||
END
|
|
||||||
GO
|
|
||||||
|
|
||||||
-- ─── 2. Reverse table alterations if ProductTypeId column exists ─────────────
|
|
||||||
|
|
||||||
IF EXISTS (SELECT 1 FROM sys.tables WHERE name = 'ChargeableCharConfig' AND schema_id = SCHEMA_ID('dbo'))
|
|
||||||
AND EXISTS (SELECT 1 FROM sys.columns
|
|
||||||
WHERE object_id = OBJECT_ID('dbo.ChargeableCharConfig')
|
|
||||||
AND name = 'ProductTypeId')
|
|
||||||
BEGIN
|
|
||||||
-- 2a. Turn off SYSTEM_VERSIONING
|
|
||||||
ALTER TABLE dbo.ChargeableCharConfig SET (SYSTEM_VERSIONING = OFF);
|
|
||||||
PRINT 'V023 ROLLBACK: SYSTEM_VERSIONING = OFF.';
|
|
||||||
|
|
||||||
-- 2b. Drop indexes on ProductTypeId
|
|
||||||
IF EXISTS (SELECT 1 FROM sys.indexes WHERE name = 'UX_ChargeableCharConfig_Vigente'
|
|
||||||
AND object_id = OBJECT_ID('dbo.ChargeableCharConfig'))
|
|
||||||
BEGIN
|
|
||||||
DROP INDEX UX_ChargeableCharConfig_Vigente ON dbo.ChargeableCharConfig;
|
|
||||||
PRINT 'V023 ROLLBACK: UX_ChargeableCharConfig_Vigente dropped.';
|
|
||||||
END
|
|
||||||
|
|
||||||
IF EXISTS (SELECT 1 FROM sys.indexes WHERE name = 'IX_ChargeableCharConfig_Query'
|
|
||||||
AND object_id = OBJECT_ID('dbo.ChargeableCharConfig'))
|
|
||||||
BEGIN
|
|
||||||
DROP INDEX IX_ChargeableCharConfig_Query ON dbo.ChargeableCharConfig;
|
|
||||||
PRINT 'V023 ROLLBACK: IX_ChargeableCharConfig_Query dropped.';
|
|
||||||
END
|
|
||||||
|
|
||||||
-- 2c. Drop FK to ProductType
|
|
||||||
DECLARE @fk_pt sysname;
|
|
||||||
SELECT @fk_pt = name
|
|
||||||
FROM sys.foreign_keys
|
|
||||||
WHERE parent_object_id = OBJECT_ID('dbo.ChargeableCharConfig')
|
|
||||||
AND referenced_object_id = OBJECT_ID('dbo.ProductType');
|
|
||||||
IF @fk_pt IS NOT NULL
|
|
||||||
BEGIN
|
|
||||||
EXEC('ALTER TABLE dbo.ChargeableCharConfig DROP CONSTRAINT ' + @fk_pt);
|
|
||||||
PRINT 'V023 ROLLBACK: FK_ChargeableCharConfig_ProductType dropped.';
|
|
||||||
END
|
|
||||||
|
|
||||||
-- 2d. Drop NonNegative price check; restore Positive check
|
|
||||||
IF EXISTS (SELECT 1 FROM sys.check_constraints
|
|
||||||
WHERE name = 'CK_ChargeableCharConfig_Price_NonNegative'
|
|
||||||
AND parent_object_id = OBJECT_ID('dbo.ChargeableCharConfig'))
|
|
||||||
BEGIN
|
|
||||||
ALTER TABLE dbo.ChargeableCharConfig DROP CONSTRAINT CK_ChargeableCharConfig_Price_NonNegative;
|
|
||||||
PRINT 'V023 ROLLBACK: CK_ChargeableCharConfig_Price_NonNegative dropped.';
|
|
||||||
END
|
|
||||||
|
|
||||||
IF NOT EXISTS (SELECT 1 FROM sys.check_constraints
|
|
||||||
WHERE name = 'CK_ChargeableCharConfig_Price_Positive'
|
|
||||||
AND parent_object_id = OBJECT_ID('dbo.ChargeableCharConfig'))
|
|
||||||
BEGIN
|
|
||||||
ALTER TABLE dbo.ChargeableCharConfig
|
|
||||||
ADD CONSTRAINT CK_ChargeableCharConfig_Price_Positive CHECK (PricePerUnit > 0);
|
|
||||||
PRINT 'V023 ROLLBACK: CK_ChargeableCharConfig_Price_Positive restored.';
|
|
||||||
END
|
|
||||||
|
|
||||||
-- 2e. Drop ProductTypeId column from main + history
|
|
||||||
ALTER TABLE dbo.ChargeableCharConfig DROP COLUMN ProductTypeId;
|
|
||||||
PRINT 'V023 ROLLBACK: ProductTypeId dropped from ChargeableCharConfig.';
|
|
||||||
|
|
||||||
IF EXISTS (SELECT 1 FROM sys.columns
|
|
||||||
WHERE object_id = OBJECT_ID('dbo.ChargeableCharConfig_History')
|
|
||||||
AND name = 'ProductTypeId')
|
|
||||||
BEGIN
|
|
||||||
ALTER TABLE dbo.ChargeableCharConfig_History DROP COLUMN ProductTypeId;
|
|
||||||
PRINT 'V023 ROLLBACK: ProductTypeId dropped from ChargeableCharConfig_History.';
|
|
||||||
END
|
|
||||||
|
|
||||||
-- 2f. Restore MedioId column
|
|
||||||
ALTER TABLE dbo.ChargeableCharConfig ADD MedioId INT NULL;
|
|
||||||
ALTER TABLE dbo.ChargeableCharConfig_History ADD MedioId INT NULL;
|
|
||||||
PRINT 'V023 ROLLBACK: MedioId restored.';
|
|
||||||
|
|
||||||
-- 2g. Restore FK to Medio
|
|
||||||
ALTER TABLE dbo.ChargeableCharConfig
|
|
||||||
ADD CONSTRAINT FK_ChargeableCharConfig_Medio
|
|
||||||
FOREIGN KEY (MedioId) REFERENCES dbo.Medio(Id) ON DELETE NO ACTION;
|
|
||||||
PRINT 'V023 ROLLBACK: FK_ChargeableCharConfig_Medio restored.';
|
|
||||||
|
|
||||||
-- 2h. Restore indexes on MedioId
|
|
||||||
CREATE UNIQUE NONCLUSTERED INDEX UX_ChargeableCharConfig_Vigente
|
|
||||||
ON dbo.ChargeableCharConfig (MedioId, Symbol)
|
|
||||||
WHERE ValidTo IS NULL;
|
|
||||||
PRINT 'V023 ROLLBACK: UX_ChargeableCharConfig_Vigente restored (MedioId).';
|
|
||||||
|
|
||||||
CREATE NONCLUSTERED INDEX IX_ChargeableCharConfig_Query
|
|
||||||
ON dbo.ChargeableCharConfig (MedioId, Symbol, ValidFrom, ValidTo)
|
|
||||||
INCLUDE (PricePerUnit, IsActive, Category);
|
|
||||||
PRINT 'V023 ROLLBACK: IX_ChargeableCharConfig_Query restored (MedioId).';
|
|
||||||
|
|
||||||
-- 2i. Restore SYSTEM_VERSIONING
|
|
||||||
ALTER TABLE dbo.ChargeableCharConfig
|
|
||||||
SET (SYSTEM_VERSIONING = ON (
|
|
||||||
HISTORY_TABLE = dbo.ChargeableCharConfig_History,
|
|
||||||
HISTORY_RETENTION_PERIOD = 10 YEARS
|
|
||||||
));
|
|
||||||
PRINT 'V023 ROLLBACK: SYSTEM_VERSIONING = ON restored.';
|
|
||||||
END
|
|
||||||
ELSE
|
|
||||||
PRINT 'V023 ROLLBACK: ProductTypeId column not found — table already in MedioId state or missing, skipping.';
|
|
||||||
GO
|
|
||||||
|
|
||||||
-- ─── 3. Restore original SPs ────────────────────────────────────────────────
|
|
||||||
|
|
||||||
IF OBJECT_ID('dbo.usp_ChargeableCharConfig_InsertWithClose', 'P') IS NULL
|
|
||||||
EXEC('CREATE PROCEDURE dbo.usp_ChargeableCharConfig_InsertWithClose AS RETURN 0');
|
|
||||||
GO
|
|
||||||
|
|
||||||
ALTER PROCEDURE dbo.usp_ChargeableCharConfig_InsertWithClose
|
|
||||||
@MedioId INT = NULL,
|
|
||||||
@Symbol NVARCHAR(4),
|
|
||||||
@Category NVARCHAR(32),
|
|
||||||
@PricePerUnit DECIMAL(18,4),
|
|
||||||
@ValidFrom 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 @MedioId IS NOT NULL
|
|
||||||
AND NOT EXISTS (SELECT 1 FROM dbo.Medio WITH (NOLOCK) WHERE Id = @MedioId)
|
|
||||||
BEGIN
|
|
||||||
ROLLBACK;
|
|
||||||
THROW 50404, 'Medio not found', 1;
|
|
||||||
END
|
|
||||||
|
|
||||||
DECLARE @ActiveId BIGINT, @ActiveValidFrom DATE;
|
|
||||||
SELECT TOP 1
|
|
||||||
@ActiveId = Id,
|
|
||||||
@ActiveValidFrom = ValidFrom
|
|
||||||
FROM dbo.ChargeableCharConfig WITH (UPDLOCK, HOLDLOCK, ROWLOCK)
|
|
||||||
WHERE ((@MedioId IS NULL AND MedioId IS NULL)
|
|
||||||
OR (@MedioId IS NOT NULL AND MedioId = @MedioId))
|
|
||||||
AND Symbol = @Symbol
|
|
||||||
AND ValidTo IS NULL;
|
|
||||||
|
|
||||||
IF @ActiveId IS NOT NULL AND @ValidFrom <= @ActiveValidFrom
|
|
||||||
BEGIN
|
|
||||||
ROLLBACK;
|
|
||||||
THROW 50409, 'ChargeableCharConfigForwardOnly: new ValidFrom must be > active.ValidFrom', 1;
|
|
||||||
END
|
|
||||||
|
|
||||||
IF @ActiveId IS NOT NULL
|
|
||||||
BEGIN
|
|
||||||
UPDATE dbo.ChargeableCharConfig
|
|
||||||
SET ValidTo = DATEADD(DAY, -1, @ValidFrom)
|
|
||||||
WHERE Id = @ActiveId;
|
|
||||||
SET @ClosedId = @ActiveId;
|
|
||||||
END
|
|
||||||
ELSE
|
|
||||||
SET @ClosedId = NULL;
|
|
||||||
|
|
||||||
INSERT INTO dbo.ChargeableCharConfig
|
|
||||||
(MedioId, Symbol, Category, PricePerUnit, ValidFrom, ValidTo, IsActive)
|
|
||||||
VALUES
|
|
||||||
(@MedioId, @Symbol, @Category, @PricePerUnit, @ValidFrom, NULL, 1);
|
|
||||||
SET @NewId = SCOPE_IDENTITY();
|
|
||||||
|
|
||||||
COMMIT TRANSACTION;
|
|
||||||
END TRY
|
|
||||||
BEGIN CATCH
|
|
||||||
IF XACT_STATE() <> 0 ROLLBACK TRANSACTION;
|
|
||||||
THROW;
|
|
||||||
END CATCH
|
|
||||||
END
|
|
||||||
GO
|
|
||||||
|
|
||||||
IF OBJECT_ID('dbo.usp_ChargeableCharConfig_GetActiveForMedio', 'P') IS NULL
|
|
||||||
EXEC('CREATE PROCEDURE dbo.usp_ChargeableCharConfig_GetActiveForMedio AS RETURN 0');
|
|
||||||
GO
|
|
||||||
|
|
||||||
ALTER PROCEDURE dbo.usp_ChargeableCharConfig_GetActiveForMedio
|
|
||||||
@MedioId INT,
|
|
||||||
@AsOfDate DATE
|
|
||||||
AS
|
|
||||||
BEGIN
|
|
||||||
SET NOCOUNT ON;
|
|
||||||
WITH Candidates AS (
|
|
||||||
SELECT
|
|
||||||
Id, MedioId, Symbol, Category, PricePerUnit, ValidFrom, ValidTo, IsActive,
|
|
||||||
ROW_NUMBER() OVER (
|
|
||||||
PARTITION BY Symbol
|
|
||||||
ORDER BY
|
|
||||||
CASE WHEN MedioId = @MedioId THEN 0 ELSE 1 END,
|
|
||||||
ValidFrom DESC
|
|
||||||
) AS rn
|
|
||||||
FROM dbo.ChargeableCharConfig
|
|
||||||
WHERE IsActive = 1
|
|
||||||
AND ValidFrom <= @AsOfDate
|
|
||||||
AND (ValidTo IS NULL OR ValidTo >= @AsOfDate)
|
|
||||||
AND (MedioId = @MedioId OR MedioId IS NULL)
|
|
||||||
)
|
|
||||||
SELECT Id, MedioId, Symbol, Category, PricePerUnit, ValidFrom, ValidTo, IsActive
|
|
||||||
FROM Candidates
|
|
||||||
WHERE rn = 1;
|
|
||||||
END
|
|
||||||
GO
|
|
||||||
|
|
||||||
PRINT '';
|
|
||||||
PRINT 'V023 ROLLBACK complete — ChargeableCharConfig restored to MedioId model.';
|
|
||||||
GO
|
|
||||||
@@ -1,414 +0,0 @@
|
|||||||
-- V023__refactor_chargeable_char_config_to_product_type.sql
|
|
||||||
-- PRC-001 scope delta: ChargeableCharConfig per ProductType (reemplaza per-Medio).
|
|
||||||
--
|
|
||||||
-- Cambios:
|
|
||||||
-- 1. DROP MedioId + FK_ChargeableCharConfig_Medio + índices que lo referencian.
|
|
||||||
-- 2. ADD ProductTypeId (nullable = global fallback) + FK_ChargeableCharConfig_ProductType.
|
|
||||||
-- 3. Recrea índices con ProductTypeId (UX_Vigente + IX_Query).
|
|
||||||
-- 4. DROP+CREATE usp_ChargeableCharConfig_InsertWithClose (@MedioId → @ProductTypeId).
|
|
||||||
-- 5. DROP usp_ChargeableCharConfig_GetActiveForMedio + CREATE usp_ChargeableCharConfig_GetActiveForProductType.
|
|
||||||
-- 6. NEW SP usp_ChargeableCharConfig_ReactivateWithGuard (opción A+guard para feature 3).
|
|
||||||
-- 7. DROP CK_ChargeableCharConfig_Price_Positive (se permite 0.0000 para opt-in billing).
|
|
||||||
-- Reemplaza con CK_ChargeableCharConfig_Price_NonNegative (>= 0).
|
|
||||||
--
|
|
||||||
-- Patrón: idempotente con IF EXISTS guards. Bloque principal protegido por la presencia
|
|
||||||
-- de la columna MedioId — si no existe ya fue refactorizada, el bloque no ejecuta.
|
|
||||||
-- SYSTEM_VERSIONING: OFF al inicio del ALTER block, ON al final (con history table + retention).
|
|
||||||
-- Depende de: V017 (dbo.ProductType debe existir).
|
|
||||||
-- Reversa: V023_ROLLBACK.sql.
|
|
||||||
-- Run on: SIGCM2 (dev), SIGCM2_Test_App, SIGCM2_Test_Api.
|
|
||||||
--
|
|
||||||
-- SDD Design: engram sdd/prc-001-word-counter-spike/design
|
|
||||||
-- Scope delta: engram sdd/prc-001-word-counter-spike/scope-delta-1
|
|
||||||
|
|
||||||
SET QUOTED_IDENTIFIER ON;
|
|
||||||
SET ANSI_NULLS ON;
|
|
||||||
SET NOCOUNT ON;
|
|
||||||
GO
|
|
||||||
|
|
||||||
-- ═══════════════════════════════════════════════════════════════════════
|
|
||||||
-- Bloque principal: solo ejecuta si la tabla existe Y todavía tiene MedioId
|
|
||||||
-- (guard idempotente: si ya fue refactorizada, el bloque se saltea completo).
|
|
||||||
-- ═══════════════════════════════════════════════════════════════════════
|
|
||||||
|
|
||||||
IF EXISTS (SELECT 1 FROM sys.tables WHERE name = 'ChargeableCharConfig' AND schema_id = SCHEMA_ID('dbo'))
|
|
||||||
AND EXISTS (SELECT 1 FROM sys.columns
|
|
||||||
WHERE object_id = OBJECT_ID('dbo.ChargeableCharConfig')
|
|
||||||
AND name = 'MedioId')
|
|
||||||
BEGIN
|
|
||||||
PRINT 'V023: MedioId column found — proceeding with refactor.';
|
|
||||||
|
|
||||||
-- ─── 1. Turn OFF SYSTEM_VERSIONING (idempotent — skip if already OFF) ───
|
|
||||||
IF EXISTS (SELECT 1 FROM sys.tables
|
|
||||||
WHERE name = 'ChargeableCharConfig'
|
|
||||||
AND schema_id = SCHEMA_ID('dbo')
|
|
||||||
AND temporal_type = 2) -- 2 = SYSTEM_VERSIONED_TEMPORAL_TABLE
|
|
||||||
BEGIN
|
|
||||||
ALTER TABLE dbo.ChargeableCharConfig SET (SYSTEM_VERSIONING = OFF);
|
|
||||||
PRINT 'V023: SYSTEM_VERSIONING = OFF.';
|
|
||||||
END
|
|
||||||
ELSE
|
|
||||||
PRINT 'V023: SYSTEM_VERSIONING already OFF — skipping.';
|
|
||||||
|
|
||||||
-- ─── 2. Drop indexes that reference MedioId ────────────────────────
|
|
||||||
IF EXISTS (SELECT 1 FROM sys.indexes WHERE name = 'UX_ChargeableCharConfig_Vigente'
|
|
||||||
AND object_id = OBJECT_ID('dbo.ChargeableCharConfig'))
|
|
||||||
BEGIN
|
|
||||||
DROP INDEX UX_ChargeableCharConfig_Vigente ON dbo.ChargeableCharConfig;
|
|
||||||
PRINT 'V023: UX_ChargeableCharConfig_Vigente dropped.';
|
|
||||||
END
|
|
||||||
|
|
||||||
IF EXISTS (SELECT 1 FROM sys.indexes WHERE name = 'IX_ChargeableCharConfig_Query'
|
|
||||||
AND object_id = OBJECT_ID('dbo.ChargeableCharConfig'))
|
|
||||||
BEGIN
|
|
||||||
DROP INDEX IX_ChargeableCharConfig_Query ON dbo.ChargeableCharConfig;
|
|
||||||
PRINT 'V023: IX_ChargeableCharConfig_Query dropped.';
|
|
||||||
END
|
|
||||||
|
|
||||||
-- ─── 3. Drop FK to Medio ────────────────────────────────────────────
|
|
||||||
DECLARE @fk_name sysname;
|
|
||||||
SELECT @fk_name = name
|
|
||||||
FROM sys.foreign_keys
|
|
||||||
WHERE parent_object_id = OBJECT_ID('dbo.ChargeableCharConfig')
|
|
||||||
AND referenced_object_id = OBJECT_ID('dbo.Medio');
|
|
||||||
IF @fk_name IS NOT NULL
|
|
||||||
BEGIN
|
|
||||||
EXEC('ALTER TABLE dbo.ChargeableCharConfig DROP CONSTRAINT ' + @fk_name);
|
|
||||||
PRINT 'V023: FK_ChargeableCharConfig_Medio dropped.';
|
|
||||||
END
|
|
||||||
|
|
||||||
-- ─── 4. Drop MedioId column (drop DF constraint first if present) ───
|
|
||||||
DECLARE @df_medio sysname;
|
|
||||||
SELECT @df_medio = dc.name
|
|
||||||
FROM sys.default_constraints dc
|
|
||||||
JOIN sys.columns c ON c.default_object_id = dc.object_id
|
|
||||||
WHERE c.object_id = OBJECT_ID('dbo.ChargeableCharConfig')
|
|
||||||
AND c.name = 'MedioId';
|
|
||||||
IF @df_medio IS NOT NULL
|
|
||||||
BEGIN
|
|
||||||
EXEC('ALTER TABLE dbo.ChargeableCharConfig DROP CONSTRAINT ' + @df_medio);
|
|
||||||
PRINT 'V023: Default constraint on MedioId dropped.';
|
|
||||||
END
|
|
||||||
|
|
||||||
ALTER TABLE dbo.ChargeableCharConfig DROP COLUMN MedioId;
|
|
||||||
PRINT 'V023: MedioId column dropped from ChargeableCharConfig.';
|
|
||||||
|
|
||||||
-- Drop MedioId from history table if present
|
|
||||||
IF EXISTS (SELECT 1 FROM sys.columns
|
|
||||||
WHERE object_id = OBJECT_ID('dbo.ChargeableCharConfig_History')
|
|
||||||
AND name = 'MedioId')
|
|
||||||
BEGIN
|
|
||||||
-- Drop default constraint on history MedioId if any
|
|
||||||
DECLARE @df_hist_medio sysname;
|
|
||||||
SELECT @df_hist_medio = dc.name
|
|
||||||
FROM sys.default_constraints dc
|
|
||||||
JOIN sys.columns c ON c.default_object_id = dc.object_id
|
|
||||||
WHERE c.object_id = OBJECT_ID('dbo.ChargeableCharConfig_History')
|
|
||||||
AND c.name = 'MedioId';
|
|
||||||
IF @df_hist_medio IS NOT NULL
|
|
||||||
EXEC('ALTER TABLE dbo.ChargeableCharConfig_History DROP CONSTRAINT ' + @df_hist_medio);
|
|
||||||
|
|
||||||
ALTER TABLE dbo.ChargeableCharConfig_History DROP COLUMN MedioId;
|
|
||||||
PRINT 'V023: MedioId column dropped from ChargeableCharConfig_History.';
|
|
||||||
END
|
|
||||||
|
|
||||||
-- ─── 5. Drop CK_Price_Positive, replace with CK_Price_NonNegative ──
|
|
||||||
-- V024 seeds PricePerUnit = 0.0000 (opt-in billing). Old check (> 0) would block it.
|
|
||||||
IF EXISTS (SELECT 1 FROM sys.check_constraints
|
|
||||||
WHERE name = 'CK_ChargeableCharConfig_Price_Positive'
|
|
||||||
AND parent_object_id = OBJECT_ID('dbo.ChargeableCharConfig'))
|
|
||||||
BEGIN
|
|
||||||
ALTER TABLE dbo.ChargeableCharConfig DROP CONSTRAINT CK_ChargeableCharConfig_Price_Positive;
|
|
||||||
PRINT 'V023: CK_ChargeableCharConfig_Price_Positive dropped.';
|
|
||||||
END
|
|
||||||
|
|
||||||
IF NOT EXISTS (SELECT 1 FROM sys.check_constraints
|
|
||||||
WHERE name = 'CK_ChargeableCharConfig_Price_NonNegative'
|
|
||||||
AND parent_object_id = OBJECT_ID('dbo.ChargeableCharConfig'))
|
|
||||||
BEGIN
|
|
||||||
ALTER TABLE dbo.ChargeableCharConfig
|
|
||||||
ADD CONSTRAINT CK_ChargeableCharConfig_Price_NonNegative
|
|
||||||
CHECK (PricePerUnit >= 0);
|
|
||||||
PRINT 'V023: CK_ChargeableCharConfig_Price_NonNegative added (>= 0, opt-in billing).';
|
|
||||||
END
|
|
||||||
|
|
||||||
-- ─── 6. Add ProductTypeId column ────────────────────────────────────
|
|
||||||
ALTER TABLE dbo.ChargeableCharConfig
|
|
||||||
ADD ProductTypeId INT NULL; -- NULL = global fallback
|
|
||||||
PRINT 'V023: ProductTypeId column added to ChargeableCharConfig.';
|
|
||||||
|
|
||||||
ALTER TABLE dbo.ChargeableCharConfig_History
|
|
||||||
ADD ProductTypeId INT NULL;
|
|
||||||
PRINT 'V023: ProductTypeId column added to ChargeableCharConfig_History.';
|
|
||||||
|
|
||||||
-- ─── 7. Add FK to ProductType ────────────────────────────────────────
|
|
||||||
ALTER TABLE dbo.ChargeableCharConfig
|
|
||||||
ADD CONSTRAINT FK_ChargeableCharConfig_ProductType
|
|
||||||
FOREIGN KEY (ProductTypeId) REFERENCES dbo.ProductType(Id) ON DELETE NO ACTION;
|
|
||||||
PRINT 'V023: FK_ChargeableCharConfig_ProductType added.';
|
|
||||||
|
|
||||||
-- ─── 8. Recreate filtered unique index with ProductTypeId ────────────
|
|
||||||
-- 1 vigente per (ProductTypeId, Symbol). NULL ProductTypeId = global fallback.
|
|
||||||
-- SQL Server trata NULL como "distinto" en unique indexes → enforza 1 vigente global.
|
|
||||||
CREATE UNIQUE NONCLUSTERED INDEX UX_ChargeableCharConfig_Vigente
|
|
||||||
ON dbo.ChargeableCharConfig (ProductTypeId, Symbol)
|
|
||||||
WHERE ValidTo IS NULL;
|
|
||||||
PRINT 'V023: UX_ChargeableCharConfig_Vigente recreated (ProductTypeId, Symbol).';
|
|
||||||
|
|
||||||
-- ─── 9. Recreate cover index with ProductTypeId ──────────────────────
|
|
||||||
CREATE NONCLUSTERED INDEX IX_ChargeableCharConfig_Query
|
|
||||||
ON dbo.ChargeableCharConfig (ProductTypeId, Symbol, ValidFrom, ValidTo)
|
|
||||||
INCLUDE (PricePerUnit, IsActive, Category);
|
|
||||||
PRINT 'V023: IX_ChargeableCharConfig_Query recreated (ProductTypeId).';
|
|
||||||
|
|
||||||
-- ─── 10. Turn SYSTEM_VERSIONING back ON ──────────────────────────────
|
|
||||||
ALTER TABLE dbo.ChargeableCharConfig
|
|
||||||
SET (SYSTEM_VERSIONING = ON (
|
|
||||||
HISTORY_TABLE = dbo.ChargeableCharConfig_History,
|
|
||||||
HISTORY_RETENTION_PERIOD = 10 YEARS
|
|
||||||
));
|
|
||||||
PRINT 'V023: SYSTEM_VERSIONING = ON (history: dbo.ChargeableCharConfig_History, retention: 10 years).';
|
|
||||||
END
|
|
||||||
ELSE
|
|
||||||
BEGIN
|
|
||||||
IF NOT EXISTS (SELECT 1 FROM sys.tables WHERE name = 'ChargeableCharConfig' AND schema_id = SCHEMA_ID('dbo'))
|
|
||||||
PRINT 'V023: dbo.ChargeableCharConfig does not exist — skipping table refactor.';
|
|
||||||
ELSE
|
|
||||||
PRINT 'V023: MedioId column not found — table already refactored, skipping.';
|
|
||||||
END
|
|
||||||
GO
|
|
||||||
|
|
||||||
-- ═══════════════════════════════════════════════════════════════════════
|
|
||||||
-- SP: usp_ChargeableCharConfig_InsertWithClose (@ProductTypeId replaces @MedioId)
|
|
||||||
-- Cierre atómico forward-only: SERIALIZABLE + UPDLOCK + HOLDLOCK.
|
|
||||||
-- @ProductTypeId NULL = global; FK validada solo cuando NOT NULL (via referential integrity).
|
|
||||||
-- THROW 50404: ProductType not found.
|
|
||||||
-- THROW 50409: ForwardOnly — new ValidFrom must be > active.ValidFrom.
|
|
||||||
-- Output: @NewId (BIGINT), @ClosedId (BIGINT — NULL if first price for symbol).
|
|
||||||
-- ═══════════════════════════════════════════════════════════════════════
|
|
||||||
|
|
||||||
IF OBJECT_ID('dbo.usp_ChargeableCharConfig_InsertWithClose', 'P') IS NOT NULL
|
|
||||||
DROP PROCEDURE dbo.usp_ChargeableCharConfig_InsertWithClose;
|
|
||||||
GO
|
|
||||||
|
|
||||||
CREATE PROCEDURE dbo.usp_ChargeableCharConfig_InsertWithClose
|
|
||||||
@ProductTypeId INT = NULL,
|
|
||||||
@Symbol NVARCHAR(4),
|
|
||||||
@Category NVARCHAR(32),
|
|
||||||
@PricePerUnit DECIMAL(18,4),
|
|
||||||
@ValidFrom 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;
|
|
||||||
|
|
||||||
-- Validate ProductTypeId only when provided (NULL = global fallback, always valid).
|
|
||||||
-- FK constraint handles referential integrity; we throw 50404 explicitly for better UX.
|
|
||||||
IF @ProductTypeId IS NOT NULL
|
|
||||||
AND NOT EXISTS (SELECT 1 FROM dbo.ProductType WITH (NOLOCK) WHERE Id = @ProductTypeId)
|
|
||||||
BEGIN
|
|
||||||
ROLLBACK;
|
|
||||||
THROW 50404, 'ProductType not found', 1;
|
|
||||||
END
|
|
||||||
|
|
||||||
-- Read current vigente with range lock for serialization.
|
|
||||||
DECLARE @ActiveId BIGINT, @ActiveValidFrom DATE;
|
|
||||||
SELECT TOP 1
|
|
||||||
@ActiveId = Id,
|
|
||||||
@ActiveValidFrom = ValidFrom
|
|
||||||
FROM dbo.ChargeableCharConfig WITH (UPDLOCK, HOLDLOCK, ROWLOCK)
|
|
||||||
WHERE ((@ProductTypeId IS NULL AND ProductTypeId IS NULL)
|
|
||||||
OR (@ProductTypeId IS NOT NULL AND ProductTypeId = @ProductTypeId))
|
|
||||||
AND Symbol = @Symbol
|
|
||||||
AND ValidTo IS NULL;
|
|
||||||
|
|
||||||
-- Forward-only strict: new ValidFrom must be STRICTLY greater than active.ValidFrom.
|
|
||||||
IF @ActiveId IS NOT NULL AND @ValidFrom <= @ActiveValidFrom
|
|
||||||
BEGIN
|
|
||||||
ROLLBACK;
|
|
||||||
THROW 50409, 'ChargeableCharConfigForwardOnly: new ValidFrom must be > active.ValidFrom', 1;
|
|
||||||
END
|
|
||||||
|
|
||||||
-- Close the current vigente: ValidTo = new ValidFrom - 1 day.
|
|
||||||
IF @ActiveId IS NOT NULL
|
|
||||||
BEGIN
|
|
||||||
UPDATE dbo.ChargeableCharConfig
|
|
||||||
SET ValidTo = DATEADD(DAY, -1, @ValidFrom)
|
|
||||||
WHERE Id = @ActiveId;
|
|
||||||
SET @ClosedId = @ActiveId;
|
|
||||||
END
|
|
||||||
ELSE
|
|
||||||
SET @ClosedId = NULL;
|
|
||||||
|
|
||||||
-- Insert the new vigente.
|
|
||||||
INSERT INTO dbo.ChargeableCharConfig
|
|
||||||
(ProductTypeId, Symbol, Category, PricePerUnit, ValidFrom, ValidTo, IsActive)
|
|
||||||
VALUES
|
|
||||||
(@ProductTypeId, @Symbol, @Category, @PricePerUnit, @ValidFrom, NULL, 1);
|
|
||||||
SET @NewId = SCOPE_IDENTITY();
|
|
||||||
|
|
||||||
COMMIT TRANSACTION;
|
|
||||||
END TRY
|
|
||||||
BEGIN CATCH
|
|
||||||
IF XACT_STATE() <> 0 ROLLBACK TRANSACTION;
|
|
||||||
THROW;
|
|
||||||
END CATCH
|
|
||||||
END
|
|
||||||
GO
|
|
||||||
|
|
||||||
-- ═══════════════════════════════════════════════════════════════════════
|
|
||||||
-- SP: drop old GetActiveForMedio (renamed to GetActiveForProductType)
|
|
||||||
-- ═══════════════════════════════════════════════════════════════════════
|
|
||||||
|
|
||||||
IF OBJECT_ID('dbo.usp_ChargeableCharConfig_GetActiveForMedio', 'P') IS NOT NULL
|
|
||||||
BEGIN
|
|
||||||
DROP PROCEDURE dbo.usp_ChargeableCharConfig_GetActiveForMedio;
|
|
||||||
PRINT 'V023: usp_ChargeableCharConfig_GetActiveForMedio dropped.';
|
|
||||||
END
|
|
||||||
GO
|
|
||||||
|
|
||||||
-- ═══════════════════════════════════════════════════════════════════════
|
|
||||||
-- SP: usp_ChargeableCharConfig_GetActiveForProductType
|
|
||||||
-- Resolución per-ProductType + global fallback: 1 fila por Symbol.
|
|
||||||
-- CTE + ROW_NUMBER PARTITION BY Symbol ORDER BY per-PT(0) vs global(1).
|
|
||||||
-- @ProductTypeId: the specific product type to resolve for.
|
|
||||||
-- @AsOfDate: resolve active rows as of this date (for pricing snapshot).
|
|
||||||
-- ═══════════════════════════════════════════════════════════════════════
|
|
||||||
|
|
||||||
CREATE PROCEDURE dbo.usp_ChargeableCharConfig_GetActiveForProductType
|
|
||||||
@ProductTypeId INT,
|
|
||||||
@AsOfDate DATE
|
|
||||||
AS
|
|
||||||
BEGIN
|
|
||||||
SET NOCOUNT ON;
|
|
||||||
WITH Candidates AS (
|
|
||||||
SELECT
|
|
||||||
Id, ProductTypeId, Symbol, Category, PricePerUnit, ValidFrom, ValidTo, IsActive,
|
|
||||||
ROW_NUMBER() OVER (
|
|
||||||
PARTITION BY Symbol
|
|
||||||
ORDER BY
|
|
||||||
CASE WHEN ProductTypeId = @ProductTypeId THEN 0 ELSE 1 END, -- prefer specific over global
|
|
||||||
ValidFrom DESC
|
|
||||||
) AS rn
|
|
||||||
FROM dbo.ChargeableCharConfig
|
|
||||||
WHERE IsActive = 1
|
|
||||||
AND ValidFrom <= @AsOfDate
|
|
||||||
AND (ValidTo IS NULL OR ValidTo >= @AsOfDate)
|
|
||||||
AND (ProductTypeId = @ProductTypeId OR ProductTypeId IS NULL)
|
|
||||||
)
|
|
||||||
SELECT Id, ProductTypeId, Symbol, Category, PricePerUnit, ValidFrom, ValidTo, IsActive
|
|
||||||
FROM Candidates
|
|
||||||
WHERE rn = 1;
|
|
||||||
END
|
|
||||||
GO
|
|
||||||
|
|
||||||
-- ═══════════════════════════════════════════════════════════════════════
|
|
||||||
-- SP: usp_ChargeableCharConfig_ReactivateWithGuard (NEW — feature 3 of scope delta)
|
|
||||||
-- Opción A+guard: literal undo of the last close for (ProductTypeId, Symbol).
|
|
||||||
-- Guards:
|
|
||||||
-- - Row must exist → THROW 50404
|
|
||||||
-- - Row must be closed (ValidTo IS NOT NULL, IsActive = 0) → THROW 50410 if already active
|
|
||||||
-- - No vigente currently exists for (ProductTypeId, Symbol) → THROW 50411
|
|
||||||
-- - No posterior rows exist for (ProductTypeId, Symbol) → THROW 50412
|
|
||||||
-- On success: UPDATE IsActive = 1, ValidTo = NULL (literal undo).
|
|
||||||
-- Preserves forward-only invariant and maintains clean history.
|
|
||||||
-- ═══════════════════════════════════════════════════════════════════════
|
|
||||||
|
|
||||||
IF OBJECT_ID('dbo.usp_ChargeableCharConfig_ReactivateWithGuard', 'P') IS NOT NULL
|
|
||||||
DROP PROCEDURE dbo.usp_ChargeableCharConfig_ReactivateWithGuard;
|
|
||||||
GO
|
|
||||||
|
|
||||||
CREATE PROCEDURE dbo.usp_ChargeableCharConfig_ReactivateWithGuard
|
|
||||||
@Id BIGINT
|
|
||||||
AS
|
|
||||||
BEGIN
|
|
||||||
SET NOCOUNT ON;
|
|
||||||
SET XACT_ABORT ON;
|
|
||||||
SET TRANSACTION ISOLATION LEVEL SERIALIZABLE;
|
|
||||||
|
|
||||||
BEGIN TRY
|
|
||||||
BEGIN TRANSACTION;
|
|
||||||
|
|
||||||
-- Step 1: Lock + load target row.
|
|
||||||
DECLARE @ProductTypeId INT, @Symbol NVARCHAR(4), @ValidTo DATE, @IsActive BIT;
|
|
||||||
SELECT @ProductTypeId = ProductTypeId,
|
|
||||||
@Symbol = Symbol,
|
|
||||||
@ValidTo = ValidTo,
|
|
||||||
@IsActive = IsActive
|
|
||||||
FROM dbo.ChargeableCharConfig WITH (UPDLOCK, HOLDLOCK)
|
|
||||||
WHERE Id = @Id;
|
|
||||||
|
|
||||||
IF @@ROWCOUNT = 0
|
|
||||||
BEGIN
|
|
||||||
ROLLBACK TRANSACTION;
|
|
||||||
THROW 50404, 'ChargeableCharConfig row not found', 1;
|
|
||||||
END
|
|
||||||
|
|
||||||
-- Step 2: Row must be closed (ValidTo IS NOT NULL and IsActive = 0).
|
|
||||||
-- If it is currently active (ValidTo IS NULL), reactivation is nonsensical.
|
|
||||||
IF @ValidTo IS NULL
|
|
||||||
BEGIN
|
|
||||||
ROLLBACK TRANSACTION;
|
|
||||||
THROW 50410, 'Row is already active — reactivation not needed', 1;
|
|
||||||
END
|
|
||||||
|
|
||||||
-- Step 3: GUARD — no vigente currently for (ProductTypeId, Symbol).
|
|
||||||
-- Prevents re-opening a row while another is already vigente.
|
|
||||||
IF EXISTS (
|
|
||||||
SELECT 1 FROM dbo.ChargeableCharConfig
|
|
||||||
WHERE ((ProductTypeId = @ProductTypeId) OR (ProductTypeId IS NULL AND @ProductTypeId IS NULL))
|
|
||||||
AND Symbol = @Symbol
|
|
||||||
AND ValidTo IS NULL
|
|
||||||
)
|
|
||||||
BEGIN
|
|
||||||
ROLLBACK TRANSACTION;
|
|
||||||
THROW 50411, 'A current active row already exists for this ProductType/Symbol — cannot reactivate', 1;
|
|
||||||
END
|
|
||||||
|
|
||||||
-- Step 4: GUARD — no posterior rows exist for (ProductTypeId, Symbol) after @ValidTo.
|
|
||||||
-- Ensures this is the LAST closed row; reactivating an older row would violate
|
|
||||||
-- forward-only ordering of the temporal chain.
|
|
||||||
IF EXISTS (
|
|
||||||
SELECT 1 FROM dbo.ChargeableCharConfig
|
|
||||||
WHERE ((ProductTypeId = @ProductTypeId) OR (ProductTypeId IS NULL AND @ProductTypeId IS NULL))
|
|
||||||
AND Symbol = @Symbol
|
|
||||||
AND ValidFrom > @ValidTo
|
|
||||||
AND Id <> @Id
|
|
||||||
)
|
|
||||||
BEGIN
|
|
||||||
ROLLBACK TRANSACTION;
|
|
||||||
THROW 50412, 'Posterior rows exist for this ProductType/Symbol — reactivation not allowed', 1;
|
|
||||||
END
|
|
||||||
|
|
||||||
-- Step 5: Literal undo — re-open the row.
|
|
||||||
UPDATE dbo.ChargeableCharConfig
|
|
||||||
SET IsActive = 1,
|
|
||||||
ValidTo = NULL
|
|
||||||
WHERE Id = @Id;
|
|
||||||
|
|
||||||
COMMIT TRANSACTION;
|
|
||||||
END TRY
|
|
||||||
BEGIN CATCH
|
|
||||||
IF XACT_STATE() <> 0 ROLLBACK TRANSACTION;
|
|
||||||
THROW;
|
|
||||||
END CATCH
|
|
||||||
END
|
|
||||||
GO
|
|
||||||
|
|
||||||
PRINT '';
|
|
||||||
PRINT 'V023 applied — ChargeableCharConfig refactored to ProductType model:';
|
|
||||||
PRINT ' - MedioId dropped, ProductTypeId added (FK to dbo.ProductType)';
|
|
||||||
PRINT ' - UX_ChargeableCharConfig_Vigente + IX_ChargeableCharConfig_Query recreated';
|
|
||||||
PRINT ' - usp_ChargeableCharConfig_InsertWithClose: @MedioId → @ProductTypeId';
|
|
||||||
PRINT ' - usp_ChargeableCharConfig_GetActiveForMedio dropped';
|
|
||||||
PRINT ' - usp_ChargeableCharConfig_GetActiveForProductType created';
|
|
||||||
PRINT ' - usp_ChargeableCharConfig_ReactivateWithGuard created (NEW)';
|
|
||||||
PRINT ' - CK_Price_Positive replaced by CK_Price_NonNegative (>= 0 for opt-in billing)';
|
|
||||||
PRINT 'Next migration: V024 (reseed global rows with PricePerUnit = 0.0000).';
|
|
||||||
GO
|
|
||||||
@@ -1,22 +0,0 @@
|
|||||||
-- V024_ROLLBACK.sql
|
|
||||||
-- PRC-001: Reversa de V024__reseed_global_with_zero_price.sql.
|
|
||||||
--
|
|
||||||
-- Restaura las 4 filas globales de seed a PricePerUnit = 1.0000 (valor original de V022).
|
|
||||||
-- Solo ejecutar si V024 fue aplicado y se desea volver al estado previo.
|
|
||||||
--
|
|
||||||
-- Run on: SIGCM2 (dev), SIGCM2_Test_App, SIGCM2_Test_Api.
|
|
||||||
|
|
||||||
SET QUOTED_IDENTIFIER ON;
|
|
||||||
SET ANSI_NULLS ON;
|
|
||||||
SET NOCOUNT ON;
|
|
||||||
GO
|
|
||||||
|
|
||||||
UPDATE dbo.ChargeableCharConfig
|
|
||||||
SET PricePerUnit = CAST(1.0000 AS DECIMAL(18,4))
|
|
||||||
WHERE ProductTypeId IS NULL
|
|
||||||
AND Symbol IN (N'$', N'%', N'!', N'¡')
|
|
||||||
AND ValidTo IS NULL;
|
|
||||||
|
|
||||||
PRINT 'V024 ROLLBACK complete — global ChargeableCharConfig prices restored to 1.0000.';
|
|
||||||
PRINT 'Rows updated: ' + CAST(@@ROWCOUNT AS NVARCHAR(10));
|
|
||||||
GO
|
|
||||||
@@ -1,34 +0,0 @@
|
|||||||
-- V024__reseed_global_with_zero_price.sql
|
|
||||||
-- PRC-001 scope delta: actualiza las 4 filas globales de seed a PricePerUnit = 0.0000.
|
|
||||||
--
|
|
||||||
-- Cambios:
|
|
||||||
-- 1. UPDATE directo de las 4 filas globales vigentes ($, %, !, ¡) a PricePerUnit = 0.0000.
|
|
||||||
--
|
|
||||||
-- Decisión: UPDATE directo (no forward-only close+insert) porque:
|
|
||||||
-- - V022 seed price 1.0000 era siempre un placeholder nunca usado en lógica de negocio.
|
|
||||||
-- - No existe historial de facturación con el valor 1.0000.
|
|
||||||
-- - La semántica correcta es "opt-in billing": por defecto ningún tipo cobra especiales.
|
|
||||||
-- - La forward-only invariante aplica a cambios de precio en producción; este es un fix
|
|
||||||
-- de seed pre-go-live dentro de la misma branch feature (no mergeada a main aún).
|
|
||||||
-- See: scope-delta-1 en engram sdd/prc-001-word-counter-spike/scope-delta-1.
|
|
||||||
--
|
|
||||||
-- Patrón: UPDATE simple WHERE ProductTypeId IS NULL AND Symbol IN (...) AND ValidTo IS NULL.
|
|
||||||
-- Idempotente: UPDATE idempotente (re-ejecutar no cambia el resultado).
|
|
||||||
-- Reversa: V024_ROLLBACK.sql.
|
|
||||||
-- Depends on: V023 (ProductTypeId column must exist; CK_Price_NonNegative >= 0 required).
|
|
||||||
-- Run on: SIGCM2 (dev), SIGCM2_Test_App, SIGCM2_Test_Api.
|
|
||||||
|
|
||||||
SET QUOTED_IDENTIFIER ON;
|
|
||||||
SET ANSI_NULLS ON;
|
|
||||||
SET NOCOUNT ON;
|
|
||||||
GO
|
|
||||||
|
|
||||||
UPDATE dbo.ChargeableCharConfig
|
|
||||||
SET PricePerUnit = CAST(0.0000 AS DECIMAL(18,4))
|
|
||||||
WHERE ProductTypeId IS NULL
|
|
||||||
AND Symbol IN (N'$', N'%', N'!', N'¡')
|
|
||||||
AND ValidTo IS NULL;
|
|
||||||
|
|
||||||
PRINT 'V024 applied — global ChargeableCharConfig prices reset to 0.0000 (opt-in billing).';
|
|
||||||
PRINT 'Rows updated: ' + CAST(@@ROWCOUNT AS NVARCHAR(10));
|
|
||||||
GO
|
|
||||||
@@ -1,20 +0,0 @@
|
|||||||
-- V025_ROLLBACK.sql
|
|
||||||
-- Reversa de V025 — elimina los overrides demo de ChargeableCharConfig.
|
|
||||||
-- Los globales V022/V024 (ProductTypeId IS NULL) NO se tocan.
|
|
||||||
|
|
||||||
SET QUOTED_IDENTIFIER ON;
|
|
||||||
SET ANSI_NULLS ON;
|
|
||||||
SET NOCOUNT ON;
|
|
||||||
GO
|
|
||||||
|
|
||||||
DELETE FROM dbo.ChargeableCharConfig
|
|
||||||
WHERE ProductTypeId IS NOT NULL
|
|
||||||
AND ValidTo IS NULL
|
|
||||||
AND Symbol IN (N'$', N'%', N'!', N'¡')
|
|
||||||
AND ProductTypeId IN (
|
|
||||||
SELECT Id FROM dbo.ProductType
|
|
||||||
WHERE Nombre IN ('Clasificado', 'Notables', 'Fúnebres', 'Funebres')
|
|
||||||
);
|
|
||||||
|
|
||||||
PRINT 'V025 rolled back — demo overrides eliminated. Globales V022/V024 preservados.';
|
|
||||||
GO
|
|
||||||
@@ -1,101 +0,0 @@
|
|||||||
-- V025__seed_chargeable_char_overrides_demo.sql
|
|
||||||
-- PRC-001 followup #54: seeders de demo con valores ficticios per-ProductType.
|
|
||||||
--
|
|
||||||
-- Estrategia:
|
|
||||||
-- 1. Los 4 globales de V022+V024 quedan en 0.0000 (opt-in billing baseline).
|
|
||||||
-- 2. Para ProductTypes conocidos del roadmap (Clasificado, Notables, Fúnebres),
|
|
||||||
-- inserta overrides con precios ficticios coherentes con datos de demo del resto
|
|
||||||
-- del proyecto. Si el ProductType no existe, el bloque correspondiente no hace nada.
|
|
||||||
-- 3. Cuando PRD-008 seede los 12 tipos legacy, V025 puede re-aplicarse y creará
|
|
||||||
-- los overrides que falten (MERGE idempotente).
|
|
||||||
--
|
|
||||||
-- Precios ficticios (placeholders de demo — NO son tarifas reales):
|
|
||||||
-- Clasificado: $ = 5.0000, % = 3.0000, ! = 2.0000, ¡ = 2.0000
|
|
||||||
-- Notables: $ = 8.0000, % = 5.0000, ! = 4.0000, ¡ = 4.0000
|
|
||||||
-- Fúnebres: $ = 6.0000, % = 4.0000, ! = 3.5000, ¡ = 3.5000
|
|
||||||
--
|
|
||||||
-- Reversa: V025_ROLLBACK.sql (elimina los overrides demo dejando solo los globales V022/V024).
|
|
||||||
-- Run on: SIGCM2 (dev), SIGCM2_Test_App, SIGCM2_Test_Api.
|
|
||||||
-- Idempotente: usa MERGE por (ProductTypeId, Symbol, ValidTo IS NULL).
|
|
||||||
|
|
||||||
SET QUOTED_IDENTIFIER ON;
|
|
||||||
SET ANSI_NULLS ON;
|
|
||||||
SET NOCOUNT ON;
|
|
||||||
GO
|
|
||||||
|
|
||||||
DECLARE @ClasificadoId INT = (SELECT TOP 1 Id FROM dbo.ProductType WHERE Nombre = 'Clasificado' AND IsActive = 1);
|
|
||||||
DECLARE @NotablesId INT = (SELECT TOP 1 Id FROM dbo.ProductType WHERE Nombre = 'Notables' AND IsActive = 1);
|
|
||||||
DECLARE @FunebresId INT = (SELECT TOP 1 Id FROM dbo.ProductType WHERE Nombre IN ('Fúnebres','Funebres') AND IsActive = 1);
|
|
||||||
|
|
||||||
DECLARE @DemoValidFrom DATE = '2026-01-01';
|
|
||||||
|
|
||||||
-- Clasificado overrides
|
|
||||||
IF @ClasificadoId IS NOT NULL
|
|
||||||
BEGIN
|
|
||||||
MERGE dbo.ChargeableCharConfig AS t
|
|
||||||
USING (VALUES
|
|
||||||
(@ClasificadoId, N'$', N'Currency', CAST(5.0000 AS DECIMAL(18,4)), @DemoValidFrom),
|
|
||||||
(@ClasificadoId, N'%', N'Percentage', CAST(3.0000 AS DECIMAL(18,4)), @DemoValidFrom),
|
|
||||||
(@ClasificadoId, N'!', N'Exclamation', CAST(2.0000 AS DECIMAL(18,4)), @DemoValidFrom),
|
|
||||||
(@ClasificadoId, N'¡', N'Exclamation', CAST(2.0000 AS DECIMAL(18,4)), @DemoValidFrom)
|
|
||||||
) AS s (ProductTypeId, Symbol, Category, PricePerUnit, ValidFrom)
|
|
||||||
ON (t.ProductTypeId = s.ProductTypeId AND t.Symbol = s.Symbol AND t.ValidTo IS NULL)
|
|
||||||
WHEN NOT MATCHED THEN
|
|
||||||
INSERT (ProductTypeId, Symbol, Category, PricePerUnit, ValidFrom, ValidTo, IsActive)
|
|
||||||
VALUES (s.ProductTypeId, s.Symbol, s.Category, s.PricePerUnit, s.ValidFrom, NULL, 1);
|
|
||||||
PRINT 'V025: Clasificado overrides seeded (ProductTypeId=' + CAST(@ClasificadoId AS NVARCHAR(10)) + ').';
|
|
||||||
END
|
|
||||||
ELSE
|
|
||||||
PRINT 'V025: ProductType "Clasificado" not found — skipping Clasificado overrides.';
|
|
||||||
GO
|
|
||||||
|
|
||||||
DECLARE @NotablesId INT = (SELECT TOP 1 Id FROM dbo.ProductType WHERE Nombre = 'Notables' AND IsActive = 1);
|
|
||||||
DECLARE @DemoValidFrom DATE = '2026-01-01';
|
|
||||||
|
|
||||||
-- Notables overrides
|
|
||||||
IF @NotablesId IS NOT NULL
|
|
||||||
BEGIN
|
|
||||||
MERGE dbo.ChargeableCharConfig AS t
|
|
||||||
USING (VALUES
|
|
||||||
(@NotablesId, N'$', N'Currency', CAST(8.0000 AS DECIMAL(18,4)), @DemoValidFrom),
|
|
||||||
(@NotablesId, N'%', N'Percentage', CAST(5.0000 AS DECIMAL(18,4)), @DemoValidFrom),
|
|
||||||
(@NotablesId, N'!', N'Exclamation', CAST(4.0000 AS DECIMAL(18,4)), @DemoValidFrom),
|
|
||||||
(@NotablesId, N'¡', N'Exclamation', CAST(4.0000 AS DECIMAL(18,4)), @DemoValidFrom)
|
|
||||||
) AS s (ProductTypeId, Symbol, Category, PricePerUnit, ValidFrom)
|
|
||||||
ON (t.ProductTypeId = s.ProductTypeId AND t.Symbol = s.Symbol AND t.ValidTo IS NULL)
|
|
||||||
WHEN NOT MATCHED THEN
|
|
||||||
INSERT (ProductTypeId, Symbol, Category, PricePerUnit, ValidFrom, ValidTo, IsActive)
|
|
||||||
VALUES (s.ProductTypeId, s.Symbol, s.Category, s.PricePerUnit, s.ValidFrom, NULL, 1);
|
|
||||||
PRINT 'V025: Notables overrides seeded (ProductTypeId=' + CAST(@NotablesId AS NVARCHAR(10)) + ').';
|
|
||||||
END
|
|
||||||
ELSE
|
|
||||||
PRINT 'V025: ProductType "Notables" not found — skipping Notables overrides.';
|
|
||||||
GO
|
|
||||||
|
|
||||||
DECLARE @FunebresId INT = (SELECT TOP 1 Id FROM dbo.ProductType WHERE Nombre IN ('Fúnebres','Funebres') AND IsActive = 1);
|
|
||||||
DECLARE @DemoValidFrom DATE = '2026-01-01';
|
|
||||||
|
|
||||||
-- Fúnebres overrides
|
|
||||||
IF @FunebresId IS NOT NULL
|
|
||||||
BEGIN
|
|
||||||
MERGE dbo.ChargeableCharConfig AS t
|
|
||||||
USING (VALUES
|
|
||||||
(@FunebresId, N'$', N'Currency', CAST(6.0000 AS DECIMAL(18,4)), @DemoValidFrom),
|
|
||||||
(@FunebresId, N'%', N'Percentage', CAST(4.0000 AS DECIMAL(18,4)), @DemoValidFrom),
|
|
||||||
(@FunebresId, N'!', N'Exclamation', CAST(3.5000 AS DECIMAL(18,4)), @DemoValidFrom),
|
|
||||||
(@FunebresId, N'¡', N'Exclamation', CAST(3.5000 AS DECIMAL(18,4)), @DemoValidFrom)
|
|
||||||
) AS s (ProductTypeId, Symbol, Category, PricePerUnit, ValidFrom)
|
|
||||||
ON (t.ProductTypeId = s.ProductTypeId AND t.Symbol = s.Symbol AND t.ValidTo IS NULL)
|
|
||||||
WHEN NOT MATCHED THEN
|
|
||||||
INSERT (ProductTypeId, Symbol, Category, PricePerUnit, ValidFrom, ValidTo, IsActive)
|
|
||||||
VALUES (s.ProductTypeId, s.Symbol, s.Category, s.PricePerUnit, s.ValidFrom, NULL, 1);
|
|
||||||
PRINT 'V025: Fúnebres overrides seeded (ProductTypeId=' + CAST(@FunebresId AS NVARCHAR(10)) + ').';
|
|
||||||
END
|
|
||||||
ELSE
|
|
||||||
PRINT 'V025: ProductType "Fúnebres/Funebres" not found — skipping Fúnebres overrides.';
|
|
||||||
GO
|
|
||||||
|
|
||||||
PRINT '';
|
|
||||||
PRINT 'V025 applied — demo overrides (fictitious prices) seeded for ProductTypes: Clasificado, Notables, Fúnebres (only where they exist).';
|
|
||||||
PRINT 'NOTE: Los 4 globales (V022/V024) quedan intactos en 0.0000. Estos overrides son PLACEHOLDERS DE DEMO — reemplazar antes de go-live.';
|
|
||||||
GO
|
|
||||||
@@ -1,247 +0,0 @@
|
|||||||
using FluentValidation;
|
|
||||||
using Microsoft.AspNetCore.Mvc;
|
|
||||||
using SIGCM2.Api.Authorization;
|
|
||||||
using SIGCM2.Application.Abstractions;
|
|
||||||
using SIGCM2.Application.Common;
|
|
||||||
using SIGCM2.Application.Pricing.ChargeableChars;
|
|
||||||
using SIGCM2.Application.Pricing.ChargeableChars.Create;
|
|
||||||
using SIGCM2.Application.Pricing.ChargeableChars.Deactivate;
|
|
||||||
using SIGCM2.Application.Pricing.ChargeableChars.Delete;
|
|
||||||
using SIGCM2.Application.Pricing.ChargeableChars.GetById;
|
|
||||||
using SIGCM2.Application.Pricing.ChargeableChars.List;
|
|
||||||
using SIGCM2.Application.Pricing.ChargeableChars.Reactivate;
|
|
||||||
using SIGCM2.Application.Pricing.ChargeableChars.SchedulePrice;
|
|
||||||
|
|
||||||
namespace SIGCM2.Api.Controllers;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// PRC-001: Admin endpoints for ChargeableCharConfig management.
|
|
||||||
/// All endpoints require 'tasacion:caracteres_especiales:gestionar'.
|
|
||||||
/// Route base: api/v1/admin/chargeable-chars
|
|
||||||
/// </summary>
|
|
||||||
[ApiController]
|
|
||||||
[Route("api/v1/admin/chargeable-chars")]
|
|
||||||
public sealed class ChargeableCharConfigController : ControllerBase
|
|
||||||
{
|
|
||||||
private readonly IDispatcher _dispatcher;
|
|
||||||
private readonly IValidator<CreateChargeableCharConfigCommand> _createValidator;
|
|
||||||
private readonly IValidator<SchedulePriceChangeCommand> _scheduleValidator;
|
|
||||||
|
|
||||||
public ChargeableCharConfigController(
|
|
||||||
IDispatcher dispatcher,
|
|
||||||
IValidator<CreateChargeableCharConfigCommand> createValidator,
|
|
||||||
IValidator<SchedulePriceChangeCommand> scheduleValidator)
|
|
||||||
{
|
|
||||||
_dispatcher = dispatcher;
|
|
||||||
_createValidator = createValidator;
|
|
||||||
_scheduleValidator = scheduleValidator;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── GET /api/v1/admin/chargeable-chars ────────────────────────────────────
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Returns a paginated list of ChargeableCharConfig rows.
|
|
||||||
/// Filters: productTypeId (optional, long?), activeOnly (bool, default true).
|
|
||||||
/// Pagination: skip/take model mapped to page/pageSize — or use page/pageSize directly.
|
|
||||||
/// Defaults: page=1, pageSize=20. Clamped: pageSize max 200.
|
|
||||||
/// </summary>
|
|
||||||
[HttpGet]
|
|
||||||
[RequirePermission("tasacion:caracteres_especiales:gestionar")]
|
|
||||||
[ProducesResponseType(typeof(PagedResult<ChargeableCharConfigDto>), StatusCodes.Status200OK)]
|
|
||||||
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
|
|
||||||
[ProducesResponseType(StatusCodes.Status403Forbidden)]
|
|
||||||
public async Task<IActionResult> List(
|
|
||||||
[FromQuery] long? productTypeId,
|
|
||||||
[FromQuery] bool activeOnly = true,
|
|
||||||
[FromQuery] int? page = null,
|
|
||||||
[FromQuery] int? pageSize = null,
|
|
||||||
[FromQuery] int? skip = null,
|
|
||||||
[FromQuery] int? take = null)
|
|
||||||
{
|
|
||||||
// Support both page/pageSize and skip/take query patterns
|
|
||||||
int resolvedPage;
|
|
||||||
int resolvedPageSize;
|
|
||||||
|
|
||||||
if (skip is not null || take is not null)
|
|
||||||
{
|
|
||||||
// Convert skip/take to page/pageSize
|
|
||||||
resolvedPageSize = Math.Min(take ?? 50, 200);
|
|
||||||
resolvedPage = resolvedPageSize > 0
|
|
||||||
? ((skip ?? 0) / resolvedPageSize) + 1
|
|
||||||
: 1;
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
resolvedPage = page ?? 1;
|
|
||||||
resolvedPageSize = Math.Min(pageSize ?? 20, 200);
|
|
||||||
}
|
|
||||||
|
|
||||||
var query = new ListChargeableCharConfigQuery(productTypeId, activeOnly, resolvedPage, resolvedPageSize);
|
|
||||||
var result = await _dispatcher.Send<ListChargeableCharConfigQuery, PagedResult<ChargeableCharConfigDto>>(query);
|
|
||||||
return Ok(result);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── GET /api/v1/admin/chargeable-chars/{id} ───────────────────────────────
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Returns a single ChargeableCharConfig by Id. Returns 404 if not found.
|
|
||||||
/// </summary>
|
|
||||||
[HttpGet("{id:long}")]
|
|
||||||
[RequirePermission("tasacion:caracteres_especiales:gestionar")]
|
|
||||||
[ProducesResponseType(typeof(ChargeableCharConfigDto), StatusCodes.Status200OK)]
|
|
||||||
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
|
|
||||||
[ProducesResponseType(StatusCodes.Status403Forbidden)]
|
|
||||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
|
||||||
public async Task<IActionResult> GetById([FromRoute] long id)
|
|
||||||
{
|
|
||||||
var result = await _dispatcher.Send<GetChargeableCharConfigByIdQuery, ChargeableCharConfigDto?>(
|
|
||||||
new GetChargeableCharConfigByIdQuery(id));
|
|
||||||
return result is null ? NotFound() : Ok(result);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── POST /api/v1/admin/chargeable-chars ───────────────────────────────────
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Creates a new ChargeableCharConfig row. Closes the current active row for (ProductTypeId, Symbol) if one exists.
|
|
||||||
/// Returns 201 Created with Location header pointing to GET /{id}.
|
|
||||||
/// </summary>
|
|
||||||
[HttpPost]
|
|
||||||
[RequirePermission("tasacion:caracteres_especiales:gestionar")]
|
|
||||||
[ProducesResponseType(typeof(CreateChargeableCharConfigResponse), StatusCodes.Status201Created)]
|
|
||||||
[ProducesResponseType(StatusCodes.Status400BadRequest)]
|
|
||||||
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
|
|
||||||
[ProducesResponseType(StatusCodes.Status403Forbidden)]
|
|
||||||
[ProducesResponseType(StatusCodes.Status409Conflict)]
|
|
||||||
public async Task<IActionResult> Create([FromBody] CreateChargeableCharConfigRequest request)
|
|
||||||
{
|
|
||||||
var command = new CreateChargeableCharConfigCommand(
|
|
||||||
request.ProductTypeId,
|
|
||||||
request.Symbol,
|
|
||||||
request.Category,
|
|
||||||
request.PricePerUnit,
|
|
||||||
request.ValidFrom);
|
|
||||||
|
|
||||||
var validation = await _createValidator.ValidateAsync(command);
|
|
||||||
if (!validation.IsValid)
|
|
||||||
{
|
|
||||||
var errors = validation.Errors
|
|
||||||
.GroupBy(e => e.PropertyName)
|
|
||||||
.ToDictionary(g => g.Key, g => g.Select(e => e.ErrorMessage).ToArray());
|
|
||||||
return BadRequest(new { errors });
|
|
||||||
}
|
|
||||||
|
|
||||||
var result = await _dispatcher.Send<CreateChargeableCharConfigCommand, CreateChargeableCharConfigResponse>(command);
|
|
||||||
return CreatedAtAction(nameof(GetById), new { id = result.Id }, result);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── PUT /api/v1/admin/chargeable-chars/{id}/price ────────────────────────
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Schedules a price change for an existing ChargeableCharConfig.
|
|
||||||
/// Closes the current active row and opens a new one with the new price + ValidFrom.
|
|
||||||
/// ValidFrom must be strictly greater than the existing row's ValidFrom (forward-only).
|
|
||||||
/// </summary>
|
|
||||||
[HttpPut("{id:long}/price")]
|
|
||||||
[RequirePermission("tasacion:caracteres_especiales:gestionar")]
|
|
||||||
[ProducesResponseType(typeof(SchedulePriceChangeResponse), StatusCodes.Status200OK)]
|
|
||||||
[ProducesResponseType(StatusCodes.Status400BadRequest)]
|
|
||||||
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
|
|
||||||
[ProducesResponseType(StatusCodes.Status403Forbidden)]
|
|
||||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
|
||||||
[ProducesResponseType(StatusCodes.Status409Conflict)]
|
|
||||||
public async Task<IActionResult> SchedulePriceChange(
|
|
||||||
[FromRoute] long id,
|
|
||||||
[FromBody] SchedulePriceChangeRequest request)
|
|
||||||
{
|
|
||||||
var command = new SchedulePriceChangeCommand(id, request.PricePerUnit, request.ValidFrom);
|
|
||||||
|
|
||||||
var validation = await _scheduleValidator.ValidateAsync(command);
|
|
||||||
if (!validation.IsValid)
|
|
||||||
{
|
|
||||||
var errors = validation.Errors
|
|
||||||
.GroupBy(e => e.PropertyName)
|
|
||||||
.ToDictionary(g => g.Key, g => g.Select(e => e.ErrorMessage).ToArray());
|
|
||||||
return BadRequest(new { errors });
|
|
||||||
}
|
|
||||||
|
|
||||||
var result = await _dispatcher.Send<SchedulePriceChangeCommand, SchedulePriceChangeResponse>(command);
|
|
||||||
return Ok(result);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── PATCH /api/v1/admin/chargeable-chars/{id}/deactivate ─────────────────
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Deactivates a ChargeableCharConfig row (sets IsActive=false, ValidTo=today_AR).
|
|
||||||
/// Idempotent: calling on an already-inactive row is a no-op.
|
|
||||||
/// </summary>
|
|
||||||
[HttpPatch("{id:long}/deactivate")]
|
|
||||||
[RequirePermission("tasacion:caracteres_especiales:gestionar")]
|
|
||||||
[ProducesResponseType(typeof(DeactivateChargeableCharConfigResponse), StatusCodes.Status200OK)]
|
|
||||||
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
|
|
||||||
[ProducesResponseType(StatusCodes.Status403Forbidden)]
|
|
||||||
public async Task<IActionResult> Deactivate([FromRoute] long id)
|
|
||||||
{
|
|
||||||
var result = await _dispatcher.Send<DeactivateChargeableCharConfigCommand, DeactivateChargeableCharConfigResponse>(
|
|
||||||
new DeactivateChargeableCharConfigCommand(id));
|
|
||||||
return Ok(result);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── PATCH /api/v1/admin/chargeable-chars/{id}/reactivate ─────────────────
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Reactivates a previously closed ChargeableCharConfig row (undo last deactivation).
|
|
||||||
/// Guard rules (enforced by SP):
|
|
||||||
/// - ALREADY_ACTIVE: target row is already active → 409
|
|
||||||
/// - VIGENTE_EXISTS: a different active row exists for (ProductTypeId, Symbol) → 409
|
|
||||||
/// - POSTERIOR_ROWS_EXIST: rows with higher ValidFrom exist after the target → 409
|
|
||||||
/// </summary>
|
|
||||||
[HttpPatch("{id:long}/reactivate")]
|
|
||||||
[RequirePermission("tasacion:caracteres_especiales:gestionar")]
|
|
||||||
[ProducesResponseType(typeof(ReactivateChargeableCharConfigResponse), StatusCodes.Status200OK)]
|
|
||||||
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
|
|
||||||
[ProducesResponseType(StatusCodes.Status403Forbidden)]
|
|
||||||
[ProducesResponseType(StatusCodes.Status409Conflict)]
|
|
||||||
public async Task<IActionResult> Reactivate([FromRoute] long id)
|
|
||||||
{
|
|
||||||
var result = await _dispatcher.Send<ReactivateChargeableCharConfigCommand, ReactivateChargeableCharConfigResponse>(
|
|
||||||
new ReactivateChargeableCharConfigCommand(id));
|
|
||||||
return Ok(result);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── DELETE /api/v1/admin/chargeable-chars/{id} ───────────────────────────
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Deletes a ChargeableCharConfig row.
|
|
||||||
/// NOTE: With SYSTEM_VERSIONING ON, the row is moved to the history table (temporal audit preserved).
|
|
||||||
/// The row disappears from all current-state queries.
|
|
||||||
/// Guard for "used in invoicing" is deferred to FAC-001 followup issue.
|
|
||||||
/// Returns 200 + { id } consistent with the Deactivate pattern.
|
|
||||||
/// </summary>
|
|
||||||
[HttpDelete("{id:long}")]
|
|
||||||
[RequirePermission("tasacion:caracteres_especiales:gestionar")]
|
|
||||||
[ProducesResponseType(typeof(DeleteChargeableCharConfigResponse), StatusCodes.Status200OK)]
|
|
||||||
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
|
|
||||||
[ProducesResponseType(StatusCodes.Status403Forbidden)]
|
|
||||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
|
||||||
public async Task<IActionResult> Delete([FromRoute] long id)
|
|
||||||
{
|
|
||||||
var result = await _dispatcher.Send<DeleteChargeableCharConfigCommand, DeleteChargeableCharConfigResponse>(
|
|
||||||
new DeleteChargeableCharConfigCommand(id));
|
|
||||||
return Ok(result);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Request body records ──────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
/// <summary>PRC-001: Create ChargeableCharConfig request body.</summary>
|
|
||||||
public sealed record CreateChargeableCharConfigRequest(
|
|
||||||
long? ProductTypeId,
|
|
||||||
string Symbol,
|
|
||||||
string Category,
|
|
||||||
decimal PricePerUnit,
|
|
||||||
DateOnly ValidFrom);
|
|
||||||
|
|
||||||
/// <summary>PRC-001: Schedule price change request body.</summary>
|
|
||||||
public sealed record SchedulePriceChangeRequest(
|
|
||||||
decimal PricePerUnit,
|
|
||||||
DateOnly ValidFrom);
|
|
||||||
@@ -1,102 +0,0 @@
|
|||||||
using FluentValidation;
|
|
||||||
using Microsoft.AspNetCore.Mvc;
|
|
||||||
using SIGCM2.Api.Authorization;
|
|
||||||
using SIGCM2.Application.Abstractions;
|
|
||||||
using SIGCM2.Application.Common;
|
|
||||||
using SIGCM2.Application.Products.Prices;
|
|
||||||
using SIGCM2.Application.Products.Prices.AddPrice;
|
|
||||||
using SIGCM2.Application.Products.Prices.GetHistory;
|
|
||||||
|
|
||||||
namespace SIGCM2.Api.Controllers;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// PRD-003: ProductPrices historic pricing management.
|
|
||||||
/// Read endpoint at GET /api/v1/products/{id}/prices — requires 'catalogo:productos:gestionar'.
|
|
||||||
/// Write endpoint at POST /api/v1/admin/products/{id}/prices — requires 'catalogo:productos:gestionar'.
|
|
||||||
/// </summary>
|
|
||||||
[ApiController]
|
|
||||||
public sealed class ProductPricesController : ControllerBase
|
|
||||||
{
|
|
||||||
private readonly IDispatcher _dispatcher;
|
|
||||||
private readonly IValidator<AddProductPriceCommand> _addValidator;
|
|
||||||
|
|
||||||
public ProductPricesController(
|
|
||||||
IDispatcher dispatcher,
|
|
||||||
IValidator<AddProductPriceCommand> addValidator)
|
|
||||||
{
|
|
||||||
_dispatcher = dispatcher;
|
|
||||||
_addValidator = addValidator;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── READ endpoint ──────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Returns a paginated page of price history for a Product, ordered descending by PriceValidFrom.
|
|
||||||
/// Defaults: page=1, pageSize=20. Clamping: page ≥ 1, pageSize ∈ [1, 100].
|
|
||||||
/// Returns 200 with empty items if the product has no prices yet or page is beyond total.
|
|
||||||
/// Returns 404 if the product does not exist.
|
|
||||||
/// Returns 401 if not authenticated, 403 if missing 'catalogo:productos:gestionar' permission.
|
|
||||||
/// </summary>
|
|
||||||
[HttpGet("api/v1/products/{id:int}/prices")]
|
|
||||||
[RequirePermission("catalogo:productos:gestionar")]
|
|
||||||
[ProducesResponseType(typeof(PagedResult<ProductPriceDto>), StatusCodes.Status200OK)]
|
|
||||||
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
|
|
||||||
[ProducesResponseType(StatusCodes.Status403Forbidden)]
|
|
||||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
|
||||||
public async Task<IActionResult> GetProductPrices(
|
|
||||||
[FromRoute] int id,
|
|
||||||
[FromQuery] int? page,
|
|
||||||
[FromQuery] int? pageSize)
|
|
||||||
{
|
|
||||||
var query = new GetProductPricesQuery(
|
|
||||||
ProductId: id,
|
|
||||||
Page: page ?? 1,
|
|
||||||
PageSize: pageSize ?? 20);
|
|
||||||
var result = await _dispatcher.Send<GetProductPricesQuery, PagedResult<ProductPriceDto>>(query);
|
|
||||||
return Ok(result);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── WRITE endpoint ─────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Adds a new price to a Product. Closes the current active price if one exists.
|
|
||||||
/// PriceValidFrom must be >= today_AR and strictly greater than the active price's PriceValidFrom.
|
|
||||||
/// Returns 201 Created with Location header pointing to GET /api/v1/products/{id}/prices.
|
|
||||||
/// </summary>
|
|
||||||
[HttpPost("api/v1/admin/products/{id:int}/prices")]
|
|
||||||
[RequirePermission("catalogo:productos:gestionar")]
|
|
||||||
[ProducesResponseType(typeof(AddProductPriceResponse), StatusCodes.Status201Created)]
|
|
||||||
[ProducesResponseType(StatusCodes.Status400BadRequest)]
|
|
||||||
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
|
|
||||||
[ProducesResponseType(StatusCodes.Status403Forbidden)]
|
|
||||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
|
||||||
[ProducesResponseType(StatusCodes.Status409Conflict)]
|
|
||||||
public async Task<IActionResult> AddProductPrice(
|
|
||||||
[FromRoute] int id,
|
|
||||||
[FromBody] AddProductPriceRequest request)
|
|
||||||
{
|
|
||||||
var command = new AddProductPriceCommand(
|
|
||||||
ProductId: id,
|
|
||||||
Price: request.Price,
|
|
||||||
PriceValidFrom: request.PriceValidFrom);
|
|
||||||
|
|
||||||
var validation = await _addValidator.ValidateAsync(command);
|
|
||||||
if (!validation.IsValid)
|
|
||||||
{
|
|
||||||
var errors = validation.Errors
|
|
||||||
.GroupBy(e => e.PropertyName)
|
|
||||||
.ToDictionary(g => g.Key, g => g.Select(e => e.ErrorMessage).ToArray());
|
|
||||||
return BadRequest(new { errors });
|
|
||||||
}
|
|
||||||
|
|
||||||
var result = await _dispatcher.Send<AddProductPriceCommand, AddProductPriceResponse>(command);
|
|
||||||
return CreatedAtAction(nameof(GetProductPrices), new { id }, result);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Request body record ───────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
/// <summary>PRD-003: Add ProductPrice request body.</summary>
|
|
||||||
public sealed record AddProductPriceRequest(
|
|
||||||
decimal Price,
|
|
||||||
DateOnly PriceValidFrom);
|
|
||||||
@@ -1,184 +0,0 @@
|
|||||||
using FluentValidation;
|
|
||||||
using Microsoft.AspNetCore.Authorization;
|
|
||||||
using Microsoft.AspNetCore.Mvc;
|
|
||||||
using SIGCM2.Api.Authorization;
|
|
||||||
using SIGCM2.Application.Abstractions;
|
|
||||||
using SIGCM2.Application.Common;
|
|
||||||
using SIGCM2.Application.ProductTypes.Create;
|
|
||||||
using SIGCM2.Application.ProductTypes.Deactivate;
|
|
||||||
using SIGCM2.Application.ProductTypes.GetById;
|
|
||||||
using SIGCM2.Application.ProductTypes.List;
|
|
||||||
using SIGCM2.Application.ProductTypes.Update;
|
|
||||||
|
|
||||||
namespace SIGCM2.Api.Controllers;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// PRD-001: ProductType catalog management.
|
|
||||||
/// Read endpoints at /api/v1/product-types — require authentication (any role).
|
|
||||||
/// Write endpoints at /api/v1/admin/product-types — require 'catalogo:tipos:gestionar'.
|
|
||||||
/// </summary>
|
|
||||||
[ApiController]
|
|
||||||
public sealed class ProductTypesController : ControllerBase
|
|
||||||
{
|
|
||||||
private readonly IDispatcher _dispatcher;
|
|
||||||
private readonly IValidator<CreateProductTypeCommand> _createValidator;
|
|
||||||
private readonly IValidator<UpdateProductTypeCommand> _updateValidator;
|
|
||||||
|
|
||||||
public ProductTypesController(
|
|
||||||
IDispatcher dispatcher,
|
|
||||||
IValidator<CreateProductTypeCommand> createValidator,
|
|
||||||
IValidator<UpdateProductTypeCommand> updateValidator)
|
|
||||||
{
|
|
||||||
_dispatcher = dispatcher;
|
|
||||||
_createValidator = createValidator;
|
|
||||||
_updateValidator = updateValidator;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── READ endpoints ─────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
/// <summary>Returns a paginated list of ProductTypes. Requires authentication.</summary>
|
|
||||||
[HttpGet("api/v1/product-types")]
|
|
||||||
[Authorize]
|
|
||||||
[ProducesResponseType(typeof(PagedResult<ProductTypeListItemDto>), StatusCodes.Status200OK)]
|
|
||||||
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
|
|
||||||
public async Task<IActionResult> ListProductTypes(
|
|
||||||
[FromQuery] int page = 1,
|
|
||||||
[FromQuery] int pageSize = 20,
|
|
||||||
[FromQuery] bool? activo = true,
|
|
||||||
[FromQuery] string? search = null)
|
|
||||||
{
|
|
||||||
var query = new ListProductTypesQuery(page, pageSize, activo, search);
|
|
||||||
var result = await _dispatcher.Send<ListProductTypesQuery, PagedResult<ProductTypeListItemDto>>(query);
|
|
||||||
return Ok(result);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>Returns a single ProductType by id. Requires authentication.</summary>
|
|
||||||
[HttpGet("api/v1/product-types/{id:int}")]
|
|
||||||
[Authorize]
|
|
||||||
[ProducesResponseType(typeof(ProductTypeDetailDto), StatusCodes.Status200OK)]
|
|
||||||
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
|
|
||||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
|
||||||
public async Task<IActionResult> GetProductTypeById([FromRoute] int id)
|
|
||||||
{
|
|
||||||
var query = new GetProductTypeByIdQuery(id);
|
|
||||||
var result = await _dispatcher.Send<GetProductTypeByIdQuery, ProductTypeDetailDto>(query);
|
|
||||||
return Ok(result);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── WRITE endpoints ────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
/// <summary>Creates a new ProductType. Requires catalogo:tipos:gestionar.</summary>
|
|
||||||
[HttpPost("api/v1/admin/product-types")]
|
|
||||||
[RequirePermission("catalogo:tipos:gestionar")]
|
|
||||||
[ProducesResponseType(typeof(ProductTypeCreatedDto), StatusCodes.Status201Created)]
|
|
||||||
[ProducesResponseType(StatusCodes.Status400BadRequest)]
|
|
||||||
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
|
|
||||||
[ProducesResponseType(StatusCodes.Status403Forbidden)]
|
|
||||||
[ProducesResponseType(StatusCodes.Status409Conflict)]
|
|
||||||
public async Task<IActionResult> CreateProductType([FromBody] CreateProductTypeRequest request)
|
|
||||||
{
|
|
||||||
var command = new CreateProductTypeCommand(
|
|
||||||
Nombre: request.Nombre ?? string.Empty,
|
|
||||||
HasDuration: request.HasDuration,
|
|
||||||
RequiresText: request.RequiresText,
|
|
||||||
RequiresCategory: request.RequiresCategory,
|
|
||||||
IsBundle: request.IsBundle,
|
|
||||||
AllowImages: request.AllowImages,
|
|
||||||
MaxImages: request.MaxImages,
|
|
||||||
MaxImageSizeMB: request.MaxImageSizeMB,
|
|
||||||
MaxImageWidth: request.MaxImageWidth,
|
|
||||||
MaxImageHeight: request.MaxImageHeight);
|
|
||||||
|
|
||||||
var validation = await _createValidator.ValidateAsync(command);
|
|
||||||
if (!validation.IsValid)
|
|
||||||
{
|
|
||||||
var errors = validation.Errors
|
|
||||||
.GroupBy(e => e.PropertyName)
|
|
||||||
.ToDictionary(g => g.Key, g => g.Select(e => e.ErrorMessage).ToArray());
|
|
||||||
return BadRequest(new { errors });
|
|
||||||
}
|
|
||||||
|
|
||||||
var result = await _dispatcher.Send<CreateProductTypeCommand, ProductTypeCreatedDto>(command);
|
|
||||||
return CreatedAtAction(nameof(GetProductTypeById), new { id = result.Id }, result);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>Updates a ProductType. Requires catalogo:tipos:gestionar.</summary>
|
|
||||||
[HttpPut("api/v1/admin/product-types/{id:int}")]
|
|
||||||
[RequirePermission("catalogo:tipos:gestionar")]
|
|
||||||
[ProducesResponseType(typeof(ProductTypeUpdatedDto), StatusCodes.Status200OK)]
|
|
||||||
[ProducesResponseType(StatusCodes.Status400BadRequest)]
|
|
||||||
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
|
|
||||||
[ProducesResponseType(StatusCodes.Status403Forbidden)]
|
|
||||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
|
||||||
[ProducesResponseType(StatusCodes.Status409Conflict)]
|
|
||||||
public async Task<IActionResult> UpdateProductType([FromRoute] int id, [FromBody] UpdateProductTypeRequest request)
|
|
||||||
{
|
|
||||||
var command = new UpdateProductTypeCommand(
|
|
||||||
Id: id,
|
|
||||||
Nombre: request.Nombre ?? string.Empty,
|
|
||||||
HasDuration: request.HasDuration,
|
|
||||||
RequiresText: request.RequiresText,
|
|
||||||
RequiresCategory: request.RequiresCategory,
|
|
||||||
IsBundle: request.IsBundle,
|
|
||||||
AllowImages: request.AllowImages,
|
|
||||||
MaxImages: request.MaxImages,
|
|
||||||
MaxImageSizeMB: request.MaxImageSizeMB,
|
|
||||||
MaxImageWidth: request.MaxImageWidth,
|
|
||||||
MaxImageHeight: request.MaxImageHeight);
|
|
||||||
|
|
||||||
var validation = await _updateValidator.ValidateAsync(command);
|
|
||||||
if (!validation.IsValid)
|
|
||||||
{
|
|
||||||
var errors = validation.Errors
|
|
||||||
.GroupBy(e => e.PropertyName)
|
|
||||||
.ToDictionary(g => g.Key, g => g.Select(e => e.ErrorMessage).ToArray());
|
|
||||||
return BadRequest(new { errors });
|
|
||||||
}
|
|
||||||
|
|
||||||
var result = await _dispatcher.Send<UpdateProductTypeCommand, ProductTypeUpdatedDto>(command);
|
|
||||||
return Ok(result);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>Soft-deletes (deactivates) a ProductType. Requires catalogo:tipos:gestionar.</summary>
|
|
||||||
[HttpDelete("api/v1/admin/product-types/{id:int}")]
|
|
||||||
[RequirePermission("catalogo:tipos:gestionar")]
|
|
||||||
[ProducesResponseType(StatusCodes.Status204NoContent)]
|
|
||||||
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
|
|
||||||
[ProducesResponseType(StatusCodes.Status403Forbidden)]
|
|
||||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
|
||||||
[ProducesResponseType(StatusCodes.Status409Conflict)]
|
|
||||||
public async Task<IActionResult> DeactivateProductType([FromRoute] int id)
|
|
||||||
{
|
|
||||||
var command = new DeactivateProductTypeCommand(id);
|
|
||||||
await _dispatcher.Send<DeactivateProductTypeCommand, ProductTypeStatusDto>(command);
|
|
||||||
return NoContent();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Request body records ──────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
/// <summary>PRD-001: Create ProductType request body.</summary>
|
|
||||||
public sealed record CreateProductTypeRequest(
|
|
||||||
string? Nombre,
|
|
||||||
bool HasDuration = false,
|
|
||||||
bool RequiresText = false,
|
|
||||||
bool RequiresCategory = false,
|
|
||||||
bool IsBundle = false,
|
|
||||||
bool AllowImages = false,
|
|
||||||
int? MaxImages = null,
|
|
||||||
decimal? MaxImageSizeMB = null,
|
|
||||||
int? MaxImageWidth = null,
|
|
||||||
int? MaxImageHeight = null);
|
|
||||||
|
|
||||||
/// <summary>PRD-001: Update ProductType request body.</summary>
|
|
||||||
public sealed record UpdateProductTypeRequest(
|
|
||||||
string? Nombre,
|
|
||||||
bool HasDuration = false,
|
|
||||||
bool RequiresText = false,
|
|
||||||
bool RequiresCategory = false,
|
|
||||||
bool IsBundle = false,
|
|
||||||
bool AllowImages = false,
|
|
||||||
int? MaxImages = null,
|
|
||||||
decimal? MaxImageSizeMB = null,
|
|
||||||
int? MaxImageWidth = null,
|
|
||||||
int? MaxImageHeight = null);
|
|
||||||
@@ -1,169 +0,0 @@
|
|||||||
using FluentValidation;
|
|
||||||
using Microsoft.AspNetCore.Authorization;
|
|
||||||
using Microsoft.AspNetCore.Mvc;
|
|
||||||
using SIGCM2.Api.Authorization;
|
|
||||||
using SIGCM2.Application.Abstractions;
|
|
||||||
using SIGCM2.Application.Common;
|
|
||||||
using SIGCM2.Application.Products.Create;
|
|
||||||
using SIGCM2.Application.Products.Deactivate;
|
|
||||||
using SIGCM2.Application.Products.GetById;
|
|
||||||
using SIGCM2.Application.Products.List;
|
|
||||||
using SIGCM2.Application.Products.Update;
|
|
||||||
|
|
||||||
namespace SIGCM2.Api.Controllers;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// PRD-002: Product catalog management.
|
|
||||||
/// Read endpoints at /api/v1/products — require authentication (any role).
|
|
||||||
/// Write endpoints at /api/v1/admin/products — require 'catalogo:productos:gestionar'.
|
|
||||||
/// </summary>
|
|
||||||
[ApiController]
|
|
||||||
public sealed class ProductsController : ControllerBase
|
|
||||||
{
|
|
||||||
private readonly IDispatcher _dispatcher;
|
|
||||||
private readonly IValidator<CreateProductCommand> _createValidator;
|
|
||||||
private readonly IValidator<UpdateProductCommand> _updateValidator;
|
|
||||||
|
|
||||||
public ProductsController(
|
|
||||||
IDispatcher dispatcher,
|
|
||||||
IValidator<CreateProductCommand> createValidator,
|
|
||||||
IValidator<UpdateProductCommand> updateValidator)
|
|
||||||
{
|
|
||||||
_dispatcher = dispatcher;
|
|
||||||
_createValidator = createValidator;
|
|
||||||
_updateValidator = updateValidator;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── READ endpoints ─────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
/// <summary>Returns a paginated list of Products. Requires authentication.</summary>
|
|
||||||
[HttpGet("api/v1/products")]
|
|
||||||
[Authorize]
|
|
||||||
[ProducesResponseType(typeof(PagedResult<ProductListItemDto>), StatusCodes.Status200OK)]
|
|
||||||
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
|
|
||||||
public async Task<IActionResult> ListProducts(
|
|
||||||
[FromQuery] int page = 1,
|
|
||||||
[FromQuery] int pageSize = 20,
|
|
||||||
[FromQuery] bool? activo = true,
|
|
||||||
[FromQuery] string? search = null,
|
|
||||||
[FromQuery] int? medioId = null,
|
|
||||||
[FromQuery] int? productTypeId = null,
|
|
||||||
[FromQuery] int? rubroId = null)
|
|
||||||
{
|
|
||||||
var query = new ListProductsQuery(page, pageSize, activo, search, medioId, productTypeId, rubroId);
|
|
||||||
var result = await _dispatcher.Send<ListProductsQuery, PagedResult<ProductListItemDto>>(query);
|
|
||||||
return Ok(result);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>Returns a single Product by id. Requires authentication.</summary>
|
|
||||||
[HttpGet("api/v1/products/{id:int}")]
|
|
||||||
[Authorize]
|
|
||||||
[ProducesResponseType(typeof(ProductDetailDto), StatusCodes.Status200OK)]
|
|
||||||
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
|
|
||||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
|
||||||
public async Task<IActionResult> GetProductById([FromRoute] int id)
|
|
||||||
{
|
|
||||||
var query = new GetProductByIdQuery(id);
|
|
||||||
var result = await _dispatcher.Send<GetProductByIdQuery, ProductDetailDto>(query);
|
|
||||||
return Ok(result);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── WRITE endpoints ────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
/// <summary>Creates a new Product. Requires catalogo:productos:gestionar.</summary>
|
|
||||||
[HttpPost("api/v1/admin/products")]
|
|
||||||
[RequirePermission("catalogo:productos:gestionar")]
|
|
||||||
[ProducesResponseType(typeof(ProductCreatedDto), StatusCodes.Status201Created)]
|
|
||||||
[ProducesResponseType(StatusCodes.Status400BadRequest)]
|
|
||||||
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
|
|
||||||
[ProducesResponseType(StatusCodes.Status403Forbidden)]
|
|
||||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
|
||||||
[ProducesResponseType(StatusCodes.Status409Conflict)]
|
|
||||||
[ProducesResponseType(StatusCodes.Status422UnprocessableEntity)]
|
|
||||||
public async Task<IActionResult> CreateProduct([FromBody] CreateProductRequest request)
|
|
||||||
{
|
|
||||||
var command = new CreateProductCommand(
|
|
||||||
Nombre: request.Nombre ?? string.Empty,
|
|
||||||
MedioId: request.MedioId,
|
|
||||||
ProductTypeId: request.ProductTypeId,
|
|
||||||
RubroId: request.RubroId,
|
|
||||||
BasePrice: request.BasePrice,
|
|
||||||
PriceDurationDays: request.PriceDurationDays);
|
|
||||||
|
|
||||||
var validation = await _createValidator.ValidateAsync(command);
|
|
||||||
if (!validation.IsValid)
|
|
||||||
{
|
|
||||||
var errors = validation.Errors
|
|
||||||
.GroupBy(e => e.PropertyName)
|
|
||||||
.ToDictionary(g => g.Key, g => g.Select(e => e.ErrorMessage).ToArray());
|
|
||||||
return BadRequest(new { errors });
|
|
||||||
}
|
|
||||||
|
|
||||||
var result = await _dispatcher.Send<CreateProductCommand, ProductCreatedDto>(command);
|
|
||||||
return CreatedAtAction(nameof(GetProductById), new { id = result.Id }, result);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>Updates a Product. Requires catalogo:productos:gestionar.</summary>
|
|
||||||
[HttpPut("api/v1/admin/products/{id:int}")]
|
|
||||||
[RequirePermission("catalogo:productos:gestionar")]
|
|
||||||
[ProducesResponseType(typeof(ProductUpdatedDto), StatusCodes.Status200OK)]
|
|
||||||
[ProducesResponseType(StatusCodes.Status400BadRequest)]
|
|
||||||
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
|
|
||||||
[ProducesResponseType(StatusCodes.Status403Forbidden)]
|
|
||||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
|
||||||
[ProducesResponseType(StatusCodes.Status409Conflict)]
|
|
||||||
[ProducesResponseType(StatusCodes.Status422UnprocessableEntity)]
|
|
||||||
public async Task<IActionResult> UpdateProduct([FromRoute] int id, [FromBody] UpdateProductRequest request)
|
|
||||||
{
|
|
||||||
var command = new UpdateProductCommand(
|
|
||||||
Id: id,
|
|
||||||
Nombre: request.Nombre ?? string.Empty,
|
|
||||||
RubroId: request.RubroId,
|
|
||||||
BasePrice: request.BasePrice,
|
|
||||||
PriceDurationDays: request.PriceDurationDays);
|
|
||||||
|
|
||||||
var validation = await _updateValidator.ValidateAsync(command);
|
|
||||||
if (!validation.IsValid)
|
|
||||||
{
|
|
||||||
var errors = validation.Errors
|
|
||||||
.GroupBy(e => e.PropertyName)
|
|
||||||
.ToDictionary(g => g.Key, g => g.Select(e => e.ErrorMessage).ToArray());
|
|
||||||
return BadRequest(new { errors });
|
|
||||||
}
|
|
||||||
|
|
||||||
var result = await _dispatcher.Send<UpdateProductCommand, ProductUpdatedDto>(command);
|
|
||||||
return Ok(result);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>Soft-deletes (deactivates) a Product. Requires catalogo:productos:gestionar.</summary>
|
|
||||||
[HttpDelete("api/v1/admin/products/{id:int}")]
|
|
||||||
[RequirePermission("catalogo:productos:gestionar")]
|
|
||||||
[ProducesResponseType(StatusCodes.Status204NoContent)]
|
|
||||||
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
|
|
||||||
[ProducesResponseType(StatusCodes.Status403Forbidden)]
|
|
||||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
|
||||||
public async Task<IActionResult> DeactivateProduct([FromRoute] int id)
|
|
||||||
{
|
|
||||||
var command = new DeactivateProductCommand(id);
|
|
||||||
await _dispatcher.Send<DeactivateProductCommand, ProductStatusDto>(command);
|
|
||||||
return NoContent();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Request body records ──────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
/// <summary>PRD-002: Create Product request body.</summary>
|
|
||||||
public sealed record CreateProductRequest(
|
|
||||||
string? Nombre,
|
|
||||||
int MedioId = 0,
|
|
||||||
int ProductTypeId = 0,
|
|
||||||
int? RubroId = null,
|
|
||||||
decimal BasePrice = 0m,
|
|
||||||
int? PriceDurationDays = null);
|
|
||||||
|
|
||||||
/// <summary>PRD-002: Update Product request body.</summary>
|
|
||||||
public sealed record UpdateProductRequest(
|
|
||||||
string? Nombre,
|
|
||||||
int? RubroId = null,
|
|
||||||
decimal BasePrice = 0m,
|
|
||||||
int? PriceDurationDays = null);
|
|
||||||
@@ -3,7 +3,6 @@ using Microsoft.AspNetCore.Mvc;
|
|||||||
using Microsoft.AspNetCore.Mvc.Filters;
|
using Microsoft.AspNetCore.Mvc.Filters;
|
||||||
using Microsoft.Data.SqlClient;
|
using Microsoft.Data.SqlClient;
|
||||||
using SIGCM2.Domain.Exceptions;
|
using SIGCM2.Domain.Exceptions;
|
||||||
using SIGCM2.Domain.Pricing.Exceptions;
|
|
||||||
|
|
||||||
namespace SIGCM2.Api.Filters;
|
namespace SIGCM2.Api.Filters;
|
||||||
|
|
||||||
@@ -243,43 +242,6 @@ public sealed class ExceptionFilter : IExceptionFilter
|
|||||||
context.ExceptionHandled = true;
|
context.ExceptionHandled = true;
|
||||||
break;
|
break;
|
||||||
|
|
||||||
// CAT-002: Rubro Regla de Oro (rama vs hoja)
|
|
||||||
case RubroPadreEsHojaConAvisosException rubroPadreHojaEx:
|
|
||||||
context.Result = new ObjectResult(new
|
|
||||||
{
|
|
||||||
error = "rubro_padre_es_hoja_con_avisos",
|
|
||||||
message = rubroPadreHojaEx.Message
|
|
||||||
})
|
|
||||||
{
|
|
||||||
StatusCode = StatusCodes.Status409Conflict
|
|
||||||
};
|
|
||||||
context.ExceptionHandled = true;
|
|
||||||
break;
|
|
||||||
|
|
||||||
case RubroEsRamaConHijosActivosException rubroRamaHijosEx:
|
|
||||||
context.Result = new ObjectResult(new
|
|
||||||
{
|
|
||||||
error = "rubro_es_rama_con_hijos_activos",
|
|
||||||
message = rubroRamaHijosEx.Message
|
|
||||||
})
|
|
||||||
{
|
|
||||||
StatusCode = StatusCodes.Status409Conflict
|
|
||||||
};
|
|
||||||
context.ExceptionHandled = true;
|
|
||||||
break;
|
|
||||||
|
|
||||||
case RubroConProductosActivosException rubroProductosEx:
|
|
||||||
context.Result = new ObjectResult(new
|
|
||||||
{
|
|
||||||
error = "rubro_con_productos_activos",
|
|
||||||
message = rubroProductosEx.Message
|
|
||||||
})
|
|
||||||
{
|
|
||||||
StatusCode = StatusCodes.Status409Conflict
|
|
||||||
};
|
|
||||||
context.ExceptionHandled = true;
|
|
||||||
break;
|
|
||||||
|
|
||||||
// ADM-001: Medio exceptions
|
// ADM-001: Medio exceptions
|
||||||
case MedioCodigoDuplicadoException medioCodDupEx:
|
case MedioCodigoDuplicadoException medioCodDupEx:
|
||||||
context.Result = new ObjectResult(new
|
context.Result = new ObjectResult(new
|
||||||
@@ -427,156 +389,6 @@ public sealed class ExceptionFilter : IExceptionFilter
|
|||||||
context.ExceptionHandled = true;
|
context.ExceptionHandled = true;
|
||||||
break;
|
break;
|
||||||
|
|
||||||
// PRD-001: ProductType exceptions
|
|
||||||
case ProductTypeNotFoundException productTypeNotFoundEx:
|
|
||||||
context.Result = new ObjectResult(new
|
|
||||||
{
|
|
||||||
error = "product_type_not_found",
|
|
||||||
message = productTypeNotFoundEx.Message
|
|
||||||
})
|
|
||||||
{
|
|
||||||
StatusCode = StatusCodes.Status404NotFound
|
|
||||||
};
|
|
||||||
context.ExceptionHandled = true;
|
|
||||||
break;
|
|
||||||
|
|
||||||
case ProductTypeNombreDuplicadoException productTypeDupEx:
|
|
||||||
context.Result = new ObjectResult(new
|
|
||||||
{
|
|
||||||
error = "product_type_nombre_duplicado",
|
|
||||||
message = productTypeDupEx.Message
|
|
||||||
})
|
|
||||||
{
|
|
||||||
StatusCode = StatusCodes.Status409Conflict
|
|
||||||
};
|
|
||||||
context.ExceptionHandled = true;
|
|
||||||
break;
|
|
||||||
|
|
||||||
case ProductTypeEnUsoException productTypeEnUsoEx:
|
|
||||||
context.Result = new ObjectResult(new
|
|
||||||
{
|
|
||||||
error = "product_type_en_uso",
|
|
||||||
message = productTypeEnUsoEx.Message
|
|
||||||
})
|
|
||||||
{
|
|
||||||
StatusCode = StatusCodes.Status409Conflict
|
|
||||||
};
|
|
||||||
context.ExceptionHandled = true;
|
|
||||||
break;
|
|
||||||
|
|
||||||
case ProductTypeFlagsIncoherentesException productTypeFlagsEx:
|
|
||||||
context.Result = new ObjectResult(new
|
|
||||||
{
|
|
||||||
error = "product_type_flags_incoherentes",
|
|
||||||
message = productTypeFlagsEx.Message
|
|
||||||
})
|
|
||||||
{
|
|
||||||
StatusCode = StatusCodes.Status422UnprocessableEntity
|
|
||||||
};
|
|
||||||
context.ExceptionHandled = true;
|
|
||||||
break;
|
|
||||||
|
|
||||||
// PRD-003: ProductPrices exceptions
|
|
||||||
case ProductPriceForwardOnlyException forwardOnlyEx:
|
|
||||||
context.Result = new ObjectResult(new
|
|
||||||
{
|
|
||||||
error = "product_price_forward_only",
|
|
||||||
message = forwardOnlyEx.Message,
|
|
||||||
productId = forwardOnlyEx.ProductId
|
|
||||||
})
|
|
||||||
{
|
|
||||||
StatusCode = StatusCodes.Status409Conflict
|
|
||||||
};
|
|
||||||
context.ExceptionHandled = true;
|
|
||||||
break;
|
|
||||||
|
|
||||||
case ProductPriceInvalidException priceInvalidEx:
|
|
||||||
context.Result = new ObjectResult(new
|
|
||||||
{
|
|
||||||
error = "product_price_invalid",
|
|
||||||
message = priceInvalidEx.Message,
|
|
||||||
field = priceInvalidEx.Field
|
|
||||||
})
|
|
||||||
{
|
|
||||||
StatusCode = StatusCodes.Status400BadRequest
|
|
||||||
};
|
|
||||||
context.ExceptionHandled = true;
|
|
||||||
break;
|
|
||||||
|
|
||||||
case ProductSinPrecioActivoException sinPrecioEx:
|
|
||||||
context.Result = new ObjectResult(new
|
|
||||||
{
|
|
||||||
error = "product_sin_precio_activo",
|
|
||||||
message = sinPrecioEx.Message
|
|
||||||
})
|
|
||||||
{
|
|
||||||
StatusCode = StatusCodes.Status404NotFound
|
|
||||||
};
|
|
||||||
context.ExceptionHandled = true;
|
|
||||||
break;
|
|
||||||
|
|
||||||
// PRD-002: Product exceptions
|
|
||||||
case ProductNotFoundException productNotFoundEx:
|
|
||||||
context.Result = new ObjectResult(new
|
|
||||||
{
|
|
||||||
error = "product_not_found",
|
|
||||||
message = productNotFoundEx.Message
|
|
||||||
})
|
|
||||||
{
|
|
||||||
StatusCode = StatusCodes.Status404NotFound
|
|
||||||
};
|
|
||||||
context.ExceptionHandled = true;
|
|
||||||
break;
|
|
||||||
|
|
||||||
case ProductNombreDuplicadoEnMedioTipoException productDupEx:
|
|
||||||
context.Result = new ObjectResult(new
|
|
||||||
{
|
|
||||||
error = "product_nombre_duplicado",
|
|
||||||
message = productDupEx.Message
|
|
||||||
})
|
|
||||||
{
|
|
||||||
StatusCode = StatusCodes.Status409Conflict
|
|
||||||
};
|
|
||||||
context.ExceptionHandled = true;
|
|
||||||
break;
|
|
||||||
|
|
||||||
case ProductTipoFlagsIncoherentesException productFlagsEx:
|
|
||||||
context.Result = new ObjectResult(new
|
|
||||||
{
|
|
||||||
error = "product_flags_incoherentes",
|
|
||||||
field = productFlagsEx.Field,
|
|
||||||
message = productFlagsEx.Message
|
|
||||||
})
|
|
||||||
{
|
|
||||||
StatusCode = StatusCodes.Status422UnprocessableEntity
|
|
||||||
};
|
|
||||||
context.ExceptionHandled = true;
|
|
||||||
break;
|
|
||||||
|
|
||||||
case ProductTypeInactivoException productTypeInactivoEx:
|
|
||||||
context.Result = new ObjectResult(new
|
|
||||||
{
|
|
||||||
error = "product_type_inactivo",
|
|
||||||
message = productTypeInactivoEx.Message
|
|
||||||
})
|
|
||||||
{
|
|
||||||
StatusCode = StatusCodes.Status409Conflict
|
|
||||||
};
|
|
||||||
context.ExceptionHandled = true;
|
|
||||||
break;
|
|
||||||
|
|
||||||
case RubroInactivoException rubroInactivoEx:
|
|
||||||
context.Result = new ObjectResult(new
|
|
||||||
{
|
|
||||||
error = "rubro_inactivo",
|
|
||||||
message = rubroInactivoEx.Message
|
|
||||||
})
|
|
||||||
{
|
|
||||||
StatusCode = StatusCodes.Status409Conflict
|
|
||||||
};
|
|
||||||
context.ExceptionHandled = true;
|
|
||||||
break;
|
|
||||||
|
|
||||||
// ADM-008: PuntoDeVenta exceptions
|
// ADM-008: PuntoDeVenta exceptions
|
||||||
case PuntoDeVentaNotFoundException puntoDeVentaNotFoundEx:
|
case PuntoDeVentaNotFoundException puntoDeVentaNotFoundEx:
|
||||||
context.Result = new ObjectResult(new
|
context.Result = new ObjectResult(new
|
||||||
@@ -646,94 +458,6 @@ public sealed class ExceptionFilter : IExceptionFilter
|
|||||||
context.ExceptionHandled = true;
|
context.ExceptionHandled = true;
|
||||||
break;
|
break;
|
||||||
|
|
||||||
// PRC-001: WordCounter + ChargeableCharConfig exceptions
|
|
||||||
case EmojiDetectedException emojiEx:
|
|
||||||
context.Result = new ObjectResult(new
|
|
||||||
{
|
|
||||||
error = "emoji_not_allowed",
|
|
||||||
code = "EMOJI_NOT_ALLOWED",
|
|
||||||
message = emojiEx.Message
|
|
||||||
})
|
|
||||||
{
|
|
||||||
StatusCode = StatusCodes.Status400BadRequest
|
|
||||||
};
|
|
||||||
context.ExceptionHandled = true;
|
|
||||||
break;
|
|
||||||
|
|
||||||
case WordCountValidationException wordEx:
|
|
||||||
context.Result = new ObjectResult(new
|
|
||||||
{
|
|
||||||
error = "word_count_validation",
|
|
||||||
code = "WORD_COUNT_VALIDATION",
|
|
||||||
field = wordEx.Field,
|
|
||||||
reason = wordEx.Reason,
|
|
||||||
message = wordEx.Message
|
|
||||||
})
|
|
||||||
{
|
|
||||||
StatusCode = StatusCodes.Status400BadRequest
|
|
||||||
};
|
|
||||||
context.ExceptionHandled = true;
|
|
||||||
break;
|
|
||||||
|
|
||||||
case ChargeableCharConfigInvalidException configInvalidEx:
|
|
||||||
context.Result = new ObjectResult(new
|
|
||||||
{
|
|
||||||
error = "chargeable_char_invalid",
|
|
||||||
code = "CHARGEABLE_CHAR_INVALID",
|
|
||||||
field = configInvalidEx.Field,
|
|
||||||
reason = configInvalidEx.Reason,
|
|
||||||
message = configInvalidEx.Message
|
|
||||||
})
|
|
||||||
{
|
|
||||||
StatusCode = StatusCodes.Status400BadRequest
|
|
||||||
};
|
|
||||||
context.ExceptionHandled = true;
|
|
||||||
break;
|
|
||||||
|
|
||||||
case ChargeableCharConfigForwardOnlyException forwardOnlyCharEx:
|
|
||||||
context.Result = new ObjectResult(new
|
|
||||||
{
|
|
||||||
error = "chargeable_char_forward_only",
|
|
||||||
code = "CHARGEABLE_CHAR_FORWARD_ONLY",
|
|
||||||
productTypeId = forwardOnlyCharEx.ProductTypeId,
|
|
||||||
symbol = forwardOnlyCharEx.Symbol,
|
|
||||||
newValidFrom = forwardOnlyCharEx.NewValidFrom,
|
|
||||||
activeValidFrom = forwardOnlyCharEx.ActiveValidFrom,
|
|
||||||
message = forwardOnlyCharEx.Message
|
|
||||||
})
|
|
||||||
{
|
|
||||||
StatusCode = StatusCodes.Status409Conflict
|
|
||||||
};
|
|
||||||
context.ExceptionHandled = true;
|
|
||||||
break;
|
|
||||||
|
|
||||||
case ChargeableCharConfigReactivationNotAllowedException reactivationEx:
|
|
||||||
context.Result = new ObjectResult(new
|
|
||||||
{
|
|
||||||
error = "chargeable_char_reactivation_not_allowed",
|
|
||||||
code = "CHARGEABLE_CHAR_REACTIVATION_NOT_ALLOWED",
|
|
||||||
id = reactivationEx.Id,
|
|
||||||
reason = reactivationEx.Reason,
|
|
||||||
message = reactivationEx.Message
|
|
||||||
})
|
|
||||||
{
|
|
||||||
StatusCode = StatusCodes.Status409Conflict
|
|
||||||
};
|
|
||||||
context.ExceptionHandled = true;
|
|
||||||
break;
|
|
||||||
|
|
||||||
case KeyNotFoundException keyNotFoundEx:
|
|
||||||
context.Result = new ObjectResult(new
|
|
||||||
{
|
|
||||||
error = "not_found",
|
|
||||||
message = keyNotFoundEx.Message
|
|
||||||
})
|
|
||||||
{
|
|
||||||
StatusCode = StatusCodes.Status404NotFound
|
|
||||||
};
|
|
||||||
context.ExceptionHandled = true;
|
|
||||||
break;
|
|
||||||
|
|
||||||
case ValidationException validationEx:
|
case ValidationException validationEx:
|
||||||
var errors = validationEx.Errors
|
var errors = validationEx.Errors
|
||||||
.GroupBy(e => e.PropertyName)
|
.GroupBy(e => e.PropertyName)
|
||||||
|
|||||||
@@ -1,24 +0,0 @@
|
|||||||
namespace SIGCM2.Application.Abstractions.Persistence;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Query-only access to Aviso counts by Rubro.
|
|
||||||
/// CAT-002 introduces the contract. The real Dapper-based impl lands in PRD-002
|
|
||||||
/// (when dbo.Aviso exists). Until then, NullAvisoQueryRepository is the binding.
|
|
||||||
/// </summary>
|
|
||||||
public interface IAvisoQueryRepository
|
|
||||||
{
|
|
||||||
/// <summary>
|
|
||||||
/// Returns the count of avisos (active, non-archived) assigned to the given rubro.
|
|
||||||
/// </summary>
|
|
||||||
Task<int> CountAvisosEnRubroAsync(int rubroId, CancellationToken ct = default);
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Returns a dictionary of { rubroId → count } for the provided ids.
|
|
||||||
/// Used by GetRubroTreeQueryHandler to avoid N+1 when populating TieneAvisos per node.
|
|
||||||
/// The implementation MUST do a single query; the stub returns an empty dictionary
|
|
||||||
/// (every rubro gets 0 via dictionary.GetValueOrDefault).
|
|
||||||
/// </summary>
|
|
||||||
Task<IReadOnlyDictionary<int, int>> CountAvisosBatchAsync(
|
|
||||||
IReadOnlyCollection<int> rubroIds,
|
|
||||||
CancellationToken ct = default);
|
|
||||||
}
|
|
||||||
@@ -1,104 +0,0 @@
|
|||||||
using SIGCM2.Domain.Pricing.ChargeableChars;
|
|
||||||
|
|
||||||
namespace SIGCM2.Application.Abstractions.Persistence;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// PRC-001 — Write + query access to dbo.ChargeableCharConfig.
|
|
||||||
/// Implemented by ChargeableCharConfigRepository (Dapper) in Infrastructure.
|
|
||||||
///
|
|
||||||
/// InsertWithCloseAsync: invokes usp_ChargeableCharConfig_InsertWithClose which atomically
|
|
||||||
/// closes any active row for (ProductTypeId, Symbol) and inserts the new row.
|
|
||||||
///
|
|
||||||
/// GetActiveForProductTypeAsync: invokes usp_ChargeableCharConfig_GetActiveForProductType which
|
|
||||||
/// returns both per-ProductType rows AND global (ProductTypeId IS NULL) rows for the given asOfDate.
|
|
||||||
/// The Application service applies the per-ProductType > global priority rule.
|
|
||||||
/// </summary>
|
|
||||||
public interface IChargeableCharConfigRepository
|
|
||||||
{
|
|
||||||
/// <summary>
|
|
||||||
/// Invokes usp_ChargeableCharConfig_InsertWithClose inside the ambient TransactionScope.
|
|
||||||
/// Closes any active row matching (ProductTypeId, Symbol) and inserts a new one.
|
|
||||||
/// Returns the Id of the newly inserted row.
|
|
||||||
/// Throws:
|
|
||||||
/// - ChargeableCharConfigForwardOnlyException on SQL THROW 50409
|
|
||||||
/// - ChargeableCharConfigInvalidException on SQL THROW 50404 (not-found guard)
|
|
||||||
/// </summary>
|
|
||||||
Task<long> InsertWithCloseAsync(
|
|
||||||
long? productTypeId,
|
|
||||||
string symbol,
|
|
||||||
string category,
|
|
||||||
decimal price,
|
|
||||||
DateOnly validFrom,
|
|
||||||
CancellationToken ct = default);
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Returns all active rows whose [ValidFrom, ValidTo] window covers the given asOfDate
|
|
||||||
/// for the specified ProductType, including global rows (ProductTypeId IS NULL).
|
|
||||||
/// The SP returns both per-ProductType AND global rows — callers apply priority.
|
|
||||||
/// </summary>
|
|
||||||
Task<IReadOnlyList<ChargeableCharConfig>> GetActiveForProductTypeAsync(
|
|
||||||
long productTypeId,
|
|
||||||
DateOnly asOfDate,
|
|
||||||
CancellationToken ct = default);
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Returns paginated rows filtered by ProductTypeId and IsActive.
|
|
||||||
/// Skip = (page - 1) * pageSize computed by the caller.
|
|
||||||
/// </summary>
|
|
||||||
Task<IReadOnlyList<ChargeableCharConfig>> ListAsync(
|
|
||||||
long? productTypeId,
|
|
||||||
bool activeOnly,
|
|
||||||
int skip,
|
|
||||||
int take,
|
|
||||||
CancellationToken ct = default);
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Returns total row count for the given filters (used for pagination metadata).
|
|
||||||
/// </summary>
|
|
||||||
Task<int> CountAsync(
|
|
||||||
long? productTypeId,
|
|
||||||
bool activeOnly,
|
|
||||||
CancellationToken ct = default);
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Returns the row with the given Id, or null if not found.
|
|
||||||
/// </summary>
|
|
||||||
Task<ChargeableCharConfig?> GetByIdAsync(
|
|
||||||
long id,
|
|
||||||
CancellationToken ct = default);
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Deactivates the row with the given Id by setting IsActive = false and ValidTo = today.
|
|
||||||
/// Idempotent: no-op if already inactive.
|
|
||||||
/// Called inside the ambient TransactionScope of the handler.
|
|
||||||
/// </summary>
|
|
||||||
Task DeactivateAsync(
|
|
||||||
long id,
|
|
||||||
DateOnly today,
|
|
||||||
CancellationToken ct = default);
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Invokes usp_ChargeableCharConfig_ReactivateWithGuard.
|
|
||||||
/// Guard rules (enforced by SP):
|
|
||||||
/// 50410 → target row is already active → ChargeableCharConfigReactivationNotAllowedException(ALREADY_ACTIVE)
|
|
||||||
/// 50411 → a vigente active row exists for (ProductTypeId, Symbol) → ChargeableCharConfigReactivationNotAllowedException(VIGENTE_EXISTS)
|
|
||||||
/// 50412 → posterior rows exist after target row → ChargeableCharConfigReactivationNotAllowedException(POSTERIOR_ROWS_EXIST)
|
|
||||||
/// 50404 → row not found → ChargeableCharConfigInvalidException
|
|
||||||
/// On success: re-opens the row (IsActive=true, ValidTo=NULL) and returns the reactivated entity.
|
|
||||||
/// </summary>
|
|
||||||
Task<ChargeableCharConfig> ReactivateAsync(
|
|
||||||
long id,
|
|
||||||
CancellationToken ct = default);
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Physically deletes the row with the given Id from dbo.ChargeableCharConfig (current state).
|
|
||||||
/// NOTE: Since SYSTEM_VERSIONING is ON, SQL Server moves the row to the history table with
|
|
||||||
/// SysEndTime set to the delete time. The row disappears from all current-state queries but
|
|
||||||
/// remains queryable via FOR SYSTEM_TIME. Temporal audit trail is preserved.
|
|
||||||
/// Future guard for "used in invoicing" is deferred to FAC-001 followup issue.
|
|
||||||
/// Throws KeyNotFoundException if the row does not exist.
|
|
||||||
/// </summary>
|
|
||||||
Task DeleteAsync(
|
|
||||||
long id,
|
|
||||||
CancellationToken ct = default);
|
|
||||||
}
|
|
||||||
@@ -1,44 +0,0 @@
|
|||||||
using SIGCM2.Application.Common;
|
|
||||||
using SIGCM2.Domain.Entities;
|
|
||||||
|
|
||||||
namespace SIGCM2.Application.Abstractions.Persistence;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// PRD-003 — Write + query access to dbo.ProductPrices.
|
|
||||||
/// Implemented by ProductPriceRepository (Dapper) in Infrastructure.
|
|
||||||
/// </summary>
|
|
||||||
public interface IProductPriceRepository
|
|
||||||
{
|
|
||||||
/// <summary>
|
|
||||||
/// Invokes dbo.usp_AddProductPrice inside the ambient TransactionScope.
|
|
||||||
/// Returns (newId, closedId?). Throws:
|
|
||||||
/// - ProductPriceForwardOnlyException on SQL THROW 50409 or unique index violation (2601/2627).
|
|
||||||
/// - ProductNotFoundException on SQL THROW 50404.
|
|
||||||
/// </summary>
|
|
||||||
Task<(long NewId, long? ClosedId)> AddAsync(
|
|
||||||
int productId,
|
|
||||||
decimal price,
|
|
||||||
DateOnly priceValidFrom,
|
|
||||||
CancellationToken ct = default);
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Returns a paginated page of price rows for the product, ordered descending by PriceValidFrom.
|
|
||||||
/// Caller is responsible for clamping page (≥ 1) and pageSize (1–100) before calling.
|
|
||||||
/// Returns PagedResult with empty Items when the product has no price history or page is beyond total.
|
|
||||||
/// </summary>
|
|
||||||
Task<PagedResult<ProductPrice>> GetByProductIdAsync(
|
|
||||||
int productId,
|
|
||||||
int page,
|
|
||||||
int pageSize,
|
|
||||||
CancellationToken ct = default);
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Returns the ProductPrice row whose window [PriceValidFrom, PriceValidTo] covers the given
|
|
||||||
/// civil date, or null if no row matches (no history, or date is before any recorded price).
|
|
||||||
/// Used by ProductPricingService.GetPriceAtAsync.
|
|
||||||
/// </summary>
|
|
||||||
Task<ProductPrice?> GetActiveAsync(
|
|
||||||
int productId,
|
|
||||||
DateOnly date,
|
|
||||||
CancellationToken ct = default);
|
|
||||||
}
|
|
||||||
@@ -1,21 +0,0 @@
|
|||||||
namespace SIGCM2.Application.Abstractions.Persistence;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// PRD-002 handoff contract — query-only access to Product data needed by ProductType handlers.
|
|
||||||
/// PRD-001 binds to NullProductQueryRepository (always returns false).
|
|
||||||
/// PRD-002 binds to Dapper impl against dbo.Product (when that table exists).
|
|
||||||
/// </summary>
|
|
||||||
public interface IProductQueryRepository
|
|
||||||
{
|
|
||||||
/// <summary>
|
|
||||||
/// Returns true if at least one active Product with the given ProductTypeId exists.
|
|
||||||
/// Used by DeactivateProductTypeCommandHandler to guard against orphaning active products.
|
|
||||||
/// </summary>
|
|
||||||
Task<bool> ExistsActiveByProductTypeAsync(int productTypeId, CancellationToken ct = default);
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Returns the count of active Products where RubroId = rubroId.
|
|
||||||
/// Used by DeactivateRubroCommandHandler to guard against orphaning active products. (issue #41)
|
|
||||||
/// </summary>
|
|
||||||
Task<int> CountActiveByRubroAsync(int rubroId, CancellationToken ct = default);
|
|
||||||
}
|
|
||||||
@@ -1,29 +0,0 @@
|
|||||||
using SIGCM2.Application.Common;
|
|
||||||
using SIGCM2.Domain.Entities;
|
|
||||||
|
|
||||||
namespace SIGCM2.Application.Abstractions.Persistence;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Write-side repository for Product.
|
|
||||||
/// All reads needed by write handlers are included here.
|
|
||||||
/// </summary>
|
|
||||||
public interface IProductRepository
|
|
||||||
{
|
|
||||||
/// <summary>Inserts a new Product and returns the DB-assigned Id.</summary>
|
|
||||||
Task<int> AddAsync(Product product, CancellationToken ct = default);
|
|
||||||
|
|
||||||
/// <summary>Returns the Product with the given Id, or null if not found.</summary>
|
|
||||||
Task<Product?> GetByIdAsync(int id, CancellationToken ct = default);
|
|
||||||
|
|
||||||
/// <summary>Returns a paged result of Products matching the query.</summary>
|
|
||||||
Task<PagedResult<Product>> GetPagedAsync(ProductsQuery query, CancellationToken ct = default);
|
|
||||||
|
|
||||||
/// <summary>Persists all changes to an existing Product row.</summary>
|
|
||||||
Task UpdateAsync(Product product, CancellationToken ct = default);
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Returns true if an active Product with the same Nombre exists for the given MedioId+ProductTypeId combination.
|
|
||||||
/// Pass excludeId to skip the self-comparison during rename (update scenario).
|
|
||||||
/// </summary>
|
|
||||||
Task<bool> ExistsByNombreAsync(string nombre, int medioId, int productTypeId, int? excludeId = null, CancellationToken ct = default);
|
|
||||||
}
|
|
||||||
@@ -1,31 +0,0 @@
|
|||||||
using SIGCM2.Application.Common;
|
|
||||||
using SIGCM2.Domain.Entities;
|
|
||||||
|
|
||||||
namespace SIGCM2.Application.Abstractions.Persistence;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Write-side repository for ProductType.
|
|
||||||
/// All reads needed by write handlers are included here.
|
|
||||||
/// Query-side (for listing, filtering) uses GetPagedAsync with ProductTypesQuery.
|
|
||||||
/// </summary>
|
|
||||||
public interface IProductTypeRepository
|
|
||||||
{
|
|
||||||
/// <summary>Inserts a new ProductType and returns the DB-assigned Id.</summary>
|
|
||||||
Task<int> AddAsync(ProductType productType, CancellationToken ct = default);
|
|
||||||
|
|
||||||
/// <summary>Returns the ProductType with the given Id, or null if not found.</summary>
|
|
||||||
Task<ProductType?> GetByIdAsync(int id, CancellationToken ct = default);
|
|
||||||
|
|
||||||
/// <summary>Returns a paged result of ProductTypes matching the query.</summary>
|
|
||||||
Task<PagedResult<ProductType>> GetPagedAsync(ProductTypesQuery query, CancellationToken ct = default);
|
|
||||||
|
|
||||||
/// <summary>Persists all changes to an existing ProductType row.</summary>
|
|
||||||
Task UpdateAsync(ProductType productType, CancellationToken ct = default);
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Returns true if an active ProductType with the given nombre exists.
|
|
||||||
/// Pass excludeId to skip the self-comparison during rename (update scenario).
|
|
||||||
/// Case-insensitive — delegates to DB collation (SQL_Latin1_General_CP1_CI_AI).
|
|
||||||
/// </summary>
|
|
||||||
Task<bool> ExistsByNombreAsync(string nombre, int? excludeId = null, CancellationToken ct = default);
|
|
||||||
}
|
|
||||||
@@ -1,22 +0,0 @@
|
|||||||
using SIGCM2.Application.Abstractions.Persistence;
|
|
||||||
|
|
||||||
namespace SIGCM2.Application.Avisos;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// STUB — PRD-002 reemplaza con AvisoQueryRepository contra dbo.Aviso.
|
|
||||||
/// Returns 0 / empty dictionary so every handler guard passes and every tree node shows TieneAvisos=false.
|
|
||||||
/// This is intentional for CAT-002: the mechanism is installed; the data feed arrives in PRD-002.
|
|
||||||
/// </summary>
|
|
||||||
public sealed class NullAvisoQueryRepository : IAvisoQueryRepository
|
|
||||||
{
|
|
||||||
private static readonly IReadOnlyDictionary<int, int> Empty =
|
|
||||||
new Dictionary<int, int>(capacity: 0);
|
|
||||||
|
|
||||||
public Task<int> CountAvisosEnRubroAsync(int rubroId, CancellationToken ct = default)
|
|
||||||
=> Task.FromResult(0);
|
|
||||||
|
|
||||||
public Task<IReadOnlyDictionary<int, int>> CountAvisosBatchAsync(
|
|
||||||
IReadOnlyCollection<int> rubroIds,
|
|
||||||
CancellationToken ct = default)
|
|
||||||
=> Task.FromResult(Empty);
|
|
||||||
}
|
|
||||||
@@ -1,10 +0,0 @@
|
|||||||
namespace SIGCM2.Application.Common;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Query parameters for listing ProductTypes (used by IProductTypeRepository.GetPagedAsync).
|
|
||||||
/// </summary>
|
|
||||||
public sealed record ProductTypesQuery(
|
|
||||||
int Page = 1,
|
|
||||||
int PageSize = 20,
|
|
||||||
bool? Activo = true,
|
|
||||||
string? Search = null);
|
|
||||||
@@ -1,13 +0,0 @@
|
|||||||
namespace SIGCM2.Application.Common;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Query parameters for listing Products (used by IProductRepository.GetPagedAsync).
|
|
||||||
/// </summary>
|
|
||||||
public sealed record ProductsQuery(
|
|
||||||
int Page = 1,
|
|
||||||
int PageSize = 20,
|
|
||||||
bool? Activo = true,
|
|
||||||
string? Search = null,
|
|
||||||
int? MedioId = null,
|
|
||||||
int? ProductTypeId = null,
|
|
||||||
int? RubroId = null);
|
|
||||||
@@ -67,30 +67,6 @@ using SIGCM2.Application.Rubros.Move;
|
|||||||
using SIGCM2.Application.Rubros.GetTree;
|
using SIGCM2.Application.Rubros.GetTree;
|
||||||
using SIGCM2.Application.Rubros.GetById;
|
using SIGCM2.Application.Rubros.GetById;
|
||||||
using SIGCM2.Application.Rubros.Dtos;
|
using SIGCM2.Application.Rubros.Dtos;
|
||||||
using SIGCM2.Application.Abstractions.Persistence;
|
|
||||||
using SIGCM2.Application.Avisos;
|
|
||||||
using SIGCM2.Application.Products.Create;
|
|
||||||
using SIGCM2.Application.Products.Update;
|
|
||||||
using SIGCM2.Application.Products.Deactivate;
|
|
||||||
using SIGCM2.Application.Products.GetById;
|
|
||||||
using SIGCM2.Application.Products.List;
|
|
||||||
using SIGCM2.Application.Products.Prices;
|
|
||||||
using SIGCM2.Application.Products.Prices.AddPrice;
|
|
||||||
using SIGCM2.Application.Products.Prices.GetHistory;
|
|
||||||
using SIGCM2.Application.Products.Pricing;
|
|
||||||
using SIGCM2.Application.ProductTypes.Create;
|
|
||||||
using SIGCM2.Application.ProductTypes.Update;
|
|
||||||
using SIGCM2.Application.ProductTypes.Deactivate;
|
|
||||||
using SIGCM2.Application.ProductTypes.List;
|
|
||||||
using SIGCM2.Application.ProductTypes.GetById;
|
|
||||||
using SIGCM2.Application.Pricing.ChargeableChars;
|
|
||||||
using SIGCM2.Application.Pricing.ChargeableChars.Create;
|
|
||||||
using SIGCM2.Application.Pricing.ChargeableChars.SchedulePrice;
|
|
||||||
using SIGCM2.Application.Pricing.ChargeableChars.Deactivate;
|
|
||||||
using SIGCM2.Application.Pricing.ChargeableChars.Reactivate;
|
|
||||||
using SIGCM2.Application.Pricing.ChargeableChars.Delete;
|
|
||||||
using SIGCM2.Application.Pricing.ChargeableChars.List;
|
|
||||||
using SIGCM2.Application.Pricing.ChargeableChars.GetById;
|
|
||||||
|
|
||||||
namespace SIGCM2.Application;
|
namespace SIGCM2.Application;
|
||||||
|
|
||||||
@@ -176,10 +152,7 @@ public static class DependencyInjection
|
|||||||
services.AddScoped<ICommandHandler<ListIngresosBrutosQuery, PagedResult<IngresosBrutosDto>>, ListIngresosBrutosQueryHandler>();
|
services.AddScoped<ICommandHandler<ListIngresosBrutosQuery, PagedResult<IngresosBrutosDto>>, ListIngresosBrutosQueryHandler>();
|
||||||
services.AddScoped<ICommandHandler<GetHistorialIngresosBrutosQuery, IReadOnlyList<HistorialCadenaIibbDto>>, GetHistorialIngresosBrutosQueryHandler>();
|
services.AddScoped<ICommandHandler<GetHistorialIngresosBrutosQuery, IReadOnlyList<HistorialCadenaIibbDto>>, GetHistorialIngresosBrutosQueryHandler>();
|
||||||
|
|
||||||
// Rubros (CAT-001 + CAT-002)
|
// Rubros (CAT-001)
|
||||||
// CAT-002: Regla de Oro Rama vs Hoja — stub binding until PRD-002 provides real impl
|
|
||||||
services.AddScoped<IAvisoQueryRepository, NullAvisoQueryRepository>();
|
|
||||||
|
|
||||||
services.AddScoped<ICommandHandler<CreateRubroCommand, RubroCreatedDto>, CreateRubroCommandHandler>();
|
services.AddScoped<ICommandHandler<CreateRubroCommand, RubroCreatedDto>, CreateRubroCommandHandler>();
|
||||||
services.AddScoped<ICommandHandler<UpdateRubroCommand, RubroUpdatedDto>, UpdateRubroCommandHandler>();
|
services.AddScoped<ICommandHandler<UpdateRubroCommand, RubroUpdatedDto>, UpdateRubroCommandHandler>();
|
||||||
services.AddScoped<ICommandHandler<DeactivateRubroCommand, RubroStatusDto>, DeactivateRubroCommandHandler>();
|
services.AddScoped<ICommandHandler<DeactivateRubroCommand, RubroStatusDto>, DeactivateRubroCommandHandler>();
|
||||||
@@ -187,37 +160,6 @@ public static class DependencyInjection
|
|||||||
services.AddScoped<ICommandHandler<GetRubroTreeQuery, IReadOnlyList<RubroTreeNodeDto>>, GetRubroTreeQueryHandler>();
|
services.AddScoped<ICommandHandler<GetRubroTreeQuery, IReadOnlyList<RubroTreeNodeDto>>, GetRubroTreeQueryHandler>();
|
||||||
services.AddScoped<ICommandHandler<GetRubroByIdQuery, RubroDetailDto>, GetRubroByIdQueryHandler>();
|
services.AddScoped<ICommandHandler<GetRubroByIdQuery, RubroDetailDto>, GetRubroByIdQueryHandler>();
|
||||||
|
|
||||||
// Products (PRD-002)
|
|
||||||
services.AddScoped<ICommandHandler<CreateProductCommand, ProductCreatedDto>, CreateProductCommandHandler>();
|
|
||||||
services.AddScoped<ICommandHandler<UpdateProductCommand, ProductUpdatedDto>, UpdateProductCommandHandler>();
|
|
||||||
services.AddScoped<ICommandHandler<DeactivateProductCommand, ProductStatusDto>, DeactivateProductCommandHandler>();
|
|
||||||
services.AddScoped<ICommandHandler<GetProductByIdQuery, ProductDetailDto>, GetProductByIdQueryHandler>();
|
|
||||||
services.AddScoped<ICommandHandler<ListProductsQuery, PagedResult<ProductListItemDto>>, ListProductsQueryHandler>();
|
|
||||||
|
|
||||||
// ProductPrices (PRD-003)
|
|
||||||
services.AddScoped<ICommandHandler<AddProductPriceCommand, AddProductPriceResponse>, AddProductPriceCommandHandler>();
|
|
||||||
services.AddScoped<ICommandHandler<GetProductPricesQuery, PagedResult<ProductPriceDto>>, GetProductPricesQueryHandler>();
|
|
||||||
services.AddScoped<IProductPricingService, ProductPricingService>();
|
|
||||||
|
|
||||||
// ProductTypes (PRD-001)
|
|
||||||
// IProductQueryRepository is now bound in Infrastructure DI (PRD-002) against dbo.Product.
|
|
||||||
|
|
||||||
services.AddScoped<ICommandHandler<CreateProductTypeCommand, ProductTypeCreatedDto>, CreateProductTypeCommandHandler>();
|
|
||||||
services.AddScoped<ICommandHandler<UpdateProductTypeCommand, ProductTypeUpdatedDto>, UpdateProductTypeCommandHandler>();
|
|
||||||
services.AddScoped<ICommandHandler<DeactivateProductTypeCommand, ProductTypeStatusDto>, DeactivateProductTypeCommandHandler>();
|
|
||||||
services.AddScoped<ICommandHandler<ListProductTypesQuery, PagedResult<ProductTypeListItemDto>>, ListProductTypesQueryHandler>();
|
|
||||||
services.AddScoped<ICommandHandler<GetProductTypeByIdQuery, ProductTypeDetailDto>, GetProductTypeByIdQueryHandler>();
|
|
||||||
|
|
||||||
// ChargeableCharConfig (PRC-001)
|
|
||||||
services.AddScoped<ICommandHandler<CreateChargeableCharConfigCommand, CreateChargeableCharConfigResponse>, CreateChargeableCharConfigCommandHandler>();
|
|
||||||
services.AddScoped<ICommandHandler<SchedulePriceChangeCommand, SchedulePriceChangeResponse>, SchedulePriceChangeCommandHandler>();
|
|
||||||
services.AddScoped<ICommandHandler<DeactivateChargeableCharConfigCommand, DeactivateChargeableCharConfigResponse>, DeactivateChargeableCharConfigCommandHandler>();
|
|
||||||
services.AddScoped<ICommandHandler<ReactivateChargeableCharConfigCommand, ReactivateChargeableCharConfigResponse>, ReactivateChargeableCharConfigCommandHandler>();
|
|
||||||
services.AddScoped<ICommandHandler<DeleteChargeableCharConfigCommand, DeleteChargeableCharConfigResponse>, DeleteChargeableCharConfigCommandHandler>();
|
|
||||||
services.AddScoped<ICommandHandler<ListChargeableCharConfigQuery, PagedResult<ChargeableCharConfigDto>>, ListChargeableCharConfigQueryHandler>();
|
|
||||||
services.AddScoped<ICommandHandler<GetChargeableCharConfigByIdQuery, ChargeableCharConfigDto?>, GetChargeableCharConfigByIdQueryHandler>();
|
|
||||||
services.AddScoped<IChargeableCharConfigService, ChargeableCharConfigService>();
|
|
||||||
|
|
||||||
// FluentValidation validators (scans entire Application assembly)
|
// FluentValidation validators (scans entire Application assembly)
|
||||||
services.AddValidatorsFromAssemblyContaining<LoginCommandValidator>();
|
services.AddValidatorsFromAssemblyContaining<LoginCommandValidator>();
|
||||||
|
|
||||||
|
|||||||
@@ -1,14 +0,0 @@
|
|||||||
namespace SIGCM2.Application.Pricing.ChargeableChars;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// PRC-001 — DTO for ChargeableCharConfig rows returned in list / get-by-id responses.
|
|
||||||
/// </summary>
|
|
||||||
public sealed record ChargeableCharConfigDto(
|
|
||||||
long Id,
|
|
||||||
long? ProductTypeId,
|
|
||||||
string Symbol,
|
|
||||||
string Category,
|
|
||||||
decimal PricePerUnit,
|
|
||||||
DateOnly ValidFrom,
|
|
||||||
DateOnly? ValidTo,
|
|
||||||
bool IsActive);
|
|
||||||
@@ -1,43 +0,0 @@
|
|||||||
using SIGCM2.Application.Abstractions.Persistence;
|
|
||||||
|
|
||||||
namespace SIGCM2.Application.Pricing.ChargeableChars;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// PRC-001 — Implements IChargeableCharConfigService.
|
|
||||||
/// Delegates to IChargeableCharConfigRepository.GetActiveForProductTypeAsync, then applies
|
|
||||||
/// the per-ProductType > global priority rule in memory.
|
|
||||||
///
|
|
||||||
/// Priority rule: if the same Symbol appears as both global (ProductTypeId IS NULL) and
|
|
||||||
/// per-ProductType, the per-ProductType row wins. The SP returns both; we resolve in Application.
|
|
||||||
/// </summary>
|
|
||||||
public sealed class ChargeableCharConfigService : IChargeableCharConfigService
|
|
||||||
{
|
|
||||||
private readonly IChargeableCharConfigRepository _repo;
|
|
||||||
|
|
||||||
public ChargeableCharConfigService(IChargeableCharConfigRepository repo)
|
|
||||||
{
|
|
||||||
_repo = repo;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <inheritdoc />
|
|
||||||
public async Task<IReadOnlyDictionary<string, ChargeableCharSnapshot>> GetActiveConfigForProductTypeAsync(
|
|
||||||
long productTypeId,
|
|
||||||
DateOnly asOf,
|
|
||||||
CancellationToken ct = default)
|
|
||||||
{
|
|
||||||
var allRows = await _repo.GetActiveForProductTypeAsync(productTypeId, asOf, ct);
|
|
||||||
|
|
||||||
// Build a dictionary keyed by Symbol.
|
|
||||||
// Per-ProductType rows (ProductTypeId != null) take priority over global rows (ProductTypeId == null).
|
|
||||||
var result = new Dictionary<string, ChargeableCharSnapshot>(StringComparer.Ordinal);
|
|
||||||
|
|
||||||
// Two-pass: first add global rows, then overwrite with per-ProductType rows.
|
|
||||||
foreach (var row in allRows.Where(r => r.ProductTypeId is null))
|
|
||||||
result[row.Symbol] = new ChargeableCharSnapshot(row.Category, row.PricePerUnit);
|
|
||||||
|
|
||||||
foreach (var row in allRows.Where(r => r.ProductTypeId is not null))
|
|
||||||
result[row.Symbol] = new ChargeableCharSnapshot(row.Category, row.PricePerUnit);
|
|
||||||
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,10 +0,0 @@
|
|||||||
namespace SIGCM2.Application.Pricing.ChargeableChars;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// PRC-001 — Lightweight value snapshot for the active chargeable-char config
|
|
||||||
/// at the time of word counting. Used by IChargeableCharConfigService.
|
|
||||||
/// Keyed by Symbol in the returned dictionary.
|
|
||||||
/// </summary>
|
|
||||||
public sealed record ChargeableCharSnapshot(
|
|
||||||
string Category,
|
|
||||||
decimal PricePerUnit);
|
|
||||||
@@ -1,12 +0,0 @@
|
|||||||
namespace SIGCM2.Application.Pricing.ChargeableChars.Create;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// PRC-001 — Command to create a new ChargeableCharConfig.
|
|
||||||
/// ProductTypeId = null → global config. ProductTypeId set → per-ProductType config.
|
|
||||||
/// </summary>
|
|
||||||
public sealed record CreateChargeableCharConfigCommand(
|
|
||||||
long? ProductTypeId,
|
|
||||||
string Symbol,
|
|
||||||
string Category,
|
|
||||||
decimal PricePerUnit,
|
|
||||||
DateOnly ValidFrom);
|
|
||||||
@@ -1,69 +0,0 @@
|
|||||||
using System.Transactions;
|
|
||||||
using SIGCM2.Application.Abstractions;
|
|
||||||
using SIGCM2.Application.Abstractions.Persistence;
|
|
||||||
using SIGCM2.Application.Audit;
|
|
||||||
|
|
||||||
namespace SIGCM2.Application.Pricing.ChargeableChars.Create;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// PRC-001 — Handler for CreateChargeableCharConfigCommand.
|
|
||||||
/// Flow: opens TransactionScope → InsertWithCloseAsync (SP) → IAuditLogger.LogAsync (fail-closed) → tx.Complete().
|
|
||||||
/// </summary>
|
|
||||||
public sealed class CreateChargeableCharConfigCommandHandler
|
|
||||||
: ICommandHandler<CreateChargeableCharConfigCommand, CreateChargeableCharConfigResponse>
|
|
||||||
{
|
|
||||||
private readonly IChargeableCharConfigRepository _repo;
|
|
||||||
private readonly IAuditLogger _audit;
|
|
||||||
private readonly TimeProvider _timeProvider;
|
|
||||||
|
|
||||||
public CreateChargeableCharConfigCommandHandler(
|
|
||||||
IChargeableCharConfigRepository repo,
|
|
||||||
IAuditLogger audit,
|
|
||||||
TimeProvider timeProvider)
|
|
||||||
{
|
|
||||||
_repo = repo;
|
|
||||||
_audit = audit;
|
|
||||||
_timeProvider = timeProvider;
|
|
||||||
}
|
|
||||||
|
|
||||||
public async Task<CreateChargeableCharConfigResponse> Handle(CreateChargeableCharConfigCommand command)
|
|
||||||
{
|
|
||||||
long newId;
|
|
||||||
using (var tx = new TransactionScope(
|
|
||||||
TransactionScopeOption.Required,
|
|
||||||
new TransactionOptions { IsolationLevel = System.Transactions.IsolationLevel.ReadCommitted },
|
|
||||||
TransactionScopeAsyncFlowOption.Enabled))
|
|
||||||
{
|
|
||||||
newId = await _repo.InsertWithCloseAsync(
|
|
||||||
command.ProductTypeId,
|
|
||||||
command.Symbol,
|
|
||||||
command.Category,
|
|
||||||
command.PricePerUnit,
|
|
||||||
command.ValidFrom);
|
|
||||||
|
|
||||||
await _audit.LogAsync(
|
|
||||||
action: "tasacion.chargeable_char.create",
|
|
||||||
targetType: "ChargeableCharConfig",
|
|
||||||
targetId: newId.ToString(),
|
|
||||||
metadata: new
|
|
||||||
{
|
|
||||||
after = new
|
|
||||||
{
|
|
||||||
command.ProductTypeId,
|
|
||||||
command.Symbol,
|
|
||||||
command.Category,
|
|
||||||
command.PricePerUnit,
|
|
||||||
validFrom = command.ValidFrom.ToString("yyyy-MM-dd"),
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
tx.Complete();
|
|
||||||
}
|
|
||||||
|
|
||||||
return new CreateChargeableCharConfigResponse(
|
|
||||||
newId,
|
|
||||||
command.Symbol,
|
|
||||||
command.PricePerUnit,
|
|
||||||
command.ValidFrom);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,41 +0,0 @@
|
|||||||
using FluentValidation;
|
|
||||||
using SIGCM2.Application.Common;
|
|
||||||
using SIGCM2.Domain.Pricing.ChargeableChars;
|
|
||||||
using SIGCM2.Domain.Pricing.WordCounter;
|
|
||||||
|
|
||||||
namespace SIGCM2.Application.Pricing.ChargeableChars.Create;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// PRC-001 — FluentValidation validator for CreateChargeableCharConfigCommand.
|
|
||||||
/// Injects TimeProvider for today_AR (Cat2, never DateTime.Now).
|
|
||||||
/// </summary>
|
|
||||||
public sealed class CreateChargeableCharConfigCommandValidator
|
|
||||||
: AbstractValidator<CreateChargeableCharConfigCommand>
|
|
||||||
{
|
|
||||||
public CreateChargeableCharConfigCommandValidator(TimeProvider timeProvider)
|
|
||||||
{
|
|
||||||
var today = timeProvider.GetArgentinaToday();
|
|
||||||
|
|
||||||
RuleFor(x => x.Symbol)
|
|
||||||
.NotEmpty()
|
|
||||||
.WithMessage("Symbol no puede estar vacío.")
|
|
||||||
.MaximumLength(4)
|
|
||||||
.WithMessage("Symbol no puede exceder 4 caracteres.")
|
|
||||||
.Must(s => !WordCounterService.ContainsEmoji(s))
|
|
||||||
.WithMessage("Symbol no puede contener emojis. Usá símbolos ASCII o latinos (ej: $, %, !, ¡).");
|
|
||||||
|
|
||||||
RuleFor(x => x.Category)
|
|
||||||
.NotEmpty()
|
|
||||||
.WithMessage("Category no puede estar vacío.")
|
|
||||||
.Must(ChargeableCharCategories.IsValid)
|
|
||||||
.WithMessage($"Category inválida. Valores válidos: {string.Join(", ", new[] { ChargeableCharCategories.Currency, ChargeableCharCategories.Percentage, ChargeableCharCategories.Exclamation, ChargeableCharCategories.Question, ChargeableCharCategories.Other })}.");
|
|
||||||
|
|
||||||
RuleFor(x => x.PricePerUnit)
|
|
||||||
.GreaterThanOrEqualTo(0m)
|
|
||||||
.WithMessage("PricePerUnit debe ser >= 0. Usá 0 para desactivar el cobro de este símbolo (opt-in billing).");
|
|
||||||
|
|
||||||
RuleFor(x => x.ValidFrom)
|
|
||||||
.GreaterThanOrEqualTo(today)
|
|
||||||
.WithMessage($"ValidFrom debe ser >= hoy ({today:yyyy-MM-dd} ART). No se permiten configuraciones con fecha retroactiva.");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,10 +0,0 @@
|
|||||||
namespace SIGCM2.Application.Pricing.ChargeableChars.Create;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// PRC-001 — Response for CreateChargeableCharConfigCommand.
|
|
||||||
/// </summary>
|
|
||||||
public sealed record CreateChargeableCharConfigResponse(
|
|
||||||
long Id,
|
|
||||||
string Symbol,
|
|
||||||
decimal PricePerUnit,
|
|
||||||
DateOnly ValidFrom);
|
|
||||||
@@ -1,6 +0,0 @@
|
|||||||
namespace SIGCM2.Application.Pricing.ChargeableChars.Deactivate;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// PRC-001 — Command to deactivate an existing ChargeableCharConfig.
|
|
||||||
/// </summary>
|
|
||||||
public sealed record DeactivateChargeableCharConfigCommand(long Id);
|
|
||||||
@@ -1,70 +0,0 @@
|
|||||||
using System.Transactions;
|
|
||||||
using SIGCM2.Application.Abstractions;
|
|
||||||
using SIGCM2.Application.Abstractions.Persistence;
|
|
||||||
using SIGCM2.Application.Audit;
|
|
||||||
using SIGCM2.Application.Common;
|
|
||||||
|
|
||||||
namespace SIGCM2.Application.Pricing.ChargeableChars.Deactivate;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// PRC-001 — Handler for DeactivateChargeableCharConfigCommand.
|
|
||||||
/// Flow: load existing → open TX → DeactivateAsync → audit → tx.Complete().
|
|
||||||
/// </summary>
|
|
||||||
public sealed class DeactivateChargeableCharConfigCommandHandler
|
|
||||||
: ICommandHandler<DeactivateChargeableCharConfigCommand, DeactivateChargeableCharConfigResponse>
|
|
||||||
{
|
|
||||||
private readonly IChargeableCharConfigRepository _repo;
|
|
||||||
private readonly IAuditLogger _audit;
|
|
||||||
private readonly TimeProvider _timeProvider;
|
|
||||||
|
|
||||||
public DeactivateChargeableCharConfigCommandHandler(
|
|
||||||
IChargeableCharConfigRepository repo,
|
|
||||||
IAuditLogger audit,
|
|
||||||
TimeProvider timeProvider)
|
|
||||||
{
|
|
||||||
_repo = repo;
|
|
||||||
_audit = audit;
|
|
||||||
_timeProvider = timeProvider;
|
|
||||||
}
|
|
||||||
|
|
||||||
public async Task<DeactivateChargeableCharConfigResponse> Handle(
|
|
||||||
DeactivateChargeableCharConfigCommand command)
|
|
||||||
{
|
|
||||||
var today = _timeProvider.GetArgentinaToday();
|
|
||||||
|
|
||||||
// 1. Load existing — ensures the row exists.
|
|
||||||
var existing = await _repo.GetByIdAsync(command.Id)
|
|
||||||
?? throw new KeyNotFoundException($"ChargeableCharConfig con Id={command.Id} no existe.");
|
|
||||||
|
|
||||||
// 2. TX + deactivate + audit (fail-closed).
|
|
||||||
using (var tx = new TransactionScope(
|
|
||||||
TransactionScopeOption.Required,
|
|
||||||
new TransactionOptions { IsolationLevel = System.Transactions.IsolationLevel.ReadCommitted },
|
|
||||||
TransactionScopeAsyncFlowOption.Enabled))
|
|
||||||
{
|
|
||||||
await _repo.DeactivateAsync(command.Id, today);
|
|
||||||
|
|
||||||
await _audit.LogAsync(
|
|
||||||
action: "tasacion.chargeable_char.deactivate",
|
|
||||||
targetType: "ChargeableCharConfig",
|
|
||||||
targetId: command.Id.ToString(),
|
|
||||||
metadata: new
|
|
||||||
{
|
|
||||||
before = new
|
|
||||||
{
|
|
||||||
id = existing.Id,
|
|
||||||
symbol = existing.Symbol,
|
|
||||||
productTypeId = existing.ProductTypeId,
|
|
||||||
validFrom = existing.ValidFrom.ToString("yyyy-MM-dd"),
|
|
||||||
},
|
|
||||||
deactivatedOn = today.ToString("yyyy-MM-dd"),
|
|
||||||
});
|
|
||||||
|
|
||||||
tx.Complete();
|
|
||||||
}
|
|
||||||
|
|
||||||
return new DeactivateChargeableCharConfigResponse(
|
|
||||||
Id: command.Id,
|
|
||||||
ValidTo: today);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,9 +0,0 @@
|
|||||||
namespace SIGCM2.Application.Pricing.ChargeableChars.Deactivate;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// PRC-001 — Response for DeactivateChargeableCharConfigCommand.
|
|
||||||
/// ValidTo is the date the config was deactivated (= today_AR at time of operation).
|
|
||||||
/// </summary>
|
|
||||||
public sealed record DeactivateChargeableCharConfigResponse(
|
|
||||||
long Id,
|
|
||||||
DateOnly ValidTo);
|
|
||||||
@@ -1,10 +0,0 @@
|
|||||||
namespace SIGCM2.Application.Pricing.ChargeableChars.Delete;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// PRC-001 — Command to physically delete a ChargeableCharConfig row.
|
|
||||||
/// NOTE: Since SYSTEM_VERSIONING is ON, the delete moves the row to the history table
|
|
||||||
/// (SysEndTime = delete time). The row disappears from all current-state queries but
|
|
||||||
/// the temporal audit trail is preserved. Guard for "used in invoicing" is deferred
|
|
||||||
/// to the FAC-001 followup issue.
|
|
||||||
/// </summary>
|
|
||||||
public sealed record DeleteChargeableCharConfigCommand(long Id);
|
|
||||||
@@ -1,75 +0,0 @@
|
|||||||
using System.Transactions;
|
|
||||||
using SIGCM2.Application.Abstractions;
|
|
||||||
using SIGCM2.Application.Abstractions.Persistence;
|
|
||||||
using SIGCM2.Application.Audit;
|
|
||||||
using SIGCM2.Application.Common;
|
|
||||||
|
|
||||||
namespace SIGCM2.Application.Pricing.ChargeableChars.Delete;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// PRC-001 — Handler for DeleteChargeableCharConfigCommand.
|
|
||||||
/// Flow: load existing → open TX → DeleteAsync → audit → tx.Complete().
|
|
||||||
///
|
|
||||||
/// NOTE on SYSTEM_VERSIONING: SQL Server moves the deleted row to the _History table with
|
|
||||||
/// SysEndTime = deletion timestamp. This means:
|
|
||||||
/// - Current-state queries (no FOR SYSTEM_TIME) return nothing — effectively "deleted".
|
|
||||||
/// - Historical queries (FOR SYSTEM_TIME ALL / AS OF) still return the row — temporal audit intact.
|
|
||||||
/// This is intentional. A "physical delete" (bypass SYSTEM_VERSIONING) is not supported here.
|
|
||||||
///
|
|
||||||
/// Future FAC-001 will add a guard to block delete if the row was used in invoicing.
|
|
||||||
/// </summary>
|
|
||||||
public sealed class DeleteChargeableCharConfigCommandHandler
|
|
||||||
: ICommandHandler<DeleteChargeableCharConfigCommand, DeleteChargeableCharConfigResponse>
|
|
||||||
{
|
|
||||||
private readonly IChargeableCharConfigRepository _repo;
|
|
||||||
private readonly IAuditLogger _audit;
|
|
||||||
private readonly TimeProvider _timeProvider;
|
|
||||||
|
|
||||||
public DeleteChargeableCharConfigCommandHandler(
|
|
||||||
IChargeableCharConfigRepository repo,
|
|
||||||
IAuditLogger audit,
|
|
||||||
TimeProvider timeProvider)
|
|
||||||
{
|
|
||||||
_repo = repo;
|
|
||||||
_audit = audit;
|
|
||||||
_timeProvider = timeProvider;
|
|
||||||
}
|
|
||||||
|
|
||||||
public async Task<DeleteChargeableCharConfigResponse> Handle(
|
|
||||||
DeleteChargeableCharConfigCommand command)
|
|
||||||
{
|
|
||||||
// 1. Load existing — ensures the row exists before opening TX.
|
|
||||||
var existing = await _repo.GetByIdAsync(command.Id)
|
|
||||||
?? throw new KeyNotFoundException($"ChargeableCharConfig con Id={command.Id} no existe.");
|
|
||||||
|
|
||||||
// 2. TX + delete + audit (fail-closed).
|
|
||||||
using (var tx = new TransactionScope(
|
|
||||||
TransactionScopeOption.Required,
|
|
||||||
new TransactionOptions { IsolationLevel = System.Transactions.IsolationLevel.ReadCommitted },
|
|
||||||
TransactionScopeAsyncFlowOption.Enabled))
|
|
||||||
{
|
|
||||||
await _repo.DeleteAsync(command.Id);
|
|
||||||
|
|
||||||
await _audit.LogAsync(
|
|
||||||
action: "tasacion.chargeable_char.delete",
|
|
||||||
targetType: "ChargeableCharConfig",
|
|
||||||
targetId: command.Id.ToString(),
|
|
||||||
metadata: new
|
|
||||||
{
|
|
||||||
before = new
|
|
||||||
{
|
|
||||||
id = existing.Id,
|
|
||||||
symbol = existing.Symbol,
|
|
||||||
productTypeId = existing.ProductTypeId,
|
|
||||||
isActive = existing.IsActive,
|
|
||||||
validFrom = existing.ValidFrom.ToString("yyyy-MM-dd"),
|
|
||||||
},
|
|
||||||
deletedOn = _timeProvider.GetArgentinaToday().ToString("yyyy-MM-dd"),
|
|
||||||
});
|
|
||||||
|
|
||||||
tx.Complete();
|
|
||||||
}
|
|
||||||
|
|
||||||
return new DeleteChargeableCharConfigResponse(Id: command.Id);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,6 +0,0 @@
|
|||||||
namespace SIGCM2.Application.Pricing.ChargeableChars.Delete;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// PRC-001 — Response for a successful delete operation.
|
|
||||||
/// </summary>
|
|
||||||
public sealed record DeleteChargeableCharConfigResponse(long Id);
|
|
||||||
@@ -1,7 +0,0 @@
|
|||||||
namespace SIGCM2.Application.Pricing.ChargeableChars.GetById;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// PRC-001 — Query to fetch a single ChargeableCharConfig by Id.
|
|
||||||
/// Returns null if not found (caller decides whether to 404).
|
|
||||||
/// </summary>
|
|
||||||
public sealed record GetChargeableCharConfigByIdQuery(long Id);
|
|
||||||
@@ -1,37 +0,0 @@
|
|||||||
using SIGCM2.Application.Abstractions;
|
|
||||||
using SIGCM2.Application.Abstractions.Persistence;
|
|
||||||
using SIGCM2.Application.Pricing.ChargeableChars;
|
|
||||||
using SIGCM2.Domain.Pricing.ChargeableChars;
|
|
||||||
|
|
||||||
namespace SIGCM2.Application.Pricing.ChargeableChars.GetById;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// PRC-001 — Handler for GetChargeableCharConfigByIdQuery.
|
|
||||||
/// Returns null DTO when not found (API layer maps to 404).
|
|
||||||
/// </summary>
|
|
||||||
public sealed class GetChargeableCharConfigByIdQueryHandler
|
|
||||||
: ICommandHandler<GetChargeableCharConfigByIdQuery, ChargeableCharConfigDto?>
|
|
||||||
{
|
|
||||||
private readonly IChargeableCharConfigRepository _repo;
|
|
||||||
|
|
||||||
public GetChargeableCharConfigByIdQueryHandler(IChargeableCharConfigRepository repo)
|
|
||||||
{
|
|
||||||
_repo = repo;
|
|
||||||
}
|
|
||||||
|
|
||||||
public async Task<ChargeableCharConfigDto?> Handle(GetChargeableCharConfigByIdQuery query)
|
|
||||||
{
|
|
||||||
var entity = await _repo.GetByIdAsync(query.Id);
|
|
||||||
return entity is null ? null : ToDto(entity);
|
|
||||||
}
|
|
||||||
|
|
||||||
private static ChargeableCharConfigDto ToDto(ChargeableCharConfig c) => new(
|
|
||||||
c.Id,
|
|
||||||
c.ProductTypeId,
|
|
||||||
c.Symbol,
|
|
||||||
c.Category,
|
|
||||||
c.PricePerUnit,
|
|
||||||
c.ValidFrom,
|
|
||||||
c.ValidTo,
|
|
||||||
c.IsActive);
|
|
||||||
}
|
|
||||||
@@ -1,21 +0,0 @@
|
|||||||
namespace SIGCM2.Application.Pricing.ChargeableChars;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// PRC-001 — Application service for resolving active chargeable-char config for a ProductType.
|
|
||||||
///
|
|
||||||
/// Priority rule: per-ProductType row overrides global (ProductTypeId IS NULL) for the same Symbol.
|
|
||||||
/// Returns a dictionary keyed by Symbol for O(1) lookup during word-count pricing.
|
|
||||||
/// </summary>
|
|
||||||
public interface IChargeableCharConfigService
|
|
||||||
{
|
|
||||||
/// <summary>
|
|
||||||
/// Returns the resolved active config for the given ProductType as of the given date.
|
|
||||||
/// Per-ProductType rows take priority over global rows for the same Symbol.
|
|
||||||
/// Global rows are used as fallback when no per-ProductType row exists for that Symbol.
|
|
||||||
/// Returns an empty dictionary if no config exists at all.
|
|
||||||
/// </summary>
|
|
||||||
Task<IReadOnlyDictionary<string, ChargeableCharSnapshot>> GetActiveConfigForProductTypeAsync(
|
|
||||||
long productTypeId,
|
|
||||||
DateOnly asOf,
|
|
||||||
CancellationToken ct = default);
|
|
||||||
}
|
|
||||||
@@ -1,11 +0,0 @@
|
|||||||
namespace SIGCM2.Application.Pricing.ChargeableChars.List;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// PRC-001 — Paginated list query for ChargeableCharConfig rows.
|
|
||||||
/// Page/PageSize are clamped by the handler (page >= 1, pageSize in [1, 100]).
|
|
||||||
/// </summary>
|
|
||||||
public sealed record ListChargeableCharConfigQuery(
|
|
||||||
long? ProductTypeId,
|
|
||||||
bool ActiveOnly,
|
|
||||||
int Page = 1,
|
|
||||||
int PageSize = 20);
|
|
||||||
@@ -1,46 +0,0 @@
|
|||||||
using SIGCM2.Application.Abstractions;
|
|
||||||
using SIGCM2.Application.Abstractions.Persistence;
|
|
||||||
using SIGCM2.Application.Common;
|
|
||||||
using SIGCM2.Application.Pricing.ChargeableChars;
|
|
||||||
using SIGCM2.Domain.Pricing.ChargeableChars;
|
|
||||||
|
|
||||||
namespace SIGCM2.Application.Pricing.ChargeableChars.List;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// PRC-001 — Handler for ListChargeableCharConfigQuery.
|
|
||||||
/// Projects ChargeableCharConfig entities to ChargeableCharConfigDto.
|
|
||||||
/// </summary>
|
|
||||||
public sealed class ListChargeableCharConfigQueryHandler
|
|
||||||
: ICommandHandler<ListChargeableCharConfigQuery, PagedResult<ChargeableCharConfigDto>>
|
|
||||||
{
|
|
||||||
private readonly IChargeableCharConfigRepository _repo;
|
|
||||||
|
|
||||||
public ListChargeableCharConfigQueryHandler(IChargeableCharConfigRepository repo)
|
|
||||||
{
|
|
||||||
_repo = repo;
|
|
||||||
}
|
|
||||||
|
|
||||||
public async Task<PagedResult<ChargeableCharConfigDto>> Handle(ListChargeableCharConfigQuery query)
|
|
||||||
{
|
|
||||||
var page = Math.Max(1, query.Page);
|
|
||||||
var pageSize = Math.Clamp(query.PageSize, 1, 100);
|
|
||||||
var skip = (page - 1) * pageSize;
|
|
||||||
|
|
||||||
var items = await _repo.ListAsync(query.ProductTypeId, query.ActiveOnly, skip, pageSize);
|
|
||||||
var total = await _repo.CountAsync(query.ProductTypeId, query.ActiveOnly);
|
|
||||||
|
|
||||||
var dtos = items.Select(ToDto).ToList();
|
|
||||||
|
|
||||||
return new PagedResult<ChargeableCharConfigDto>(dtos, page, pageSize, total);
|
|
||||||
}
|
|
||||||
|
|
||||||
private static ChargeableCharConfigDto ToDto(ChargeableCharConfig c) => new(
|
|
||||||
c.Id,
|
|
||||||
c.ProductTypeId,
|
|
||||||
c.Symbol,
|
|
||||||
c.Category,
|
|
||||||
c.PricePerUnit,
|
|
||||||
c.ValidFrom,
|
|
||||||
c.ValidTo,
|
|
||||||
c.IsActive);
|
|
||||||
}
|
|
||||||
@@ -1,7 +0,0 @@
|
|||||||
namespace SIGCM2.Application.Pricing.ChargeableChars.Reactivate;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// PRC-001 — Command to reactivate a previously closed ChargeableCharConfig row.
|
|
||||||
/// Guard rules enforced by the SP (50410 ALREADY_ACTIVE / 50411 VIGENTE_EXISTS / 50412 POSTERIOR_ROWS_EXIST).
|
|
||||||
/// </summary>
|
|
||||||
public sealed record ReactivateChargeableCharConfigCommand(long Id);
|
|
||||||
@@ -1,71 +0,0 @@
|
|||||||
using System.Transactions;
|
|
||||||
using SIGCM2.Application.Abstractions;
|
|
||||||
using SIGCM2.Application.Abstractions.Persistence;
|
|
||||||
using SIGCM2.Application.Audit;
|
|
||||||
using SIGCM2.Application.Common;
|
|
||||||
|
|
||||||
namespace SIGCM2.Application.Pricing.ChargeableChars.Reactivate;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// PRC-001 — Handler for ReactivateChargeableCharConfigCommand.
|
|
||||||
/// Flow: open TransactionScope → ReactivateAsync (SP with guard) → audit → tx.Complete().
|
|
||||||
///
|
|
||||||
/// Guard failures (ALREADY_ACTIVE / VIGENTE_EXISTS / POSTERIOR_ROWS_EXIST) are thrown by the
|
|
||||||
/// repository as ChargeableCharConfigReactivationNotAllowedException and propagate to the
|
|
||||||
/// ExceptionFilter which maps them to HTTP 409.
|
|
||||||
/// </summary>
|
|
||||||
public sealed class ReactivateChargeableCharConfigCommandHandler
|
|
||||||
: ICommandHandler<ReactivateChargeableCharConfigCommand, ReactivateChargeableCharConfigResponse>
|
|
||||||
{
|
|
||||||
private readonly IChargeableCharConfigRepository _repo;
|
|
||||||
private readonly IAuditLogger _audit;
|
|
||||||
private readonly TimeProvider _timeProvider;
|
|
||||||
|
|
||||||
public ReactivateChargeableCharConfigCommandHandler(
|
|
||||||
IChargeableCharConfigRepository repo,
|
|
||||||
IAuditLogger audit,
|
|
||||||
TimeProvider timeProvider)
|
|
||||||
{
|
|
||||||
_repo = repo;
|
|
||||||
_audit = audit;
|
|
||||||
_timeProvider = timeProvider;
|
|
||||||
}
|
|
||||||
|
|
||||||
public async Task<ReactivateChargeableCharConfigResponse> Handle(
|
|
||||||
ReactivateChargeableCharConfigCommand command)
|
|
||||||
{
|
|
||||||
// Open TX before calling SP so that audit failure rolls back the SP work.
|
|
||||||
using var tx = new TransactionScope(
|
|
||||||
TransactionScopeOption.Required,
|
|
||||||
new TransactionOptions { IsolationLevel = System.Transactions.IsolationLevel.ReadCommitted },
|
|
||||||
TransactionScopeAsyncFlowOption.Enabled);
|
|
||||||
|
|
||||||
// SP enforces guard rules; throws ChargeableCharConfigReactivationNotAllowedException on failure.
|
|
||||||
// Returns the reactivated entity so we can populate the response and the audit log.
|
|
||||||
var reactivated = await _repo.ReactivateAsync(command.Id, CancellationToken.None);
|
|
||||||
|
|
||||||
await _audit.LogAsync(
|
|
||||||
action: "tasacion.chargeable_char.reactivate",
|
|
||||||
targetType: "ChargeableCharConfig",
|
|
||||||
targetId: command.Id.ToString(),
|
|
||||||
metadata: new
|
|
||||||
{
|
|
||||||
id = reactivated.Id,
|
|
||||||
symbol = reactivated.Symbol,
|
|
||||||
productTypeId = reactivated.ProductTypeId,
|
|
||||||
validFrom = reactivated.ValidFrom.ToString("yyyy-MM-dd"),
|
|
||||||
reactivatedOn = _timeProvider.GetArgentinaToday().ToString("yyyy-MM-dd"),
|
|
||||||
});
|
|
||||||
|
|
||||||
tx.Complete();
|
|
||||||
|
|
||||||
return new ReactivateChargeableCharConfigResponse(
|
|
||||||
Id: reactivated.Id,
|
|
||||||
ProductTypeId: reactivated.ProductTypeId,
|
|
||||||
Symbol: reactivated.Symbol,
|
|
||||||
Category: reactivated.Category,
|
|
||||||
PricePerUnit: reactivated.PricePerUnit,
|
|
||||||
ValidFrom: reactivated.ValidFrom,
|
|
||||||
IsActive: reactivated.IsActive);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,14 +0,0 @@
|
|||||||
namespace SIGCM2.Application.Pricing.ChargeableChars.Reactivate;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// PRC-001 — Response for a successful reactivation.
|
|
||||||
/// Returns the current state of the row after it has been re-opened.
|
|
||||||
/// </summary>
|
|
||||||
public sealed record ReactivateChargeableCharConfigResponse(
|
|
||||||
long Id,
|
|
||||||
long? ProductTypeId,
|
|
||||||
string Symbol,
|
|
||||||
string Category,
|
|
||||||
decimal PricePerUnit,
|
|
||||||
DateOnly ValidFrom,
|
|
||||||
bool IsActive);
|
|
||||||
@@ -1,11 +0,0 @@
|
|||||||
namespace SIGCM2.Application.Pricing.ChargeableChars.SchedulePrice;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// PRC-001 — Command to schedule a new price for an existing ChargeableCharConfig.
|
|
||||||
/// Id: the existing row whose price should be superseded.
|
|
||||||
/// ValidFrom must be > existing row's ValidFrom (forward-only, enforced in handler).
|
|
||||||
/// </summary>
|
|
||||||
public sealed record SchedulePriceChangeCommand(
|
|
||||||
long Id,
|
|
||||||
decimal PricePerUnit,
|
|
||||||
DateOnly ValidFrom);
|
|
||||||
@@ -1,80 +0,0 @@
|
|||||||
using System.Transactions;
|
|
||||||
using SIGCM2.Application.Abstractions;
|
|
||||||
using SIGCM2.Application.Abstractions.Persistence;
|
|
||||||
using SIGCM2.Application.Audit;
|
|
||||||
|
|
||||||
namespace SIGCM2.Application.Pricing.ChargeableChars.SchedulePrice;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// PRC-001 — Handler for SchedulePriceChangeCommand.
|
|
||||||
/// Flow: load existing → validate forward-only via entity → open TX → InsertWithCloseAsync → audit → tx.Complete().
|
|
||||||
/// </summary>
|
|
||||||
public sealed class SchedulePriceChangeCommandHandler
|
|
||||||
: ICommandHandler<SchedulePriceChangeCommand, SchedulePriceChangeResponse>
|
|
||||||
{
|
|
||||||
private readonly IChargeableCharConfigRepository _repo;
|
|
||||||
private readonly IAuditLogger _audit;
|
|
||||||
private readonly TimeProvider _timeProvider;
|
|
||||||
|
|
||||||
public SchedulePriceChangeCommandHandler(
|
|
||||||
IChargeableCharConfigRepository repo,
|
|
||||||
IAuditLogger audit,
|
|
||||||
TimeProvider timeProvider)
|
|
||||||
{
|
|
||||||
_repo = repo;
|
|
||||||
_audit = audit;
|
|
||||||
_timeProvider = timeProvider;
|
|
||||||
}
|
|
||||||
|
|
||||||
public async Task<SchedulePriceChangeResponse> Handle(SchedulePriceChangeCommand command)
|
|
||||||
{
|
|
||||||
// 1. Load existing row — validates it exists and exposes ProductTypeId/Symbol/Category.
|
|
||||||
var existing = await _repo.GetByIdAsync(command.Id)
|
|
||||||
?? throw new KeyNotFoundException($"ChargeableCharConfig con Id={command.Id} no existe.");
|
|
||||||
|
|
||||||
// 2. Domain entity validates forward-only rule and builds the new entity value.
|
|
||||||
// ScheduleNewPrice throws ChargeableCharConfigForwardOnlyException if not strictly forward.
|
|
||||||
var newEntity = existing.ScheduleNewPrice(command.PricePerUnit, command.ValidFrom, _timeProvider);
|
|
||||||
|
|
||||||
// 3. TX + SP + audit (fail-closed).
|
|
||||||
long newId;
|
|
||||||
using (var tx = new TransactionScope(
|
|
||||||
TransactionScopeOption.Required,
|
|
||||||
new TransactionOptions { IsolationLevel = System.Transactions.IsolationLevel.ReadCommitted },
|
|
||||||
TransactionScopeAsyncFlowOption.Enabled))
|
|
||||||
{
|
|
||||||
newId = await _repo.InsertWithCloseAsync(
|
|
||||||
newEntity.ProductTypeId,
|
|
||||||
newEntity.Symbol,
|
|
||||||
newEntity.Category,
|
|
||||||
newEntity.PricePerUnit,
|
|
||||||
newEntity.ValidFrom);
|
|
||||||
|
|
||||||
await _audit.LogAsync(
|
|
||||||
action: "tasacion.chargeable_char.price_change",
|
|
||||||
targetType: "ChargeableCharConfig",
|
|
||||||
targetId: newId.ToString(),
|
|
||||||
metadata: new
|
|
||||||
{
|
|
||||||
before = new
|
|
||||||
{
|
|
||||||
id = existing.Id,
|
|
||||||
pricePerUnit = existing.PricePerUnit,
|
|
||||||
validFrom = existing.ValidFrom.ToString("yyyy-MM-dd"),
|
|
||||||
},
|
|
||||||
after = new
|
|
||||||
{
|
|
||||||
pricePerUnit = newEntity.PricePerUnit,
|
|
||||||
validFrom = newEntity.ValidFrom.ToString("yyyy-MM-dd"),
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
tx.Complete();
|
|
||||||
}
|
|
||||||
|
|
||||||
return new SchedulePriceChangeResponse(
|
|
||||||
NewId: newId,
|
|
||||||
PreviousValidFrom: existing.ValidFrom,
|
|
||||||
NewValidFrom: newEntity.ValidFrom);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,30 +0,0 @@
|
|||||||
using FluentValidation;
|
|
||||||
using SIGCM2.Application.Common;
|
|
||||||
|
|
||||||
namespace SIGCM2.Application.Pricing.ChargeableChars.SchedulePrice;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// PRC-001 — FluentValidation validator for SchedulePriceChangeCommand.
|
|
||||||
/// Surface validation only (price > 0, validFrom >= today_AR, id > 0).
|
|
||||||
/// Forward-only check (ValidFrom > existing row's ValidFrom) is performed in the handler
|
|
||||||
/// where the existing entity is loaded.
|
|
||||||
/// </summary>
|
|
||||||
public sealed class SchedulePriceChangeCommandValidator : AbstractValidator<SchedulePriceChangeCommand>
|
|
||||||
{
|
|
||||||
public SchedulePriceChangeCommandValidator(TimeProvider timeProvider)
|
|
||||||
{
|
|
||||||
var today = timeProvider.GetArgentinaToday();
|
|
||||||
|
|
||||||
RuleFor(x => x.Id)
|
|
||||||
.GreaterThan(0L)
|
|
||||||
.WithMessage("Id debe ser un entero positivo.");
|
|
||||||
|
|
||||||
RuleFor(x => x.PricePerUnit)
|
|
||||||
.GreaterThan(0m)
|
|
||||||
.WithMessage("PricePerUnit debe ser > 0.");
|
|
||||||
|
|
||||||
RuleFor(x => x.ValidFrom)
|
|
||||||
.GreaterThanOrEqualTo(today)
|
|
||||||
.WithMessage($"ValidFrom debe ser >= hoy ({today:yyyy-MM-dd} ART).");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,9 +0,0 @@
|
|||||||
namespace SIGCM2.Application.Pricing.ChargeableChars.SchedulePrice;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// PRC-001 — Response for SchedulePriceChangeCommand.
|
|
||||||
/// </summary>
|
|
||||||
public sealed record SchedulePriceChangeResponse(
|
|
||||||
long NewId,
|
|
||||||
DateOnly PreviousValidFrom,
|
|
||||||
DateOnly NewValidFrom);
|
|
||||||
@@ -1,13 +0,0 @@
|
|||||||
namespace SIGCM2.Application.ProductTypes.Create;
|
|
||||||
|
|
||||||
public sealed record CreateProductTypeCommand(
|
|
||||||
string Nombre,
|
|
||||||
bool HasDuration,
|
|
||||||
bool RequiresText,
|
|
||||||
bool RequiresCategory,
|
|
||||||
bool IsBundle,
|
|
||||||
bool AllowImages,
|
|
||||||
int? MaxImages,
|
|
||||||
decimal? MaxImageSizeMB,
|
|
||||||
int? MaxImageWidth,
|
|
||||||
int? MaxImageHeight);
|
|
||||||
@@ -1,80 +0,0 @@
|
|||||||
using System.Transactions;
|
|
||||||
using SIGCM2.Application.Abstractions;
|
|
||||||
using SIGCM2.Application.Abstractions.Persistence;
|
|
||||||
using SIGCM2.Application.Audit;
|
|
||||||
using SIGCM2.Domain.Entities;
|
|
||||||
using SIGCM2.Domain.Exceptions;
|
|
||||||
|
|
||||||
namespace SIGCM2.Application.ProductTypes.Create;
|
|
||||||
|
|
||||||
public sealed class CreateProductTypeCommandHandler
|
|
||||||
: ICommandHandler<CreateProductTypeCommand, ProductTypeCreatedDto>
|
|
||||||
{
|
|
||||||
private readonly IProductTypeRepository _repo;
|
|
||||||
private readonly IAuditLogger _audit;
|
|
||||||
private readonly TimeProvider _timeProvider;
|
|
||||||
|
|
||||||
public CreateProductTypeCommandHandler(
|
|
||||||
IProductTypeRepository repo,
|
|
||||||
IAuditLogger audit,
|
|
||||||
TimeProvider timeProvider)
|
|
||||||
{
|
|
||||||
_repo = repo;
|
|
||||||
_audit = audit;
|
|
||||||
_timeProvider = timeProvider;
|
|
||||||
}
|
|
||||||
|
|
||||||
public async Task<ProductTypeCreatedDto> Handle(CreateProductTypeCommand command)
|
|
||||||
{
|
|
||||||
// 1. Duplicate name check (before factory — avoids wasting domain allocation on error)
|
|
||||||
var exists = await _repo.ExistsByNombreAsync(command.Nombre, excludeId: null);
|
|
||||||
if (exists)
|
|
||||||
throw new ProductTypeNombreDuplicadoException(command.Nombre);
|
|
||||||
|
|
||||||
// 2. Build entity (factory normalizes multimedia if AllowImages=false)
|
|
||||||
var entity = ProductType.ForCreation(
|
|
||||||
command.Nombre,
|
|
||||||
command.HasDuration, command.RequiresText, command.RequiresCategory, command.IsBundle,
|
|
||||||
command.AllowImages,
|
|
||||||
command.MaxImages, command.MaxImageSizeMB, command.MaxImageWidth, command.MaxImageHeight,
|
|
||||||
_timeProvider);
|
|
||||||
|
|
||||||
// 3. Persist + audit (fail-closed: if audit throws, TX rolls back)
|
|
||||||
using var tx = new TransactionScope(
|
|
||||||
TransactionScopeOption.Required,
|
|
||||||
new TransactionOptions { IsolationLevel = System.Transactions.IsolationLevel.ReadCommitted },
|
|
||||||
TransactionScopeAsyncFlowOption.Enabled);
|
|
||||||
|
|
||||||
var newId = await _repo.AddAsync(entity);
|
|
||||||
|
|
||||||
await _audit.LogAsync(
|
|
||||||
action: "producto_tipo.created",
|
|
||||||
targetType: "ProductType",
|
|
||||||
targetId: newId.ToString(),
|
|
||||||
metadata: new
|
|
||||||
{
|
|
||||||
after = new
|
|
||||||
{
|
|
||||||
entity.Nombre,
|
|
||||||
entity.HasDuration,
|
|
||||||
entity.RequiresText,
|
|
||||||
entity.RequiresCategory,
|
|
||||||
entity.IsBundle,
|
|
||||||
entity.AllowImages,
|
|
||||||
entity.MaxImages,
|
|
||||||
entity.MaxImageSizeMB,
|
|
||||||
entity.MaxImageWidth,
|
|
||||||
entity.MaxImageHeight,
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
tx.Complete();
|
|
||||||
|
|
||||||
return new ProductTypeCreatedDto(
|
|
||||||
newId, entity.Nombre,
|
|
||||||
entity.HasDuration, entity.RequiresText, entity.RequiresCategory, entity.IsBundle,
|
|
||||||
entity.AllowImages,
|
|
||||||
entity.MaxImages, entity.MaxImageSizeMB, entity.MaxImageWidth, entity.MaxImageHeight,
|
|
||||||
entity.IsActive);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,29 +0,0 @@
|
|||||||
using FluentValidation;
|
|
||||||
|
|
||||||
namespace SIGCM2.Application.ProductTypes.Create;
|
|
||||||
|
|
||||||
public sealed class CreateProductTypeCommandValidator : AbstractValidator<CreateProductTypeCommand>
|
|
||||||
{
|
|
||||||
public CreateProductTypeCommandValidator()
|
|
||||||
{
|
|
||||||
RuleFor(x => x.Nombre)
|
|
||||||
.NotEmpty().WithMessage("El nombre del tipo de producto es requerido.")
|
|
||||||
.MaximumLength(200).WithMessage("El nombre no puede superar los 200 caracteres.");
|
|
||||||
|
|
||||||
RuleFor(x => x.MaxImages)
|
|
||||||
.GreaterThan(0).When(x => x.MaxImages.HasValue)
|
|
||||||
.WithMessage("MaxImages debe ser mayor que 0 (o null para sin límite).");
|
|
||||||
|
|
||||||
RuleFor(x => x.MaxImageSizeMB)
|
|
||||||
.GreaterThan(0).When(x => x.MaxImageSizeMB.HasValue)
|
|
||||||
.WithMessage("MaxImageSizeMB debe ser mayor que 0 (o null para sin límite).");
|
|
||||||
|
|
||||||
RuleFor(x => x.MaxImageWidth)
|
|
||||||
.GreaterThan(0).When(x => x.MaxImageWidth.HasValue)
|
|
||||||
.WithMessage("MaxImageWidth debe ser mayor que 0 (o null para sin límite).");
|
|
||||||
|
|
||||||
RuleFor(x => x.MaxImageHeight)
|
|
||||||
.GreaterThan(0).When(x => x.MaxImageHeight.HasValue)
|
|
||||||
.WithMessage("MaxImageHeight debe ser mayor que 0 (o null para sin límite).");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,15 +0,0 @@
|
|||||||
namespace SIGCM2.Application.ProductTypes.Create;
|
|
||||||
|
|
||||||
public sealed record ProductTypeCreatedDto(
|
|
||||||
int Id,
|
|
||||||
string Nombre,
|
|
||||||
bool HasDuration,
|
|
||||||
bool RequiresText,
|
|
||||||
bool RequiresCategory,
|
|
||||||
bool IsBundle,
|
|
||||||
bool AllowImages,
|
|
||||||
int? MaxImages,
|
|
||||||
decimal? MaxImageSizeMB,
|
|
||||||
int? MaxImageWidth,
|
|
||||||
int? MaxImageHeight,
|
|
||||||
bool IsActive);
|
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
namespace SIGCM2.Application.ProductTypes.Deactivate;
|
|
||||||
|
|
||||||
public sealed record DeactivateProductTypeCommand(int Id);
|
|
||||||
@@ -1,70 +0,0 @@
|
|||||||
using System.Transactions;
|
|
||||||
using SIGCM2.Application.Abstractions;
|
|
||||||
using SIGCM2.Application.Abstractions.Persistence;
|
|
||||||
using SIGCM2.Application.Audit;
|
|
||||||
using SIGCM2.Domain.Entities;
|
|
||||||
using SIGCM2.Domain.Exceptions;
|
|
||||||
|
|
||||||
namespace SIGCM2.Application.ProductTypes.Deactivate;
|
|
||||||
|
|
||||||
public sealed class DeactivateProductTypeCommandHandler
|
|
||||||
: ICommandHandler<DeactivateProductTypeCommand, ProductTypeStatusDto>
|
|
||||||
{
|
|
||||||
private readonly IProductTypeRepository _repo;
|
|
||||||
private readonly IProductQueryRepository _productQuery;
|
|
||||||
private readonly IAuditLogger _audit;
|
|
||||||
private readonly TimeProvider _timeProvider;
|
|
||||||
|
|
||||||
public DeactivateProductTypeCommandHandler(
|
|
||||||
IProductTypeRepository repo,
|
|
||||||
IProductQueryRepository productQuery,
|
|
||||||
IAuditLogger audit,
|
|
||||||
TimeProvider timeProvider)
|
|
||||||
{
|
|
||||||
_repo = repo;
|
|
||||||
_productQuery = productQuery;
|
|
||||||
_audit = audit;
|
|
||||||
_timeProvider = timeProvider;
|
|
||||||
}
|
|
||||||
|
|
||||||
public async Task<ProductTypeStatusDto> Handle(DeactivateProductTypeCommand command)
|
|
||||||
{
|
|
||||||
// 1. Load entity
|
|
||||||
var target = await _repo.GetByIdAsync(command.Id)
|
|
||||||
?? throw new ProductTypeNotFoundException(command.Id);
|
|
||||||
|
|
||||||
// 2. Idempotent: already inactive → return without side effects (I7)
|
|
||||||
if (!target.IsActive)
|
|
||||||
return new ProductTypeStatusDto(command.Id, false);
|
|
||||||
|
|
||||||
// 3. Guard: check if any active product uses this type
|
|
||||||
var inUse = await _productQuery.ExistsActiveByProductTypeAsync(command.Id);
|
|
||||||
if (inUse)
|
|
||||||
throw new ProductTypeEnUsoException(command.Id, productsActivos: 1);
|
|
||||||
|
|
||||||
// 4. Deactivate (immutable — returns new instance)
|
|
||||||
var deactivated = target.WithDeactivated(_timeProvider);
|
|
||||||
|
|
||||||
// 5. Persist + audit (fail-closed)
|
|
||||||
using var tx = new TransactionScope(
|
|
||||||
TransactionScopeOption.Required,
|
|
||||||
new TransactionOptions { IsolationLevel = System.Transactions.IsolationLevel.ReadCommitted },
|
|
||||||
TransactionScopeAsyncFlowOption.Enabled);
|
|
||||||
|
|
||||||
await _repo.UpdateAsync(deactivated);
|
|
||||||
|
|
||||||
await _audit.LogAsync(
|
|
||||||
action: "producto_tipo.deactivated",
|
|
||||||
targetType: "ProductType",
|
|
||||||
targetId: command.Id.ToString(),
|
|
||||||
metadata: new
|
|
||||||
{
|
|
||||||
productTypeId = command.Id,
|
|
||||||
nombre = target.Nombre,
|
|
||||||
});
|
|
||||||
|
|
||||||
tx.Complete();
|
|
||||||
|
|
||||||
return new ProductTypeStatusDto(deactivated.Id, deactivated.IsActive);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
namespace SIGCM2.Application.ProductTypes.Deactivate;
|
|
||||||
|
|
||||||
public sealed record ProductTypeStatusDto(int Id, bool IsActive);
|
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
namespace SIGCM2.Application.ProductTypes.GetById;
|
|
||||||
|
|
||||||
public sealed record GetProductTypeByIdQuery(int Id);
|
|
||||||
@@ -1,30 +0,0 @@
|
|||||||
using SIGCM2.Application.Abstractions;
|
|
||||||
using SIGCM2.Application.Abstractions.Persistence;
|
|
||||||
using SIGCM2.Domain.Exceptions;
|
|
||||||
|
|
||||||
namespace SIGCM2.Application.ProductTypes.GetById;
|
|
||||||
|
|
||||||
public sealed class GetProductTypeByIdQueryHandler
|
|
||||||
: ICommandHandler<GetProductTypeByIdQuery, ProductTypeDetailDto>
|
|
||||||
{
|
|
||||||
private readonly IProductTypeRepository _repo;
|
|
||||||
|
|
||||||
public GetProductTypeByIdQueryHandler(IProductTypeRepository repo)
|
|
||||||
{
|
|
||||||
_repo = repo;
|
|
||||||
}
|
|
||||||
|
|
||||||
public async Task<ProductTypeDetailDto> Handle(GetProductTypeByIdQuery query)
|
|
||||||
{
|
|
||||||
var pt = await _repo.GetByIdAsync(query.Id)
|
|
||||||
?? throw new ProductTypeNotFoundException(query.Id);
|
|
||||||
|
|
||||||
return new ProductTypeDetailDto(
|
|
||||||
pt.Id, pt.Nombre,
|
|
||||||
pt.HasDuration, pt.RequiresText, pt.RequiresCategory, pt.IsBundle,
|
|
||||||
pt.AllowImages,
|
|
||||||
pt.MaxImages, pt.MaxImageSizeMB, pt.MaxImageWidth, pt.MaxImageHeight,
|
|
||||||
pt.IsActive,
|
|
||||||
pt.FechaCreacion, pt.FechaModificacion);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,17 +0,0 @@
|
|||||||
namespace SIGCM2.Application.ProductTypes.GetById;
|
|
||||||
|
|
||||||
public sealed record ProductTypeDetailDto(
|
|
||||||
int Id,
|
|
||||||
string Nombre,
|
|
||||||
bool HasDuration,
|
|
||||||
bool RequiresText,
|
|
||||||
bool RequiresCategory,
|
|
||||||
bool IsBundle,
|
|
||||||
bool AllowImages,
|
|
||||||
int? MaxImages,
|
|
||||||
decimal? MaxImageSizeMB,
|
|
||||||
int? MaxImageWidth,
|
|
||||||
int? MaxImageHeight,
|
|
||||||
bool IsActive,
|
|
||||||
DateTime FechaCreacion,
|
|
||||||
DateTime? FechaModificacion);
|
|
||||||
@@ -1,7 +0,0 @@
|
|||||||
namespace SIGCM2.Application.ProductTypes.List;
|
|
||||||
|
|
||||||
public sealed record ListProductTypesQuery(
|
|
||||||
int Page = 1,
|
|
||||||
int PageSize = 20,
|
|
||||||
bool? Activo = true,
|
|
||||||
string? Search = null);
|
|
||||||
@@ -1,32 +0,0 @@
|
|||||||
using SIGCM2.Application.Abstractions;
|
|
||||||
using SIGCM2.Application.Abstractions.Persistence;
|
|
||||||
using SIGCM2.Application.Common;
|
|
||||||
|
|
||||||
namespace SIGCM2.Application.ProductTypes.List;
|
|
||||||
|
|
||||||
public sealed class ListProductTypesQueryHandler
|
|
||||||
: ICommandHandler<ListProductTypesQuery, PagedResult<ProductTypeListItemDto>>
|
|
||||||
{
|
|
||||||
private readonly IProductTypeRepository _repo;
|
|
||||||
|
|
||||||
public ListProductTypesQueryHandler(IProductTypeRepository repo)
|
|
||||||
{
|
|
||||||
_repo = repo;
|
|
||||||
}
|
|
||||||
|
|
||||||
public async Task<PagedResult<ProductTypeListItemDto>> Handle(ListProductTypesQuery query)
|
|
||||||
{
|
|
||||||
var page = Math.Max(1, query.Page);
|
|
||||||
var pageSize = Math.Clamp(query.PageSize, 1, 100);
|
|
||||||
|
|
||||||
var repoQuery = new ProductTypesQuery(page, pageSize, query.Activo, query.Search);
|
|
||||||
var paged = await _repo.GetPagedAsync(repoQuery);
|
|
||||||
|
|
||||||
var items = paged.Items.Select(p => new ProductTypeListItemDto(
|
|
||||||
p.Id, p.Nombre,
|
|
||||||
p.HasDuration, p.RequiresText, p.RequiresCategory, p.IsBundle,
|
|
||||||
p.AllowImages, p.IsActive)).ToList();
|
|
||||||
|
|
||||||
return new PagedResult<ProductTypeListItemDto>(items, paged.Page, paged.PageSize, paged.Total);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,11 +0,0 @@
|
|||||||
namespace SIGCM2.Application.ProductTypes.List;
|
|
||||||
|
|
||||||
public sealed record ProductTypeListItemDto(
|
|
||||||
int Id,
|
|
||||||
string Nombre,
|
|
||||||
bool HasDuration,
|
|
||||||
bool RequiresText,
|
|
||||||
bool RequiresCategory,
|
|
||||||
bool IsBundle,
|
|
||||||
bool AllowImages,
|
|
||||||
bool IsActive);
|
|
||||||
@@ -1,16 +0,0 @@
|
|||||||
namespace SIGCM2.Application.ProductTypes.Update;
|
|
||||||
|
|
||||||
public sealed record ProductTypeUpdatedDto(
|
|
||||||
int Id,
|
|
||||||
string Nombre,
|
|
||||||
bool HasDuration,
|
|
||||||
bool RequiresText,
|
|
||||||
bool RequiresCategory,
|
|
||||||
bool IsBundle,
|
|
||||||
bool AllowImages,
|
|
||||||
int? MaxImages,
|
|
||||||
decimal? MaxImageSizeMB,
|
|
||||||
int? MaxImageWidth,
|
|
||||||
int? MaxImageHeight,
|
|
||||||
bool IsActive,
|
|
||||||
DateTime? FechaModificacion);
|
|
||||||
@@ -1,14 +0,0 @@
|
|||||||
namespace SIGCM2.Application.ProductTypes.Update;
|
|
||||||
|
|
||||||
public sealed record UpdateProductTypeCommand(
|
|
||||||
int Id,
|
|
||||||
string Nombre,
|
|
||||||
bool HasDuration,
|
|
||||||
bool RequiresText,
|
|
||||||
bool RequiresCategory,
|
|
||||||
bool IsBundle,
|
|
||||||
bool AllowImages,
|
|
||||||
int? MaxImages,
|
|
||||||
decimal? MaxImageSizeMB,
|
|
||||||
int? MaxImageWidth,
|
|
||||||
int? MaxImageHeight);
|
|
||||||
@@ -1,74 +0,0 @@
|
|||||||
using System.Transactions;
|
|
||||||
using SIGCM2.Application.Abstractions;
|
|
||||||
using SIGCM2.Application.Abstractions.Persistence;
|
|
||||||
using SIGCM2.Application.Audit;
|
|
||||||
using SIGCM2.Domain.Entities;
|
|
||||||
using SIGCM2.Domain.Exceptions;
|
|
||||||
|
|
||||||
namespace SIGCM2.Application.ProductTypes.Update;
|
|
||||||
|
|
||||||
public sealed class UpdateProductTypeCommandHandler
|
|
||||||
: ICommandHandler<UpdateProductTypeCommand, ProductTypeUpdatedDto>
|
|
||||||
{
|
|
||||||
private readonly IProductTypeRepository _repo;
|
|
||||||
private readonly IAuditLogger _audit;
|
|
||||||
private readonly TimeProvider _timeProvider;
|
|
||||||
|
|
||||||
public UpdateProductTypeCommandHandler(
|
|
||||||
IProductTypeRepository repo,
|
|
||||||
IAuditLogger audit,
|
|
||||||
TimeProvider timeProvider)
|
|
||||||
{
|
|
||||||
_repo = repo;
|
|
||||||
_audit = audit;
|
|
||||||
_timeProvider = timeProvider;
|
|
||||||
}
|
|
||||||
|
|
||||||
public async Task<ProductTypeUpdatedDto> Handle(UpdateProductTypeCommand command)
|
|
||||||
{
|
|
||||||
// 1. Load entity (throws if not found)
|
|
||||||
var target = await _repo.GetByIdAsync(command.Id)
|
|
||||||
?? throw new ProductTypeNotFoundException(command.Id);
|
|
||||||
|
|
||||||
// 2. If nombre changed, check for duplicate (skip call when same name — optimization)
|
|
||||||
if (!string.Equals(command.Nombre, target.Nombre, StringComparison.OrdinalIgnoreCase))
|
|
||||||
{
|
|
||||||
var duplicateExists = await _repo.ExistsByNombreAsync(command.Nombre, excludeId: command.Id);
|
|
||||||
if (duplicateExists)
|
|
||||||
throw new ProductTypeNombreDuplicadoException(command.Nombre);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 3. Build updated entity via With* methods (immutable, each returns new instance)
|
|
||||||
var updated = target
|
|
||||||
.WithRenamed(command.Nombre, _timeProvider)
|
|
||||||
.WithUpdatedFlags(command.HasDuration, command.RequiresText, command.RequiresCategory, command.IsBundle, _timeProvider)
|
|
||||||
.WithUpdatedMultimedia(command.AllowImages, command.MaxImages, command.MaxImageSizeMB, command.MaxImageWidth, command.MaxImageHeight, _timeProvider);
|
|
||||||
|
|
||||||
// 4. Persist + audit (fail-closed)
|
|
||||||
using var tx = new TransactionScope(
|
|
||||||
TransactionScopeOption.Required,
|
|
||||||
new TransactionOptions { IsolationLevel = System.Transactions.IsolationLevel.ReadCommitted },
|
|
||||||
TransactionScopeAsyncFlowOption.Enabled);
|
|
||||||
|
|
||||||
await _repo.UpdateAsync(updated);
|
|
||||||
|
|
||||||
await _audit.LogAsync(
|
|
||||||
action: "producto_tipo.updated",
|
|
||||||
targetType: "ProductType",
|
|
||||||
targetId: command.Id.ToString(),
|
|
||||||
metadata: new
|
|
||||||
{
|
|
||||||
before = new { target.Nombre, target.HasDuration, target.RequiresText, target.RequiresCategory, target.IsBundle, target.AllowImages },
|
|
||||||
after = new { updated.Nombre, updated.HasDuration, updated.RequiresText, updated.RequiresCategory, updated.IsBundle, updated.AllowImages }
|
|
||||||
});
|
|
||||||
|
|
||||||
tx.Complete();
|
|
||||||
|
|
||||||
return new ProductTypeUpdatedDto(
|
|
||||||
updated.Id, updated.Nombre,
|
|
||||||
updated.HasDuration, updated.RequiresText, updated.RequiresCategory, updated.IsBundle,
|
|
||||||
updated.AllowImages,
|
|
||||||
updated.MaxImages, updated.MaxImageSizeMB, updated.MaxImageWidth, updated.MaxImageHeight,
|
|
||||||
updated.IsActive, updated.FechaModificacion);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,32 +0,0 @@
|
|||||||
using FluentValidation;
|
|
||||||
|
|
||||||
namespace SIGCM2.Application.ProductTypes.Update;
|
|
||||||
|
|
||||||
public sealed class UpdateProductTypeCommandValidator : AbstractValidator<UpdateProductTypeCommand>
|
|
||||||
{
|
|
||||||
public UpdateProductTypeCommandValidator()
|
|
||||||
{
|
|
||||||
RuleFor(x => x.Id)
|
|
||||||
.GreaterThan(0).WithMessage("El Id debe ser un entero positivo.");
|
|
||||||
|
|
||||||
RuleFor(x => x.Nombre)
|
|
||||||
.NotEmpty().WithMessage("El nombre del tipo de producto es requerido.")
|
|
||||||
.MaximumLength(200).WithMessage("El nombre no puede superar los 200 caracteres.");
|
|
||||||
|
|
||||||
RuleFor(x => x.MaxImages)
|
|
||||||
.GreaterThan(0).When(x => x.MaxImages.HasValue)
|
|
||||||
.WithMessage("MaxImages debe ser mayor que 0 (o null para sin límite).");
|
|
||||||
|
|
||||||
RuleFor(x => x.MaxImageSizeMB)
|
|
||||||
.GreaterThan(0).When(x => x.MaxImageSizeMB.HasValue)
|
|
||||||
.WithMessage("MaxImageSizeMB debe ser mayor que 0 (o null para sin límite).");
|
|
||||||
|
|
||||||
RuleFor(x => x.MaxImageWidth)
|
|
||||||
.GreaterThan(0).When(x => x.MaxImageWidth.HasValue)
|
|
||||||
.WithMessage("MaxImageWidth debe ser mayor que 0 (o null para sin límite).");
|
|
||||||
|
|
||||||
RuleFor(x => x.MaxImageHeight)
|
|
||||||
.GreaterThan(0).When(x => x.MaxImageHeight.HasValue)
|
|
||||||
.WithMessage("MaxImageHeight debe ser mayor que 0 (o null para sin límite).");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,9 +0,0 @@
|
|||||||
namespace SIGCM2.Application.Products.Create;
|
|
||||||
|
|
||||||
public sealed record CreateProductCommand(
|
|
||||||
string Nombre,
|
|
||||||
int MedioId,
|
|
||||||
int ProductTypeId,
|
|
||||||
int? RubroId,
|
|
||||||
decimal BasePrice,
|
|
||||||
int? PriceDurationDays);
|
|
||||||
@@ -1,112 +0,0 @@
|
|||||||
using System.Transactions;
|
|
||||||
using SIGCM2.Application.Abstractions;
|
|
||||||
using SIGCM2.Application.Abstractions.Persistence;
|
|
||||||
using SIGCM2.Application.Audit;
|
|
||||||
using SIGCM2.Domain.Entities;
|
|
||||||
using SIGCM2.Domain.Exceptions;
|
|
||||||
|
|
||||||
namespace SIGCM2.Application.Products.Create;
|
|
||||||
|
|
||||||
public sealed class CreateProductCommandHandler
|
|
||||||
: ICommandHandler<CreateProductCommand, ProductCreatedDto>
|
|
||||||
{
|
|
||||||
private readonly IProductRepository _repo;
|
|
||||||
private readonly IProductTypeRepository _ptRepo;
|
|
||||||
private readonly IMedioRepository _medioRepo;
|
|
||||||
private readonly IRubroRepository _rubroRepo;
|
|
||||||
private readonly IAuditLogger _audit;
|
|
||||||
private readonly TimeProvider _timeProvider;
|
|
||||||
|
|
||||||
public CreateProductCommandHandler(
|
|
||||||
IProductRepository repo,
|
|
||||||
IProductTypeRepository ptRepo,
|
|
||||||
IMedioRepository medioRepo,
|
|
||||||
IRubroRepository rubroRepo,
|
|
||||||
IAuditLogger audit,
|
|
||||||
TimeProvider timeProvider)
|
|
||||||
{
|
|
||||||
_repo = repo;
|
|
||||||
_ptRepo = ptRepo;
|
|
||||||
_medioRepo = medioRepo;
|
|
||||||
_rubroRepo = rubroRepo;
|
|
||||||
_audit = audit;
|
|
||||||
_timeProvider = timeProvider;
|
|
||||||
}
|
|
||||||
|
|
||||||
public async Task<ProductCreatedDto> Handle(CreateProductCommand command)
|
|
||||||
{
|
|
||||||
// 1. Validate Medio exists and is active
|
|
||||||
var medio = await _medioRepo.GetByIdAsync(command.MedioId)
|
|
||||||
?? throw new MedioNotFoundException(command.MedioId);
|
|
||||||
if (!medio.Activo)
|
|
||||||
throw new MedioInactivoException(command.MedioId);
|
|
||||||
|
|
||||||
// 2. Validate ProductType exists and is active
|
|
||||||
var productType = await _ptRepo.GetByIdAsync(command.ProductTypeId)
|
|
||||||
?? throw new ProductTypeNotFoundException(command.ProductTypeId);
|
|
||||||
if (!productType.IsActive)
|
|
||||||
throw new ProductTypeInactivoException(command.ProductTypeId);
|
|
||||||
|
|
||||||
// 3. Flags coherence: RequiresCategory → RubroId required
|
|
||||||
if (productType.RequiresCategory && !command.RubroId.HasValue)
|
|
||||||
throw new ProductTipoFlagsIncoherentesException(
|
|
||||||
$"El tipo '{productType.Nombre}' requiere RubroId (RequiresCategory=true)", "rubroId");
|
|
||||||
|
|
||||||
// 4. Flags coherence: HasDuration → PriceDurationDays required
|
|
||||||
if (productType.HasDuration && !command.PriceDurationDays.HasValue)
|
|
||||||
throw new ProductTipoFlagsIncoherentesException(
|
|
||||||
$"El tipo '{productType.Nombre}' requiere PriceDurationDays (HasDuration=true)", "priceDurationDays");
|
|
||||||
|
|
||||||
// 5. Validate Rubro if provided: must be active
|
|
||||||
if (command.RubroId.HasValue)
|
|
||||||
{
|
|
||||||
var rubro = await _rubroRepo.GetByIdAsync(command.RubroId.Value);
|
|
||||||
if (rubro == null || !rubro.Activo)
|
|
||||||
throw new RubroInactivoException(command.RubroId.Value);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 6. Duplicate nombre check (filtered on IsActive=1 — allows reuse after soft-delete)
|
|
||||||
var exists = await _repo.ExistsByNombreAsync(command.Nombre, command.MedioId, command.ProductTypeId, excludeId: null);
|
|
||||||
if (exists)
|
|
||||||
throw new ProductNombreDuplicadoEnMedioTipoException(command.MedioId, command.ProductTypeId, command.Nombre);
|
|
||||||
|
|
||||||
// 7. Build entity
|
|
||||||
var entity = Product.ForCreation(
|
|
||||||
command.Nombre, command.MedioId, command.ProductTypeId,
|
|
||||||
command.RubroId, command.BasePrice, command.PriceDurationDays,
|
|
||||||
_timeProvider);
|
|
||||||
|
|
||||||
// 8. Persist + audit (fail-closed)
|
|
||||||
using var tx = new TransactionScope(
|
|
||||||
TransactionScopeOption.Required,
|
|
||||||
new TransactionOptions { IsolationLevel = System.Transactions.IsolationLevel.ReadCommitted },
|
|
||||||
TransactionScopeAsyncFlowOption.Enabled);
|
|
||||||
|
|
||||||
var newId = await _repo.AddAsync(entity);
|
|
||||||
|
|
||||||
await _audit.LogAsync(
|
|
||||||
action: "producto.created",
|
|
||||||
targetType: "Product",
|
|
||||||
targetId: newId.ToString(),
|
|
||||||
metadata: new
|
|
||||||
{
|
|
||||||
after = new
|
|
||||||
{
|
|
||||||
entity.Nombre,
|
|
||||||
entity.MedioId,
|
|
||||||
entity.ProductTypeId,
|
|
||||||
entity.RubroId,
|
|
||||||
entity.BasePrice,
|
|
||||||
entity.PriceDurationDays,
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
tx.Complete();
|
|
||||||
|
|
||||||
return new ProductCreatedDto(
|
|
||||||
newId, entity.Nombre,
|
|
||||||
entity.MedioId, entity.ProductTypeId, entity.RubroId,
|
|
||||||
entity.BasePrice, entity.PriceDurationDays,
|
|
||||||
entity.IsActive, entity.FechaCreacion);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,26 +0,0 @@
|
|||||||
using FluentValidation;
|
|
||||||
|
|
||||||
namespace SIGCM2.Application.Products.Create;
|
|
||||||
|
|
||||||
public sealed class CreateProductCommandValidator : AbstractValidator<CreateProductCommand>
|
|
||||||
{
|
|
||||||
public CreateProductCommandValidator()
|
|
||||||
{
|
|
||||||
RuleFor(x => x.Nombre)
|
|
||||||
.NotEmpty().WithMessage("El nombre del producto es requerido.")
|
|
||||||
.MaximumLength(300).WithMessage("El nombre no puede superar los 300 caracteres.");
|
|
||||||
|
|
||||||
RuleFor(x => x.MedioId)
|
|
||||||
.GreaterThan(0).WithMessage("MedioId debe ser un entero positivo.");
|
|
||||||
|
|
||||||
RuleFor(x => x.ProductTypeId)
|
|
||||||
.GreaterThan(0).WithMessage("ProductTypeId debe ser un entero positivo.");
|
|
||||||
|
|
||||||
RuleFor(x => x.BasePrice)
|
|
||||||
.GreaterThanOrEqualTo(0m).WithMessage("El precio base no puede ser negativo.");
|
|
||||||
|
|
||||||
RuleFor(x => x.PriceDurationDays)
|
|
||||||
.GreaterThan(0).When(x => x.PriceDurationDays.HasValue)
|
|
||||||
.WithMessage("PriceDurationDays debe ser >= 1 cuando se provee.");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,12 +0,0 @@
|
|||||||
namespace SIGCM2.Application.Products.Create;
|
|
||||||
|
|
||||||
public sealed record ProductCreatedDto(
|
|
||||||
int Id,
|
|
||||||
string Nombre,
|
|
||||||
int MedioId,
|
|
||||||
int ProductTypeId,
|
|
||||||
int? RubroId,
|
|
||||||
decimal BasePrice,
|
|
||||||
int? PriceDurationDays,
|
|
||||||
bool IsActive,
|
|
||||||
DateTime FechaCreacion);
|
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
namespace SIGCM2.Application.Products.Deactivate;
|
|
||||||
|
|
||||||
public sealed record DeactivateProductCommand(int Id);
|
|
||||||
@@ -1,62 +0,0 @@
|
|||||||
using System.Transactions;
|
|
||||||
using SIGCM2.Application.Abstractions;
|
|
||||||
using SIGCM2.Application.Abstractions.Persistence;
|
|
||||||
using SIGCM2.Application.Audit;
|
|
||||||
using SIGCM2.Domain.Entities;
|
|
||||||
using SIGCM2.Domain.Exceptions;
|
|
||||||
|
|
||||||
namespace SIGCM2.Application.Products.Deactivate;
|
|
||||||
|
|
||||||
public sealed class DeactivateProductCommandHandler
|
|
||||||
: ICommandHandler<DeactivateProductCommand, ProductStatusDto>
|
|
||||||
{
|
|
||||||
private readonly IProductRepository _repo;
|
|
||||||
private readonly IAuditLogger _audit;
|
|
||||||
private readonly TimeProvider _timeProvider;
|
|
||||||
|
|
||||||
public DeactivateProductCommandHandler(
|
|
||||||
IProductRepository repo,
|
|
||||||
IAuditLogger audit,
|
|
||||||
TimeProvider timeProvider)
|
|
||||||
{
|
|
||||||
_repo = repo;
|
|
||||||
_audit = audit;
|
|
||||||
_timeProvider = timeProvider;
|
|
||||||
}
|
|
||||||
|
|
||||||
public async Task<ProductStatusDto> Handle(DeactivateProductCommand command)
|
|
||||||
{
|
|
||||||
// 1. Load entity
|
|
||||||
var target = await _repo.GetByIdAsync(command.Id)
|
|
||||||
?? throw new ProductNotFoundException(command.Id);
|
|
||||||
|
|
||||||
// 2. Idempotent: already inactive → return without side effects
|
|
||||||
if (!target.IsActive)
|
|
||||||
return new ProductStatusDto(command.Id, false, target.FechaModificacion);
|
|
||||||
|
|
||||||
// 3. Deactivate (immutable)
|
|
||||||
var deactivated = target.WithDeactivated(_timeProvider);
|
|
||||||
|
|
||||||
// 4. Persist + audit (fail-closed)
|
|
||||||
using var tx = new TransactionScope(
|
|
||||||
TransactionScopeOption.Required,
|
|
||||||
new TransactionOptions { IsolationLevel = System.Transactions.IsolationLevel.ReadCommitted },
|
|
||||||
TransactionScopeAsyncFlowOption.Enabled);
|
|
||||||
|
|
||||||
await _repo.UpdateAsync(deactivated);
|
|
||||||
|
|
||||||
await _audit.LogAsync(
|
|
||||||
action: "producto.deactivated",
|
|
||||||
targetType: "Product",
|
|
||||||
targetId: command.Id.ToString(),
|
|
||||||
metadata: new
|
|
||||||
{
|
|
||||||
productId = command.Id,
|
|
||||||
nombre = target.Nombre,
|
|
||||||
});
|
|
||||||
|
|
||||||
tx.Complete();
|
|
||||||
|
|
||||||
return new ProductStatusDto(deactivated.Id, deactivated.IsActive, deactivated.FechaModificacion);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,6 +0,0 @@
|
|||||||
namespace SIGCM2.Application.Products.Deactivate;
|
|
||||||
|
|
||||||
public sealed record ProductStatusDto(
|
|
||||||
int Id,
|
|
||||||
bool IsActive,
|
|
||||||
DateTime? FechaModificacion);
|
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
namespace SIGCM2.Application.Products.GetById;
|
|
||||||
|
|
||||||
public sealed record GetProductByIdQuery(int Id);
|
|
||||||
@@ -1,29 +0,0 @@
|
|||||||
using SIGCM2.Application.Abstractions;
|
|
||||||
using SIGCM2.Application.Abstractions.Persistence;
|
|
||||||
using SIGCM2.Domain.Exceptions;
|
|
||||||
|
|
||||||
namespace SIGCM2.Application.Products.GetById;
|
|
||||||
|
|
||||||
public sealed class GetProductByIdQueryHandler
|
|
||||||
: ICommandHandler<GetProductByIdQuery, ProductDetailDto>
|
|
||||||
{
|
|
||||||
private readonly IProductRepository _repo;
|
|
||||||
|
|
||||||
public GetProductByIdQueryHandler(IProductRepository repo)
|
|
||||||
{
|
|
||||||
_repo = repo;
|
|
||||||
}
|
|
||||||
|
|
||||||
public async Task<ProductDetailDto> Handle(GetProductByIdQuery query)
|
|
||||||
{
|
|
||||||
var product = await _repo.GetByIdAsync(query.Id)
|
|
||||||
?? throw new ProductNotFoundException(query.Id);
|
|
||||||
|
|
||||||
return new ProductDetailDto(
|
|
||||||
product.Id, product.Nombre,
|
|
||||||
product.MedioId, product.ProductTypeId, product.RubroId,
|
|
||||||
product.BasePrice, product.PriceDurationDays,
|
|
||||||
product.IsActive,
|
|
||||||
product.FechaCreacion, product.FechaModificacion);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,13 +0,0 @@
|
|||||||
namespace SIGCM2.Application.Products.GetById;
|
|
||||||
|
|
||||||
public sealed record ProductDetailDto(
|
|
||||||
int Id,
|
|
||||||
string Nombre,
|
|
||||||
int MedioId,
|
|
||||||
int ProductTypeId,
|
|
||||||
int? RubroId,
|
|
||||||
decimal BasePrice,
|
|
||||||
int? PriceDurationDays,
|
|
||||||
bool IsActive,
|
|
||||||
DateTime FechaCreacion,
|
|
||||||
DateTime? FechaModificacion);
|
|
||||||
@@ -1,10 +0,0 @@
|
|||||||
namespace SIGCM2.Application.Products.List;
|
|
||||||
|
|
||||||
public sealed record ListProductsQuery(
|
|
||||||
int Page = 1,
|
|
||||||
int PageSize = 20,
|
|
||||||
bool? Activo = true,
|
|
||||||
string? Search = null,
|
|
||||||
int? MedioId = null,
|
|
||||||
int? ProductTypeId = null,
|
|
||||||
int? RubroId = null);
|
|
||||||
@@ -1,35 +0,0 @@
|
|||||||
using SIGCM2.Application.Abstractions;
|
|
||||||
using SIGCM2.Application.Abstractions.Persistence;
|
|
||||||
using SIGCM2.Application.Common;
|
|
||||||
|
|
||||||
namespace SIGCM2.Application.Products.List;
|
|
||||||
|
|
||||||
public sealed class ListProductsQueryHandler
|
|
||||||
: ICommandHandler<ListProductsQuery, PagedResult<ProductListItemDto>>
|
|
||||||
{
|
|
||||||
private readonly IProductRepository _repo;
|
|
||||||
|
|
||||||
public ListProductsQueryHandler(IProductRepository repo)
|
|
||||||
{
|
|
||||||
_repo = repo;
|
|
||||||
}
|
|
||||||
|
|
||||||
public async Task<PagedResult<ProductListItemDto>> Handle(ListProductsQuery query)
|
|
||||||
{
|
|
||||||
var page = Math.Max(1, query.Page);
|
|
||||||
var pageSize = Math.Clamp(query.PageSize, 1, 100);
|
|
||||||
|
|
||||||
var repoQuery = new ProductsQuery(
|
|
||||||
page, pageSize, query.Activo, query.Search,
|
|
||||||
query.MedioId, query.ProductTypeId, query.RubroId);
|
|
||||||
var paged = await _repo.GetPagedAsync(repoQuery);
|
|
||||||
|
|
||||||
var items = paged.Items.Select(p => new ProductListItemDto(
|
|
||||||
p.Id, p.Nombre,
|
|
||||||
p.MedioId, p.ProductTypeId, p.RubroId,
|
|
||||||
p.BasePrice, p.PriceDurationDays,
|
|
||||||
p.IsActive)).ToList();
|
|
||||||
|
|
||||||
return new PagedResult<ProductListItemDto>(items, paged.Page, paged.PageSize, paged.Total);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,11 +0,0 @@
|
|||||||
namespace SIGCM2.Application.Products.List;
|
|
||||||
|
|
||||||
public sealed record ProductListItemDto(
|
|
||||||
int Id,
|
|
||||||
string Nombre,
|
|
||||||
int MedioId,
|
|
||||||
int ProductTypeId,
|
|
||||||
int? RubroId,
|
|
||||||
decimal BasePrice,
|
|
||||||
int? PriceDurationDays,
|
|
||||||
bool IsActive);
|
|
||||||
@@ -1,10 +0,0 @@
|
|||||||
namespace SIGCM2.Application.Products.Prices.AddPrice;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// PRD-003 — Comando para registrar un nuevo precio histórico para un Product.
|
|
||||||
/// Price debe ser > 0. PriceValidFrom debe ser >= hoy_AR (Cat2, TimeProvider).
|
|
||||||
/// </summary>
|
|
||||||
public sealed record AddProductPriceCommand(
|
|
||||||
int ProductId,
|
|
||||||
decimal Price,
|
|
||||||
DateOnly PriceValidFrom);
|
|
||||||
@@ -1,92 +0,0 @@
|
|||||||
using System.Transactions;
|
|
||||||
using SIGCM2.Application.Abstractions;
|
|
||||||
using SIGCM2.Application.Abstractions.Persistence;
|
|
||||||
using SIGCM2.Application.Audit;
|
|
||||||
using SIGCM2.Domain.Entities;
|
|
||||||
using SIGCM2.Domain.Exceptions;
|
|
||||||
|
|
||||||
namespace SIGCM2.Application.Products.Prices.AddPrice;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// PRD-003 — Handler del comando AddProductPrice.
|
|
||||||
/// Flujo: verifica producto activo → abre TransactionScope (AsyncFlow) →
|
|
||||||
/// AddAsync (SP usp_AddProductPrice) → IAuditLogger.LogAsync (fail-closed) →
|
|
||||||
/// tx.Complete() → construye response con GetByProductIdAsync.
|
|
||||||
/// </summary>
|
|
||||||
public sealed class AddProductPriceCommandHandler
|
|
||||||
: ICommandHandler<AddProductPriceCommand, AddProductPriceResponse>
|
|
||||||
{
|
|
||||||
private readonly IProductPriceRepository _pricesRepo;
|
|
||||||
private readonly IProductRepository _productsRepo;
|
|
||||||
private readonly IAuditLogger _audit;
|
|
||||||
private readonly TimeProvider _timeProvider;
|
|
||||||
|
|
||||||
public AddProductPriceCommandHandler(
|
|
||||||
IProductPriceRepository pricesRepo,
|
|
||||||
IProductRepository productsRepo,
|
|
||||||
IAuditLogger audit,
|
|
||||||
TimeProvider timeProvider)
|
|
||||||
{
|
|
||||||
_pricesRepo = pricesRepo;
|
|
||||||
_productsRepo = productsRepo;
|
|
||||||
_audit = audit;
|
|
||||||
_timeProvider = timeProvider;
|
|
||||||
}
|
|
||||||
|
|
||||||
public async Task<AddProductPriceResponse> Handle(AddProductPriceCommand command)
|
|
||||||
{
|
|
||||||
// 1. Producto debe existir Y estar activo (defensa Application — el SP también valida en BD).
|
|
||||||
var product = await _productsRepo.GetByIdAsync(command.ProductId)
|
|
||||||
?? throw new ProductNotFoundException(command.ProductId);
|
|
||||||
|
|
||||||
if (!product.IsActive)
|
|
||||||
throw new ProductNotFoundException(command.ProductId); // inactivo = invisible para clientes
|
|
||||||
|
|
||||||
// 2. TX + SP + audit (fail-closed).
|
|
||||||
// El audit.LogAsync enlista en el mismo TransactionScope — si falla, rollback total.
|
|
||||||
// GetByProductIdAsync se ejecuta FUERA del scope (post-commit) para evitar
|
|
||||||
// "TransactionScope is already complete" al abrir una nueva conexión dentro del using.
|
|
||||||
long newId;
|
|
||||||
long? closedId;
|
|
||||||
using (var tx = new TransactionScope(
|
|
||||||
TransactionScopeOption.Required,
|
|
||||||
new TransactionOptions { IsolationLevel = System.Transactions.IsolationLevel.ReadCommitted },
|
|
||||||
TransactionScopeAsyncFlowOption.Enabled))
|
|
||||||
{
|
|
||||||
(newId, closedId) = await _pricesRepo.AddAsync(
|
|
||||||
command.ProductId, command.Price, command.PriceValidFrom);
|
|
||||||
|
|
||||||
await _audit.LogAsync(
|
|
||||||
action: "product_price.created",
|
|
||||||
targetType: "ProductPrice",
|
|
||||||
targetId: newId.ToString(),
|
|
||||||
metadata: new
|
|
||||||
{
|
|
||||||
after = new
|
|
||||||
{
|
|
||||||
command.ProductId,
|
|
||||||
command.Price,
|
|
||||||
priceValidFrom = command.PriceValidFrom.ToString("yyyy-MM-dd"),
|
|
||||||
},
|
|
||||||
closedPriceId = closedId
|
|
||||||
});
|
|
||||||
|
|
||||||
tx.Complete();
|
|
||||||
} // TX disposed (committed) here — BEFORE the post-commit read below.
|
|
||||||
|
|
||||||
// 3. Compongo la respuesta post-commit con lectura de historial actualizado.
|
|
||||||
// La primera página (pageSize=2) es suficiente: solo necesitamos el nuevo y el cerrado,
|
|
||||||
// que son siempre los más recientes (ORDER BY PriceValidFrom DESC).
|
|
||||||
var pricesPage = await _pricesRepo.GetByProductIdAsync(command.ProductId, page: 1, pageSize: 2);
|
|
||||||
var prices = pricesPage.Items;
|
|
||||||
var created = prices.Single(p => p.Id == newId);
|
|
||||||
var closed = closedId.HasValue
|
|
||||||
? prices.SingleOrDefault(p => p.Id == closedId.Value)
|
|
||||||
: null;
|
|
||||||
|
|
||||||
return new AddProductPriceResponse(ToDto(created), closed is null ? null : ToDto(closed));
|
|
||||||
}
|
|
||||||
|
|
||||||
private static ProductPriceDto ToDto(ProductPrice p)
|
|
||||||
=> new(p.Id, p.ProductId, p.Price, p.PriceValidFrom, p.PriceValidTo, p.IsActive);
|
|
||||||
}
|
|
||||||
@@ -1,29 +0,0 @@
|
|||||||
using FluentValidation;
|
|
||||||
using SIGCM2.Application.Common;
|
|
||||||
|
|
||||||
namespace SIGCM2.Application.Products.Prices.AddPrice;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// PRD-003 — FluentValidation validator para AddProductPriceCommand.
|
|
||||||
/// Inyecta TimeProvider para obtener hoy_AR (Cat2, nunca DateTime.Now).
|
|
||||||
/// FakeTimeProvider en tests garantiza determinismo.
|
|
||||||
/// </summary>
|
|
||||||
public sealed class AddProductPriceCommandValidator : AbstractValidator<AddProductPriceCommand>
|
|
||||||
{
|
|
||||||
public AddProductPriceCommandValidator(TimeProvider timeProvider)
|
|
||||||
{
|
|
||||||
var today = timeProvider.GetArgentinaToday();
|
|
||||||
|
|
||||||
RuleFor(x => x.ProductId)
|
|
||||||
.GreaterThan(0)
|
|
||||||
.WithMessage("ProductId debe ser un entero positivo.");
|
|
||||||
|
|
||||||
RuleFor(x => x.Price)
|
|
||||||
.GreaterThan(0m)
|
|
||||||
.WithMessage("El precio debe ser mayor a cero.");
|
|
||||||
|
|
||||||
RuleFor(x => x.PriceValidFrom)
|
|
||||||
.GreaterThanOrEqualTo(today)
|
|
||||||
.WithMessage($"PriceValidFrom debe ser >= hoy ({today:yyyy-MM-dd} ART). No se permiten precios con fecha retroactiva.");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,9 +0,0 @@
|
|||||||
namespace SIGCM2.Application.Products.Prices.AddPrice;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// PRD-003 — Respuesta del comando AddProductPrice.
|
|
||||||
/// Closed es null si era el primer precio registrado para el producto.
|
|
||||||
/// </summary>
|
|
||||||
public sealed record AddProductPriceResponse(
|
|
||||||
ProductPriceDto Created,
|
|
||||||
ProductPriceDto? Closed);
|
|
||||||
@@ -1,12 +0,0 @@
|
|||||||
namespace SIGCM2.Application.Products.Prices.GetHistory;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// PRD-003 (paginated) — Query para obtener el historial de precios de un Product.
|
|
||||||
/// Devuelve PagedResult ordenado descending por PriceValidFrom (activo primero).
|
|
||||||
/// Lanza ProductNotFoundException si el producto no existe.
|
|
||||||
/// Page y PageSize son clampeados por el handler: page ≥ 1, pageSize ∈ [1, 100].
|
|
||||||
/// </summary>
|
|
||||||
public sealed record GetProductPricesQuery(
|
|
||||||
int ProductId,
|
|
||||||
int Page = 1,
|
|
||||||
int PageSize = 20);
|
|
||||||
@@ -1,49 +0,0 @@
|
|||||||
using SIGCM2.Application.Abstractions;
|
|
||||||
using SIGCM2.Application.Abstractions.Persistence;
|
|
||||||
using SIGCM2.Application.Common;
|
|
||||||
using SIGCM2.Application.Products.Prices;
|
|
||||||
using SIGCM2.Domain.Exceptions;
|
|
||||||
|
|
||||||
namespace SIGCM2.Application.Products.Prices.GetHistory;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// PRD-003 (paginated) — Handler de GetProductPricesQuery.
|
|
||||||
/// Verifica que el producto exista (404 si no), aplica clamping defensivo de
|
|
||||||
/// page/pageSize y retorna PagedResult ordenado descending por PriceValidFrom.
|
|
||||||
/// Lista vacía es válida (nuevo producto sin precios o página más allá del total).
|
|
||||||
/// </summary>
|
|
||||||
public sealed class GetProductPricesQueryHandler
|
|
||||||
: ICommandHandler<GetProductPricesQuery, PagedResult<ProductPriceDto>>
|
|
||||||
{
|
|
||||||
private readonly IProductPriceRepository _pricesRepo;
|
|
||||||
private readonly IProductRepository _productsRepo;
|
|
||||||
|
|
||||||
public GetProductPricesQueryHandler(
|
|
||||||
IProductPriceRepository pricesRepo,
|
|
||||||
IProductRepository productsRepo)
|
|
||||||
{
|
|
||||||
_pricesRepo = pricesRepo;
|
|
||||||
_productsRepo = productsRepo;
|
|
||||||
}
|
|
||||||
|
|
||||||
public async Task<PagedResult<ProductPriceDto>> Handle(GetProductPricesQuery query)
|
|
||||||
{
|
|
||||||
// Verifica existencia del producto (lanza 404 si no existe).
|
|
||||||
_ = await _productsRepo.GetByIdAsync(query.ProductId)
|
|
||||||
?? throw new ProductNotFoundException(query.ProductId);
|
|
||||||
|
|
||||||
// Clamping defensivo — igual al patrón de ListProductsQueryHandler.
|
|
||||||
var page = Math.Max(1, query.Page);
|
|
||||||
var pageSize = Math.Clamp(query.PageSize, 1, 100);
|
|
||||||
|
|
||||||
var paged = await _pricesRepo.GetByProductIdAsync(query.ProductId, page, pageSize);
|
|
||||||
|
|
||||||
var dtoItems = paged.Items
|
|
||||||
.Select(p => new ProductPriceDto(
|
|
||||||
p.Id, p.ProductId, p.Price,
|
|
||||||
p.PriceValidFrom, p.PriceValidTo, p.IsActive))
|
|
||||||
.ToList();
|
|
||||||
|
|
||||||
return new PagedResult<ProductPriceDto>(dtoItems, paged.Page, paged.PageSize, paged.Total);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,13 +0,0 @@
|
|||||||
namespace SIGCM2.Application.Products.Prices;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// PRD-003 — DTO de lectura para un registro de precio histórico de Product.
|
|
||||||
/// IsActive = true cuando PriceValidTo is null (precio vigente en curso).
|
|
||||||
/// </summary>
|
|
||||||
public sealed record ProductPriceDto(
|
|
||||||
long Id,
|
|
||||||
int ProductId,
|
|
||||||
decimal Price,
|
|
||||||
DateOnly PriceValidFrom,
|
|
||||||
DateOnly? PriceValidTo,
|
|
||||||
bool IsActive);
|
|
||||||
@@ -1,18 +0,0 @@
|
|||||||
namespace SIGCM2.Application.Products.Pricing;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// PRD-003 — Servicio de consulta de precio vigente de un Product para una fecha civil (Cat2).
|
|
||||||
/// Contrato forward para PRC-001 (tasación).
|
|
||||||
///
|
|
||||||
/// Retorna null si no existe historial de precios para el producto en la fecha indicada.
|
|
||||||
/// La política de fallback (usar Product.BasePrice o lanzar ProductSinPrecioActivoException)
|
|
||||||
/// queda en el consumidor (OQ-B: Product.BasePrice es ortogonal a ProductPrices).
|
|
||||||
/// </summary>
|
|
||||||
public interface IProductPricingService
|
|
||||||
{
|
|
||||||
/// <summary>
|
|
||||||
/// Devuelve el precio cuya ventana [PriceValidFrom, PriceValidTo] cubre la fecha civil dada,
|
|
||||||
/// o null si ningún registro de precio cubre esa fecha.
|
|
||||||
/// </summary>
|
|
||||||
Task<decimal?> GetPriceAtAsync(int productId, DateOnly date, CancellationToken ct = default);
|
|
||||||
}
|
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user