Compare commits
112 Commits
01ad4cbfbc
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 5a231c206e | |||
| bcb0c94fc5 | |||
| 2aae873a4b | |||
| 3a534f7ad3 | |||
| dfeb5fb7e1 | |||
| 3e7c4bfde9 | |||
| 0eab947975 | |||
| ee36d86b5a | |||
| 0e2e4c9c94 | |||
| 3a596080cb | |||
| d7c6cbd4ff | |||
| 40b5f3904a | |||
| 3eecb05634 | |||
| f7fb76219a | |||
| 5c1675e59a | |||
| 5175cc1ece | |||
| c2a0612a70 | |||
| 8fc7b363d5 | |||
| 3b1edfd696 | |||
| f1b38cd9ce | |||
| ded76fcdc7 | |||
| 8ac91a13aa | |||
| 9144c2e89e | |||
| dd4d4a1673 | |||
| e997409e95 | |||
| 34b07a1d55 | |||
| 0dce3ee4ac | |||
| da063ad677 | |||
| 7d06ac721b | |||
| 5a55fdaaae | |||
| 9f1a312bb9 | |||
| dd0e5e4fe8 | |||
| 7cabb677f3 | |||
| 6a9818b0ae | |||
| f6f24bc4be | |||
| 2d2e90fa3c | |||
| 4b0567d252 | |||
| 54b0265994 | |||
| 59f30cddfb | |||
| e735afb5b4 | |||
| 50a5118a78 | |||
| c974e824e0 | |||
| 900fd5e975 | |||
| e9d1e3237d | |||
| e33e9f332e | |||
| 0e363d1cfc | |||
| c5a8cd9edd | |||
| 616f6432d1 | |||
| 1730b0623e | |||
| d7fb3105fa | |||
| b4f17d6961 | |||
| a7cfcdb683 | |||
| 0f5455aba6 | |||
| 2b79b6f769 | |||
| d262454b28 | |||
| 08a4738daf | |||
| a41a4ea341 | |||
| 165abc8245 | |||
| 733ca0e2e2 | |||
| 8c9a50504d | |||
| bb455be745 | |||
| 8b555e1f8b | |||
| 16197cf242 | |||
| 0462970ea1 | |||
| d6ec618ff2 | |||
| 230405e056 | |||
| 9cb1e84ec0 | |||
| 3db4dedb91 | |||
| 170789886b | |||
| 936d1dc353 | |||
| 5c8f19bf39 | |||
| 3c9e852379 | |||
| 132d17c99f | |||
| de70152d3e | |||
| d8d1da8ea4 | |||
| a0a1874ac2 | |||
| 4f25233bab | |||
| bb5dde6e24 | |||
| f861dfa826 | |||
| c03aad8c5a | |||
| 216983623a | |||
| 9e50a929ae | |||
| 673194e249 | |||
| ddd28ea4d5 | |||
| 205f9c76ad | |||
| 389dda6e5e | |||
| bd2febf411 | |||
| 46ef3878de | |||
| 022a36a90c | |||
| f07802f769 | |||
| b22e9fe59a | |||
| 5e2323e0bc | |||
| f8e9d18379 | |||
| d9fc9a2867 | |||
| dcb2e5ada6 | |||
| 9f78425a93 | |||
| 0d50d4f3cc | |||
| 9886524645 | |||
| bcbba2c012 | |||
| 3cb89f80a3 | |||
| 18ce4f6841 | |||
| 8daadc8a77 | |||
| a0dcc7258b | |||
| e5b6c06f64 | |||
| e0b9cba948 | |||
| 03a695feb9 | |||
| e987228f14 | |||
| d4a2b3bc3e | |||
| 50a3c87b14 | |||
| 9957724c40 | |||
| 1cb69cbaf3 | |||
| 8353f73230 |
21
README.md
21
README.md
@@ -73,6 +73,27 @@ 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`.
|
||||||
|
|||||||
48
coverlet.runsettings
Normal file
48
coverlet.runsettings
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
<?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>
|
||||||
@@ -29,6 +29,19 @@ database/
|
|||||||
| **V010** | **`V010__audit_infrastructure.sql`** | **UDT-010** | **Infra de auditoría + Temporal Tables. Ver nota abajo.** |
|
| **V010** | **`V010__audit_infrastructure.sql`** | **UDT-010** | **Infra de auditoría + Temporal Tables. Ver nota abajo.** |
|
||||||
| V011 | `V011__create_medio_seccion.sql` | ADM-001 | Medio + Seccion (temporal, retention 10y) + permiso `administracion:secciones:gestionar` |
|
| V011 | `V011__create_medio_seccion.sql` | ADM-001 | Medio + Seccion (temporal, retention 10y) + permiso `administracion:secciones:gestionar` |
|
||||||
| V012 | `V012__seed_medios.sql` | ADM-001 | Seed idempotente de Medios ELDIA y ELPLATA |
|
| V012 | `V012__seed_medios.sql` | ADM-001 | Seed idempotente de Medios ELDIA y ELPLATA |
|
||||||
|
| V013 | `V013__create_puntos_de_venta.sql` | ADM-008 | PuntosDeVenta (temporal, retention 10y) + permiso `administracion:puntos_de_venta:gestionar` |
|
||||||
|
| 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 |
|
||||||
|
| **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
|
||||||
|
|
||||||
@@ -36,23 +49,24 @@ 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 AMBAS bases**: `SIGCM2` (dev) y `SIGCM2_Test` (integration tests). El orden debe ser idéntico.
|
- **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.
|
||||||
|
|
||||||
## Cómo aplicar migraciones
|
## Cómo aplicar migraciones
|
||||||
|
|
||||||
### En dev (manual)
|
### En dev (manual)
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Con sqlcmd:
|
# Con sqlcmd (aplicar a las tres bases en orden):
|
||||||
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 -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_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` al inicializar el fixture (`EnsureV008SchemaAsync`, `EnsureV009SchemaAsync`, `EnsureV010SchemaAsync`…). **NO** hace falta correr el script manualmente.
|
`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.
|
||||||
|
|
||||||
### En producción (roadmap futuro)
|
### En producción (roadmap futuro)
|
||||||
|
|
||||||
@@ -90,6 +104,22 @@ 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`
|
||||||
|
|||||||
30
database/init/create-test-api-db.sql
Normal file
30
database/init/create-test-api-db.sql
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
-- 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
|
||||||
82
database/migrations/V016_ROLLBACK.sql
Normal file
82
database/migrations/V016_ROLLBACK.sql
Normal file
@@ -0,0 +1,82 @@
|
|||||||
|
-- V016_ROLLBACK.sql
|
||||||
|
-- Reversa de V016__create_rubro.sql.
|
||||||
|
--
|
||||||
|
-- ⚠️ ADVERTENCIA: ejecutar ELIMINA dbo.Rubro, dbo.Rubro_History,
|
||||||
|
-- el permiso 'catalogo:rubros:gestionar' y sus asignaciones.
|
||||||
|
--
|
||||||
|
-- Uso intended: ROLLBACK en entornos NO-productivos.
|
||||||
|
-- Prerequisito: no deben existir FKs vivas apuntando a Rubro (p.ej., Producto, Tarifario).
|
||||||
|
-- Si CAT-002..006 o PRC-001 ya están aplicados, agregar TarifarioBaseId FK,
|
||||||
|
-- este rollback fallará — usar backup.
|
||||||
|
|
||||||
|
SET QUOTED_IDENTIFIER ON;
|
||||||
|
SET ANSI_NULLS ON;
|
||||||
|
SET NOCOUNT ON;
|
||||||
|
GO
|
||||||
|
|
||||||
|
-- ═══════════════════════════════════════════════════════════════════════
|
||||||
|
-- 1. Apagar SYSTEM_VERSIONING + remover PERIOD en Rubro
|
||||||
|
-- ═══════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
IF EXISTS (SELECT 1 FROM sys.tables WHERE object_id = OBJECT_ID('dbo.Rubro') AND temporal_type = 2)
|
||||||
|
BEGIN
|
||||||
|
ALTER TABLE dbo.Rubro SET (SYSTEM_VERSIONING = OFF);
|
||||||
|
PRINT 'Rubro: SYSTEM_VERSIONING OFF.';
|
||||||
|
END
|
||||||
|
GO
|
||||||
|
|
||||||
|
IF EXISTS (SELECT 1 FROM sys.periods WHERE object_id = OBJECT_ID('dbo.Rubro'))
|
||||||
|
BEGIN
|
||||||
|
ALTER TABLE dbo.Rubro DROP PERIOD FOR SYSTEM_TIME;
|
||||||
|
PRINT 'Rubro: PERIOD FOR SYSTEM_TIME dropped.';
|
||||||
|
END
|
||||||
|
GO
|
||||||
|
|
||||||
|
IF COL_LENGTH('dbo.Rubro', 'ValidFrom') IS NOT NULL
|
||||||
|
BEGIN
|
||||||
|
ALTER TABLE dbo.Rubro DROP CONSTRAINT IF EXISTS DF_Rubro_ValidFrom;
|
||||||
|
ALTER TABLE dbo.Rubro DROP CONSTRAINT IF EXISTS DF_Rubro_ValidTo;
|
||||||
|
ALTER TABLE dbo.Rubro DROP COLUMN ValidFrom, ValidTo;
|
||||||
|
PRINT 'Rubro: ValidFrom/ValidTo dropped.';
|
||||||
|
END
|
||||||
|
GO
|
||||||
|
|
||||||
|
IF OBJECT_ID(N'dbo.Rubro_History', N'U') IS NOT NULL
|
||||||
|
BEGIN
|
||||||
|
DROP TABLE dbo.Rubro_History;
|
||||||
|
PRINT 'Rubro_History dropped.';
|
||||||
|
END
|
||||||
|
GO
|
||||||
|
|
||||||
|
-- ═══════════════════════════════════════════════════════════════════════
|
||||||
|
-- 2. Drop índices + tabla Rubro
|
||||||
|
-- ═══════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
-- Self-FK must be dropped before dropping the table (SQL Server handles it
|
||||||
|
-- automatically when the table is dropped, but explicit is safer).
|
||||||
|
|
||||||
|
IF OBJECT_ID(N'dbo.Rubro', N'U') IS NOT NULL
|
||||||
|
BEGIN
|
||||||
|
DROP TABLE dbo.Rubro;
|
||||||
|
PRINT 'Table dbo.Rubro dropped.';
|
||||||
|
END
|
||||||
|
GO
|
||||||
|
|
||||||
|
-- ═══════════════════════════════════════════════════════════════════════
|
||||||
|
-- 3. Remover permiso 'catalogo:rubros:gestionar' + RolPermiso
|
||||||
|
-- ═══════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
DELETE rp
|
||||||
|
FROM dbo.RolPermiso rp
|
||||||
|
JOIN dbo.Permiso p ON p.Id = rp.PermisoId
|
||||||
|
WHERE p.Codigo = 'catalogo:rubros:gestionar';
|
||||||
|
GO
|
||||||
|
|
||||||
|
DELETE FROM dbo.Permiso
|
||||||
|
WHERE Codigo = 'catalogo:rubros:gestionar';
|
||||||
|
GO
|
||||||
|
|
||||||
|
PRINT '';
|
||||||
|
PRINT 'V016 rolled back. dbo.Rubro and dbo.Rubro_History removed.';
|
||||||
|
PRINT 'catalogo:rubros:gestionar permission and role assignment removed.';
|
||||||
|
GO
|
||||||
152
database/migrations/V016__create_rubro.sql
Normal file
152
database/migrations/V016__create_rubro.sql
Normal file
@@ -0,0 +1,152 @@
|
|||||||
|
-- V016__create_rubro.sql
|
||||||
|
-- CAT-001: Árbol N-ario de Rubros — tabla fundacional del catálogo comercial.
|
||||||
|
--
|
||||||
|
-- Cambios:
|
||||||
|
-- 1. dbo.Rubro (adjacency list, self-FK, soft-delete, SYSTEM_VERSIONING ON, retention 10 años).
|
||||||
|
-- 2. Índice filtrado unique UQ_Rubro_ParentId_Nombre_Activo (unicidad CI por padre en activos).
|
||||||
|
-- 3. Índice cubriente IX_Rubro_ParentId_Activo (child lookups ordenados).
|
||||||
|
-- 4. Permiso 'catalogo:rubros:gestionar' + asignación a rol 'admin'.
|
||||||
|
--
|
||||||
|
-- Patrón: V011 (dbo.Medio con SYSTEM_VERSIONING + PAGE compression + MERGE permisos).
|
||||||
|
-- Idempotente: seguro para re-ejecutar.
|
||||||
|
-- Reversa: V016_ROLLBACK.sql.
|
||||||
|
-- Run on: SIGCM2 (dev) y SIGCM2_Test (integration tests).
|
||||||
|
--
|
||||||
|
-- Notas:
|
||||||
|
-- - TarifarioBaseId es INT NULL SIN FK — la FK se agrega en PRC-001.
|
||||||
|
-- - UQ_Rubro_ParentId_Nombre_Activo cubre solo ParentId IS NOT NULL;
|
||||||
|
-- para roots (ParentId IS NULL) la unicidad CI la garantiza Application
|
||||||
|
-- via ExistsByNombreUnderParentAsync(null, ...) — SQL Server trata NULLs
|
||||||
|
-- como distintos en índices únicos. Ver Design §9 Risk 1.
|
||||||
|
-- - FechaCreacion / FechaModificacion: DATETIME2(3) alineado con Medio/Seccion.
|
||||||
|
-- - ValidFrom / ValidTo: DATETIME2(3) GENERATED ALWAYS HIDDEN (idéntico a V011).
|
||||||
|
--
|
||||||
|
-- Source of truth: Obsidian/02-ARQUITECTURA-y-TECH-STACK/2.5 📋 Auditoría.md
|
||||||
|
-- SDD Design: engram sdd/cat-001-arbol-nario-rubros/design
|
||||||
|
|
||||||
|
SET QUOTED_IDENTIFIER ON;
|
||||||
|
SET ANSI_NULLS ON;
|
||||||
|
SET NOCOUNT ON;
|
||||||
|
GO
|
||||||
|
|
||||||
|
-- ═══════════════════════════════════════════════════════════════════════
|
||||||
|
-- 1. dbo.Rubro
|
||||||
|
-- ═══════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
IF OBJECT_ID(N'dbo.Rubro', N'U') IS NULL
|
||||||
|
BEGIN
|
||||||
|
CREATE TABLE dbo.Rubro (
|
||||||
|
Id INT IDENTITY(1,1) NOT NULL CONSTRAINT PK_Rubro PRIMARY KEY,
|
||||||
|
ParentId INT NULL,
|
||||||
|
Nombre NVARCHAR(200) COLLATE SQL_Latin1_General_CP1_CI_AI NOT NULL,
|
||||||
|
Orden INT NOT NULL CONSTRAINT DF_Rubro_Orden DEFAULT(0),
|
||||||
|
Activo BIT NOT NULL CONSTRAINT DF_Rubro_Activo DEFAULT(1),
|
||||||
|
TarifarioBaseId INT NULL, -- FK reservada para PRC-001 (sin constraint por ahora)
|
||||||
|
FechaCreacion DATETIME2(3) NOT NULL CONSTRAINT DF_Rubro_FechaCreacion DEFAULT(SYSUTCDATETIME()),
|
||||||
|
FechaModificacion DATETIME2(3) NULL,
|
||||||
|
CONSTRAINT FK_Rubro_Parent FOREIGN KEY (ParentId) REFERENCES dbo.Rubro(Id) ON DELETE NO ACTION
|
||||||
|
);
|
||||||
|
PRINT 'Table dbo.Rubro created.';
|
||||||
|
END
|
||||||
|
ELSE
|
||||||
|
PRINT 'Table dbo.Rubro already exists — skip.';
|
||||||
|
GO
|
||||||
|
|
||||||
|
-- ═══════════════════════════════════════════════════════════════════════
|
||||||
|
-- 2. SYSTEM_VERSIONING — Rubro
|
||||||
|
-- ═══════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
IF COL_LENGTH('dbo.Rubro', 'ValidFrom') IS NULL
|
||||||
|
BEGIN
|
||||||
|
ALTER TABLE dbo.Rubro
|
||||||
|
ADD
|
||||||
|
ValidFrom DATETIME2(3) GENERATED ALWAYS AS ROW START HIDDEN NOT NULL
|
||||||
|
CONSTRAINT DF_Rubro_ValidFrom DEFAULT(SYSUTCDATETIME()),
|
||||||
|
ValidTo DATETIME2(3) GENERATED ALWAYS AS ROW END HIDDEN NOT NULL
|
||||||
|
CONSTRAINT DF_Rubro_ValidTo DEFAULT(CONVERT(DATETIME2(3), '9999-12-31 23:59:59.999')),
|
||||||
|
PERIOD FOR SYSTEM_TIME (ValidFrom, ValidTo);
|
||||||
|
PRINT 'Rubro: PERIOD FOR SYSTEM_TIME added.';
|
||||||
|
END
|
||||||
|
GO
|
||||||
|
|
||||||
|
IF NOT EXISTS (SELECT 1 FROM sys.tables WHERE object_id = OBJECT_ID('dbo.Rubro') AND temporal_type = 2)
|
||||||
|
BEGIN
|
||||||
|
ALTER TABLE dbo.Rubro
|
||||||
|
SET (SYSTEM_VERSIONING = ON (
|
||||||
|
HISTORY_TABLE = dbo.Rubro_History,
|
||||||
|
HISTORY_RETENTION_PERIOD = 10 YEARS
|
||||||
|
));
|
||||||
|
PRINT 'Rubro: SYSTEM_VERSIONING = ON (history: dbo.Rubro_History, retention: 10 years).';
|
||||||
|
END
|
||||||
|
ELSE
|
||||||
|
PRINT 'Rubro: SYSTEM_VERSIONING already ON — skip.';
|
||||||
|
GO
|
||||||
|
|
||||||
|
IF EXISTS (SELECT 1 FROM sys.tables WHERE name = 'Rubro_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 = 'Rubro_History' AND p.data_compression = 2
|
||||||
|
)
|
||||||
|
BEGIN
|
||||||
|
ALTER TABLE dbo.Rubro_History REBUILD WITH (DATA_COMPRESSION = PAGE);
|
||||||
|
PRINT 'Rubro_History: rebuilt with PAGE compression.';
|
||||||
|
END
|
||||||
|
GO
|
||||||
|
|
||||||
|
-- ═══════════════════════════════════════════════════════════════════════
|
||||||
|
-- 3. Índices
|
||||||
|
-- ═══════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
-- Unicidad CI por nombre bajo el mismo padre (solo filas activas + ParentId NOT NULL).
|
||||||
|
-- Para roots (ParentId IS NULL) la unicidad la garantiza Application layer.
|
||||||
|
IF NOT EXISTS (SELECT 1 FROM sys.indexes WHERE name = 'UQ_Rubro_ParentId_Nombre_Activo' AND object_id = OBJECT_ID('dbo.Rubro'))
|
||||||
|
BEGIN
|
||||||
|
CREATE UNIQUE INDEX UQ_Rubro_ParentId_Nombre_Activo
|
||||||
|
ON dbo.Rubro(ParentId, Nombre)
|
||||||
|
WHERE Activo = 1 AND ParentId IS NOT NULL;
|
||||||
|
PRINT 'Index UQ_Rubro_ParentId_Nombre_Activo created.';
|
||||||
|
END
|
||||||
|
GO
|
||||||
|
|
||||||
|
-- Cubriente para child lookups ordenados por Orden.
|
||||||
|
IF NOT EXISTS (SELECT 1 FROM sys.indexes WHERE name = 'IX_Rubro_ParentId_Activo' AND object_id = OBJECT_ID('dbo.Rubro'))
|
||||||
|
BEGIN
|
||||||
|
CREATE INDEX IX_Rubro_ParentId_Activo
|
||||||
|
ON dbo.Rubro(ParentId, Activo)
|
||||||
|
INCLUDE (Nombre, Orden);
|
||||||
|
PRINT 'Index IX_Rubro_ParentId_Activo created.';
|
||||||
|
END
|
||||||
|
GO
|
||||||
|
|
||||||
|
-- ═══════════════════════════════════════════════════════════════════════
|
||||||
|
-- 4. Permiso: catalogo:rubros:gestionar + asignación a rol 'admin'
|
||||||
|
-- ═══════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
MERGE dbo.Permiso AS t
|
||||||
|
USING (VALUES
|
||||||
|
('catalogo:rubros:gestionar', N'Gestionar rubros del catálogo', N'Crear, editar, mover y desactivar rubros del árbol de 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:rubros: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 'V016 applied successfully — dbo.Rubro (temporal, retention 10y) + permiso catalogo:rubros:gestionar.';
|
||||||
|
PRINT 'Next: V017 (future — TBD by next UDT).';
|
||||||
|
GO
|
||||||
71
database/migrations/V017_ROLLBACK.sql
Normal file
71
database/migrations/V017_ROLLBACK.sql
Normal file
@@ -0,0 +1,71 @@
|
|||||||
|
-- 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
|
||||||
158
database/migrations/V017__create_product_type.sql
Normal file
158
database/migrations/V017__create_product_type.sql
Normal file
@@ -0,0 +1,158 @@
|
|||||||
|
-- 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
|
||||||
67
database/migrations/V018_ROLLBACK.sql
Normal file
67
database/migrations/V018_ROLLBACK.sql
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
-- 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
|
||||||
172
database/migrations/V018__create_product.sql
Normal file
172
database/migrations/V018__create_product.sql
Normal file
@@ -0,0 +1,172 @@
|
|||||||
|
-- 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
|
||||||
71
database/migrations/V019_ROLLBACK.sql
Normal file
71
database/migrations/V019_ROLLBACK.sql
Normal file
@@ -0,0 +1,71 @@
|
|||||||
|
-- V019_ROLLBACK.sql
|
||||||
|
-- PRD-003: Reversa de V019__create_product_prices.sql.
|
||||||
|
--
|
||||||
|
-- Pasos:
|
||||||
|
-- 1. Deshabilita SYSTEM_VERSIONING en dbo.ProductPrices (requerido antes de DROP TABLE).
|
||||||
|
-- 2. Elimina el PERIOD FOR SYSTEM_TIME y las columnas hidden SysStartTime/SysEndTime.
|
||||||
|
-- 3. Drop de dbo.ProductPrices_History.
|
||||||
|
-- 4. Drop de dbo.ProductPrices (y sus constraints + índices en cascada).
|
||||||
|
-- 5. Drop de dbo.usp_AddProductPrice.
|
||||||
|
--
|
||||||
|
-- ADVERTENCIA: destruye todo el historial de precios. Ejecutar sólo en DEV o TEST.
|
||||||
|
-- Run on: SIGCM2 (dev), SIGCM2_Test_App, SIGCM2_Test_Api.
|
||||||
|
|
||||||
|
SET QUOTED_IDENTIFIER ON;
|
||||||
|
SET ANSI_NULLS ON;
|
||||||
|
SET NOCOUNT ON;
|
||||||
|
GO
|
||||||
|
|
||||||
|
-- 1. Deshabilita SYSTEM_VERSIONING (imprescindible antes de DROP TABLE temporal).
|
||||||
|
IF EXISTS (SELECT 1 FROM sys.tables WHERE object_id = OBJECT_ID('dbo.ProductPrices') AND temporal_type = 2)
|
||||||
|
BEGIN
|
||||||
|
ALTER TABLE dbo.ProductPrices SET (SYSTEM_VERSIONING = OFF);
|
||||||
|
PRINT 'ProductPrices: SYSTEM_VERSIONING = OFF.';
|
||||||
|
END
|
||||||
|
GO
|
||||||
|
|
||||||
|
-- 2. Elimina el PERIOD y las hidden cols (si existen, independientemente del versioning).
|
||||||
|
IF COL_LENGTH('dbo.ProductPrices', 'SysStartTime') IS NOT NULL
|
||||||
|
BEGIN
|
||||||
|
ALTER TABLE dbo.ProductPrices
|
||||||
|
DROP PERIOD FOR SYSTEM_TIME;
|
||||||
|
|
||||||
|
-- Drop default constraints antes de drop de columnas.
|
||||||
|
IF EXISTS (SELECT 1 FROM sys.default_constraints WHERE name = 'DF_ProductPrices_SysStartTime')
|
||||||
|
ALTER TABLE dbo.ProductPrices DROP CONSTRAINT DF_ProductPrices_SysStartTime;
|
||||||
|
IF EXISTS (SELECT 1 FROM sys.default_constraints WHERE name = 'DF_ProductPrices_SysEndTime')
|
||||||
|
ALTER TABLE dbo.ProductPrices DROP CONSTRAINT DF_ProductPrices_SysEndTime;
|
||||||
|
|
||||||
|
ALTER TABLE dbo.ProductPrices DROP COLUMN SysStartTime;
|
||||||
|
ALTER TABLE dbo.ProductPrices DROP COLUMN SysEndTime;
|
||||||
|
PRINT 'ProductPrices: PERIOD + hidden cols dropped.';
|
||||||
|
END
|
||||||
|
GO
|
||||||
|
|
||||||
|
-- 3. Drop de la history table.
|
||||||
|
IF OBJECT_ID(N'dbo.ProductPrices_History', N'U') IS NOT NULL
|
||||||
|
BEGIN
|
||||||
|
DROP TABLE dbo.ProductPrices_History;
|
||||||
|
PRINT 'Table dbo.ProductPrices_History dropped.';
|
||||||
|
END
|
||||||
|
GO
|
||||||
|
|
||||||
|
-- 4. Drop de la tabla principal (constraints + índices en cascada).
|
||||||
|
IF OBJECT_ID(N'dbo.ProductPrices', N'U') IS NOT NULL
|
||||||
|
BEGIN
|
||||||
|
DROP TABLE dbo.ProductPrices;
|
||||||
|
PRINT 'Table dbo.ProductPrices dropped.';
|
||||||
|
END
|
||||||
|
GO
|
||||||
|
|
||||||
|
-- 5. Drop del SP.
|
||||||
|
IF OBJECT_ID(N'dbo.usp_AddProductPrice', N'P') IS NOT NULL
|
||||||
|
BEGIN
|
||||||
|
DROP PROCEDURE dbo.usp_AddProductPrice;
|
||||||
|
PRINT 'Procedure dbo.usp_AddProductPrice dropped.';
|
||||||
|
END
|
||||||
|
GO
|
||||||
|
|
||||||
|
PRINT '';
|
||||||
|
PRINT 'V019 rollback complete — dbo.ProductPrices, dbo.ProductPrices_History, dbo.usp_AddProductPrice removed.';
|
||||||
|
GO
|
||||||
196
database/migrations/V019__create_product_prices.sql
Normal file
196
database/migrations/V019__create_product_prices.sql
Normal file
@@ -0,0 +1,196 @@
|
|||||||
|
-- V019__create_product_prices.sql
|
||||||
|
-- PRD-003: ProductPrices — historial de precios por Producto con vigencia civil (Cat2).
|
||||||
|
--
|
||||||
|
-- Cambios:
|
||||||
|
-- 1. dbo.ProductPrices (FK Product, SYSTEM_VERSIONING ON, retention 10 años).
|
||||||
|
-- 2. Índices: filtered UQ un único activo; cover compuesto para GetPriceAt.
|
||||||
|
-- 3. SP dbo.usp_AddProductPrice (SERIALIZABLE + UPDLOCK, cierre atómico forward-only).
|
||||||
|
--
|
||||||
|
-- Patrón: V018 (SYSTEM_VERSIONING + PAGE compression).
|
||||||
|
-- Idempotente: seguro para re-ejecutar.
|
||||||
|
-- Reversa: V019_ROLLBACK.sql.
|
||||||
|
-- Run on: SIGCM2 (dev), SIGCM2_Test_App, SIGCM2_Test_Api.
|
||||||
|
--
|
||||||
|
-- Notas:
|
||||||
|
-- - SysStartTime/SysEndTime como nombres de cols HIDDEN (no ValidFrom/ValidTo):
|
||||||
|
-- evita colisión con las business cols PriceValidFrom/PriceValidTo (D1).
|
||||||
|
-- - DECIMAL(12,2) para Price (distinto de Product.BasePrice DECIMAL(18,4)) — precios retail
|
||||||
|
-- en pesos con 2 decimales; la diferencia es intencional (D6).
|
||||||
|
-- - Sin seed inicial — Product.BasePrice queda ortogonal como fallback (OQ-B, D8).
|
||||||
|
-- - Forward-only estricto en SP: THROW 50409 si new PVF <= active PVF (no solo <).
|
||||||
|
--
|
||||||
|
-- SDD Design: engram sdd/prd-003-product-prices-historicos/design
|
||||||
|
|
||||||
|
SET QUOTED_IDENTIFIER ON;
|
||||||
|
SET ANSI_NULLS ON;
|
||||||
|
SET NOCOUNT ON;
|
||||||
|
GO
|
||||||
|
|
||||||
|
-- ═══════════════════════════════════════════════════════════════════════
|
||||||
|
-- 1. dbo.ProductPrices
|
||||||
|
-- ═══════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
IF OBJECT_ID(N'dbo.ProductPrices', N'U') IS NULL
|
||||||
|
BEGIN
|
||||||
|
CREATE TABLE dbo.ProductPrices (
|
||||||
|
Id BIGINT IDENTITY(1,1) NOT NULL
|
||||||
|
CONSTRAINT PK_ProductPrices PRIMARY KEY,
|
||||||
|
ProductId INT NOT NULL,
|
||||||
|
Price DECIMAL(12,2) NOT NULL,
|
||||||
|
PriceValidFrom DATE NOT NULL,
|
||||||
|
PriceValidTo DATE NULL,
|
||||||
|
FechaCreacion DATETIME2(3) NOT NULL
|
||||||
|
CONSTRAINT DF_ProductPrices_FechaCreacion DEFAULT(SYSUTCDATETIME()),
|
||||||
|
CONSTRAINT FK_ProductPrices_Product
|
||||||
|
FOREIGN KEY (ProductId) REFERENCES dbo.Product(Id) ON DELETE NO ACTION,
|
||||||
|
CONSTRAINT CK_ProductPrices_Price_Positive
|
||||||
|
CHECK (Price > 0),
|
||||||
|
CONSTRAINT CK_ProductPrices_ValidRange
|
||||||
|
CHECK (PriceValidTo IS NULL OR PriceValidTo >= PriceValidFrom)
|
||||||
|
);
|
||||||
|
PRINT 'Table dbo.ProductPrices created.';
|
||||||
|
END
|
||||||
|
ELSE
|
||||||
|
PRINT 'Table dbo.ProductPrices already exists — skip.';
|
||||||
|
GO
|
||||||
|
|
||||||
|
-- ═══════════════════════════════════════════════════════════════════════
|
||||||
|
-- 2. SYSTEM_VERSIONING — ProductPrices
|
||||||
|
-- Las hidden cols se llaman SysStartTime/SysEndTime para evitar
|
||||||
|
-- colisión con las business cols PriceValidFrom/PriceValidTo (D1).
|
||||||
|
-- ═══════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
IF COL_LENGTH('dbo.ProductPrices', 'SysStartTime') IS NULL
|
||||||
|
BEGIN
|
||||||
|
ALTER TABLE dbo.ProductPrices
|
||||||
|
ADD
|
||||||
|
SysStartTime DATETIME2(3) GENERATED ALWAYS AS ROW START HIDDEN NOT NULL
|
||||||
|
CONSTRAINT DF_ProductPrices_SysStartTime DEFAULT(SYSUTCDATETIME()),
|
||||||
|
SysEndTime DATETIME2(3) GENERATED ALWAYS AS ROW END HIDDEN NOT NULL
|
||||||
|
CONSTRAINT DF_ProductPrices_SysEndTime DEFAULT(CONVERT(DATETIME2(3),'9999-12-31 23:59:59.999')),
|
||||||
|
PERIOD FOR SYSTEM_TIME (SysStartTime, SysEndTime);
|
||||||
|
PRINT 'ProductPrices: PERIOD FOR SYSTEM_TIME added (SysStartTime/SysEndTime).';
|
||||||
|
END
|
||||||
|
GO
|
||||||
|
|
||||||
|
IF NOT EXISTS (SELECT 1 FROM sys.tables WHERE object_id = OBJECT_ID('dbo.ProductPrices') AND temporal_type = 2)
|
||||||
|
BEGIN
|
||||||
|
ALTER TABLE dbo.ProductPrices
|
||||||
|
SET (SYSTEM_VERSIONING = ON (
|
||||||
|
HISTORY_TABLE = dbo.ProductPrices_History,
|
||||||
|
HISTORY_RETENTION_PERIOD = 10 YEARS
|
||||||
|
));
|
||||||
|
PRINT 'ProductPrices: SYSTEM_VERSIONING = ON (history: dbo.ProductPrices_History, retention: 10 years).';
|
||||||
|
END
|
||||||
|
ELSE
|
||||||
|
PRINT 'ProductPrices: SYSTEM_VERSIONING already ON — skip.';
|
||||||
|
GO
|
||||||
|
|
||||||
|
IF EXISTS (SELECT 1 FROM sys.tables WHERE name = 'ProductPrices_History' AND schema_id = SCHEMA_ID('dbo'))
|
||||||
|
AND NOT EXISTS (
|
||||||
|
SELECT 1 FROM sys.partitions p
|
||||||
|
JOIN sys.tables t ON t.object_id = p.object_id
|
||||||
|
WHERE t.name = 'ProductPrices_History' AND p.data_compression = 2
|
||||||
|
)
|
||||||
|
BEGIN
|
||||||
|
ALTER TABLE dbo.ProductPrices_History REBUILD WITH (DATA_COMPRESSION = PAGE);
|
||||||
|
PRINT 'ProductPrices_History: rebuilt with PAGE compression.';
|
||||||
|
END
|
||||||
|
GO
|
||||||
|
|
||||||
|
-- ═══════════════════════════════════════════════════════════════════════
|
||||||
|
-- 3. Índices
|
||||||
|
-- ═══════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
-- Un único activo por producto (imposibilita violar a nivel BD).
|
||||||
|
IF NOT EXISTS (SELECT 1 FROM sys.indexes WHERE name = 'UX_ProductPrices_Active' AND object_id = OBJECT_ID('dbo.ProductPrices'))
|
||||||
|
BEGIN
|
||||||
|
CREATE UNIQUE INDEX UX_ProductPrices_Active
|
||||||
|
ON dbo.ProductPrices (ProductId)
|
||||||
|
WHERE PriceValidTo IS NULL;
|
||||||
|
PRINT 'Index UX_ProductPrices_Active created.';
|
||||||
|
END
|
||||||
|
GO
|
||||||
|
|
||||||
|
-- Cover para GetPriceAt / GetByProductIdAsync (ProductId + PriceValidFrom con INCLUDEs).
|
||||||
|
IF NOT EXISTS (SELECT 1 FROM sys.indexes WHERE name = 'IX_ProductPrices_Lookup' AND object_id = OBJECT_ID('dbo.ProductPrices'))
|
||||||
|
BEGIN
|
||||||
|
CREATE INDEX IX_ProductPrices_Lookup
|
||||||
|
ON dbo.ProductPrices (ProductId, PriceValidFrom DESC)
|
||||||
|
INCLUDE (Price, PriceValidTo);
|
||||||
|
PRINT 'Index IX_ProductPrices_Lookup created.';
|
||||||
|
END
|
||||||
|
GO
|
||||||
|
|
||||||
|
-- ═══════════════════════════════════════════════════════════════════════
|
||||||
|
-- 4. SP — dbo.usp_AddProductPrice
|
||||||
|
-- Cierre atómico forward-only: SERIALIZABLE + UPDLOCK + HOLDLOCK.
|
||||||
|
-- Params de salida: @NewId (BIGINT), @ClosedId (BIGINT — NULL si primer precio).
|
||||||
|
-- ═══════════════════════════════════════════════════════════════════════
|
||||||
|
GO
|
||||||
|
CREATE OR ALTER PROCEDURE dbo.usp_AddProductPrice
|
||||||
|
@ProductId INT,
|
||||||
|
@Price DECIMAL(12,2),
|
||||||
|
@PriceValidFrom DATE,
|
||||||
|
@NewId BIGINT OUTPUT,
|
||||||
|
@ClosedId BIGINT OUTPUT
|
||||||
|
AS
|
||||||
|
BEGIN
|
||||||
|
SET NOCOUNT ON;
|
||||||
|
SET XACT_ABORT ON;
|
||||||
|
SET TRANSACTION ISOLATION LEVEL SERIALIZABLE;
|
||||||
|
|
||||||
|
BEGIN TRY
|
||||||
|
BEGIN TRANSACTION;
|
||||||
|
|
||||||
|
-- Validación: producto debe existir y estar activo.
|
||||||
|
IF NOT EXISTS (SELECT 1 FROM dbo.Product WITH (NOLOCK) WHERE Id = @ProductId AND IsActive = 1)
|
||||||
|
BEGIN
|
||||||
|
ROLLBACK;
|
||||||
|
THROW 50404, 'Product not found or inactive', 1;
|
||||||
|
END
|
||||||
|
|
||||||
|
-- Lee activo con UPDLOCK + HOLDLOCK — bloquea el range key del filtered index.
|
||||||
|
DECLARE @ActiveId BIGINT, @ActivePVF DATE;
|
||||||
|
SELECT TOP 1
|
||||||
|
@ActiveId = Id,
|
||||||
|
@ActivePVF = PriceValidFrom
|
||||||
|
FROM dbo.ProductPrices WITH (UPDLOCK, HOLDLOCK, ROWLOCK)
|
||||||
|
WHERE ProductId = @ProductId AND PriceValidTo IS NULL;
|
||||||
|
|
||||||
|
-- Forward-only estricto: el nuevo PVF debe ser ESTRICTAMENTE mayor al activo.
|
||||||
|
IF @ActiveId IS NOT NULL AND @PriceValidFrom <= @ActivePVF
|
||||||
|
BEGIN
|
||||||
|
ROLLBACK;
|
||||||
|
THROW 50409, 'ProductPriceForwardOnly: new PriceValidFrom must be > active.PriceValidFrom', 1;
|
||||||
|
END
|
||||||
|
|
||||||
|
-- Cierra el activo previo: PVT = PVF(nuevo) - 1 día.
|
||||||
|
IF @ActiveId IS NOT NULL
|
||||||
|
BEGIN
|
||||||
|
UPDATE dbo.ProductPrices
|
||||||
|
SET PriceValidTo = DATEADD(DAY, -1, @PriceValidFrom)
|
||||||
|
WHERE Id = @ActiveId;
|
||||||
|
SET @ClosedId = @ActiveId;
|
||||||
|
END
|
||||||
|
ELSE
|
||||||
|
SET @ClosedId = NULL;
|
||||||
|
|
||||||
|
-- Inserta el nuevo activo.
|
||||||
|
INSERT INTO dbo.ProductPrices (ProductId, Price, PriceValidFrom, PriceValidTo)
|
||||||
|
VALUES (@ProductId, @Price, @PriceValidFrom, NULL);
|
||||||
|
SET @NewId = SCOPE_IDENTITY();
|
||||||
|
|
||||||
|
COMMIT TRANSACTION;
|
||||||
|
END TRY
|
||||||
|
BEGIN CATCH
|
||||||
|
IF XACT_STATE() <> 0 ROLLBACK TRANSACTION;
|
||||||
|
THROW;
|
||||||
|
END CATCH
|
||||||
|
END
|
||||||
|
GO
|
||||||
|
|
||||||
|
PRINT '';
|
||||||
|
PRINT 'V019 applied — dbo.ProductPrices (temporal, retention 10y) + UX_ProductPrices_Active + IX_ProductPrices_Lookup + usp_AddProductPrice.';
|
||||||
|
PRINT 'Next migration: V020 (TBD).';
|
||||||
|
GO
|
||||||
33
database/migrations/V020_ROLLBACK.sql
Normal file
33
database/migrations/V020_ROLLBACK.sql
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
-- 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
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
-- 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
|
||||||
79
database/migrations/V021_ROLLBACK.sql
Normal file
79
database/migrations/V021_ROLLBACK.sql
Normal file
@@ -0,0 +1,79 @@
|
|||||||
|
-- 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
|
||||||
256
database/migrations/V021__create_chargeable_char_config.sql
Normal file
256
database/migrations/V021__create_chargeable_char_config.sql
Normal file
@@ -0,0 +1,256 @@
|
|||||||
|
-- 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
|
||||||
23
database/migrations/V022_ROLLBACK.sql
Normal file
23
database/migrations/V022_ROLLBACK.sql
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
-- 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
|
||||||
44
database/migrations/V022__seed_chargeable_char_config.sql
Normal file
44
database/migrations/V022__seed_chargeable_char_config.sql
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
-- 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
|
||||||
246
database/migrations/V023_ROLLBACK.sql
Normal file
246
database/migrations/V023_ROLLBACK.sql
Normal file
@@ -0,0 +1,246 @@
|
|||||||
|
-- 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
|
||||||
@@ -0,0 +1,414 @@
|
|||||||
|
-- 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
|
||||||
22
database/migrations/V024_ROLLBACK.sql
Normal file
22
database/migrations/V024_ROLLBACK.sql
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
-- 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
|
||||||
34
database/migrations/V024__reseed_global_with_zero_price.sql
Normal file
34
database/migrations/V024__reseed_global_with_zero_price.sql
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
-- 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
|
||||||
20
database/migrations/V025_ROLLBACK.sql
Normal file
20
database/migrations/V025_ROLLBACK.sql
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
-- 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
|
||||||
@@ -0,0 +1,101 @@
|
|||||||
|
-- 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
|
||||||
247
src/api/SIGCM2.Api/Controllers/ChargeableCharConfigController.cs
Normal file
247
src/api/SIGCM2.Api/Controllers/ChargeableCharConfigController.cs
Normal file
@@ -0,0 +1,247 @@
|
|||||||
|
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);
|
||||||
102
src/api/SIGCM2.Api/Controllers/ProductPricesController.cs
Normal file
102
src/api/SIGCM2.Api/Controllers/ProductPricesController.cs
Normal file
@@ -0,0 +1,102 @@
|
|||||||
|
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);
|
||||||
184
src/api/SIGCM2.Api/Controllers/ProductTypesController.cs
Normal file
184
src/api/SIGCM2.Api/Controllers/ProductTypesController.cs
Normal file
@@ -0,0 +1,184 @@
|
|||||||
|
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);
|
||||||
169
src/api/SIGCM2.Api/Controllers/ProductsController.cs
Normal file
169
src/api/SIGCM2.Api/Controllers/ProductsController.cs
Normal file
@@ -0,0 +1,169 @@
|
|||||||
|
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);
|
||||||
151
src/api/SIGCM2.Api/Controllers/RubrosController.cs
Normal file
151
src/api/SIGCM2.Api/Controllers/RubrosController.cs
Normal file
@@ -0,0 +1,151 @@
|
|||||||
|
using Microsoft.AspNetCore.Authorization;
|
||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
using SIGCM2.Api.Authorization;
|
||||||
|
using SIGCM2.Application.Abstractions;
|
||||||
|
using SIGCM2.Application.Rubros.Create;
|
||||||
|
using SIGCM2.Application.Rubros.Deactivate;
|
||||||
|
using SIGCM2.Application.Rubros.Dtos;
|
||||||
|
using SIGCM2.Application.Rubros.GetById;
|
||||||
|
using SIGCM2.Application.Rubros.GetTree;
|
||||||
|
using SIGCM2.Application.Rubros.Move;
|
||||||
|
using SIGCM2.Application.Rubros.Update;
|
||||||
|
|
||||||
|
namespace SIGCM2.Api.Controllers;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// CAT-001: Rubro N-ary tree management.
|
||||||
|
/// Read endpoints at /api/v1/rubros — require authentication (any role).
|
||||||
|
/// Write endpoints at /api/v1/admin/rubros — require 'catalogo:rubros:gestionar'.
|
||||||
|
/// </summary>
|
||||||
|
[ApiController]
|
||||||
|
public sealed class RubrosController : ControllerBase
|
||||||
|
{
|
||||||
|
private readonly IDispatcher _dispatcher;
|
||||||
|
|
||||||
|
public RubrosController(IDispatcher dispatcher)
|
||||||
|
{
|
||||||
|
_dispatcher = dispatcher;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── READ endpoints ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/// <summary>Returns the full Rubro tree. Requires authentication.</summary>
|
||||||
|
[HttpGet("api/v1/rubros/tree")]
|
||||||
|
[Authorize]
|
||||||
|
[ProducesResponseType(typeof(IReadOnlyList<RubroTreeNodeDto>), StatusCodes.Status200OK)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
|
||||||
|
public async Task<IActionResult> GetRubroTree([FromQuery] bool incluirInactivos = false)
|
||||||
|
{
|
||||||
|
var query = new GetRubroTreeQuery(incluirInactivos);
|
||||||
|
var result = await _dispatcher.Send<GetRubroTreeQuery, IReadOnlyList<RubroTreeNodeDto>>(query);
|
||||||
|
return Ok(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Returns a single Rubro by id. Requires authentication.</summary>
|
||||||
|
[HttpGet("api/v1/rubros/{id:int}")]
|
||||||
|
[Authorize]
|
||||||
|
[ProducesResponseType(typeof(RubroDetailDto), StatusCodes.Status200OK)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||||
|
public async Task<IActionResult> GetRubroById([FromRoute] int id)
|
||||||
|
{
|
||||||
|
var query = new GetRubroByIdQuery(id);
|
||||||
|
var result = await _dispatcher.Send<GetRubroByIdQuery, RubroDetailDto>(query);
|
||||||
|
return Ok(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── WRITE endpoints ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/// <summary>Creates a new Rubro. Requires catalogo:rubros:gestionar.</summary>
|
||||||
|
[HttpPost("api/v1/admin/rubros")]
|
||||||
|
[RequirePermission("catalogo:rubros:gestionar")]
|
||||||
|
[ProducesResponseType(typeof(RubroCreatedDto), StatusCodes.Status201Created)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status400BadRequest)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status403Forbidden)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status409Conflict)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status422UnprocessableEntity)]
|
||||||
|
public async Task<IActionResult> CreateRubro([FromBody] CreateRubroRequest request)
|
||||||
|
{
|
||||||
|
var command = new CreateRubroCommand(
|
||||||
|
Nombre: request.Nombre ?? string.Empty,
|
||||||
|
ParentId: request.ParentId,
|
||||||
|
TarifarioBaseId: request.TarifarioBaseId);
|
||||||
|
|
||||||
|
var result = await _dispatcher.Send<CreateRubroCommand, RubroCreatedDto>(command);
|
||||||
|
return CreatedAtAction(nameof(GetRubroById), new { id = result.Id }, result);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Updates a Rubro's nombre. Requires catalogo:rubros:gestionar.</summary>
|
||||||
|
[HttpPut("api/v1/admin/rubros/{id:int}")]
|
||||||
|
[RequirePermission("catalogo:rubros:gestionar")]
|
||||||
|
[ProducesResponseType(typeof(RubroUpdatedDto), StatusCodes.Status200OK)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status400BadRequest)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status403Forbidden)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status409Conflict)]
|
||||||
|
public async Task<IActionResult> UpdateRubro([FromRoute] int id, [FromBody] UpdateRubroRequest request)
|
||||||
|
{
|
||||||
|
var command = new UpdateRubroCommand(
|
||||||
|
Id: id,
|
||||||
|
Nombre: request.Nombre ?? string.Empty);
|
||||||
|
|
||||||
|
var result = await _dispatcher.Send<UpdateRubroCommand, RubroUpdatedDto>(command);
|
||||||
|
return Ok(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Soft-deletes (deactivates) a Rubro. Requires catalogo:rubros:gestionar.</summary>
|
||||||
|
[HttpDelete("api/v1/admin/rubros/{id:int}")]
|
||||||
|
[RequirePermission("catalogo:rubros:gestionar")]
|
||||||
|
[ProducesResponseType(StatusCodes.Status204NoContent)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status403Forbidden)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status409Conflict)]
|
||||||
|
public async Task<IActionResult> DeactivateRubro([FromRoute] int id)
|
||||||
|
{
|
||||||
|
var command = new DeactivateRubroCommand(id);
|
||||||
|
await _dispatcher.Send<DeactivateRubroCommand, RubroStatusDto>(command);
|
||||||
|
return NoContent();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Moves a Rubro to a new parent. Requires catalogo:rubros:gestionar.</summary>
|
||||||
|
[HttpPatch("api/v1/admin/rubros/{id:int}/mover")]
|
||||||
|
[RequirePermission("catalogo:rubros:gestionar")]
|
||||||
|
[ProducesResponseType(typeof(RubroMovedDto), StatusCodes.Status200OK)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status400BadRequest)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status403Forbidden)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status409Conflict)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status422UnprocessableEntity)]
|
||||||
|
public async Task<IActionResult> MoveRubro([FromRoute] int id, [FromBody] MoveRubroRequest request)
|
||||||
|
{
|
||||||
|
var command = new MoveRubroCommand(
|
||||||
|
Id: id,
|
||||||
|
NuevoParentId: request.NuevoParentId,
|
||||||
|
NuevoOrden: request.NuevoOrden);
|
||||||
|
|
||||||
|
var result = await _dispatcher.Send<MoveRubroCommand, RubroMovedDto>(command);
|
||||||
|
return Ok(result);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Request body records ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/// <summary>CAT-001: Create rubro request body.</summary>
|
||||||
|
public sealed record CreateRubroRequest(
|
||||||
|
string? Nombre,
|
||||||
|
int? ParentId,
|
||||||
|
int? TarifarioBaseId);
|
||||||
|
|
||||||
|
/// <summary>CAT-001: Update rubro request body.</summary>
|
||||||
|
public sealed record UpdateRubroRequest(
|
||||||
|
string? Nombre);
|
||||||
|
|
||||||
|
/// <summary>CAT-001: Move rubro request body.</summary>
|
||||||
|
public sealed record MoveRubroRequest(
|
||||||
|
int? NuevoParentId,
|
||||||
|
int NuevoOrden);
|
||||||
@@ -3,6 +3,7 @@ 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;
|
||||||
|
|
||||||
@@ -169,6 +170,116 @@ public sealed class ExceptionFilter : IExceptionFilter
|
|||||||
context.ExceptionHandled = true;
|
context.ExceptionHandled = true;
|
||||||
break;
|
break;
|
||||||
|
|
||||||
|
// CAT-001: Rubro exceptions
|
||||||
|
case RubroNotFoundException rubroNotFoundEx:
|
||||||
|
context.Result = new ObjectResult(new
|
||||||
|
{
|
||||||
|
error = "rubro_not_found",
|
||||||
|
message = rubroNotFoundEx.Message
|
||||||
|
})
|
||||||
|
{
|
||||||
|
StatusCode = StatusCodes.Status404NotFound
|
||||||
|
};
|
||||||
|
context.ExceptionHandled = true;
|
||||||
|
break;
|
||||||
|
|
||||||
|
case RubroNombreDuplicadoEnPadreException rubroDupEx:
|
||||||
|
context.Result = new ObjectResult(new
|
||||||
|
{
|
||||||
|
error = "rubro_nombre_duplicado",
|
||||||
|
message = rubroDupEx.Message
|
||||||
|
})
|
||||||
|
{
|
||||||
|
StatusCode = StatusCodes.Status409Conflict
|
||||||
|
};
|
||||||
|
context.ExceptionHandled = true;
|
||||||
|
break;
|
||||||
|
|
||||||
|
case RubroTieneHijosActivosException rubroHijosEx:
|
||||||
|
context.Result = new ObjectResult(new
|
||||||
|
{
|
||||||
|
error = "rubro_tiene_hijos_activos",
|
||||||
|
message = rubroHijosEx.Message
|
||||||
|
})
|
||||||
|
{
|
||||||
|
StatusCode = StatusCodes.Status409Conflict
|
||||||
|
};
|
||||||
|
context.ExceptionHandled = true;
|
||||||
|
break;
|
||||||
|
|
||||||
|
case RubroPadreInactivoException rubroPadreEx:
|
||||||
|
context.Result = new ObjectResult(new
|
||||||
|
{
|
||||||
|
error = "rubro_padre_inactivo",
|
||||||
|
message = rubroPadreEx.Message
|
||||||
|
})
|
||||||
|
{
|
||||||
|
StatusCode = StatusCodes.Status400BadRequest
|
||||||
|
};
|
||||||
|
context.ExceptionHandled = true;
|
||||||
|
break;
|
||||||
|
|
||||||
|
case RubroMaxDepthExceededException rubroDepthEx:
|
||||||
|
context.Result = new ObjectResult(new
|
||||||
|
{
|
||||||
|
error = "rubro_max_depth_exceeded",
|
||||||
|
message = rubroDepthEx.Message
|
||||||
|
})
|
||||||
|
{
|
||||||
|
StatusCode = StatusCodes.Status422UnprocessableEntity
|
||||||
|
};
|
||||||
|
context.ExceptionHandled = true;
|
||||||
|
break;
|
||||||
|
|
||||||
|
case RubroCycleDetectedException rubroCycleEx:
|
||||||
|
context.Result = new ObjectResult(new
|
||||||
|
{
|
||||||
|
error = "rubro_cycle_detected",
|
||||||
|
message = rubroCycleEx.Message
|
||||||
|
})
|
||||||
|
{
|
||||||
|
StatusCode = StatusCodes.Status400BadRequest
|
||||||
|
};
|
||||||
|
context.ExceptionHandled = true;
|
||||||
|
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
|
||||||
@@ -316,6 +427,156 @@ 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
|
||||||
@@ -385,6 +646,94 @@ 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)
|
||||||
|
|||||||
@@ -32,5 +32,8 @@
|
|||||||
],
|
],
|
||||||
"Enrich": [ "FromLogContext", "WithMachineName", "WithThreadId" ]
|
"Enrich": [ "FromLogContext", "WithMachineName", "WithThreadId" ]
|
||||||
},
|
},
|
||||||
|
"Rubros": {
|
||||||
|
"MaxDepth": 10
|
||||||
|
},
|
||||||
"AllowedHosts": "*"
|
"AllowedHosts": "*"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,24 @@
|
|||||||
|
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);
|
||||||
|
}
|
||||||
@@ -0,0 +1,104 @@
|
|||||||
|
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);
|
||||||
|
}
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
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);
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
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);
|
||||||
|
}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
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);
|
||||||
|
}
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
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);
|
||||||
|
}
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
using SIGCM2.Domain.Entities;
|
||||||
|
|
||||||
|
namespace SIGCM2.Application.Abstractions.Persistence;
|
||||||
|
|
||||||
|
public interface IRubroRepository
|
||||||
|
{
|
||||||
|
Task<int> AddAsync(Rubro rubro, CancellationToken ct = default);
|
||||||
|
Task<Rubro?> GetByIdAsync(int id, CancellationToken ct = default);
|
||||||
|
Task<IReadOnlyList<Rubro>> GetAllAsync(bool incluirInactivos, CancellationToken ct = default);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Returns all descendants of rootId via recursive CTE (used only by MoveRubro for cycle detection).
|
||||||
|
/// </summary>
|
||||||
|
Task<IReadOnlyList<Rubro>> GetDescendantsAsync(int rootId, CancellationToken ct = default);
|
||||||
|
|
||||||
|
Task UpdateAsync(Rubro rubro, CancellationToken ct = default);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Returns the count of active children for the given parentId.
|
||||||
|
/// Used by soft-delete to guard against deleting non-leaf rubros.
|
||||||
|
/// </summary>
|
||||||
|
Task<int> CountActiveChildrenAsync(int id, CancellationToken ct = default);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Returns MAX(Orden)+1 among siblings of the given parentId (0 if no siblings).
|
||||||
|
/// Used for append-on-create ordering.
|
||||||
|
/// </summary>
|
||||||
|
Task<int> GetMaxOrdenAsync(int? parentId, CancellationToken ct = default);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Returns true if an active Rubro with the same Nombre (CI) exists under the same parentId,
|
||||||
|
/// optionally excluding the Rubro with the given id (for rename operations).
|
||||||
|
/// </summary>
|
||||||
|
Task<bool> ExistsByNombreUnderParentAsync(int? parentId, string nombre, int? excludeId, CancellationToken ct = default);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Returns the depth of the given parentId (0 if parentId is null = root level).
|
||||||
|
/// Uses a recursive CTE going upward through ancestors.
|
||||||
|
/// </summary>
|
||||||
|
Task<int> GetDepthAsync(int? parentId, CancellationToken ct = default);
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
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);
|
||||||
|
}
|
||||||
10
src/api/SIGCM2.Application/Common/ProductTypesQuery.cs
Normal file
10
src/api/SIGCM2.Application/Common/ProductTypesQuery.cs
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
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);
|
||||||
13
src/api/SIGCM2.Application/Common/ProductsQuery.cs
Normal file
13
src/api/SIGCM2.Application/Common/ProductsQuery.cs
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
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);
|
||||||
@@ -60,6 +60,37 @@ using SIGCM2.Application.Usuarios.Reactivate;
|
|||||||
using SIGCM2.Application.Usuarios.ResetPassword;
|
using SIGCM2.Application.Usuarios.ResetPassword;
|
||||||
using SIGCM2.Application.Usuarios.Permisos;
|
using SIGCM2.Application.Usuarios.Permisos;
|
||||||
using SIGCM2.Application.Usuarios.Update;
|
using SIGCM2.Application.Usuarios.Update;
|
||||||
|
using SIGCM2.Application.Rubros.Create;
|
||||||
|
using SIGCM2.Application.Rubros.Update;
|
||||||
|
using SIGCM2.Application.Rubros.Deactivate;
|
||||||
|
using SIGCM2.Application.Rubros.Move;
|
||||||
|
using SIGCM2.Application.Rubros.GetTree;
|
||||||
|
using SIGCM2.Application.Rubros.GetById;
|
||||||
|
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;
|
||||||
|
|
||||||
@@ -145,6 +176,48 @@ 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)
|
||||||
|
// 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<UpdateRubroCommand, RubroUpdatedDto>, UpdateRubroCommandHandler>();
|
||||||
|
services.AddScoped<ICommandHandler<DeactivateRubroCommand, RubroStatusDto>, DeactivateRubroCommandHandler>();
|
||||||
|
services.AddScoped<ICommandHandler<MoveRubroCommand, RubroMovedDto>, MoveRubroCommandHandler>();
|
||||||
|
services.AddScoped<ICommandHandler<GetRubroTreeQuery, IReadOnlyList<RubroTreeNodeDto>>, GetRubroTreeQueryHandler>();
|
||||||
|
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>();
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,14 @@
|
|||||||
|
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);
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
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);
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
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);
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
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.");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
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);
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
namespace SIGCM2.Application.Pricing.ChargeableChars.Deactivate;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// PRC-001 — Command to deactivate an existing ChargeableCharConfig.
|
||||||
|
/// </summary>
|
||||||
|
public sealed record DeactivateChargeableCharConfigCommand(long Id);
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
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);
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
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);
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
namespace SIGCM2.Application.Pricing.ChargeableChars.Delete;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// PRC-001 — Response for a successful delete operation.
|
||||||
|
/// </summary>
|
||||||
|
public sealed record DeleteChargeableCharConfigResponse(long Id);
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
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);
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
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);
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
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);
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
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);
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
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);
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
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);
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
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);
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
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);
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
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).");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
namespace SIGCM2.Application.Pricing.ChargeableChars.SchedulePrice;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// PRC-001 — Response for SchedulePriceChangeCommand.
|
||||||
|
/// </summary>
|
||||||
|
public sealed record SchedulePriceChangeResponse(
|
||||||
|
long NewId,
|
||||||
|
DateOnly PreviousValidFrom,
|
||||||
|
DateOnly NewValidFrom);
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
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);
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
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).");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
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);
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
namespace SIGCM2.Application.ProductTypes.Deactivate;
|
||||||
|
|
||||||
|
public sealed record DeactivateProductTypeCommand(int Id);
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
namespace SIGCM2.Application.ProductTypes.Deactivate;
|
||||||
|
|
||||||
|
public sealed record ProductTypeStatusDto(int Id, bool IsActive);
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
namespace SIGCM2.Application.ProductTypes.GetById;
|
||||||
|
|
||||||
|
public sealed record GetProductTypeByIdQuery(int Id);
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
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);
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
namespace SIGCM2.Application.ProductTypes.List;
|
||||||
|
|
||||||
|
public sealed record ListProductTypesQuery(
|
||||||
|
int Page = 1,
|
||||||
|
int PageSize = 20,
|
||||||
|
bool? Activo = true,
|
||||||
|
string? Search = null);
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
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);
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
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);
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
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);
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
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).");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
namespace SIGCM2.Application.Products.Create;
|
||||||
|
|
||||||
|
public sealed record CreateProductCommand(
|
||||||
|
string Nombre,
|
||||||
|
int MedioId,
|
||||||
|
int ProductTypeId,
|
||||||
|
int? RubroId,
|
||||||
|
decimal BasePrice,
|
||||||
|
int? PriceDurationDays);
|
||||||
@@ -0,0 +1,112 @@
|
|||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
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.");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
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);
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
namespace SIGCM2.Application.Products.Deactivate;
|
||||||
|
|
||||||
|
public sealed record DeactivateProductCommand(int Id);
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
namespace SIGCM2.Application.Products.Deactivate;
|
||||||
|
|
||||||
|
public sealed record ProductStatusDto(
|
||||||
|
int Id,
|
||||||
|
bool IsActive,
|
||||||
|
DateTime? FechaModificacion);
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
namespace SIGCM2.Application.Products.GetById;
|
||||||
|
|
||||||
|
public sealed record GetProductByIdQuery(int Id);
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
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);
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
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);
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
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);
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
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);
|
||||||
@@ -0,0 +1,92 @@
|
|||||||
|
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);
|
||||||
|
}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
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.");
|
||||||
|
}
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user