Compare commits

..

12 Commits

Author SHA1 Message Date
f5ed9c4b3c fix(frontend): MoveRubroDialog type cast para zodResolver output (CAT-001) 2026-04-18 21:05:51 -03:00
d49d2f7536 feat(frontend): MoveRubroDialog + wire en RubrosPage + aria-describedby (CAT-001)
Implementa MoveRubroDialog con flattenExcludingSubtree para prevenir ciclos en UI,
lo conecta en RubrosPage y agrega DialogDescription en RubroFormDialog.
2026-04-18 20:52:08 -03:00
443380d1d1 test(application): GetRubroByIdQueryHandlerTests dedicado (CAT-001) 2026-04-18 20:50:16 -03:00
f8d861a25a fix(frontend): corregir tipos zodResolver en RubroFormDialog (CAT-001)
- Reemplaza z.union([z.coerce.number(), z.literal('')]) por z.string().transform+pipe para evitar inferencia unknown en zodResolver
- Simplifica RubroFormValues a {nombre: string, tarifarioBaseId?: number | null}
- Actualiza RubrosPage: tarifarioId ya llega como number|null del schema transform
2026-04-18 20:36:12 -03:00
f6733acfbb feat(frontend): rubros feature + CategoryTree + CRUD dialogs (CAT-001)
Co-Authored-By: none
2026-04-18 20:21:11 -03:00
ff7c28789e feat(api): RubrosController + integration tests e2e + audit verification (CAT-001) 2026-04-18 20:05:20 -03:00
cc3108dfdb feat(infrastructure): RubroRepository Dapper + DI + integration tests (CAT-001) 2026-04-18 20:00:51 -03:00
b1be4a5573 fix(tests): propagar Rubro_History + permisos 25 a integration tests (CAT-001)
- SqlTestFixture: agrega EnsureV016SchemaAsync + seed del permiso catalogo:rubros:gestionar + Rubro_History al TablesToIgnore
- 6 test files con Respawner propio: agrega Rubro_History al TablesToIgnore
- 2 tests con count hardcoded (Permiso/RolPermiso): 24 -> 25 + rename
- 3 Api tests con count hardcoded (Auth/Permisos): 24 -> 25 + rename
2026-04-18 19:48:33 -03:00
d4c05cc364 feat(application): Rubros commands/queries + RubroTreeBuilder + audit (CAT-001) 2026-04-18 19:25:35 -03:00
4c9b7eabaf feat(domain): Rubro entity + domain exceptions (CAT-001) 2026-04-18 19:17:33 -03:00
4a88cb4319 fix(bd): V016 COLLATE order — SQL Server requiere COLLATE antes de NOT NULL (CAT-001) 2026-04-18 19:10:03 -03:00
d3ed8300f0 feat(bd): V016 create Rubro table con SYSTEM_VERSIONING (CAT-001)
- dbo.Rubro: adjacency list, self-FK, soft-delete, temporal retention 10y
- Filtered unique index UQ_Rubro_ParentId_Nombre_Activo + covering IX_Rubro_ParentId_Activo
- Permission catalogo:rubros:gestionar seeded + assigned to admin role
- V016_ROLLBACK.sql: full reversal script
- RubrosOptions class (MaxDepth=10) + appsettings.json Rubros section
- services.Configure<RubrosOptions> registered in Infrastructure DI
- database/README.md updated with V013-V016 entries
2026-04-18 19:04:24 -03:00
375 changed files with 1068 additions and 29313 deletions

View File

@@ -73,27 +73,6 @@ dotnet test tests/SIGCM2.Api.Tests # integration (requiere SIGCM2_
cd src/web && npx vitest run cd src/web && npx vitest run
``` ```
### Coverage (backend)
```bash
# Generar reporte de coverage en formato Cobertura
dotnet test --collect:"XPlat Code Coverage" --settings coverlet.runsettings --results-directory ./TestResults
```
El comando genera un `coverage.cobertura.xml` por cada proyecto de test en `./TestResults/`.
Para convertirlo a HTML:
```bash
# Instalar ReportGenerator (solo la primera vez)
dotnet tool install -g dotnet-reportgenerator-globaltool
# Generar reporte HTML
reportgenerator -reports:"./TestResults/**/coverage.cobertura.xml" -targetdir:"./coverage-report" -reporttypes:Html
```
Abrí `./coverage-report/index.html` en el browser para ver el detalle por archivo.
## Convenciones ## Convenciones
- Ramas: `feature/UDT-XXX` desde `main`. - Ramas: `feature/UDT-XXX` desde `main`.

View File

@@ -1,48 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<RunSettings>
<!--
Configuracion de coverage con coverlet.collector.
Uso: dotnet test /collect:"XPlat Code Coverage" /settings:coverlet.runsettings /results-directory:./TestResults
-->
<RunConfiguration>
<!-- Mantener ejecución secuencial (hereda política de tests.runsettings) -->
<MaxCpuCount>1</MaxCpuCount>
</RunConfiguration>
<DataCollectionRunSettings>
<DataCollectors>
<DataCollector friendlyName="XPlat Code Coverage">
<Configuration>
<!-- Formato de salida: cobertura (compatible con ReportGenerator y CI/CD) -->
<Format>cobertura</Format>
<!-- Exclusiones por atributo generado -->
<ExcludeByAttribute>GeneratedCodeAttribute,ExcludeFromCodeCoverageAttribute</ExcludeByAttribute>
<!-- Exclusiones por tipo/namespace -->
<Exclude>
<!-- Migrations embebidas (SQL scripts, no lógica de negocio) -->
[*.Migrations]*,
<!-- Los proyectos de test no se miden a sí mismos -->
[*.Tests]*,
[SIGCM2.TestSupport]*,
<!-- Program.cs: host wiring, no testeable unitariamente -->
[SIGCM2.Api]Program,
<!-- Extension methods de DI: una línea por registro, ruido sin valor -->
[*]*.Extensions.*Extensions,
[*]*.DependencyInjection
</Exclude>
<!-- No medir las propiedades auto-implementadas -->
<SkipAutoProps>true</SkipAutoProps>
<!-- No incluir el assembly de tests en el reporte -->
<IncludeTestAssembly>false</IncludeTestAssembly>
<!-- Permitir timestamps reales en el reporte (no forzar determinismo) -->
<DeterministicReport>false</DeterministicReport>
</Configuration>
</DataCollector>
</DataCollectors>
</DataCollectionRunSettings>
</RunSettings>

View File

@@ -33,15 +33,6 @@ database/
| V014 | `V014__create_tablas_fiscales.sql` | ADM-009 | TiposDeIva + IngresosBrutos (versioning por cadena) + permisos fiscales | | V014 | `V014__create_tablas_fiscales.sql` | ADM-009 | TiposDeIva + IngresosBrutos (versioning por cadena) + permisos fiscales |
| V015 | `V015__create_local_timezone_views.sql` | UDT-011 | Vistas admin con OccurredAt convertido a hora Argentina | | V015 | `V015__create_local_timezone_views.sql` | UDT-011 | Vistas admin con OccurredAt convertido a hora Argentina |
| **V016** | **`V016__create_rubro.sql`** | **CAT-001** | **Rubro (adjacency list, temporal 10y) + permiso `catalogo:rubros:gestionar`** | | **V016** | **`V016__create_rubro.sql`** | **CAT-001** | **Rubro (adjacency list, temporal 10y) + permiso `catalogo:rubros:gestionar`** |
| **V017** | **`V017__create_product_type.sql`** | **PRD-001** | **ProductType (flags + multimedia limits, temporal 10y) + permiso `catalogo:tipos:gestionar`** |
| V018 | `V018__create_product.sql` | PRD-002 | Product (temporal 10y) + permiso `catalogo:productos:gestionar` + índices |
| V019 | `V019__create_product_prices.sql` | PRD-003 | ProductPrices (temporal 10y, forward-only) + SP `sp_ProductPrices_InsertWithClose` + permiso implícito |
| V020 | `V020__add_chargeable_chars_permission.sql` | PRC-001 | Permiso `tasacion:caracteres_especiales:gestionar` + asignación a admin |
| V021 | `V021__create_chargeable_char_config.sql` | PRC-001 | ChargeableCharConfig + ChargeableCharConfig_History (temporal 10y) + 2 SPs (`InsertWithClose`, `GetActiveForProductType`) + 2 índices |
| V022 | `V022__seed_chargeable_char_config.sql` | PRC-001 | Seed 4 filas globales (`$`, `%`, `!`, `¡`) con PricePerUnit=1.0000 |
| V023 | `V023__refactor_chargeable_char_config_to_product_type.sql` | PRC-001 (scope delta) | Refactor MedioId→ProductTypeId + nuevo SP `ReactivateWithGuard` + CK_Price_NonNegative (>= 0) |
| V024 | `V024__reseed_global_with_zero_price.sql` | PRC-001 (scope delta) | Reseed 4 globales a PricePerUnit=0.0000 (opt-in billing) |
| V025 | `V025__seed_chargeable_char_overrides_demo.sql` | PRC-001 (followup #54) | Seed demo de overrides ficticios per-ProductType (Clasificado/Notables/Fúnebres). Idempotente: no-op si los tipos no existen |
## Convenciones ## Convenciones
@@ -49,24 +40,23 @@ database/
- **Idempotentes**: cada migración usa `IF NOT EXISTS` / `MERGE` / `IF NOT EXISTS (SELECT 1 FROM sys....)`. Re-ejecutarlas es seguro. - **Idempotentes**: cada migración usa `IF NOT EXISTS` / `MERGE` / `IF NOT EXISTS (SELECT 1 FROM sys....)`. Re-ejecutarlas es seguro.
- **Boilerplate** inicial: `SET QUOTED_IDENTIFIER ON; SET ANSI_NULLS ON; SET NOCOUNT ON; GO`. - **Boilerplate** inicial: `SET QUOTED_IDENTIFIER ON; SET ANSI_NULLS ON; SET NOCOUNT ON; GO`.
- **Naming**: `PK_*`, `UQ_*`, `FK_*`, `DF_*`, `CK_*`, `IX_*`. - **Naming**: `PK_*`, `UQ_*`, `FK_*`, `DF_*`, `CK_*`, `IX_*`.
- **Se aplican a TRES bases**: `SIGCM2` (dev), `SIGCM2_Test_App` (Application.Tests) y `SIGCM2_Test_Api` (Api.Tests). El orden debe ser idéntico en las tres. - **Se aplican a AMBAS bases**: `SIGCM2` (dev) y `SIGCM2_Test` (integration tests). El orden debe ser idéntico.
## Cómo aplicar migraciones ## Cómo aplicar migraciones
### En dev (manual) ### En dev (manual)
```bash ```bash
# Con sqlcmd (aplicar a las tres bases en orden): # Con sqlcmd:
sqlcmd -S TECNICA3 -d SIGCM2 -U desarrollo -P desarrollo2026 -i database/migrations/V010__audit_infrastructure.sql sqlcmd -S TECNICA3 -d SIGCM2 -U desarrollo -P desarrollo2026 -i database/migrations/V010__audit_infrastructure.sql
sqlcmd -S TECNICA3 -d SIGCM2_Test_App -U desarrollo -P desarrollo2026 -i database/migrations/V010__audit_infrastructure.sql sqlcmd -S TECNICA3 -d SIGCM2_Test -U desarrollo -P desarrollo2026 -i database/migrations/V010__audit_infrastructure.sql
sqlcmd -S TECNICA3 -d SIGCM2_Test_Api -U desarrollo -P desarrollo2026 -i database/migrations/V010__audit_infrastructure.sql
``` ```
O desde SSMS: abrir el archivo, conectar a cada base, F5. O desde SSMS: abrir el archivo, conectar a cada base, F5.
### En integration tests ### En integration tests
`tests/SIGCM2.TestSupport/SqlTestFixture.cs` aplica automáticamente el schema necesario en `SIGCM2_Test_App` al inicializar el fixture (`EnsureV008SchemaAsync`, `EnsureV009SchemaAsync`, `EnsureV010SchemaAsync`…). `TestWebAppFactory` hace lo mismo contra `SIGCM2_Test_Api`. **NO** hace falta correr los scripts manualmente si el fixture ya lo cubre. `tests/SIGCM2.TestSupport/SqlTestFixture.cs` aplica automáticamente el schema necesario en `SIGCM2_Test` al inicializar el fixture (`EnsureV008SchemaAsync`, `EnsureV009SchemaAsync`, `EnsureV010SchemaAsync`…). **NO** hace falta correr el script manualmente.
### En producción (roadmap futuro) ### En producción (roadmap futuro)
@@ -104,22 +94,6 @@ O desde SSMS: abrir el archivo, conectar a cada base, F5.
- `Seccion.Tipo` acepta `'clasificados' | 'notables' | 'suplementos'` (CHECK constraint). Config avanzada (par/impar, suplementos, cupos) se introduce en ADM-003. - `Seccion.Tipo` acepta `'clasificados' | 'notables' | 'suplementos'` (CHECK constraint). Config avanzada (par/impar, suplementos, cupos) se introduce en ADM-003.
- Rollback: `V012_ROLLBACK.sql` (falla si hay Secciones vivas) → `V011_ROLLBACK.sql`. Rollback en prod NO soportado si ADM-008/009 o PRC-* ya aplicados. - Rollback: `V012_ROLLBACK.sql` (falla si hay Secciones vivas) → `V011_ROLLBACK.sql`. Rollback en prod NO soportado si ADM-008/009 o PRC-* ya aplicados.
## Bases de datos de integration tests
| Base | Propósito | Usada por |
|---|---|---|
| `SIGCM2_Test_App` | Tests de repositorios y Application layer | `SIGCM2.Application.Tests` vía `SqlTestFixture` (parameterless ctor) |
| `SIGCM2_Test_Api` | Tests de endpoints HTTP / WebApplicationFactory | `SIGCM2.Api.Tests` vía `TestWebAppFactory` |
**Script de creación inicial** (idempotente): `database/init/create-test-api-db.sql`
Ambas bases deben tener **todas las migraciones V001V015** 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 V001V015 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`

View File

@@ -1,30 +0,0 @@
-- create-test-api-db.sql
-- Creates test databases for integration tests (idempotent).
-- Run once per environment on TECNICA3 before executing integration tests.
--
-- SIGCM2_Test_App -> used by SIGCM2.Application.Tests
-- SIGCM2_Test_Api -> used by SIGCM2.Api.Tests
-- SIGCM2_Test -> legacy (kept for old branches e.g. pre-merge CAT-001)
--
-- After creating the DBs, apply V010 to both new DBs:
-- See database/README.md > "Test DBs" section for the PowerShell runbook.
IF DB_ID(N'SIGCM2_Test_App') IS NULL
BEGIN
CREATE DATABASE [SIGCM2_Test_App]
COLLATE Modern_Spanish_CI_AS;
PRINT 'Database SIGCM2_Test_App created.';
END
ELSE
PRINT 'Database SIGCM2_Test_App already exists -- skip.';
GO
IF DB_ID(N'SIGCM2_Test_Api') IS NULL
BEGIN
CREATE DATABASE [SIGCM2_Test_Api]
COLLATE Modern_Spanish_CI_AS;
PRINT 'Database SIGCM2_Test_Api created.';
END
ELSE
PRINT 'Database SIGCM2_Test_Api already exists -- skip.';
GO

View File

@@ -1,71 +0,0 @@
-- V017_ROLLBACK.sql
-- Reversa de V017__create_product_type.sql.
-- PRD-001: ProductType rollback.
--
-- ADVERTENCIA: Si PRD-002 ya fue mergeado (IProductQueryRepository real), hacer rollback
-- de PRD-002 primero (la interfaz es removida por esta rollback).
--
-- Idempotente: cada paso usa IF EXISTS guards.
SET QUOTED_IDENTIFIER ON;
SET ANSI_NULLS ON;
SET NOCOUNT ON;
GO
-- 1. Desactivar SYSTEM_VERSIONING
IF EXISTS (SELECT 1 FROM sys.tables WHERE object_id = OBJECT_ID('dbo.ProductType') AND temporal_type = 2)
BEGIN
ALTER TABLE dbo.ProductType SET (SYSTEM_VERSIONING = OFF);
PRINT 'ProductType: SYSTEM_VERSIONING = OFF.';
END
GO
-- 2. Remover PERIOD FOR SYSTEM_TIME
IF EXISTS (SELECT 1 FROM sys.periods WHERE object_id = OBJECT_ID('dbo.ProductType'))
BEGIN
ALTER TABLE dbo.ProductType DROP PERIOD FOR SYSTEM_TIME;
PRINT 'ProductType: PERIOD FOR SYSTEM_TIME dropped.';
END
GO
-- 3. Remover columnas HIDDEN + default constraints
IF COL_LENGTH('dbo.ProductType', 'ValidFrom') IS NOT NULL
BEGIN
ALTER TABLE dbo.ProductType DROP CONSTRAINT IF EXISTS DF_ProductType_ValidFrom;
ALTER TABLE dbo.ProductType DROP CONSTRAINT IF EXISTS DF_ProductType_ValidTo;
ALTER TABLE dbo.ProductType DROP COLUMN ValidFrom, ValidTo;
PRINT 'ProductType: ValidFrom/ValidTo columns dropped.';
END
GO
-- 4. Drop history table
IF OBJECT_ID(N'dbo.ProductType_History', N'U') IS NOT NULL
BEGIN
DROP TABLE dbo.ProductType_History;
PRINT 'Table dbo.ProductType_History dropped.';
END
GO
-- 5. Drop main table
IF OBJECT_ID(N'dbo.ProductType', N'U') IS NOT NULL
BEGIN
DROP TABLE dbo.ProductType;
PRINT 'Table dbo.ProductType dropped.';
END
GO
-- 6. Remover RolPermiso para catalogo:tipos:gestionar
DELETE rp FROM dbo.RolPermiso rp
JOIN dbo.Permiso p ON p.Id = rp.PermisoId
WHERE p.Codigo = 'catalogo:tipos:gestionar';
PRINT 'RolPermiso rows for catalogo:tipos:gestionar deleted.';
GO
-- 7. Remover Permiso
DELETE FROM dbo.Permiso WHERE Codigo = 'catalogo:tipos:gestionar';
PRINT 'Permiso catalogo:tipos:gestionar deleted.';
GO
PRINT '';
PRINT 'V017 rolled back successfully.';
GO

View File

@@ -1,158 +0,0 @@
-- V017__create_product_type.sql
-- PRD-001: ProductType — tipología dinámica de productos con flags de comportamiento + límites multimedia.
--
-- Cambios:
-- 1. dbo.ProductType (flags + multimedia limits, SYSTEM_VERSIONING ON, retention 10 años).
-- 2. Índice filtrado unique UQ_ProductType_Nombre_Activo (unicidad CI entre activos).
-- 3. Índice cubriente IX_ProductType_IsActive_Cover.
-- 4. Permiso 'catalogo:tipos:gestionar' + asignación a rol 'admin'.
--
-- Patrón: V016 (dbo.Rubro con SYSTEM_VERSIONING + PAGE compression + MERGE permisos).
-- Idempotente: seguro para re-ejecutar.
-- Reversa: V017_ROLLBACK.sql.
-- Run on: SIGCM2 (dev) y SIGCM2_Test (integration tests).
--
-- Notas:
-- - SIN seed de datos — PRD-008 (V018) seedea los 12 tipos legacy.
-- - SIN FK desde dbo.Product — PRD-002 agrega ALTER TABLE con FK.
-- - Invariante aplicada en Application: si AllowImages=0, los 4 campos multimedia son NULL (handler normaliza).
-- - MaxImages/MaxImageSizeMB/MaxImageWidth/MaxImageHeight: NULL = sin límite; >=1 = tope (validator rechaza <=0).
-- - Desviación del UDT: "0 = ilimitado" → usamos NULL (convención canónica). Ver PRD-001 archive-report.
--
-- SDD Design: engram sdd/prd-001-product-type-flags-multimedia/design
SET QUOTED_IDENTIFIER ON;
SET ANSI_NULLS ON;
SET NOCOUNT ON;
GO
-- ═══════════════════════════════════════════════════════════════════════
-- 1. dbo.ProductType
-- ═══════════════════════════════════════════════════════════════════════
IF OBJECT_ID(N'dbo.ProductType', N'U') IS NULL
BEGIN
CREATE TABLE dbo.ProductType (
Id INT IDENTITY(1,1) NOT NULL CONSTRAINT PK_ProductType PRIMARY KEY,
Nombre NVARCHAR(200) COLLATE SQL_Latin1_General_CP1_CI_AI NOT NULL,
-- Flags de comportamiento
HasDuration BIT NOT NULL CONSTRAINT DF_ProductType_HasDuration DEFAULT(0),
RequiresText BIT NOT NULL CONSTRAINT DF_ProductType_RequiresText DEFAULT(0),
RequiresCategory BIT NOT NULL CONSTRAINT DF_ProductType_RequiresCategory DEFAULT(0),
IsBundle BIT NOT NULL CONSTRAINT DF_ProductType_IsBundle DEFAULT(0),
-- Multimedia (AllowImages=0 => handler normaliza los 4 siguientes a NULL)
AllowImages BIT NOT NULL CONSTRAINT DF_ProductType_AllowImages DEFAULT(0),
MaxImages INT NULL, -- NULL = sin límite; >=1 tope (validator rechaza <=0)
MaxImageSizeMB DECIMAL(10,2) NULL, -- NULL = sin límite; DECIMAL(10,2) permite 0.5 MB, 2.75 MB
MaxImageWidth INT NULL, -- NULL = sin límite; >=1 px
MaxImageHeight INT NULL, -- NULL = sin límite; >=1 px
-- Lifecycle
IsActive BIT NOT NULL CONSTRAINT DF_ProductType_IsActive DEFAULT(1),
FechaCreacion DATETIME2(3) NOT NULL CONSTRAINT DF_ProductType_FechaCreacion DEFAULT(SYSUTCDATETIME()),
FechaModificacion DATETIME2(3) NULL
);
PRINT 'Table dbo.ProductType created.';
END
ELSE
PRINT 'Table dbo.ProductType already exists — skip.';
GO
-- ═══════════════════════════════════════════════════════════════════════
-- 2. SYSTEM_VERSIONING — ProductType
-- ═══════════════════════════════════════════════════════════════════════
IF COL_LENGTH('dbo.ProductType', 'ValidFrom') IS NULL
BEGIN
ALTER TABLE dbo.ProductType
ADD
ValidFrom DATETIME2(3) GENERATED ALWAYS AS ROW START HIDDEN NOT NULL
CONSTRAINT DF_ProductType_ValidFrom DEFAULT(SYSUTCDATETIME()),
ValidTo DATETIME2(3) GENERATED ALWAYS AS ROW END HIDDEN NOT NULL
CONSTRAINT DF_ProductType_ValidTo DEFAULT(CONVERT(DATETIME2(3), '9999-12-31 23:59:59.999')),
PERIOD FOR SYSTEM_TIME (ValidFrom, ValidTo);
PRINT 'ProductType: PERIOD FOR SYSTEM_TIME added.';
END
GO
IF NOT EXISTS (SELECT 1 FROM sys.tables WHERE object_id = OBJECT_ID('dbo.ProductType') AND temporal_type = 2)
BEGIN
ALTER TABLE dbo.ProductType
SET (SYSTEM_VERSIONING = ON (
HISTORY_TABLE = dbo.ProductType_History,
HISTORY_RETENTION_PERIOD = 10 YEARS
));
PRINT 'ProductType: SYSTEM_VERSIONING = ON (history: dbo.ProductType_History, retention: 10 years).';
END
ELSE
PRINT 'ProductType: SYSTEM_VERSIONING already ON — skip.';
GO
IF EXISTS (SELECT 1 FROM sys.tables WHERE name = 'ProductType_History' AND schema_id = SCHEMA_ID('dbo'))
AND NOT EXISTS (
SELECT 1 FROM sys.partitions p
JOIN sys.tables t ON t.object_id = p.object_id
WHERE t.name = 'ProductType_History' AND p.data_compression = 2
)
BEGIN
ALTER TABLE dbo.ProductType_History REBUILD WITH (DATA_COMPRESSION = PAGE);
PRINT 'ProductType_History: rebuilt with PAGE compression.';
END
GO
-- ═══════════════════════════════════════════════════════════════════════
-- 3. Índices
-- ═══════════════════════════════════════════════════════════════════════
IF NOT EXISTS (SELECT 1 FROM sys.indexes WHERE name = 'UQ_ProductType_Nombre_Activo' AND object_id = OBJECT_ID('dbo.ProductType'))
BEGIN
CREATE UNIQUE INDEX UQ_ProductType_Nombre_Activo
ON dbo.ProductType(Nombre)
WHERE IsActive = 1;
PRINT 'Index UQ_ProductType_Nombre_Activo created.';
END
GO
IF NOT EXISTS (SELECT 1 FROM sys.indexes WHERE name = 'IX_ProductType_IsActive_Cover' AND object_id = OBJECT_ID('dbo.ProductType'))
BEGIN
CREATE INDEX IX_ProductType_IsActive_Cover
ON dbo.ProductType(IsActive)
INCLUDE (Nombre, HasDuration, RequiresText, RequiresCategory, IsBundle, AllowImages);
PRINT 'Index IX_ProductType_IsActive_Cover created.';
END
GO
-- ═══════════════════════════════════════════════════════════════════════
-- 4. Permiso: catalogo:tipos:gestionar + asignación a rol 'admin'
-- ═══════════════════════════════════════════════════════════════════════
MERGE dbo.Permiso AS t
USING (VALUES
('catalogo:tipos:gestionar',
N'Gestionar tipos de producto',
N'Crear, editar y desactivar ProductTypes del catálogo (flags + límites multimedia)',
'catalogo')
) AS s (Codigo, Nombre, Descripcion, Modulo)
ON t.Codigo = s.Codigo
WHEN NOT MATCHED BY TARGET THEN
INSERT (Codigo, Nombre, Descripcion, Modulo)
VALUES (s.Codigo, s.Nombre, s.Descripcion, s.Modulo);
GO
MERGE dbo.RolPermiso AS t
USING (
SELECT r.Id AS RolId, p.Id AS PermisoId
FROM (VALUES ('admin', 'catalogo:tipos:gestionar')) AS x (RolCodigo, PermisoCodigo)
JOIN dbo.Rol r ON r.Codigo = x.RolCodigo
JOIN dbo.Permiso p ON p.Codigo = x.PermisoCodigo
) AS s ON t.RolId = s.RolId AND t.PermisoId = s.PermisoId
WHEN NOT MATCHED BY TARGET THEN
INSERT (RolId, PermisoId) VALUES (s.RolId, s.PermisoId);
GO
PRINT '';
PRINT 'V017 applied — dbo.ProductType (temporal, retention 10y) + permiso catalogo:tipos:gestionar.';
PRINT 'Next: V018 (PRD-008 — seed 12 tipos legacy).';
GO

View File

@@ -1,67 +0,0 @@
-- V018_ROLLBACK.sql
-- Reversa de V018__create_product.sql — PRD-002.
--
-- Idempotente: cada paso usa IF EXISTS guards.
-- ADVERTENCIA: Ejecutar antes de V017_ROLLBACK (FK desde Product hacia ProductType).
SET QUOTED_IDENTIFIER ON;
SET ANSI_NULLS ON;
SET NOCOUNT ON;
GO
-- 1. SYSTEM_VERSIONING OFF
IF EXISTS (SELECT 1 FROM sys.tables WHERE object_id = OBJECT_ID('dbo.Product') AND temporal_type = 2)
BEGIN
ALTER TABLE dbo.Product SET (SYSTEM_VERSIONING = OFF);
PRINT 'Product: SYSTEM_VERSIONING = OFF.';
END
GO
-- 2. DROP PERIOD
IF EXISTS (SELECT 1 FROM sys.periods WHERE object_id = OBJECT_ID('dbo.Product'))
BEGIN
ALTER TABLE dbo.Product DROP PERIOD FOR SYSTEM_TIME;
PRINT 'Product: PERIOD FOR SYSTEM_TIME dropped.';
END
GO
-- 3. Drop HIDDEN columns + default constraints
IF COL_LENGTH('dbo.Product', 'ValidFrom') IS NOT NULL
BEGIN
ALTER TABLE dbo.Product DROP CONSTRAINT IF EXISTS DF_Product_ValidFrom;
ALTER TABLE dbo.Product DROP CONSTRAINT IF EXISTS DF_Product_ValidTo;
ALTER TABLE dbo.Product DROP COLUMN ValidFrom, ValidTo;
PRINT 'Product: ValidFrom/ValidTo columns dropped.';
END
GO
-- 4. Drop history
IF OBJECT_ID(N'dbo.Product_History', N'U') IS NOT NULL
BEGIN
DROP TABLE dbo.Product_History;
PRINT 'Table dbo.Product_History dropped.';
END
GO
-- 5. Drop main
IF OBJECT_ID(N'dbo.Product', N'U') IS NOT NULL
BEGIN
DROP TABLE dbo.Product;
PRINT 'Table dbo.Product dropped.';
END
GO
-- 6. Remove RolPermiso / Permiso
DELETE rp FROM dbo.RolPermiso rp
JOIN dbo.Permiso p ON p.Id = rp.PermisoId
WHERE p.Codigo = 'catalogo:productos:gestionar';
PRINT 'RolPermiso rows for catalogo:productos:gestionar deleted.';
GO
DELETE FROM dbo.Permiso WHERE Codigo = 'catalogo:productos:gestionar';
PRINT 'Permiso catalogo:productos:gestionar deleted.';
GO
PRINT '';
PRINT 'V018 rolled back successfully.';
GO

View File

@@ -1,172 +0,0 @@
-- V018__create_product.sql
-- PRD-002: Product — entidad vendible concreta del catálogo comercial.
--
-- Cambios:
-- 1. dbo.Product (FK Medio/ProductType/Rubro, SYSTEM_VERSIONING ON, retention 10 años).
-- 2. Índices: filtered UQ por (MedioId, ProductTypeId, Nombre) activos; cover por ProductTypeId
-- (para IProductQueryRepository); cover por MedioId; cover filtrado por RubroId.
-- 3. Permiso 'catalogo:productos:gestionar' + asignación a rol 'admin'.
--
-- Patrón: V017 (dbo.ProductType con SYSTEM_VERSIONING + PAGE compression + MERGE permisos).
-- Idempotente: seguro para re-ejecutar.
-- Reversa: V018_ROLLBACK.sql.
-- Run on: SIGCM2 (dev) y SIGCM2_Test (integration tests).
--
-- Notas:
-- - SIN seed de datos — PRD-008 (V019) seedea los 12 productos legacy.
-- - Validación de flags (RequiresCategory, HasDuration) vive en Application layer:
-- un ProductType puede cambiar flags; la Product queda en estado snapshot.
-- - UQ filtered WHERE IsActive=1: permite reusar nombres tras soft-delete.
--
-- SDD Design: engram sdd/prd-002-product-crud/design
SET QUOTED_IDENTIFIER ON;
SET ANSI_NULLS ON;
SET NOCOUNT ON;
GO
-- ═══════════════════════════════════════════════════════════════════════
-- 1. dbo.Product
-- ═══════════════════════════════════════════════════════════════════════
IF OBJECT_ID(N'dbo.Product', N'U') IS NULL
BEGIN
CREATE TABLE dbo.Product (
Id INT IDENTITY(1,1) NOT NULL CONSTRAINT PK_Product PRIMARY KEY,
Nombre NVARCHAR(300) COLLATE SQL_Latin1_General_CP1_CI_AI NOT NULL,
MedioId INT NOT NULL,
ProductTypeId INT NOT NULL,
RubroId INT NULL,
BasePrice DECIMAL(18,4) NOT NULL,
PriceDurationDays INT NULL,
IsActive BIT NOT NULL CONSTRAINT DF_Product_IsActive DEFAULT(1),
FechaCreacion DATETIME2(3) NOT NULL CONSTRAINT DF_Product_FechaCreacion DEFAULT(SYSUTCDATETIME()),
FechaModificacion DATETIME2(3) NULL,
CONSTRAINT FK_Product_Medio FOREIGN KEY (MedioId) REFERENCES dbo.Medio(Id) ON DELETE NO ACTION,
CONSTRAINT FK_Product_ProductType FOREIGN KEY (ProductTypeId) REFERENCES dbo.ProductType(Id) ON DELETE NO ACTION,
CONSTRAINT FK_Product_Rubro FOREIGN KEY (RubroId) REFERENCES dbo.Rubro(Id) ON DELETE NO ACTION,
CONSTRAINT CK_Product_BasePrice_NonNegative CHECK (BasePrice >= 0),
CONSTRAINT CK_Product_PriceDurationDays_Positive CHECK (PriceDurationDays IS NULL OR PriceDurationDays >= 1)
);
PRINT 'Table dbo.Product created.';
END
ELSE
PRINT 'Table dbo.Product already exists — skip.';
GO
-- ═══════════════════════════════════════════════════════════════════════
-- 2. SYSTEM_VERSIONING — Product
-- ═══════════════════════════════════════════════════════════════════════
IF COL_LENGTH('dbo.Product', 'ValidFrom') IS NULL
BEGIN
ALTER TABLE dbo.Product
ADD
ValidFrom DATETIME2(3) GENERATED ALWAYS AS ROW START HIDDEN NOT NULL
CONSTRAINT DF_Product_ValidFrom DEFAULT(SYSUTCDATETIME()),
ValidTo DATETIME2(3) GENERATED ALWAYS AS ROW END HIDDEN NOT NULL
CONSTRAINT DF_Product_ValidTo DEFAULT(CONVERT(DATETIME2(3), '9999-12-31 23:59:59.999')),
PERIOD FOR SYSTEM_TIME (ValidFrom, ValidTo);
PRINT 'Product: PERIOD FOR SYSTEM_TIME added.';
END
GO
IF NOT EXISTS (SELECT 1 FROM sys.tables WHERE object_id = OBJECT_ID('dbo.Product') AND temporal_type = 2)
BEGIN
ALTER TABLE dbo.Product
SET (SYSTEM_VERSIONING = ON (
HISTORY_TABLE = dbo.Product_History,
HISTORY_RETENTION_PERIOD = 10 YEARS
));
PRINT 'Product: SYSTEM_VERSIONING = ON (history: dbo.Product_History, retention: 10 years).';
END
ELSE
PRINT 'Product: SYSTEM_VERSIONING already ON — skip.';
GO
IF EXISTS (SELECT 1 FROM sys.tables WHERE name = 'Product_History' AND schema_id = SCHEMA_ID('dbo'))
AND NOT EXISTS (
SELECT 1 FROM sys.partitions p
JOIN sys.tables t ON t.object_id = p.object_id
WHERE t.name = 'Product_History' AND p.data_compression = 2
)
BEGIN
ALTER TABLE dbo.Product_History REBUILD WITH (DATA_COMPRESSION = PAGE);
PRINT 'Product_History: rebuilt with PAGE compression.';
END
GO
-- ═══════════════════════════════════════════════════════════════════════
-- 3. Índices
-- ═══════════════════════════════════════════════════════════════════════
-- Filtered UQ: unicidad activa por (Medio, Tipo, Nombre). Permite reusar nombres tras soft-delete.
IF NOT EXISTS (SELECT 1 FROM sys.indexes WHERE name = 'UQ_Product_MedioId_ProductTypeId_Nombre_Active' AND object_id = OBJECT_ID('dbo.Product'))
BEGIN
CREATE UNIQUE INDEX UQ_Product_MedioId_ProductTypeId_Nombre_Active
ON dbo.Product (MedioId, ProductTypeId, Nombre)
WHERE IsActive = 1;
PRINT 'Index UQ_Product_MedioId_ProductTypeId_Nombre_Active created.';
END
GO
-- Cover para IProductQueryRepository.ExistsActiveByProductTypeAsync
IF NOT EXISTS (SELECT 1 FROM sys.indexes WHERE name = 'IX_Product_ProductTypeId_IsActive' AND object_id = OBJECT_ID('dbo.Product'))
BEGIN
CREATE INDEX IX_Product_ProductTypeId_IsActive
ON dbo.Product (ProductTypeId, IsActive);
PRINT 'Index IX_Product_ProductTypeId_IsActive created.';
END
GO
-- Cover para list filtered by MedioId
IF NOT EXISTS (SELECT 1 FROM sys.indexes WHERE name = 'IX_Product_MedioId_IsActive' AND object_id = OBJECT_ID('dbo.Product'))
BEGIN
CREATE INDEX IX_Product_MedioId_IsActive
ON dbo.Product (MedioId, IsActive);
PRINT 'Index IX_Product_MedioId_IsActive created.';
END
GO
-- Cover para list filtered by RubroId
IF NOT EXISTS (SELECT 1 FROM sys.indexes WHERE name = 'IX_Product_RubroId_IsActive' AND object_id = OBJECT_ID('dbo.Product'))
BEGIN
CREATE INDEX IX_Product_RubroId_IsActive
ON dbo.Product (RubroId, IsActive)
WHERE RubroId IS NOT NULL;
PRINT 'Index IX_Product_RubroId_IsActive created.';
END
GO
-- ═══════════════════════════════════════════════════════════════════════
-- 4. Permiso: catalogo:productos:gestionar + asignación a rol 'admin'
-- ═══════════════════════════════════════════════════════════════════════
MERGE dbo.Permiso AS t
USING (VALUES
('catalogo:productos:gestionar',
N'Gestionar productos del catálogo',
N'Crear, editar y desactivar productos del catálogo comercial',
'catalogo')
) AS s (Codigo, Nombre, Descripcion, Modulo)
ON t.Codigo = s.Codigo
WHEN NOT MATCHED BY TARGET THEN
INSERT (Codigo, Nombre, Descripcion, Modulo)
VALUES (s.Codigo, s.Nombre, s.Descripcion, s.Modulo);
GO
MERGE dbo.RolPermiso AS t
USING (
SELECT r.Id AS RolId, p.Id AS PermisoId
FROM (VALUES ('admin', 'catalogo:productos:gestionar')) AS x (RolCodigo, PermisoCodigo)
JOIN dbo.Rol r ON r.Codigo = x.RolCodigo
JOIN dbo.Permiso p ON p.Codigo = x.PermisoCodigo
) AS s ON t.RolId = s.RolId AND t.PermisoId = s.PermisoId
WHEN NOT MATCHED BY TARGET THEN
INSERT (RolId, PermisoId) VALUES (s.RolId, s.PermisoId);
GO
PRINT '';
PRINT 'V018 applied — dbo.Product (temporal, retention 10y) + permiso catalogo:productos:gestionar.';
PRINT 'Next: V019 (PRD-008 — seed 12 productos legacy).';
GO

View File

@@ -1,71 +0,0 @@
-- V019_ROLLBACK.sql
-- PRD-003: Reversa de V019__create_product_prices.sql.
--
-- Pasos:
-- 1. Deshabilita SYSTEM_VERSIONING en dbo.ProductPrices (requerido antes de DROP TABLE).
-- 2. Elimina el PERIOD FOR SYSTEM_TIME y las columnas hidden SysStartTime/SysEndTime.
-- 3. Drop de dbo.ProductPrices_History.
-- 4. Drop de dbo.ProductPrices (y sus constraints + índices en cascada).
-- 5. Drop de dbo.usp_AddProductPrice.
--
-- ADVERTENCIA: destruye todo el historial de precios. Ejecutar sólo en DEV o TEST.
-- Run on: SIGCM2 (dev), SIGCM2_Test_App, SIGCM2_Test_Api.
SET QUOTED_IDENTIFIER ON;
SET ANSI_NULLS ON;
SET NOCOUNT ON;
GO
-- 1. Deshabilita SYSTEM_VERSIONING (imprescindible antes de DROP TABLE temporal).
IF EXISTS (SELECT 1 FROM sys.tables WHERE object_id = OBJECT_ID('dbo.ProductPrices') AND temporal_type = 2)
BEGIN
ALTER TABLE dbo.ProductPrices SET (SYSTEM_VERSIONING = OFF);
PRINT 'ProductPrices: SYSTEM_VERSIONING = OFF.';
END
GO
-- 2. Elimina el PERIOD y las hidden cols (si existen, independientemente del versioning).
IF COL_LENGTH('dbo.ProductPrices', 'SysStartTime') IS NOT NULL
BEGIN
ALTER TABLE dbo.ProductPrices
DROP PERIOD FOR SYSTEM_TIME;
-- Drop default constraints antes de drop de columnas.
IF EXISTS (SELECT 1 FROM sys.default_constraints WHERE name = 'DF_ProductPrices_SysStartTime')
ALTER TABLE dbo.ProductPrices DROP CONSTRAINT DF_ProductPrices_SysStartTime;
IF EXISTS (SELECT 1 FROM sys.default_constraints WHERE name = 'DF_ProductPrices_SysEndTime')
ALTER TABLE dbo.ProductPrices DROP CONSTRAINT DF_ProductPrices_SysEndTime;
ALTER TABLE dbo.ProductPrices DROP COLUMN SysStartTime;
ALTER TABLE dbo.ProductPrices DROP COLUMN SysEndTime;
PRINT 'ProductPrices: PERIOD + hidden cols dropped.';
END
GO
-- 3. Drop de la history table.
IF OBJECT_ID(N'dbo.ProductPrices_History', N'U') IS NOT NULL
BEGIN
DROP TABLE dbo.ProductPrices_History;
PRINT 'Table dbo.ProductPrices_History dropped.';
END
GO
-- 4. Drop de la tabla principal (constraints + índices en cascada).
IF OBJECT_ID(N'dbo.ProductPrices', N'U') IS NOT NULL
BEGIN
DROP TABLE dbo.ProductPrices;
PRINT 'Table dbo.ProductPrices dropped.';
END
GO
-- 5. Drop del SP.
IF OBJECT_ID(N'dbo.usp_AddProductPrice', N'P') IS NOT NULL
BEGIN
DROP PROCEDURE dbo.usp_AddProductPrice;
PRINT 'Procedure dbo.usp_AddProductPrice dropped.';
END
GO
PRINT '';
PRINT 'V019 rollback complete — dbo.ProductPrices, dbo.ProductPrices_History, dbo.usp_AddProductPrice removed.';
GO

View File

@@ -1,196 +0,0 @@
-- V019__create_product_prices.sql
-- PRD-003: ProductPrices — historial de precios por Producto con vigencia civil (Cat2).
--
-- Cambios:
-- 1. dbo.ProductPrices (FK Product, SYSTEM_VERSIONING ON, retention 10 años).
-- 2. Índices: filtered UQ un único activo; cover compuesto para GetPriceAt.
-- 3. SP dbo.usp_AddProductPrice (SERIALIZABLE + UPDLOCK, cierre atómico forward-only).
--
-- Patrón: V018 (SYSTEM_VERSIONING + PAGE compression).
-- Idempotente: seguro para re-ejecutar.
-- Reversa: V019_ROLLBACK.sql.
-- Run on: SIGCM2 (dev), SIGCM2_Test_App, SIGCM2_Test_Api.
--
-- Notas:
-- - SysStartTime/SysEndTime como nombres de cols HIDDEN (no ValidFrom/ValidTo):
-- evita colisión con las business cols PriceValidFrom/PriceValidTo (D1).
-- - DECIMAL(12,2) para Price (distinto de Product.BasePrice DECIMAL(18,4)) — precios retail
-- en pesos con 2 decimales; la diferencia es intencional (D6).
-- - Sin seed inicial — Product.BasePrice queda ortogonal como fallback (OQ-B, D8).
-- - Forward-only estricto en SP: THROW 50409 si new PVF <= active PVF (no solo <).
--
-- SDD Design: engram sdd/prd-003-product-prices-historicos/design
SET QUOTED_IDENTIFIER ON;
SET ANSI_NULLS ON;
SET NOCOUNT ON;
GO
-- ═══════════════════════════════════════════════════════════════════════
-- 1. dbo.ProductPrices
-- ═══════════════════════════════════════════════════════════════════════
IF OBJECT_ID(N'dbo.ProductPrices', N'U') IS NULL
BEGIN
CREATE TABLE dbo.ProductPrices (
Id BIGINT IDENTITY(1,1) NOT NULL
CONSTRAINT PK_ProductPrices PRIMARY KEY,
ProductId INT NOT NULL,
Price DECIMAL(12,2) NOT NULL,
PriceValidFrom DATE NOT NULL,
PriceValidTo DATE NULL,
FechaCreacion DATETIME2(3) NOT NULL
CONSTRAINT DF_ProductPrices_FechaCreacion DEFAULT(SYSUTCDATETIME()),
CONSTRAINT FK_ProductPrices_Product
FOREIGN KEY (ProductId) REFERENCES dbo.Product(Id) ON DELETE NO ACTION,
CONSTRAINT CK_ProductPrices_Price_Positive
CHECK (Price > 0),
CONSTRAINT CK_ProductPrices_ValidRange
CHECK (PriceValidTo IS NULL OR PriceValidTo >= PriceValidFrom)
);
PRINT 'Table dbo.ProductPrices created.';
END
ELSE
PRINT 'Table dbo.ProductPrices already exists — skip.';
GO
-- ═══════════════════════════════════════════════════════════════════════
-- 2. SYSTEM_VERSIONING — ProductPrices
-- Las hidden cols se llaman SysStartTime/SysEndTime para evitar
-- colisión con las business cols PriceValidFrom/PriceValidTo (D1).
-- ═══════════════════════════════════════════════════════════════════════
IF COL_LENGTH('dbo.ProductPrices', 'SysStartTime') IS NULL
BEGIN
ALTER TABLE dbo.ProductPrices
ADD
SysStartTime DATETIME2(3) GENERATED ALWAYS AS ROW START HIDDEN NOT NULL
CONSTRAINT DF_ProductPrices_SysStartTime DEFAULT(SYSUTCDATETIME()),
SysEndTime DATETIME2(3) GENERATED ALWAYS AS ROW END HIDDEN NOT NULL
CONSTRAINT DF_ProductPrices_SysEndTime DEFAULT(CONVERT(DATETIME2(3),'9999-12-31 23:59:59.999')),
PERIOD FOR SYSTEM_TIME (SysStartTime, SysEndTime);
PRINT 'ProductPrices: PERIOD FOR SYSTEM_TIME added (SysStartTime/SysEndTime).';
END
GO
IF NOT EXISTS (SELECT 1 FROM sys.tables WHERE object_id = OBJECT_ID('dbo.ProductPrices') AND temporal_type = 2)
BEGIN
ALTER TABLE dbo.ProductPrices
SET (SYSTEM_VERSIONING = ON (
HISTORY_TABLE = dbo.ProductPrices_History,
HISTORY_RETENTION_PERIOD = 10 YEARS
));
PRINT 'ProductPrices: SYSTEM_VERSIONING = ON (history: dbo.ProductPrices_History, retention: 10 years).';
END
ELSE
PRINT 'ProductPrices: SYSTEM_VERSIONING already ON — skip.';
GO
IF EXISTS (SELECT 1 FROM sys.tables WHERE name = 'ProductPrices_History' AND schema_id = SCHEMA_ID('dbo'))
AND NOT EXISTS (
SELECT 1 FROM sys.partitions p
JOIN sys.tables t ON t.object_id = p.object_id
WHERE t.name = 'ProductPrices_History' AND p.data_compression = 2
)
BEGIN
ALTER TABLE dbo.ProductPrices_History REBUILD WITH (DATA_COMPRESSION = PAGE);
PRINT 'ProductPrices_History: rebuilt with PAGE compression.';
END
GO
-- ═══════════════════════════════════════════════════════════════════════
-- 3. Índices
-- ═══════════════════════════════════════════════════════════════════════
-- Un único activo por producto (imposibilita violar a nivel BD).
IF NOT EXISTS (SELECT 1 FROM sys.indexes WHERE name = 'UX_ProductPrices_Active' AND object_id = OBJECT_ID('dbo.ProductPrices'))
BEGIN
CREATE UNIQUE INDEX UX_ProductPrices_Active
ON dbo.ProductPrices (ProductId)
WHERE PriceValidTo IS NULL;
PRINT 'Index UX_ProductPrices_Active created.';
END
GO
-- Cover para GetPriceAt / GetByProductIdAsync (ProductId + PriceValidFrom con INCLUDEs).
IF NOT EXISTS (SELECT 1 FROM sys.indexes WHERE name = 'IX_ProductPrices_Lookup' AND object_id = OBJECT_ID('dbo.ProductPrices'))
BEGIN
CREATE INDEX IX_ProductPrices_Lookup
ON dbo.ProductPrices (ProductId, PriceValidFrom DESC)
INCLUDE (Price, PriceValidTo);
PRINT 'Index IX_ProductPrices_Lookup created.';
END
GO
-- ═══════════════════════════════════════════════════════════════════════
-- 4. SP — dbo.usp_AddProductPrice
-- Cierre atómico forward-only: SERIALIZABLE + UPDLOCK + HOLDLOCK.
-- Params de salida: @NewId (BIGINT), @ClosedId (BIGINT — NULL si primer precio).
-- ═══════════════════════════════════════════════════════════════════════
GO
CREATE OR ALTER PROCEDURE dbo.usp_AddProductPrice
@ProductId INT,
@Price DECIMAL(12,2),
@PriceValidFrom DATE,
@NewId BIGINT OUTPUT,
@ClosedId BIGINT OUTPUT
AS
BEGIN
SET NOCOUNT ON;
SET XACT_ABORT ON;
SET TRANSACTION ISOLATION LEVEL SERIALIZABLE;
BEGIN TRY
BEGIN TRANSACTION;
-- Validación: producto debe existir y estar activo.
IF NOT EXISTS (SELECT 1 FROM dbo.Product WITH (NOLOCK) WHERE Id = @ProductId AND IsActive = 1)
BEGIN
ROLLBACK;
THROW 50404, 'Product not found or inactive', 1;
END
-- Lee activo con UPDLOCK + HOLDLOCK — bloquea el range key del filtered index.
DECLARE @ActiveId BIGINT, @ActivePVF DATE;
SELECT TOP 1
@ActiveId = Id,
@ActivePVF = PriceValidFrom
FROM dbo.ProductPrices WITH (UPDLOCK, HOLDLOCK, ROWLOCK)
WHERE ProductId = @ProductId AND PriceValidTo IS NULL;
-- Forward-only estricto: el nuevo PVF debe ser ESTRICTAMENTE mayor al activo.
IF @ActiveId IS NOT NULL AND @PriceValidFrom <= @ActivePVF
BEGIN
ROLLBACK;
THROW 50409, 'ProductPriceForwardOnly: new PriceValidFrom must be > active.PriceValidFrom', 1;
END
-- Cierra el activo previo: PVT = PVF(nuevo) - 1 día.
IF @ActiveId IS NOT NULL
BEGIN
UPDATE dbo.ProductPrices
SET PriceValidTo = DATEADD(DAY, -1, @PriceValidFrom)
WHERE Id = @ActiveId;
SET @ClosedId = @ActiveId;
END
ELSE
SET @ClosedId = NULL;
-- Inserta el nuevo activo.
INSERT INTO dbo.ProductPrices (ProductId, Price, PriceValidFrom, PriceValidTo)
VALUES (@ProductId, @Price, @PriceValidFrom, NULL);
SET @NewId = SCOPE_IDENTITY();
COMMIT TRANSACTION;
END TRY
BEGIN CATCH
IF XACT_STATE() <> 0 ROLLBACK TRANSACTION;
THROW;
END CATCH
END
GO
PRINT '';
PRINT 'V019 applied — dbo.ProductPrices (temporal, retention 10y) + UX_ProductPrices_Active + IX_ProductPrices_Lookup + usp_AddProductPrice.';
PRINT 'Next migration: V020 (TBD).';
GO

View File

@@ -1,33 +0,0 @@
-- V020_ROLLBACK.sql
-- PRC-001: Reversa de V020__add_chargeable_chars_permission.sql.
--
-- Pasos:
-- 1. Elimina la asignación del permiso al rol 'admin'.
-- 2. Elimina el permiso del catálogo.
--
-- ADVERTENCIA: si algún usuario o rol tiene este permiso asignado explícitamente,
-- la FK de RolPermiso causará error. Limpiar RolPermiso primero.
-- Run on: SIGCM2 (dev), SIGCM2_Test_App, SIGCM2_Test_Api.
SET QUOTED_IDENTIFIER ON;
SET ANSI_NULLS ON;
SET NOCOUNT ON;
GO
-- 1. Eliminar asignaciones del permiso a cualquier rol.
DELETE rp
FROM dbo.RolPermiso rp
JOIN dbo.Permiso p ON p.Id = rp.PermisoId
WHERE p.Codigo = 'tasacion:caracteres_especiales:gestionar';
PRINT 'V020 rollback: RolPermiso entries for tasacion:caracteres_especiales:gestionar removed.';
GO
-- 2. Eliminar el permiso del catálogo.
DELETE FROM dbo.Permiso
WHERE Codigo = 'tasacion:caracteres_especiales:gestionar';
PRINT 'V020 rollback: Permiso tasacion:caracteres_especiales:gestionar removed.';
GO
PRINT '';
PRINT 'V020 rollback complete.';
GO

View File

@@ -1,54 +0,0 @@
-- V020__add_chargeable_chars_permission.sql
-- PRC-001: permiso RBAC para ABM de caracteres tasables.
--
-- Cambios:
-- 1. Agrega permiso 'tasacion:caracteres_especiales:gestionar' al catálogo.
-- 2. Asigna el permiso al rol 'admin'.
--
-- Convención RBAC: modulo:recurso:accion.
-- Patrón: V007 (MERGE idempotente).
-- Idempotente: seguro para re-ejecutar.
-- Reversa: V020_ROLLBACK.sql.
-- Run on: SIGCM2 (dev), SIGCM2_Test_App, SIGCM2_Test_Api.
--
-- NOTA: V020 se ejecuta ANTES de V021 (tabla) porque el permiso debe existir
-- antes de que la API arranque con [RequirePermission(...)].
-- V021 crea la tabla dbo.ChargeableCharConfig.
-- V022 siembra las 4 filas globales por defecto.
--
-- SDD Design: engram sdd/prc-001-word-counter-spike/design (D16/D17)
SET QUOTED_IDENTIFIER ON;
SET ANSI_NULLS ON;
SET NOCOUNT ON;
GO
-- Agregar permiso al catálogo (idempotente via MERGE).
MERGE dbo.Permiso AS t
USING (VALUES
('tasacion:caracteres_especiales:gestionar',
N'Gestionar caracteres tasables',
N'Crear, editar precio y desactivar la configuración de caracteres especiales para tasación.',
'tasacion')
) AS s (Codigo, Nombre, Descripcion, Modulo)
ON t.Codigo = s.Codigo
WHEN NOT MATCHED BY TARGET THEN
INSERT (Codigo, Nombre, Descripcion, Modulo)
VALUES (s.Codigo, s.Nombre, s.Descripcion, s.Modulo);
GO
-- Asignar a rol 'admin' (idempotente via MERGE).
MERGE dbo.RolPermiso AS t
USING (
SELECT r.Id AS RolId, p.Id AS PermisoId
FROM dbo.Rol r
CROSS JOIN dbo.Permiso p
WHERE r.Codigo = 'admin'
AND p.Codigo = 'tasacion:caracteres_especiales:gestionar'
) AS s ON t.RolId = s.RolId AND t.PermisoId = s.PermisoId
WHEN NOT MATCHED BY TARGET THEN
INSERT (RolId, PermisoId) VALUES (s.RolId, s.PermisoId);
GO
PRINT 'V020 applied — tasacion:caracteres_especiales:gestionar added to catalog and assigned to admin.';
GO

View File

@@ -1,79 +0,0 @@
-- V021_ROLLBACK.sql
-- PRC-001: Reversa de V021__create_chargeable_char_config.sql.
--
-- Pasos:
-- 1. Deshabilita SYSTEM_VERSIONING en dbo.ChargeableCharConfig (requerido antes de DROP TABLE).
-- 2. Elimina el PERIOD FOR SYSTEM_TIME y las columnas hidden SysStartTime/SysEndTime.
-- 3. Drop de dbo.ChargeableCharConfig_History.
-- 4. Drop de dbo.ChargeableCharConfig (constraints + índices en cascada).
-- 5. Drop de dbo.usp_ChargeableCharConfig_InsertWithClose.
-- 6. Drop de dbo.usp_ChargeableCharConfig_GetActiveForMedio.
--
-- ADVERTENCIA: destruye toda la configuración de caracteres tasables. Solo DEV/TEST.
-- Run on: SIGCM2 (dev), SIGCM2_Test_App, SIGCM2_Test_Api.
SET QUOTED_IDENTIFIER ON;
SET ANSI_NULLS ON;
SET NOCOUNT ON;
GO
-- 1. Deshabilita SYSTEM_VERSIONING (imprescindible antes de DROP TABLE temporal).
IF EXISTS (SELECT 1 FROM sys.tables WHERE object_id = OBJECT_ID('dbo.ChargeableCharConfig') AND temporal_type = 2)
BEGIN
ALTER TABLE dbo.ChargeableCharConfig SET (SYSTEM_VERSIONING = OFF);
PRINT 'ChargeableCharConfig: SYSTEM_VERSIONING = OFF.';
END
GO
-- 2. Elimina el PERIOD y las hidden cols.
IF COL_LENGTH('dbo.ChargeableCharConfig', 'SysStartTime') IS NOT NULL
BEGIN
ALTER TABLE dbo.ChargeableCharConfig
DROP PERIOD FOR SYSTEM_TIME;
IF EXISTS (SELECT 1 FROM sys.default_constraints WHERE name = 'DF_ChargeableCharConfig_SysStartTime')
ALTER TABLE dbo.ChargeableCharConfig DROP CONSTRAINT DF_ChargeableCharConfig_SysStartTime;
IF EXISTS (SELECT 1 FROM sys.default_constraints WHERE name = 'DF_ChargeableCharConfig_SysEndTime')
ALTER TABLE dbo.ChargeableCharConfig DROP CONSTRAINT DF_ChargeableCharConfig_SysEndTime;
ALTER TABLE dbo.ChargeableCharConfig DROP COLUMN SysStartTime;
ALTER TABLE dbo.ChargeableCharConfig DROP COLUMN SysEndTime;
PRINT 'ChargeableCharConfig: PERIOD + hidden cols dropped.';
END
GO
-- 3. Drop de la history table.
IF OBJECT_ID(N'dbo.ChargeableCharConfig_History', N'U') IS NOT NULL
BEGIN
DROP TABLE dbo.ChargeableCharConfig_History;
PRINT 'Table dbo.ChargeableCharConfig_History dropped.';
END
GO
-- 4. Drop de la tabla principal.
IF OBJECT_ID(N'dbo.ChargeableCharConfig', N'U') IS NOT NULL
BEGIN
DROP TABLE dbo.ChargeableCharConfig;
PRINT 'Table dbo.ChargeableCharConfig dropped.';
END
GO
-- 5. Drop del SP InsertWithClose.
IF OBJECT_ID(N'dbo.usp_ChargeableCharConfig_InsertWithClose', N'P') IS NOT NULL
BEGIN
DROP PROCEDURE dbo.usp_ChargeableCharConfig_InsertWithClose;
PRINT 'Procedure dbo.usp_ChargeableCharConfig_InsertWithClose dropped.';
END
GO
-- 6. Drop del SP GetActiveForMedio.
IF OBJECT_ID(N'dbo.usp_ChargeableCharConfig_GetActiveForMedio', N'P') IS NOT NULL
BEGIN
DROP PROCEDURE dbo.usp_ChargeableCharConfig_GetActiveForMedio;
PRINT 'Procedure dbo.usp_ChargeableCharConfig_GetActiveForMedio dropped.';
END
GO
PRINT '';
PRINT 'V021 rollback complete — dbo.ChargeableCharConfig, dbo.ChargeableCharConfig_History, usp_ChargeableCharConfig_InsertWithClose, usp_ChargeableCharConfig_GetActiveForMedio removed.';
GO

View File

@@ -1,256 +0,0 @@
-- V021__create_chargeable_char_config.sql
-- PRC-001: ChargeableCharConfig — configuración de caracteres especiales tasables con vigencia civil.
--
-- Cambios:
-- 1. dbo.ChargeableCharConfig (FK Medios NULL=global, SYSTEM_VERSIONING ON, retention 10 años).
-- 2. Índices: filtered UX vigente por (MedioId,Symbol); cover IX para GetActiveForMedio.
-- 3. SP dbo.usp_ChargeableCharConfig_InsertWithClose (SERIALIZABLE + UPDLOCK, forward-only).
-- 4. SP dbo.usp_ChargeableCharConfig_GetActiveForMedio (CTE + ROW_NUMBER per-medio/global).
--
-- Patrón: V019 (SYSTEM_VERSIONING + PAGE compression + SERIALIZABLE SP).
-- Idempotente: seguro para re-ejecutar.
-- Reversa: V021_ROLLBACK.sql.
-- Run on: SIGCM2 (dev), SIGCM2_Test_App, SIGCM2_Test_Api.
--
-- Notas:
-- - SysStartTime/SysEndTime como hidden cols: evita colisión con business cols ValidFrom/ValidTo (D4).
-- - DECIMAL(18,4) para PricePerUnit (mayor granularidad que ProductPrices) (D8).
-- - MedioId NULL = global fallback; per-medio overrides global in GetActiveForMedio (D2/D6).
-- - Forward-only estricto: THROW 50409 si new ValidFrom <= activo.ValidFrom (D9).
-- - UX filtered WHERE ValidTo IS NULL: SQL Server trata (NULL,'$') como valor igual → enforza 1 vigente global (D7).
-- - dbo.ChargeableCharConfig_History debe agregarse a TablesToIgnore en SqlTestFixture.cs (Respawn).
--
-- SDD Design: engram sdd/prc-001-word-counter-spike/design
SET QUOTED_IDENTIFIER ON;
SET ANSI_NULLS ON;
SET NOCOUNT ON;
GO
-- ═══════════════════════════════════════════════════════════════════════
-- 1. dbo.ChargeableCharConfig
-- ═══════════════════════════════════════════════════════════════════════
IF OBJECT_ID(N'dbo.ChargeableCharConfig', N'U') IS NULL
BEGIN
CREATE TABLE dbo.ChargeableCharConfig (
Id BIGINT IDENTITY(1,1) NOT NULL
CONSTRAINT PK_ChargeableCharConfig PRIMARY KEY,
MedioId INT NULL, -- NULL = global fallback
Symbol NVARCHAR(4) NOT NULL,
Category NVARCHAR(32) NOT NULL, -- enum-as-string: Currency/Percentage/Exclamation/Question/Other
PricePerUnit DECIMAL(18,4) NOT NULL,
ValidFrom DATE NOT NULL,
ValidTo DATE NULL,
IsActive BIT NOT NULL
CONSTRAINT DF_ChargeableCharConfig_IsActive DEFAULT(1),
FechaCreacion DATETIME2(3) NOT NULL
CONSTRAINT DF_ChargeableCharConfig_FechaCreacion DEFAULT(SYSUTCDATETIME()),
CONSTRAINT FK_ChargeableCharConfig_Medio
FOREIGN KEY (MedioId) REFERENCES dbo.Medio(Id) ON DELETE NO ACTION,
CONSTRAINT CK_ChargeableCharConfig_Price_Positive
CHECK (PricePerUnit > 0),
CONSTRAINT CK_ChargeableCharConfig_Symbol_NotEmpty
CHECK (LEN(Symbol) > 0),
CONSTRAINT CK_ChargeableCharConfig_ValidRange
CHECK (ValidTo IS NULL OR ValidTo >= ValidFrom)
);
PRINT 'Table dbo.ChargeableCharConfig created.';
END
ELSE
PRINT 'Table dbo.ChargeableCharConfig already exists — skip.';
GO
-- ═══════════════════════════════════════════════════════════════════════
-- 2. SYSTEM_VERSIONING — ChargeableCharConfig
-- SysStartTime/SysEndTime para no colisionar con business cols ValidFrom/ValidTo (D4).
-- ═══════════════════════════════════════════════════════════════════════
IF COL_LENGTH('dbo.ChargeableCharConfig', 'SysStartTime') IS NULL
BEGIN
ALTER TABLE dbo.ChargeableCharConfig
ADD
SysStartTime DATETIME2(3) GENERATED ALWAYS AS ROW START HIDDEN NOT NULL
CONSTRAINT DF_ChargeableCharConfig_SysStartTime DEFAULT(SYSUTCDATETIME()),
SysEndTime DATETIME2(3) GENERATED ALWAYS AS ROW END HIDDEN NOT NULL
CONSTRAINT DF_ChargeableCharConfig_SysEndTime DEFAULT(CONVERT(DATETIME2(3),'9999-12-31 23:59:59.999')),
PERIOD FOR SYSTEM_TIME (SysStartTime, SysEndTime);
PRINT 'ChargeableCharConfig: PERIOD FOR SYSTEM_TIME added (SysStartTime/SysEndTime).';
END
GO
IF NOT EXISTS (SELECT 1 FROM sys.tables WHERE object_id = OBJECT_ID('dbo.ChargeableCharConfig') AND temporal_type = 2)
BEGIN
ALTER TABLE dbo.ChargeableCharConfig
SET (SYSTEM_VERSIONING = ON (
HISTORY_TABLE = dbo.ChargeableCharConfig_History,
HISTORY_RETENTION_PERIOD = 10 YEARS
));
PRINT 'ChargeableCharConfig: SYSTEM_VERSIONING = ON (history: dbo.ChargeableCharConfig_History, retention: 10 years).';
END
ELSE
PRINT 'ChargeableCharConfig: SYSTEM_VERSIONING already ON — skip.';
GO
IF EXISTS (SELECT 1 FROM sys.tables WHERE name = 'ChargeableCharConfig_History' AND schema_id = SCHEMA_ID('dbo'))
AND NOT EXISTS (
SELECT 1 FROM sys.partitions p
JOIN sys.tables t ON t.object_id = p.object_id
WHERE t.name = 'ChargeableCharConfig_History' AND p.data_compression = 2
)
BEGIN
ALTER TABLE dbo.ChargeableCharConfig_History REBUILD WITH (DATA_COMPRESSION = PAGE);
PRINT 'ChargeableCharConfig_History: rebuilt with PAGE compression.';
END
GO
-- ═══════════════════════════════════════════════════════════════════════
-- 3. Índices
-- ═══════════════════════════════════════════════════════════════════════
-- Un único vigente por (MedioId, Symbol).
-- SQL Server trata NULL como "distinto" en índices únicos: (NULL,'$') colisiona consigo mismo
-- → enforza exactamente 1 vigente global por símbolo (D7).
IF NOT EXISTS (
SELECT 1 FROM sys.indexes
WHERE name = 'UX_ChargeableCharConfig_Vigente'
AND object_id = OBJECT_ID('dbo.ChargeableCharConfig')
)
BEGIN
CREATE UNIQUE INDEX UX_ChargeableCharConfig_Vigente
ON dbo.ChargeableCharConfig (MedioId, Symbol)
WHERE ValidTo IS NULL;
PRINT 'Index UX_ChargeableCharConfig_Vigente created.';
END
GO
-- Cover para GetActiveForMedio y List.
IF NOT EXISTS (
SELECT 1 FROM sys.indexes
WHERE name = 'IX_ChargeableCharConfig_Query'
AND object_id = OBJECT_ID('dbo.ChargeableCharConfig')
)
BEGIN
CREATE INDEX IX_ChargeableCharConfig_Query
ON dbo.ChargeableCharConfig (MedioId, Symbol, ValidFrom, ValidTo)
INCLUDE (PricePerUnit, IsActive, Category);
PRINT 'Index IX_ChargeableCharConfig_Query created.';
END
GO
-- ═══════════════════════════════════════════════════════════════════════
-- 4. SP — dbo.usp_ChargeableCharConfig_InsertWithClose
-- Cierre atómico forward-only: SERIALIZABLE + UPDLOCK + HOLDLOCK.
-- @MedioId NULL = global; existencia validada sólo cuando NOT NULL.
-- THROW 50404: Medio not found.
-- THROW 50409: ForwardOnly — new ValidFrom must be > active.ValidFrom.
-- Params de salida: @NewId (BIGINT), @ClosedId (BIGINT — NULL si primer precio).
-- ═══════════════════════════════════════════════════════════════════════
GO
CREATE OR ALTER PROCEDURE dbo.usp_ChargeableCharConfig_InsertWithClose
@MedioId INT = NULL,
@Symbol NVARCHAR(4),
@Category NVARCHAR(32),
@PricePerUnit DECIMAL(18,4),
@ValidFrom DATE,
@NewId BIGINT OUTPUT,
@ClosedId BIGINT OUTPUT
AS
BEGIN
SET NOCOUNT ON;
SET XACT_ABORT ON;
SET TRANSACTION ISOLATION LEVEL SERIALIZABLE;
BEGIN TRY
BEGIN TRANSACTION;
-- Validar MedioId sólo cuando se proporciona (NULL = global fallback siempre válido).
IF @MedioId IS NOT NULL
AND NOT EXISTS (SELECT 1 FROM dbo.Medio WITH (NOLOCK) WHERE Id = @MedioId)
BEGIN
ROLLBACK;
THROW 50404, 'Medio not found', 1;
END
-- Lee el vigente actual con bloqueo de rango para serialización.
DECLARE @ActiveId BIGINT, @ActiveValidFrom DATE;
SELECT TOP 1
@ActiveId = Id,
@ActiveValidFrom = ValidFrom
FROM dbo.ChargeableCharConfig WITH (UPDLOCK, HOLDLOCK, ROWLOCK)
WHERE ((@MedioId IS NULL AND MedioId IS NULL)
OR (@MedioId IS NOT NULL AND MedioId = @MedioId))
AND Symbol = @Symbol
AND ValidTo IS NULL;
-- Forward-only estricto: new ValidFrom debe ser ESTRICTAMENTE mayor al activo.
IF @ActiveId IS NOT NULL AND @ValidFrom <= @ActiveValidFrom
BEGIN
ROLLBACK;
THROW 50409, 'ChargeableCharConfigForwardOnly: new ValidFrom must be > active.ValidFrom', 1;
END
-- Cierra el vigente previo: ValidTo = ValidFrom(nuevo) - 1 día.
IF @ActiveId IS NOT NULL
BEGIN
UPDATE dbo.ChargeableCharConfig
SET ValidTo = DATEADD(DAY, -1, @ValidFrom)
WHERE Id = @ActiveId;
SET @ClosedId = @ActiveId;
END
ELSE
SET @ClosedId = NULL;
-- Inserta el nuevo vigente.
INSERT INTO dbo.ChargeableCharConfig
(MedioId, Symbol, Category, PricePerUnit, ValidFrom, ValidTo, IsActive)
VALUES
(@MedioId, @Symbol, @Category, @PricePerUnit, @ValidFrom, NULL, 1);
SET @NewId = SCOPE_IDENTITY();
COMMIT TRANSACTION;
END TRY
BEGIN CATCH
IF XACT_STATE() <> 0 ROLLBACK TRANSACTION;
THROW;
END CATCH
END
GO
-- ═══════════════════════════════════════════════════════════════════════
-- 5. SP — dbo.usp_ChargeableCharConfig_GetActiveForMedio
-- Resolución per-medio + global fallback: 1 fila por Symbol.
-- CTE + ROW_NUMBER PARTITION BY Symbol ORDER BY per-medio(0) vs global(1).
-- ═══════════════════════════════════════════════════════════════════════
GO
CREATE OR ALTER PROCEDURE dbo.usp_ChargeableCharConfig_GetActiveForMedio
@MedioId INT,
@AsOfDate DATE
AS
BEGIN
SET NOCOUNT ON;
WITH Candidates AS (
SELECT
Id, MedioId, Symbol, Category, PricePerUnit, ValidFrom, ValidTo, IsActive,
ROW_NUMBER() OVER (
PARTITION BY Symbol
ORDER BY
CASE WHEN MedioId = @MedioId THEN 0 ELSE 1 END, -- prefer specific over global
ValidFrom DESC
) AS rn
FROM dbo.ChargeableCharConfig
WHERE IsActive = 1
AND ValidFrom <= @AsOfDate
AND (ValidTo IS NULL OR ValidTo >= @AsOfDate)
AND (MedioId = @MedioId OR MedioId IS NULL)
)
SELECT Id, MedioId, Symbol, Category, PricePerUnit, ValidFrom, ValidTo, IsActive
FROM Candidates
WHERE rn = 1;
END
GO
PRINT '';
PRINT 'V021 applied — dbo.ChargeableCharConfig (temporal, retention 10y) + UX_ChargeableCharConfig_Vigente + IX_ChargeableCharConfig_Query + usp_ChargeableCharConfig_InsertWithClose + usp_ChargeableCharConfig_GetActiveForMedio.';
PRINT 'Next migration: V022 (seed ChargeableCharConfig).';
GO

View File

@@ -1,23 +0,0 @@
-- V022_ROLLBACK.sql
-- PRC-001: Reversa de V022__seed_chargeable_char_config.sql.
--
-- Elimina las 4 filas globales de seed (MedioId NULL, símbolos $/%/!/¡, ValidTo NULL).
-- Solo elimina las filas vigentes (ValidTo IS NULL) para no romper el historial temporal.
--
-- ADVERTENCIA: si alguna de estas filas fue cerrada (ValidTo SET), el rollback las ignora
-- (ya no son vigentes). La historia temporal queda intacta.
-- Run on: SIGCM2 (dev), SIGCM2_Test_App, SIGCM2_Test_Api.
SET QUOTED_IDENTIFIER ON;
SET ANSI_NULLS ON;
SET NOCOUNT ON;
GO
DELETE FROM dbo.ChargeableCharConfig
WHERE MedioId IS NULL
AND Symbol IN (N'$', N'%', N'!', N'¡')
AND ValidTo IS NULL;
GO
PRINT 'V022 rollback complete — global seed rows ($, %, !, ¡) removed.';
GO

View File

@@ -1,44 +0,0 @@
-- V022__seed_chargeable_char_config.sql
-- PRC-001: seed de las 4 configuraciones globales de caracteres tasables por defecto.
--
-- Cambios:
-- 1. Inserta 4 filas globales (MedioId NULL): $, %, !, ¡ — precios placeholder 1.0000.
-- El equipo de negocio seteará los valores reales desde el CMS.
--
-- Patrón: MERGE idempotente ON (MedioId IS NULL AND Symbol AND ValidTo IS NULL).
-- Idempotente: seguro para re-ejecutar.
-- Reversa: V022_ROLLBACK.sql.
-- Run on: SIGCM2 (dev), SIGCM2_Test_App, SIGCM2_Test_Api.
--
-- Depends on: V021 (dbo.ChargeableCharConfig must exist).
--
-- Notas:
-- - MedioId NULL = global fallback; aplica a todos los medios a menos que exista
-- una fila per-medio más específica (resolución en usp_ChargeableCharConfig_GetActiveForMedio).
-- - ValidFrom = 2026-01-01: retroactivo al inicio del año fiscal 2026.
-- - ValidTo NULL = vigente (sin fecha de cierre).
-- - PricePerUnit 1.0000 son placeholders — CONFIRMAR con el área de tasación.
--
-- SDD Design: engram sdd/prc-001-word-counter-spike/design (§3.3)
SET QUOTED_IDENTIFIER ON;
SET ANSI_NULLS ON;
SET NOCOUNT ON;
GO
MERGE dbo.ChargeableCharConfig AS t
USING (VALUES
(NULL, N'$', N'Currency', CAST(1.0000 AS DECIMAL(18,4)), CAST('2026-01-01' AS DATE)),
(NULL, N'%', N'Percentage', CAST(1.0000 AS DECIMAL(18,4)), CAST('2026-01-01' AS DATE)),
(NULL, N'!', N'Exclamation', CAST(1.0000 AS DECIMAL(18,4)), CAST('2026-01-01' AS DATE)),
(NULL, N'¡', N'Exclamation', CAST(1.0000 AS DECIMAL(18,4)), CAST('2026-01-01' AS DATE))
) AS s (MedioId, Symbol, Category, PricePerUnit, ValidFrom)
ON (t.MedioId IS NULL AND s.MedioId IS NULL AND t.Symbol = s.Symbol AND t.ValidTo IS NULL)
WHEN NOT MATCHED THEN
INSERT (MedioId, Symbol, Category, PricePerUnit, ValidFrom, ValidTo, IsActive)
VALUES (s.MedioId, s.Symbol, s.Category, s.PricePerUnit, s.ValidFrom, NULL, 1);
GO
PRINT 'V022 applied — 4 global ChargeableCharConfig defaults seeded ($, %, !, ¡).';
PRINT 'NOTE: PricePerUnit values are placeholders (1.0000). Update via CMS before going live.';
GO

View File

@@ -1,246 +0,0 @@
-- V023_ROLLBACK.sql
-- PRC-001: Reversa de V023__refactor_chargeable_char_config_to_product_type.sql.
--
-- ADVERTENCIA: rollback destructivo — elimina ProductTypeId y restaura MedioId.
-- - Todos los datos de ProductTypeId se pierden.
-- - Las filas globales (ProductTypeId NULL) se preservan como globales (MedioId NULL).
-- - El historial temporal puede quedar inconsistente si la tabla fue modificada después.
--
-- Solo para uso en DEV/TEST. No ejecutar en producción si hay datos de ProductTypeId.
-- Run on: SIGCM2 (dev), SIGCM2_Test_App, SIGCM2_Test_Api.
SET QUOTED_IDENTIFIER ON;
SET ANSI_NULLS ON;
SET NOCOUNT ON;
GO
-- ─── 1. Drop new SPs ────────────────────────────────────────────────────────
IF OBJECT_ID('dbo.usp_ChargeableCharConfig_ReactivateWithGuard', 'P') IS NOT NULL
BEGIN
DROP PROCEDURE dbo.usp_ChargeableCharConfig_ReactivateWithGuard;
PRINT 'V023 ROLLBACK: usp_ChargeableCharConfig_ReactivateWithGuard dropped.';
END
GO
IF OBJECT_ID('dbo.usp_ChargeableCharConfig_GetActiveForProductType', 'P') IS NOT NULL
BEGIN
DROP PROCEDURE dbo.usp_ChargeableCharConfig_GetActiveForProductType;
PRINT 'V023 ROLLBACK: usp_ChargeableCharConfig_GetActiveForProductType dropped.';
END
GO
IF OBJECT_ID('dbo.usp_ChargeableCharConfig_InsertWithClose', 'P') IS NOT NULL
BEGIN
DROP PROCEDURE dbo.usp_ChargeableCharConfig_InsertWithClose;
PRINT 'V023 ROLLBACK: usp_ChargeableCharConfig_InsertWithClose dropped.';
END
GO
-- ─── 2. Reverse table alterations if ProductTypeId column exists ─────────────
IF EXISTS (SELECT 1 FROM sys.tables WHERE name = 'ChargeableCharConfig' AND schema_id = SCHEMA_ID('dbo'))
AND EXISTS (SELECT 1 FROM sys.columns
WHERE object_id = OBJECT_ID('dbo.ChargeableCharConfig')
AND name = 'ProductTypeId')
BEGIN
-- 2a. Turn off SYSTEM_VERSIONING
ALTER TABLE dbo.ChargeableCharConfig SET (SYSTEM_VERSIONING = OFF);
PRINT 'V023 ROLLBACK: SYSTEM_VERSIONING = OFF.';
-- 2b. Drop indexes on ProductTypeId
IF EXISTS (SELECT 1 FROM sys.indexes WHERE name = 'UX_ChargeableCharConfig_Vigente'
AND object_id = OBJECT_ID('dbo.ChargeableCharConfig'))
BEGIN
DROP INDEX UX_ChargeableCharConfig_Vigente ON dbo.ChargeableCharConfig;
PRINT 'V023 ROLLBACK: UX_ChargeableCharConfig_Vigente dropped.';
END
IF EXISTS (SELECT 1 FROM sys.indexes WHERE name = 'IX_ChargeableCharConfig_Query'
AND object_id = OBJECT_ID('dbo.ChargeableCharConfig'))
BEGIN
DROP INDEX IX_ChargeableCharConfig_Query ON dbo.ChargeableCharConfig;
PRINT 'V023 ROLLBACK: IX_ChargeableCharConfig_Query dropped.';
END
-- 2c. Drop FK to ProductType
DECLARE @fk_pt sysname;
SELECT @fk_pt = name
FROM sys.foreign_keys
WHERE parent_object_id = OBJECT_ID('dbo.ChargeableCharConfig')
AND referenced_object_id = OBJECT_ID('dbo.ProductType');
IF @fk_pt IS NOT NULL
BEGIN
EXEC('ALTER TABLE dbo.ChargeableCharConfig DROP CONSTRAINT ' + @fk_pt);
PRINT 'V023 ROLLBACK: FK_ChargeableCharConfig_ProductType dropped.';
END
-- 2d. Drop NonNegative price check; restore Positive check
IF EXISTS (SELECT 1 FROM sys.check_constraints
WHERE name = 'CK_ChargeableCharConfig_Price_NonNegative'
AND parent_object_id = OBJECT_ID('dbo.ChargeableCharConfig'))
BEGIN
ALTER TABLE dbo.ChargeableCharConfig DROP CONSTRAINT CK_ChargeableCharConfig_Price_NonNegative;
PRINT 'V023 ROLLBACK: CK_ChargeableCharConfig_Price_NonNegative dropped.';
END
IF NOT EXISTS (SELECT 1 FROM sys.check_constraints
WHERE name = 'CK_ChargeableCharConfig_Price_Positive'
AND parent_object_id = OBJECT_ID('dbo.ChargeableCharConfig'))
BEGIN
ALTER TABLE dbo.ChargeableCharConfig
ADD CONSTRAINT CK_ChargeableCharConfig_Price_Positive CHECK (PricePerUnit > 0);
PRINT 'V023 ROLLBACK: CK_ChargeableCharConfig_Price_Positive restored.';
END
-- 2e. Drop ProductTypeId column from main + history
ALTER TABLE dbo.ChargeableCharConfig DROP COLUMN ProductTypeId;
PRINT 'V023 ROLLBACK: ProductTypeId dropped from ChargeableCharConfig.';
IF EXISTS (SELECT 1 FROM sys.columns
WHERE object_id = OBJECT_ID('dbo.ChargeableCharConfig_History')
AND name = 'ProductTypeId')
BEGIN
ALTER TABLE dbo.ChargeableCharConfig_History DROP COLUMN ProductTypeId;
PRINT 'V023 ROLLBACK: ProductTypeId dropped from ChargeableCharConfig_History.';
END
-- 2f. Restore MedioId column
ALTER TABLE dbo.ChargeableCharConfig ADD MedioId INT NULL;
ALTER TABLE dbo.ChargeableCharConfig_History ADD MedioId INT NULL;
PRINT 'V023 ROLLBACK: MedioId restored.';
-- 2g. Restore FK to Medio
ALTER TABLE dbo.ChargeableCharConfig
ADD CONSTRAINT FK_ChargeableCharConfig_Medio
FOREIGN KEY (MedioId) REFERENCES dbo.Medio(Id) ON DELETE NO ACTION;
PRINT 'V023 ROLLBACK: FK_ChargeableCharConfig_Medio restored.';
-- 2h. Restore indexes on MedioId
CREATE UNIQUE NONCLUSTERED INDEX UX_ChargeableCharConfig_Vigente
ON dbo.ChargeableCharConfig (MedioId, Symbol)
WHERE ValidTo IS NULL;
PRINT 'V023 ROLLBACK: UX_ChargeableCharConfig_Vigente restored (MedioId).';
CREATE NONCLUSTERED INDEX IX_ChargeableCharConfig_Query
ON dbo.ChargeableCharConfig (MedioId, Symbol, ValidFrom, ValidTo)
INCLUDE (PricePerUnit, IsActive, Category);
PRINT 'V023 ROLLBACK: IX_ChargeableCharConfig_Query restored (MedioId).';
-- 2i. Restore SYSTEM_VERSIONING
ALTER TABLE dbo.ChargeableCharConfig
SET (SYSTEM_VERSIONING = ON (
HISTORY_TABLE = dbo.ChargeableCharConfig_History,
HISTORY_RETENTION_PERIOD = 10 YEARS
));
PRINT 'V023 ROLLBACK: SYSTEM_VERSIONING = ON restored.';
END
ELSE
PRINT 'V023 ROLLBACK: ProductTypeId column not found — table already in MedioId state or missing, skipping.';
GO
-- ─── 3. Restore original SPs ────────────────────────────────────────────────
IF OBJECT_ID('dbo.usp_ChargeableCharConfig_InsertWithClose', 'P') IS NULL
EXEC('CREATE PROCEDURE dbo.usp_ChargeableCharConfig_InsertWithClose AS RETURN 0');
GO
ALTER PROCEDURE dbo.usp_ChargeableCharConfig_InsertWithClose
@MedioId INT = NULL,
@Symbol NVARCHAR(4),
@Category NVARCHAR(32),
@PricePerUnit DECIMAL(18,4),
@ValidFrom DATE,
@NewId BIGINT OUTPUT,
@ClosedId BIGINT OUTPUT
AS
BEGIN
SET NOCOUNT ON;
SET XACT_ABORT ON;
SET TRANSACTION ISOLATION LEVEL SERIALIZABLE;
BEGIN TRY
BEGIN TRANSACTION;
IF @MedioId IS NOT NULL
AND NOT EXISTS (SELECT 1 FROM dbo.Medio WITH (NOLOCK) WHERE Id = @MedioId)
BEGIN
ROLLBACK;
THROW 50404, 'Medio not found', 1;
END
DECLARE @ActiveId BIGINT, @ActiveValidFrom DATE;
SELECT TOP 1
@ActiveId = Id,
@ActiveValidFrom = ValidFrom
FROM dbo.ChargeableCharConfig WITH (UPDLOCK, HOLDLOCK, ROWLOCK)
WHERE ((@MedioId IS NULL AND MedioId IS NULL)
OR (@MedioId IS NOT NULL AND MedioId = @MedioId))
AND Symbol = @Symbol
AND ValidTo IS NULL;
IF @ActiveId IS NOT NULL AND @ValidFrom <= @ActiveValidFrom
BEGIN
ROLLBACK;
THROW 50409, 'ChargeableCharConfigForwardOnly: new ValidFrom must be > active.ValidFrom', 1;
END
IF @ActiveId IS NOT NULL
BEGIN
UPDATE dbo.ChargeableCharConfig
SET ValidTo = DATEADD(DAY, -1, @ValidFrom)
WHERE Id = @ActiveId;
SET @ClosedId = @ActiveId;
END
ELSE
SET @ClosedId = NULL;
INSERT INTO dbo.ChargeableCharConfig
(MedioId, Symbol, Category, PricePerUnit, ValidFrom, ValidTo, IsActive)
VALUES
(@MedioId, @Symbol, @Category, @PricePerUnit, @ValidFrom, NULL, 1);
SET @NewId = SCOPE_IDENTITY();
COMMIT TRANSACTION;
END TRY
BEGIN CATCH
IF XACT_STATE() <> 0 ROLLBACK TRANSACTION;
THROW;
END CATCH
END
GO
IF OBJECT_ID('dbo.usp_ChargeableCharConfig_GetActiveForMedio', 'P') IS NULL
EXEC('CREATE PROCEDURE dbo.usp_ChargeableCharConfig_GetActiveForMedio AS RETURN 0');
GO
ALTER PROCEDURE dbo.usp_ChargeableCharConfig_GetActiveForMedio
@MedioId INT,
@AsOfDate DATE
AS
BEGIN
SET NOCOUNT ON;
WITH Candidates AS (
SELECT
Id, MedioId, Symbol, Category, PricePerUnit, ValidFrom, ValidTo, IsActive,
ROW_NUMBER() OVER (
PARTITION BY Symbol
ORDER BY
CASE WHEN MedioId = @MedioId THEN 0 ELSE 1 END,
ValidFrom DESC
) AS rn
FROM dbo.ChargeableCharConfig
WHERE IsActive = 1
AND ValidFrom <= @AsOfDate
AND (ValidTo IS NULL OR ValidTo >= @AsOfDate)
AND (MedioId = @MedioId OR MedioId IS NULL)
)
SELECT Id, MedioId, Symbol, Category, PricePerUnit, ValidFrom, ValidTo, IsActive
FROM Candidates
WHERE rn = 1;
END
GO
PRINT '';
PRINT 'V023 ROLLBACK complete — ChargeableCharConfig restored to MedioId model.';
GO

View File

@@ -1,414 +0,0 @@
-- V023__refactor_chargeable_char_config_to_product_type.sql
-- PRC-001 scope delta: ChargeableCharConfig per ProductType (reemplaza per-Medio).
--
-- Cambios:
-- 1. DROP MedioId + FK_ChargeableCharConfig_Medio + índices que lo referencian.
-- 2. ADD ProductTypeId (nullable = global fallback) + FK_ChargeableCharConfig_ProductType.
-- 3. Recrea índices con ProductTypeId (UX_Vigente + IX_Query).
-- 4. DROP+CREATE usp_ChargeableCharConfig_InsertWithClose (@MedioId → @ProductTypeId).
-- 5. DROP usp_ChargeableCharConfig_GetActiveForMedio + CREATE usp_ChargeableCharConfig_GetActiveForProductType.
-- 6. NEW SP usp_ChargeableCharConfig_ReactivateWithGuard (opción A+guard para feature 3).
-- 7. DROP CK_ChargeableCharConfig_Price_Positive (se permite 0.0000 para opt-in billing).
-- Reemplaza con CK_ChargeableCharConfig_Price_NonNegative (>= 0).
--
-- Patrón: idempotente con IF EXISTS guards. Bloque principal protegido por la presencia
-- de la columna MedioId — si no existe ya fue refactorizada, el bloque no ejecuta.
-- SYSTEM_VERSIONING: OFF al inicio del ALTER block, ON al final (con history table + retention).
-- Depende de: V017 (dbo.ProductType debe existir).
-- Reversa: V023_ROLLBACK.sql.
-- Run on: SIGCM2 (dev), SIGCM2_Test_App, SIGCM2_Test_Api.
--
-- SDD Design: engram sdd/prc-001-word-counter-spike/design
-- Scope delta: engram sdd/prc-001-word-counter-spike/scope-delta-1
SET QUOTED_IDENTIFIER ON;
SET ANSI_NULLS ON;
SET NOCOUNT ON;
GO
-- ═══════════════════════════════════════════════════════════════════════
-- Bloque principal: solo ejecuta si la tabla existe Y todavía tiene MedioId
-- (guard idempotente: si ya fue refactorizada, el bloque se saltea completo).
-- ═══════════════════════════════════════════════════════════════════════
IF EXISTS (SELECT 1 FROM sys.tables WHERE name = 'ChargeableCharConfig' AND schema_id = SCHEMA_ID('dbo'))
AND EXISTS (SELECT 1 FROM sys.columns
WHERE object_id = OBJECT_ID('dbo.ChargeableCharConfig')
AND name = 'MedioId')
BEGIN
PRINT 'V023: MedioId column found — proceeding with refactor.';
-- ─── 1. Turn OFF SYSTEM_VERSIONING (idempotent — skip if already OFF) ───
IF EXISTS (SELECT 1 FROM sys.tables
WHERE name = 'ChargeableCharConfig'
AND schema_id = SCHEMA_ID('dbo')
AND temporal_type = 2) -- 2 = SYSTEM_VERSIONED_TEMPORAL_TABLE
BEGIN
ALTER TABLE dbo.ChargeableCharConfig SET (SYSTEM_VERSIONING = OFF);
PRINT 'V023: SYSTEM_VERSIONING = OFF.';
END
ELSE
PRINT 'V023: SYSTEM_VERSIONING already OFF — skipping.';
-- ─── 2. Drop indexes that reference MedioId ────────────────────────
IF EXISTS (SELECT 1 FROM sys.indexes WHERE name = 'UX_ChargeableCharConfig_Vigente'
AND object_id = OBJECT_ID('dbo.ChargeableCharConfig'))
BEGIN
DROP INDEX UX_ChargeableCharConfig_Vigente ON dbo.ChargeableCharConfig;
PRINT 'V023: UX_ChargeableCharConfig_Vigente dropped.';
END
IF EXISTS (SELECT 1 FROM sys.indexes WHERE name = 'IX_ChargeableCharConfig_Query'
AND object_id = OBJECT_ID('dbo.ChargeableCharConfig'))
BEGIN
DROP INDEX IX_ChargeableCharConfig_Query ON dbo.ChargeableCharConfig;
PRINT 'V023: IX_ChargeableCharConfig_Query dropped.';
END
-- ─── 3. Drop FK to Medio ────────────────────────────────────────────
DECLARE @fk_name sysname;
SELECT @fk_name = name
FROM sys.foreign_keys
WHERE parent_object_id = OBJECT_ID('dbo.ChargeableCharConfig')
AND referenced_object_id = OBJECT_ID('dbo.Medio');
IF @fk_name IS NOT NULL
BEGIN
EXEC('ALTER TABLE dbo.ChargeableCharConfig DROP CONSTRAINT ' + @fk_name);
PRINT 'V023: FK_ChargeableCharConfig_Medio dropped.';
END
-- ─── 4. Drop MedioId column (drop DF constraint first if present) ───
DECLARE @df_medio sysname;
SELECT @df_medio = dc.name
FROM sys.default_constraints dc
JOIN sys.columns c ON c.default_object_id = dc.object_id
WHERE c.object_id = OBJECT_ID('dbo.ChargeableCharConfig')
AND c.name = 'MedioId';
IF @df_medio IS NOT NULL
BEGIN
EXEC('ALTER TABLE dbo.ChargeableCharConfig DROP CONSTRAINT ' + @df_medio);
PRINT 'V023: Default constraint on MedioId dropped.';
END
ALTER TABLE dbo.ChargeableCharConfig DROP COLUMN MedioId;
PRINT 'V023: MedioId column dropped from ChargeableCharConfig.';
-- Drop MedioId from history table if present
IF EXISTS (SELECT 1 FROM sys.columns
WHERE object_id = OBJECT_ID('dbo.ChargeableCharConfig_History')
AND name = 'MedioId')
BEGIN
-- Drop default constraint on history MedioId if any
DECLARE @df_hist_medio sysname;
SELECT @df_hist_medio = dc.name
FROM sys.default_constraints dc
JOIN sys.columns c ON c.default_object_id = dc.object_id
WHERE c.object_id = OBJECT_ID('dbo.ChargeableCharConfig_History')
AND c.name = 'MedioId';
IF @df_hist_medio IS NOT NULL
EXEC('ALTER TABLE dbo.ChargeableCharConfig_History DROP CONSTRAINT ' + @df_hist_medio);
ALTER TABLE dbo.ChargeableCharConfig_History DROP COLUMN MedioId;
PRINT 'V023: MedioId column dropped from ChargeableCharConfig_History.';
END
-- ─── 5. Drop CK_Price_Positive, replace with CK_Price_NonNegative ──
-- V024 seeds PricePerUnit = 0.0000 (opt-in billing). Old check (> 0) would block it.
IF EXISTS (SELECT 1 FROM sys.check_constraints
WHERE name = 'CK_ChargeableCharConfig_Price_Positive'
AND parent_object_id = OBJECT_ID('dbo.ChargeableCharConfig'))
BEGIN
ALTER TABLE dbo.ChargeableCharConfig DROP CONSTRAINT CK_ChargeableCharConfig_Price_Positive;
PRINT 'V023: CK_ChargeableCharConfig_Price_Positive dropped.';
END
IF NOT EXISTS (SELECT 1 FROM sys.check_constraints
WHERE name = 'CK_ChargeableCharConfig_Price_NonNegative'
AND parent_object_id = OBJECT_ID('dbo.ChargeableCharConfig'))
BEGIN
ALTER TABLE dbo.ChargeableCharConfig
ADD CONSTRAINT CK_ChargeableCharConfig_Price_NonNegative
CHECK (PricePerUnit >= 0);
PRINT 'V023: CK_ChargeableCharConfig_Price_NonNegative added (>= 0, opt-in billing).';
END
-- ─── 6. Add ProductTypeId column ────────────────────────────────────
ALTER TABLE dbo.ChargeableCharConfig
ADD ProductTypeId INT NULL; -- NULL = global fallback
PRINT 'V023: ProductTypeId column added to ChargeableCharConfig.';
ALTER TABLE dbo.ChargeableCharConfig_History
ADD ProductTypeId INT NULL;
PRINT 'V023: ProductTypeId column added to ChargeableCharConfig_History.';
-- ─── 7. Add FK to ProductType ────────────────────────────────────────
ALTER TABLE dbo.ChargeableCharConfig
ADD CONSTRAINT FK_ChargeableCharConfig_ProductType
FOREIGN KEY (ProductTypeId) REFERENCES dbo.ProductType(Id) ON DELETE NO ACTION;
PRINT 'V023: FK_ChargeableCharConfig_ProductType added.';
-- ─── 8. Recreate filtered unique index with ProductTypeId ────────────
-- 1 vigente per (ProductTypeId, Symbol). NULL ProductTypeId = global fallback.
-- SQL Server trata NULL como "distinto" en unique indexes → enforza 1 vigente global.
CREATE UNIQUE NONCLUSTERED INDEX UX_ChargeableCharConfig_Vigente
ON dbo.ChargeableCharConfig (ProductTypeId, Symbol)
WHERE ValidTo IS NULL;
PRINT 'V023: UX_ChargeableCharConfig_Vigente recreated (ProductTypeId, Symbol).';
-- ─── 9. Recreate cover index with ProductTypeId ──────────────────────
CREATE NONCLUSTERED INDEX IX_ChargeableCharConfig_Query
ON dbo.ChargeableCharConfig (ProductTypeId, Symbol, ValidFrom, ValidTo)
INCLUDE (PricePerUnit, IsActive, Category);
PRINT 'V023: IX_ChargeableCharConfig_Query recreated (ProductTypeId).';
-- ─── 10. Turn SYSTEM_VERSIONING back ON ──────────────────────────────
ALTER TABLE dbo.ChargeableCharConfig
SET (SYSTEM_VERSIONING = ON (
HISTORY_TABLE = dbo.ChargeableCharConfig_History,
HISTORY_RETENTION_PERIOD = 10 YEARS
));
PRINT 'V023: SYSTEM_VERSIONING = ON (history: dbo.ChargeableCharConfig_History, retention: 10 years).';
END
ELSE
BEGIN
IF NOT EXISTS (SELECT 1 FROM sys.tables WHERE name = 'ChargeableCharConfig' AND schema_id = SCHEMA_ID('dbo'))
PRINT 'V023: dbo.ChargeableCharConfig does not exist — skipping table refactor.';
ELSE
PRINT 'V023: MedioId column not found — table already refactored, skipping.';
END
GO
-- ═══════════════════════════════════════════════════════════════════════
-- SP: usp_ChargeableCharConfig_InsertWithClose (@ProductTypeId replaces @MedioId)
-- Cierre atómico forward-only: SERIALIZABLE + UPDLOCK + HOLDLOCK.
-- @ProductTypeId NULL = global; FK validada solo cuando NOT NULL (via referential integrity).
-- THROW 50404: ProductType not found.
-- THROW 50409: ForwardOnly — new ValidFrom must be > active.ValidFrom.
-- Output: @NewId (BIGINT), @ClosedId (BIGINT — NULL if first price for symbol).
-- ═══════════════════════════════════════════════════════════════════════
IF OBJECT_ID('dbo.usp_ChargeableCharConfig_InsertWithClose', 'P') IS NOT NULL
DROP PROCEDURE dbo.usp_ChargeableCharConfig_InsertWithClose;
GO
CREATE PROCEDURE dbo.usp_ChargeableCharConfig_InsertWithClose
@ProductTypeId INT = NULL,
@Symbol NVARCHAR(4),
@Category NVARCHAR(32),
@PricePerUnit DECIMAL(18,4),
@ValidFrom DATE,
@NewId BIGINT OUTPUT,
@ClosedId BIGINT OUTPUT
AS
BEGIN
SET NOCOUNT ON;
SET XACT_ABORT ON;
SET TRANSACTION ISOLATION LEVEL SERIALIZABLE;
BEGIN TRY
BEGIN TRANSACTION;
-- Validate ProductTypeId only when provided (NULL = global fallback, always valid).
-- FK constraint handles referential integrity; we throw 50404 explicitly for better UX.
IF @ProductTypeId IS NOT NULL
AND NOT EXISTS (SELECT 1 FROM dbo.ProductType WITH (NOLOCK) WHERE Id = @ProductTypeId)
BEGIN
ROLLBACK;
THROW 50404, 'ProductType not found', 1;
END
-- Read current vigente with range lock for serialization.
DECLARE @ActiveId BIGINT, @ActiveValidFrom DATE;
SELECT TOP 1
@ActiveId = Id,
@ActiveValidFrom = ValidFrom
FROM dbo.ChargeableCharConfig WITH (UPDLOCK, HOLDLOCK, ROWLOCK)
WHERE ((@ProductTypeId IS NULL AND ProductTypeId IS NULL)
OR (@ProductTypeId IS NOT NULL AND ProductTypeId = @ProductTypeId))
AND Symbol = @Symbol
AND ValidTo IS NULL;
-- Forward-only strict: new ValidFrom must be STRICTLY greater than active.ValidFrom.
IF @ActiveId IS NOT NULL AND @ValidFrom <= @ActiveValidFrom
BEGIN
ROLLBACK;
THROW 50409, 'ChargeableCharConfigForwardOnly: new ValidFrom must be > active.ValidFrom', 1;
END
-- Close the current vigente: ValidTo = new ValidFrom - 1 day.
IF @ActiveId IS NOT NULL
BEGIN
UPDATE dbo.ChargeableCharConfig
SET ValidTo = DATEADD(DAY, -1, @ValidFrom)
WHERE Id = @ActiveId;
SET @ClosedId = @ActiveId;
END
ELSE
SET @ClosedId = NULL;
-- Insert the new vigente.
INSERT INTO dbo.ChargeableCharConfig
(ProductTypeId, Symbol, Category, PricePerUnit, ValidFrom, ValidTo, IsActive)
VALUES
(@ProductTypeId, @Symbol, @Category, @PricePerUnit, @ValidFrom, NULL, 1);
SET @NewId = SCOPE_IDENTITY();
COMMIT TRANSACTION;
END TRY
BEGIN CATCH
IF XACT_STATE() <> 0 ROLLBACK TRANSACTION;
THROW;
END CATCH
END
GO
-- ═══════════════════════════════════════════════════════════════════════
-- SP: drop old GetActiveForMedio (renamed to GetActiveForProductType)
-- ═══════════════════════════════════════════════════════════════════════
IF OBJECT_ID('dbo.usp_ChargeableCharConfig_GetActiveForMedio', 'P') IS NOT NULL
BEGIN
DROP PROCEDURE dbo.usp_ChargeableCharConfig_GetActiveForMedio;
PRINT 'V023: usp_ChargeableCharConfig_GetActiveForMedio dropped.';
END
GO
-- ═══════════════════════════════════════════════════════════════════════
-- SP: usp_ChargeableCharConfig_GetActiveForProductType
-- Resolución per-ProductType + global fallback: 1 fila por Symbol.
-- CTE + ROW_NUMBER PARTITION BY Symbol ORDER BY per-PT(0) vs global(1).
-- @ProductTypeId: the specific product type to resolve for.
-- @AsOfDate: resolve active rows as of this date (for pricing snapshot).
-- ═══════════════════════════════════════════════════════════════════════
CREATE PROCEDURE dbo.usp_ChargeableCharConfig_GetActiveForProductType
@ProductTypeId INT,
@AsOfDate DATE
AS
BEGIN
SET NOCOUNT ON;
WITH Candidates AS (
SELECT
Id, ProductTypeId, Symbol, Category, PricePerUnit, ValidFrom, ValidTo, IsActive,
ROW_NUMBER() OVER (
PARTITION BY Symbol
ORDER BY
CASE WHEN ProductTypeId = @ProductTypeId THEN 0 ELSE 1 END, -- prefer specific over global
ValidFrom DESC
) AS rn
FROM dbo.ChargeableCharConfig
WHERE IsActive = 1
AND ValidFrom <= @AsOfDate
AND (ValidTo IS NULL OR ValidTo >= @AsOfDate)
AND (ProductTypeId = @ProductTypeId OR ProductTypeId IS NULL)
)
SELECT Id, ProductTypeId, Symbol, Category, PricePerUnit, ValidFrom, ValidTo, IsActive
FROM Candidates
WHERE rn = 1;
END
GO
-- ═══════════════════════════════════════════════════════════════════════
-- SP: usp_ChargeableCharConfig_ReactivateWithGuard (NEW — feature 3 of scope delta)
-- Opción A+guard: literal undo of the last close for (ProductTypeId, Symbol).
-- Guards:
-- - Row must exist → THROW 50404
-- - Row must be closed (ValidTo IS NOT NULL, IsActive = 0) → THROW 50410 if already active
-- - No vigente currently exists for (ProductTypeId, Symbol) → THROW 50411
-- - No posterior rows exist for (ProductTypeId, Symbol) → THROW 50412
-- On success: UPDATE IsActive = 1, ValidTo = NULL (literal undo).
-- Preserves forward-only invariant and maintains clean history.
-- ═══════════════════════════════════════════════════════════════════════
IF OBJECT_ID('dbo.usp_ChargeableCharConfig_ReactivateWithGuard', 'P') IS NOT NULL
DROP PROCEDURE dbo.usp_ChargeableCharConfig_ReactivateWithGuard;
GO
CREATE PROCEDURE dbo.usp_ChargeableCharConfig_ReactivateWithGuard
@Id BIGINT
AS
BEGIN
SET NOCOUNT ON;
SET XACT_ABORT ON;
SET TRANSACTION ISOLATION LEVEL SERIALIZABLE;
BEGIN TRY
BEGIN TRANSACTION;
-- Step 1: Lock + load target row.
DECLARE @ProductTypeId INT, @Symbol NVARCHAR(4), @ValidTo DATE, @IsActive BIT;
SELECT @ProductTypeId = ProductTypeId,
@Symbol = Symbol,
@ValidTo = ValidTo,
@IsActive = IsActive
FROM dbo.ChargeableCharConfig WITH (UPDLOCK, HOLDLOCK)
WHERE Id = @Id;
IF @@ROWCOUNT = 0
BEGIN
ROLLBACK TRANSACTION;
THROW 50404, 'ChargeableCharConfig row not found', 1;
END
-- Step 2: Row must be closed (ValidTo IS NOT NULL and IsActive = 0).
-- If it is currently active (ValidTo IS NULL), reactivation is nonsensical.
IF @ValidTo IS NULL
BEGIN
ROLLBACK TRANSACTION;
THROW 50410, 'Row is already active — reactivation not needed', 1;
END
-- Step 3: GUARD — no vigente currently for (ProductTypeId, Symbol).
-- Prevents re-opening a row while another is already vigente.
IF EXISTS (
SELECT 1 FROM dbo.ChargeableCharConfig
WHERE ((ProductTypeId = @ProductTypeId) OR (ProductTypeId IS NULL AND @ProductTypeId IS NULL))
AND Symbol = @Symbol
AND ValidTo IS NULL
)
BEGIN
ROLLBACK TRANSACTION;
THROW 50411, 'A current active row already exists for this ProductType/Symbol — cannot reactivate', 1;
END
-- Step 4: GUARD — no posterior rows exist for (ProductTypeId, Symbol) after @ValidTo.
-- Ensures this is the LAST closed row; reactivating an older row would violate
-- forward-only ordering of the temporal chain.
IF EXISTS (
SELECT 1 FROM dbo.ChargeableCharConfig
WHERE ((ProductTypeId = @ProductTypeId) OR (ProductTypeId IS NULL AND @ProductTypeId IS NULL))
AND Symbol = @Symbol
AND ValidFrom > @ValidTo
AND Id <> @Id
)
BEGIN
ROLLBACK TRANSACTION;
THROW 50412, 'Posterior rows exist for this ProductType/Symbol — reactivation not allowed', 1;
END
-- Step 5: Literal undo — re-open the row.
UPDATE dbo.ChargeableCharConfig
SET IsActive = 1,
ValidTo = NULL
WHERE Id = @Id;
COMMIT TRANSACTION;
END TRY
BEGIN CATCH
IF XACT_STATE() <> 0 ROLLBACK TRANSACTION;
THROW;
END CATCH
END
GO
PRINT '';
PRINT 'V023 applied — ChargeableCharConfig refactored to ProductType model:';
PRINT ' - MedioId dropped, ProductTypeId added (FK to dbo.ProductType)';
PRINT ' - UX_ChargeableCharConfig_Vigente + IX_ChargeableCharConfig_Query recreated';
PRINT ' - usp_ChargeableCharConfig_InsertWithClose: @MedioId → @ProductTypeId';
PRINT ' - usp_ChargeableCharConfig_GetActiveForMedio dropped';
PRINT ' - usp_ChargeableCharConfig_GetActiveForProductType created';
PRINT ' - usp_ChargeableCharConfig_ReactivateWithGuard created (NEW)';
PRINT ' - CK_Price_Positive replaced by CK_Price_NonNegative (>= 0 for opt-in billing)';
PRINT 'Next migration: V024 (reseed global rows with PricePerUnit = 0.0000).';
GO

View File

@@ -1,22 +0,0 @@
-- V024_ROLLBACK.sql
-- PRC-001: Reversa de V024__reseed_global_with_zero_price.sql.
--
-- Restaura las 4 filas globales de seed a PricePerUnit = 1.0000 (valor original de V022).
-- Solo ejecutar si V024 fue aplicado y se desea volver al estado previo.
--
-- Run on: SIGCM2 (dev), SIGCM2_Test_App, SIGCM2_Test_Api.
SET QUOTED_IDENTIFIER ON;
SET ANSI_NULLS ON;
SET NOCOUNT ON;
GO
UPDATE dbo.ChargeableCharConfig
SET PricePerUnit = CAST(1.0000 AS DECIMAL(18,4))
WHERE ProductTypeId IS NULL
AND Symbol IN (N'$', N'%', N'!', N'¡')
AND ValidTo IS NULL;
PRINT 'V024 ROLLBACK complete — global ChargeableCharConfig prices restored to 1.0000.';
PRINT 'Rows updated: ' + CAST(@@ROWCOUNT AS NVARCHAR(10));
GO

View File

@@ -1,34 +0,0 @@
-- V024__reseed_global_with_zero_price.sql
-- PRC-001 scope delta: actualiza las 4 filas globales de seed a PricePerUnit = 0.0000.
--
-- Cambios:
-- 1. UPDATE directo de las 4 filas globales vigentes ($, %, !, ¡) a PricePerUnit = 0.0000.
--
-- Decisión: UPDATE directo (no forward-only close+insert) porque:
-- - V022 seed price 1.0000 era siempre un placeholder nunca usado en lógica de negocio.
-- - No existe historial de facturación con el valor 1.0000.
-- - La semántica correcta es "opt-in billing": por defecto ningún tipo cobra especiales.
-- - La forward-only invariante aplica a cambios de precio en producción; este es un fix
-- de seed pre-go-live dentro de la misma branch feature (no mergeada a main aún).
-- See: scope-delta-1 en engram sdd/prc-001-word-counter-spike/scope-delta-1.
--
-- Patrón: UPDATE simple WHERE ProductTypeId IS NULL AND Symbol IN (...) AND ValidTo IS NULL.
-- Idempotente: UPDATE idempotente (re-ejecutar no cambia el resultado).
-- Reversa: V024_ROLLBACK.sql.
-- Depends on: V023 (ProductTypeId column must exist; CK_Price_NonNegative >= 0 required).
-- Run on: SIGCM2 (dev), SIGCM2_Test_App, SIGCM2_Test_Api.
SET QUOTED_IDENTIFIER ON;
SET ANSI_NULLS ON;
SET NOCOUNT ON;
GO
UPDATE dbo.ChargeableCharConfig
SET PricePerUnit = CAST(0.0000 AS DECIMAL(18,4))
WHERE ProductTypeId IS NULL
AND Symbol IN (N'$', N'%', N'!', N'¡')
AND ValidTo IS NULL;
PRINT 'V024 applied — global ChargeableCharConfig prices reset to 0.0000 (opt-in billing).';
PRINT 'Rows updated: ' + CAST(@@ROWCOUNT AS NVARCHAR(10));
GO

View File

@@ -1,20 +0,0 @@
-- V025_ROLLBACK.sql
-- Reversa de V025 — elimina los overrides demo de ChargeableCharConfig.
-- Los globales V022/V024 (ProductTypeId IS NULL) NO se tocan.
SET QUOTED_IDENTIFIER ON;
SET ANSI_NULLS ON;
SET NOCOUNT ON;
GO
DELETE FROM dbo.ChargeableCharConfig
WHERE ProductTypeId IS NOT NULL
AND ValidTo IS NULL
AND Symbol IN (N'$', N'%', N'!', N'¡')
AND ProductTypeId IN (
SELECT Id FROM dbo.ProductType
WHERE Nombre IN ('Clasificado', 'Notables', 'Fúnebres', 'Funebres')
);
PRINT 'V025 rolled back — demo overrides eliminated. Globales V022/V024 preservados.';
GO

View File

@@ -1,101 +0,0 @@
-- V025__seed_chargeable_char_overrides_demo.sql
-- PRC-001 followup #54: seeders de demo con valores ficticios per-ProductType.
--
-- Estrategia:
-- 1. Los 4 globales de V022+V024 quedan en 0.0000 (opt-in billing baseline).
-- 2. Para ProductTypes conocidos del roadmap (Clasificado, Notables, Fúnebres),
-- inserta overrides con precios ficticios coherentes con datos de demo del resto
-- del proyecto. Si el ProductType no existe, el bloque correspondiente no hace nada.
-- 3. Cuando PRD-008 seede los 12 tipos legacy, V025 puede re-aplicarse y creará
-- los overrides que falten (MERGE idempotente).
--
-- Precios ficticios (placeholders de demo — NO son tarifas reales):
-- Clasificado: $ = 5.0000, % = 3.0000, ! = 2.0000, ¡ = 2.0000
-- Notables: $ = 8.0000, % = 5.0000, ! = 4.0000, ¡ = 4.0000
-- Fúnebres: $ = 6.0000, % = 4.0000, ! = 3.5000, ¡ = 3.5000
--
-- Reversa: V025_ROLLBACK.sql (elimina los overrides demo dejando solo los globales V022/V024).
-- Run on: SIGCM2 (dev), SIGCM2_Test_App, SIGCM2_Test_Api.
-- Idempotente: usa MERGE por (ProductTypeId, Symbol, ValidTo IS NULL).
SET QUOTED_IDENTIFIER ON;
SET ANSI_NULLS ON;
SET NOCOUNT ON;
GO
DECLARE @ClasificadoId INT = (SELECT TOP 1 Id FROM dbo.ProductType WHERE Nombre = 'Clasificado' AND IsActive = 1);
DECLARE @NotablesId INT = (SELECT TOP 1 Id FROM dbo.ProductType WHERE Nombre = 'Notables' AND IsActive = 1);
DECLARE @FunebresId INT = (SELECT TOP 1 Id FROM dbo.ProductType WHERE Nombre IN ('Fúnebres','Funebres') AND IsActive = 1);
DECLARE @DemoValidFrom DATE = '2026-01-01';
-- Clasificado overrides
IF @ClasificadoId IS NOT NULL
BEGIN
MERGE dbo.ChargeableCharConfig AS t
USING (VALUES
(@ClasificadoId, N'$', N'Currency', CAST(5.0000 AS DECIMAL(18,4)), @DemoValidFrom),
(@ClasificadoId, N'%', N'Percentage', CAST(3.0000 AS DECIMAL(18,4)), @DemoValidFrom),
(@ClasificadoId, N'!', N'Exclamation', CAST(2.0000 AS DECIMAL(18,4)), @DemoValidFrom),
(@ClasificadoId, N'¡', N'Exclamation', CAST(2.0000 AS DECIMAL(18,4)), @DemoValidFrom)
) AS s (ProductTypeId, Symbol, Category, PricePerUnit, ValidFrom)
ON (t.ProductTypeId = s.ProductTypeId AND t.Symbol = s.Symbol AND t.ValidTo IS NULL)
WHEN NOT MATCHED THEN
INSERT (ProductTypeId, Symbol, Category, PricePerUnit, ValidFrom, ValidTo, IsActive)
VALUES (s.ProductTypeId, s.Symbol, s.Category, s.PricePerUnit, s.ValidFrom, NULL, 1);
PRINT 'V025: Clasificado overrides seeded (ProductTypeId=' + CAST(@ClasificadoId AS NVARCHAR(10)) + ').';
END
ELSE
PRINT 'V025: ProductType "Clasificado" not found — skipping Clasificado overrides.';
GO
DECLARE @NotablesId INT = (SELECT TOP 1 Id FROM dbo.ProductType WHERE Nombre = 'Notables' AND IsActive = 1);
DECLARE @DemoValidFrom DATE = '2026-01-01';
-- Notables overrides
IF @NotablesId IS NOT NULL
BEGIN
MERGE dbo.ChargeableCharConfig AS t
USING (VALUES
(@NotablesId, N'$', N'Currency', CAST(8.0000 AS DECIMAL(18,4)), @DemoValidFrom),
(@NotablesId, N'%', N'Percentage', CAST(5.0000 AS DECIMAL(18,4)), @DemoValidFrom),
(@NotablesId, N'!', N'Exclamation', CAST(4.0000 AS DECIMAL(18,4)), @DemoValidFrom),
(@NotablesId, N'¡', N'Exclamation', CAST(4.0000 AS DECIMAL(18,4)), @DemoValidFrom)
) AS s (ProductTypeId, Symbol, Category, PricePerUnit, ValidFrom)
ON (t.ProductTypeId = s.ProductTypeId AND t.Symbol = s.Symbol AND t.ValidTo IS NULL)
WHEN NOT MATCHED THEN
INSERT (ProductTypeId, Symbol, Category, PricePerUnit, ValidFrom, ValidTo, IsActive)
VALUES (s.ProductTypeId, s.Symbol, s.Category, s.PricePerUnit, s.ValidFrom, NULL, 1);
PRINT 'V025: Notables overrides seeded (ProductTypeId=' + CAST(@NotablesId AS NVARCHAR(10)) + ').';
END
ELSE
PRINT 'V025: ProductType "Notables" not found — skipping Notables overrides.';
GO
DECLARE @FunebresId INT = (SELECT TOP 1 Id FROM dbo.ProductType WHERE Nombre IN ('Fúnebres','Funebres') AND IsActive = 1);
DECLARE @DemoValidFrom DATE = '2026-01-01';
-- Fúnebres overrides
IF @FunebresId IS NOT NULL
BEGIN
MERGE dbo.ChargeableCharConfig AS t
USING (VALUES
(@FunebresId, N'$', N'Currency', CAST(6.0000 AS DECIMAL(18,4)), @DemoValidFrom),
(@FunebresId, N'%', N'Percentage', CAST(4.0000 AS DECIMAL(18,4)), @DemoValidFrom),
(@FunebresId, N'!', N'Exclamation', CAST(3.5000 AS DECIMAL(18,4)), @DemoValidFrom),
(@FunebresId, N'¡', N'Exclamation', CAST(3.5000 AS DECIMAL(18,4)), @DemoValidFrom)
) AS s (ProductTypeId, Symbol, Category, PricePerUnit, ValidFrom)
ON (t.ProductTypeId = s.ProductTypeId AND t.Symbol = s.Symbol AND t.ValidTo IS NULL)
WHEN NOT MATCHED THEN
INSERT (ProductTypeId, Symbol, Category, PricePerUnit, ValidFrom, ValidTo, IsActive)
VALUES (s.ProductTypeId, s.Symbol, s.Category, s.PricePerUnit, s.ValidFrom, NULL, 1);
PRINT 'V025: Fúnebres overrides seeded (ProductTypeId=' + CAST(@FunebresId AS NVARCHAR(10)) + ').';
END
ELSE
PRINT 'V025: ProductType "Fúnebres/Funebres" not found — skipping Fúnebres overrides.';
GO
PRINT '';
PRINT 'V025 applied — demo overrides (fictitious prices) seeded for ProductTypes: Clasificado, Notables, Fúnebres (only where they exist).';
PRINT 'NOTE: Los 4 globales (V022/V024) quedan intactos en 0.0000. Estos overrides son PLACEHOLDERS DE DEMO — reemplazar antes de go-live.';
GO

View File

@@ -1,247 +0,0 @@
using FluentValidation;
using Microsoft.AspNetCore.Mvc;
using SIGCM2.Api.Authorization;
using SIGCM2.Application.Abstractions;
using SIGCM2.Application.Common;
using SIGCM2.Application.Pricing.ChargeableChars;
using SIGCM2.Application.Pricing.ChargeableChars.Create;
using SIGCM2.Application.Pricing.ChargeableChars.Deactivate;
using SIGCM2.Application.Pricing.ChargeableChars.Delete;
using SIGCM2.Application.Pricing.ChargeableChars.GetById;
using SIGCM2.Application.Pricing.ChargeableChars.List;
using SIGCM2.Application.Pricing.ChargeableChars.Reactivate;
using SIGCM2.Application.Pricing.ChargeableChars.SchedulePrice;
namespace SIGCM2.Api.Controllers;
/// <summary>
/// PRC-001: Admin endpoints for ChargeableCharConfig management.
/// All endpoints require 'tasacion:caracteres_especiales:gestionar'.
/// Route base: api/v1/admin/chargeable-chars
/// </summary>
[ApiController]
[Route("api/v1/admin/chargeable-chars")]
public sealed class ChargeableCharConfigController : ControllerBase
{
private readonly IDispatcher _dispatcher;
private readonly IValidator<CreateChargeableCharConfigCommand> _createValidator;
private readonly IValidator<SchedulePriceChangeCommand> _scheduleValidator;
public ChargeableCharConfigController(
IDispatcher dispatcher,
IValidator<CreateChargeableCharConfigCommand> createValidator,
IValidator<SchedulePriceChangeCommand> scheduleValidator)
{
_dispatcher = dispatcher;
_createValidator = createValidator;
_scheduleValidator = scheduleValidator;
}
// ── GET /api/v1/admin/chargeable-chars ────────────────────────────────────
/// <summary>
/// Returns a paginated list of ChargeableCharConfig rows.
/// Filters: productTypeId (optional, long?), activeOnly (bool, default true).
/// Pagination: skip/take model mapped to page/pageSize — or use page/pageSize directly.
/// Defaults: page=1, pageSize=20. Clamped: pageSize max 200.
/// </summary>
[HttpGet]
[RequirePermission("tasacion:caracteres_especiales:gestionar")]
[ProducesResponseType(typeof(PagedResult<ChargeableCharConfigDto>), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
[ProducesResponseType(StatusCodes.Status403Forbidden)]
public async Task<IActionResult> List(
[FromQuery] long? productTypeId,
[FromQuery] bool activeOnly = true,
[FromQuery] int? page = null,
[FromQuery] int? pageSize = null,
[FromQuery] int? skip = null,
[FromQuery] int? take = null)
{
// Support both page/pageSize and skip/take query patterns
int resolvedPage;
int resolvedPageSize;
if (skip is not null || take is not null)
{
// Convert skip/take to page/pageSize
resolvedPageSize = Math.Min(take ?? 50, 200);
resolvedPage = resolvedPageSize > 0
? ((skip ?? 0) / resolvedPageSize) + 1
: 1;
}
else
{
resolvedPage = page ?? 1;
resolvedPageSize = Math.Min(pageSize ?? 20, 200);
}
var query = new ListChargeableCharConfigQuery(productTypeId, activeOnly, resolvedPage, resolvedPageSize);
var result = await _dispatcher.Send<ListChargeableCharConfigQuery, PagedResult<ChargeableCharConfigDto>>(query);
return Ok(result);
}
// ── GET /api/v1/admin/chargeable-chars/{id} ───────────────────────────────
/// <summary>
/// Returns a single ChargeableCharConfig by Id. Returns 404 if not found.
/// </summary>
[HttpGet("{id:long}")]
[RequirePermission("tasacion:caracteres_especiales:gestionar")]
[ProducesResponseType(typeof(ChargeableCharConfigDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
[ProducesResponseType(StatusCodes.Status403Forbidden)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<IActionResult> GetById([FromRoute] long id)
{
var result = await _dispatcher.Send<GetChargeableCharConfigByIdQuery, ChargeableCharConfigDto?>(
new GetChargeableCharConfigByIdQuery(id));
return result is null ? NotFound() : Ok(result);
}
// ── POST /api/v1/admin/chargeable-chars ───────────────────────────────────
/// <summary>
/// Creates a new ChargeableCharConfig row. Closes the current active row for (ProductTypeId, Symbol) if one exists.
/// Returns 201 Created with Location header pointing to GET /{id}.
/// </summary>
[HttpPost]
[RequirePermission("tasacion:caracteres_especiales:gestionar")]
[ProducesResponseType(typeof(CreateChargeableCharConfigResponse), StatusCodes.Status201Created)]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
[ProducesResponseType(StatusCodes.Status403Forbidden)]
[ProducesResponseType(StatusCodes.Status409Conflict)]
public async Task<IActionResult> Create([FromBody] CreateChargeableCharConfigRequest request)
{
var command = new CreateChargeableCharConfigCommand(
request.ProductTypeId,
request.Symbol,
request.Category,
request.PricePerUnit,
request.ValidFrom);
var validation = await _createValidator.ValidateAsync(command);
if (!validation.IsValid)
{
var errors = validation.Errors
.GroupBy(e => e.PropertyName)
.ToDictionary(g => g.Key, g => g.Select(e => e.ErrorMessage).ToArray());
return BadRequest(new { errors });
}
var result = await _dispatcher.Send<CreateChargeableCharConfigCommand, CreateChargeableCharConfigResponse>(command);
return CreatedAtAction(nameof(GetById), new { id = result.Id }, result);
}
// ── PUT /api/v1/admin/chargeable-chars/{id}/price ────────────────────────
/// <summary>
/// Schedules a price change for an existing ChargeableCharConfig.
/// Closes the current active row and opens a new one with the new price + ValidFrom.
/// ValidFrom must be strictly greater than the existing row's ValidFrom (forward-only).
/// </summary>
[HttpPut("{id:long}/price")]
[RequirePermission("tasacion:caracteres_especiales:gestionar")]
[ProducesResponseType(typeof(SchedulePriceChangeResponse), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
[ProducesResponseType(StatusCodes.Status403Forbidden)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
[ProducesResponseType(StatusCodes.Status409Conflict)]
public async Task<IActionResult> SchedulePriceChange(
[FromRoute] long id,
[FromBody] SchedulePriceChangeRequest request)
{
var command = new SchedulePriceChangeCommand(id, request.PricePerUnit, request.ValidFrom);
var validation = await _scheduleValidator.ValidateAsync(command);
if (!validation.IsValid)
{
var errors = validation.Errors
.GroupBy(e => e.PropertyName)
.ToDictionary(g => g.Key, g => g.Select(e => e.ErrorMessage).ToArray());
return BadRequest(new { errors });
}
var result = await _dispatcher.Send<SchedulePriceChangeCommand, SchedulePriceChangeResponse>(command);
return Ok(result);
}
// ── PATCH /api/v1/admin/chargeable-chars/{id}/deactivate ─────────────────
/// <summary>
/// Deactivates a ChargeableCharConfig row (sets IsActive=false, ValidTo=today_AR).
/// Idempotent: calling on an already-inactive row is a no-op.
/// </summary>
[HttpPatch("{id:long}/deactivate")]
[RequirePermission("tasacion:caracteres_especiales:gestionar")]
[ProducesResponseType(typeof(DeactivateChargeableCharConfigResponse), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
[ProducesResponseType(StatusCodes.Status403Forbidden)]
public async Task<IActionResult> Deactivate([FromRoute] long id)
{
var result = await _dispatcher.Send<DeactivateChargeableCharConfigCommand, DeactivateChargeableCharConfigResponse>(
new DeactivateChargeableCharConfigCommand(id));
return Ok(result);
}
// ── PATCH /api/v1/admin/chargeable-chars/{id}/reactivate ─────────────────
/// <summary>
/// Reactivates a previously closed ChargeableCharConfig row (undo last deactivation).
/// Guard rules (enforced by SP):
/// - ALREADY_ACTIVE: target row is already active → 409
/// - VIGENTE_EXISTS: a different active row exists for (ProductTypeId, Symbol) → 409
/// - POSTERIOR_ROWS_EXIST: rows with higher ValidFrom exist after the target → 409
/// </summary>
[HttpPatch("{id:long}/reactivate")]
[RequirePermission("tasacion:caracteres_especiales:gestionar")]
[ProducesResponseType(typeof(ReactivateChargeableCharConfigResponse), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
[ProducesResponseType(StatusCodes.Status403Forbidden)]
[ProducesResponseType(StatusCodes.Status409Conflict)]
public async Task<IActionResult> Reactivate([FromRoute] long id)
{
var result = await _dispatcher.Send<ReactivateChargeableCharConfigCommand, ReactivateChargeableCharConfigResponse>(
new ReactivateChargeableCharConfigCommand(id));
return Ok(result);
}
// ── DELETE /api/v1/admin/chargeable-chars/{id} ───────────────────────────
/// <summary>
/// Deletes a ChargeableCharConfig row.
/// NOTE: With SYSTEM_VERSIONING ON, the row is moved to the history table (temporal audit preserved).
/// The row disappears from all current-state queries.
/// Guard for "used in invoicing" is deferred to FAC-001 followup issue.
/// Returns 200 + { id } consistent with the Deactivate pattern.
/// </summary>
[HttpDelete("{id:long}")]
[RequirePermission("tasacion:caracteres_especiales:gestionar")]
[ProducesResponseType(typeof(DeleteChargeableCharConfigResponse), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
[ProducesResponseType(StatusCodes.Status403Forbidden)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<IActionResult> Delete([FromRoute] long id)
{
var result = await _dispatcher.Send<DeleteChargeableCharConfigCommand, DeleteChargeableCharConfigResponse>(
new DeleteChargeableCharConfigCommand(id));
return Ok(result);
}
}
// ── Request body records ──────────────────────────────────────────────────────
/// <summary>PRC-001: Create ChargeableCharConfig request body.</summary>
public sealed record CreateChargeableCharConfigRequest(
long? ProductTypeId,
string Symbol,
string Category,
decimal PricePerUnit,
DateOnly ValidFrom);
/// <summary>PRC-001: Schedule price change request body.</summary>
public sealed record SchedulePriceChangeRequest(
decimal PricePerUnit,
DateOnly ValidFrom);

View File

@@ -53,12 +53,12 @@ public sealed class FiscalController : ControllerBase
IValidator<UpdateIngresosBrutosCommand> updateIibbValidator, IValidator<UpdateIngresosBrutosCommand> updateIibbValidator,
IValidator<NuevaVersionIngresosBrutosCommand> nuevaVersionIibbValidator) IValidator<NuevaVersionIngresosBrutosCommand> nuevaVersionIibbValidator)
{ {
_dispatcher = dispatcher; _dispatcher = dispatcher;
_createIvaValidator = createIvaValidator; _createIvaValidator = createIvaValidator;
_updateIvaValidator = updateIvaValidator; _updateIvaValidator = updateIvaValidator;
_nuevaVersionIvaValidator = nuevaVersionIvaValidator; _nuevaVersionIvaValidator = nuevaVersionIvaValidator;
_createIibbValidator = createIibbValidator; _createIibbValidator = createIibbValidator;
_updateIibbValidator = updateIibbValidator; _updateIibbValidator = updateIibbValidator;
_nuevaVersionIibbValidator = nuevaVersionIibbValidator; _nuevaVersionIibbValidator = nuevaVersionIibbValidator;
} }
@@ -78,15 +78,15 @@ public sealed class FiscalController : ControllerBase
[FromQuery] bool? activo = null, [FromQuery] bool? activo = null,
[FromQuery] string? codigo = null) [FromQuery] string? codigo = null)
{ {
if (page < 1) return BadRequest(new { error = "page must be >= 1" }); if (page < 1) return BadRequest(new { error = "page must be >= 1" });
if (pageSize < 1) return BadRequest(new { error = "pageSize must be >= 1" }); if (pageSize < 1) return BadRequest(new { error = "pageSize must be >= 1" });
var query = new ListTiposDeIvaQuery(page, pageSize, activo, codigo); var query = new ListTiposDeIvaQuery(page, pageSize, activo, codigo);
var result = await _dispatcher.Send<ListTiposDeIvaQuery, PagedResult<TipoDeIvaDto>>(query); var result = await _dispatcher.Send<ListTiposDeIvaQuery, PagedResult<TipoDeIvaDto>>(query);
return Ok(new return Ok(new
{ {
Items = result.Items.Select(FiscalContractMapper.ToIvaResponse).ToList(), Items = result.Items.Select(FiscalContractMapper.ToIvaResponse).ToList(),
result.Page, result.Page,
result.PageSize, result.PageSize,
result.Total result.Total
@@ -102,7 +102,7 @@ public sealed class FiscalController : ControllerBase
[ProducesResponseType(StatusCodes.Status404NotFound)] [ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<IActionResult> GetIvaById([FromRoute] int id) public async Task<IActionResult> GetIvaById([FromRoute] int id)
{ {
var query = new GetTipoDeIvaByIdQuery(id); var query = new GetTipoDeIvaByIdQuery(id);
var result = await _dispatcher.Send<GetTipoDeIvaByIdQuery, TipoDeIvaDto>(query); var result = await _dispatcher.Send<GetTipoDeIvaByIdQuery, TipoDeIvaDto>(query);
return Ok(FiscalContractMapper.ToIvaResponse(result)); return Ok(FiscalContractMapper.ToIvaResponse(result));
} }
@@ -115,7 +115,7 @@ public sealed class FiscalController : ControllerBase
[ProducesResponseType(StatusCodes.Status403Forbidden)] [ProducesResponseType(StatusCodes.Status403Forbidden)]
public async Task<IActionResult> GetHistorialIva([FromRoute] int id) public async Task<IActionResult> GetHistorialIva([FromRoute] int id)
{ {
var query = new GetHistorialTipoDeIvaQuery(id); var query = new GetHistorialTipoDeIvaQuery(id);
var result = await _dispatcher.Send<GetHistorialTipoDeIvaQuery, IReadOnlyList<HistorialCadenaDto>>(query); var result = await _dispatcher.Send<GetHistorialTipoDeIvaQuery, IReadOnlyList<HistorialCadenaDto>>(query);
return Ok(result.Select(FiscalContractMapper.ToHistorialIvaResponse).ToList()); return Ok(result.Select(FiscalContractMapper.ToHistorialIvaResponse).ToList());
} }
@@ -143,10 +143,10 @@ public sealed class FiscalController : ControllerBase
} }
var command = new CreateTipoDeIvaCommand( var command = new CreateTipoDeIvaCommand(
Codigo: request.Codigo ?? string.Empty, Codigo: request.Codigo ?? string.Empty,
Descripcion: request.Descripcion ?? string.Empty, Descripcion: request.Descripcion ?? string.Empty,
Porcentaje: request.Porcentaje ?? 0m, Porcentaje: request.Porcentaje ?? 0m,
AplicaIVA: request.AplicaIVA ?? false, AplicaIVA: request.AplicaIVA ?? false,
VigenciaDesde: vigenciaDesde.Value, VigenciaDesde: vigenciaDesde.Value,
VigenciaHasta: vigenciaHasta); VigenciaHasta: vigenciaHasta);
@@ -202,11 +202,11 @@ public sealed class FiscalController : ControllerBase
return BadRequest(new { error = "Request body is required" }); return BadRequest(new { error = "Request body is required" });
var command = new UpdateTipoDeIvaCommand( var command = new UpdateTipoDeIvaCommand(
Id: id, Id: id,
Codigo: request.Codigo ?? string.Empty, Codigo: request.Codigo ?? string.Empty,
Descripcion: request.Descripcion ?? string.Empty, Descripcion: request.Descripcion ?? string.Empty,
AplicaIVA: request.AplicaIVA ?? false, AplicaIVA: request.AplicaIVA ?? false,
Activo: request.Activo ?? true); Activo: request.Activo ?? true);
var validation = await _updateIvaValidator.ValidateAsync(command); var validation = await _updateIvaValidator.ValidateAsync(command);
if (!validation.IsValid) if (!validation.IsValid)
@@ -239,9 +239,9 @@ public sealed class FiscalController : ControllerBase
return BadRequest(new { error = "vigenciaDesde must be a valid date (yyyy-MM-dd)" }); return BadRequest(new { error = "vigenciaDesde must be a valid date (yyyy-MM-dd)" });
var command = new NuevaVersionTipoDeIvaCommand( var command = new NuevaVersionTipoDeIvaCommand(
PredecesoraId: id, PredecesoraId: id,
NuevoPorcentaje: request.Porcentaje ?? 0m, NuevoPorcentaje: request.Porcentaje ?? 0m,
VigenciaDesde: vigenciaDesde.Value); VigenciaDesde: vigenciaDesde.Value);
var validation = await _nuevaVersionIvaValidator.ValidateAsync(command); var validation = await _nuevaVersionIvaValidator.ValidateAsync(command);
if (!validation.IsValid) if (!validation.IsValid)
@@ -269,7 +269,7 @@ public sealed class FiscalController : ControllerBase
public async Task<IActionResult> DeactivateIva([FromRoute] int id) public async Task<IActionResult> DeactivateIva([FromRoute] int id)
{ {
var command = new DeactivateTipoDeIvaCommand(id); var command = new DeactivateTipoDeIvaCommand(id);
var result = await _dispatcher.Send<DeactivateTipoDeIvaCommand, TipoDeIvaDto>(command); var result = await _dispatcher.Send<DeactivateTipoDeIvaCommand, TipoDeIvaDto>(command);
return Ok(FiscalContractMapper.ToIvaResponse(result)); return Ok(FiscalContractMapper.ToIvaResponse(result));
} }
@@ -283,7 +283,7 @@ public sealed class FiscalController : ControllerBase
public async Task<IActionResult> ReactivateIva([FromRoute] int id) public async Task<IActionResult> ReactivateIva([FromRoute] int id)
{ {
var command = new ReactivateTipoDeIvaCommand(id); var command = new ReactivateTipoDeIvaCommand(id);
var result = await _dispatcher.Send<ReactivateTipoDeIvaCommand, TipoDeIvaDto>(command); var result = await _dispatcher.Send<ReactivateTipoDeIvaCommand, TipoDeIvaDto>(command);
return Ok(FiscalContractMapper.ToIvaResponse(result)); return Ok(FiscalContractMapper.ToIvaResponse(result));
} }
@@ -303,7 +303,7 @@ public sealed class FiscalController : ControllerBase
[FromQuery] bool? activo = null, [FromQuery] bool? activo = null,
[FromQuery] string? provincia = null) [FromQuery] string? provincia = null)
{ {
if (page < 1) return BadRequest(new { error = "page must be >= 1" }); if (page < 1) return BadRequest(new { error = "page must be >= 1" });
if (pageSize < 1) return BadRequest(new { error = "pageSize must be >= 1" }); if (pageSize < 1) return BadRequest(new { error = "pageSize must be >= 1" });
ProvinciaArgentina? provinciaEnum = null; ProvinciaArgentina? provinciaEnum = null;
@@ -314,12 +314,12 @@ public sealed class FiscalController : ControllerBase
provinciaEnum = parsed; provinciaEnum = parsed;
} }
var query = new ListIngresosBrutosQuery(page, pageSize, activo, provinciaEnum); var query = new ListIngresosBrutosQuery(page, pageSize, activo, provinciaEnum);
var result = await _dispatcher.Send<ListIngresosBrutosQuery, PagedResult<IngresosBrutosDto>>(query); var result = await _dispatcher.Send<ListIngresosBrutosQuery, PagedResult<IngresosBrutosDto>>(query);
return Ok(new return Ok(new
{ {
Items = result.Items.Select(FiscalContractMapper.ToIibbResponse).ToList(), Items = result.Items.Select(FiscalContractMapper.ToIibbResponse).ToList(),
result.Page, result.Page,
result.PageSize, result.PageSize,
result.Total result.Total
@@ -335,7 +335,7 @@ public sealed class FiscalController : ControllerBase
[ProducesResponseType(StatusCodes.Status404NotFound)] [ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<IActionResult> GetIibbById([FromRoute] int id) public async Task<IActionResult> GetIibbById([FromRoute] int id)
{ {
var query = new GetIngresosBrutosByIdQuery(id); var query = new GetIngresosBrutosByIdQuery(id);
var result = await _dispatcher.Send<GetIngresosBrutosByIdQuery, IngresosBrutosDto>(query); var result = await _dispatcher.Send<GetIngresosBrutosByIdQuery, IngresosBrutosDto>(query);
return Ok(FiscalContractMapper.ToIibbResponse(result)); return Ok(FiscalContractMapper.ToIibbResponse(result));
} }
@@ -348,7 +348,7 @@ public sealed class FiscalController : ControllerBase
[ProducesResponseType(StatusCodes.Status403Forbidden)] [ProducesResponseType(StatusCodes.Status403Forbidden)]
public async Task<IActionResult> GetHistorialIibb([FromRoute] int id) public async Task<IActionResult> GetHistorialIibb([FromRoute] int id)
{ {
var query = new GetHistorialIngresosBrutosQuery(id); var query = new GetHistorialIngresosBrutosQuery(id);
var result = await _dispatcher.Send<GetHistorialIngresosBrutosQuery, IReadOnlyList<HistorialCadenaIibbDto>>(query); var result = await _dispatcher.Send<GetHistorialIngresosBrutosQuery, IReadOnlyList<HistorialCadenaIibbDto>>(query);
return Ok(result.Select(FiscalContractMapper.ToHistorialIibbResponse).ToList()); return Ok(result.Select(FiscalContractMapper.ToHistorialIibbResponse).ToList());
} }
@@ -397,9 +397,9 @@ public sealed class FiscalController : ControllerBase
} }
var command = new CreateIngresosBrutosCommand( var command = new CreateIngresosBrutosCommand(
Provincia: provinciaEnum, Provincia: provinciaEnum,
Descripcion: request.Descripcion ?? string.Empty, Descripcion: request.Descripcion ?? string.Empty,
Alicuota: request.Alicuota ?? 0m, Alicuota: request.Alicuota ?? 0m,
VigenciaDesde: vigenciaDesde.Value, VigenciaDesde: vigenciaDesde.Value,
VigenciaHasta: vigenciaHasta); VigenciaHasta: vigenciaHasta);
@@ -453,9 +453,9 @@ public sealed class FiscalController : ControllerBase
return BadRequest(new { error = "Request body is required" }); return BadRequest(new { error = "Request body is required" });
var command = new UpdateIngresosBrutosCommand( var command = new UpdateIngresosBrutosCommand(
Id: id, Id: id,
Descripcion: request.Descripcion ?? string.Empty, Descripcion: request.Descripcion ?? string.Empty,
Activo: request.Activo ?? true); Activo: request.Activo ?? true);
var validation = await _updateIibbValidator.ValidateAsync(command); var validation = await _updateIibbValidator.ValidateAsync(command);
if (!validation.IsValid) if (!validation.IsValid)
@@ -518,7 +518,7 @@ public sealed class FiscalController : ControllerBase
public async Task<IActionResult> DeactivateIibb([FromRoute] int id) public async Task<IActionResult> DeactivateIibb([FromRoute] int id)
{ {
var command = new DeactivateIngresosBrutosCommand(id); var command = new DeactivateIngresosBrutosCommand(id);
var result = await _dispatcher.Send<DeactivateIngresosBrutosCommand, IngresosBrutosDto>(command); var result = await _dispatcher.Send<DeactivateIngresosBrutosCommand, IngresosBrutosDto>(command);
return Ok(FiscalContractMapper.ToIibbResponse(result)); return Ok(FiscalContractMapper.ToIibbResponse(result));
} }
@@ -532,7 +532,7 @@ public sealed class FiscalController : ControllerBase
public async Task<IActionResult> ReactivateIibb([FromRoute] int id) public async Task<IActionResult> ReactivateIibb([FromRoute] int id)
{ {
var command = new ReactivateIngresosBrutosCommand(id); var command = new ReactivateIngresosBrutosCommand(id);
var result = await _dispatcher.Send<ReactivateIngresosBrutosCommand, IngresosBrutosDto>(command); var result = await _dispatcher.Send<ReactivateIngresosBrutosCommand, IngresosBrutosDto>(command);
return Ok(FiscalContractMapper.ToIibbResponse(result)); return Ok(FiscalContractMapper.ToIibbResponse(result));
} }

View File

@@ -1,102 +0,0 @@
using FluentValidation;
using Microsoft.AspNetCore.Mvc;
using SIGCM2.Api.Authorization;
using SIGCM2.Application.Abstractions;
using SIGCM2.Application.Common;
using SIGCM2.Application.Products.Prices;
using SIGCM2.Application.Products.Prices.AddPrice;
using SIGCM2.Application.Products.Prices.GetHistory;
namespace SIGCM2.Api.Controllers;
/// <summary>
/// PRD-003: ProductPrices historic pricing management.
/// Read endpoint at GET /api/v1/products/{id}/prices — requires 'catalogo:productos:gestionar'.
/// Write endpoint at POST /api/v1/admin/products/{id}/prices — requires 'catalogo:productos:gestionar'.
/// </summary>
[ApiController]
public sealed class ProductPricesController : ControllerBase
{
private readonly IDispatcher _dispatcher;
private readonly IValidator<AddProductPriceCommand> _addValidator;
public ProductPricesController(
IDispatcher dispatcher,
IValidator<AddProductPriceCommand> addValidator)
{
_dispatcher = dispatcher;
_addValidator = addValidator;
}
// ── READ endpoint ──────────────────────────────────────────────────────────
/// <summary>
/// Returns a paginated page of price history for a Product, ordered descending by PriceValidFrom.
/// Defaults: page=1, pageSize=20. Clamping: page ≥ 1, pageSize ∈ [1, 100].
/// Returns 200 with empty items if the product has no prices yet or page is beyond total.
/// Returns 404 if the product does not exist.
/// Returns 401 if not authenticated, 403 if missing 'catalogo:productos:gestionar' permission.
/// </summary>
[HttpGet("api/v1/products/{id:int}/prices")]
[RequirePermission("catalogo:productos:gestionar")]
[ProducesResponseType(typeof(PagedResult<ProductPriceDto>), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
[ProducesResponseType(StatusCodes.Status403Forbidden)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<IActionResult> GetProductPrices(
[FromRoute] int id,
[FromQuery] int? page,
[FromQuery] int? pageSize)
{
var query = new GetProductPricesQuery(
ProductId: id,
Page: page ?? 1,
PageSize: pageSize ?? 20);
var result = await _dispatcher.Send<GetProductPricesQuery, PagedResult<ProductPriceDto>>(query);
return Ok(result);
}
// ── WRITE endpoint ─────────────────────────────────────────────────────────
/// <summary>
/// Adds a new price to a Product. Closes the current active price if one exists.
/// PriceValidFrom must be >= today_AR and strictly greater than the active price's PriceValidFrom.
/// Returns 201 Created with Location header pointing to GET /api/v1/products/{id}/prices.
/// </summary>
[HttpPost("api/v1/admin/products/{id:int}/prices")]
[RequirePermission("catalogo:productos:gestionar")]
[ProducesResponseType(typeof(AddProductPriceResponse), StatusCodes.Status201Created)]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
[ProducesResponseType(StatusCodes.Status403Forbidden)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
[ProducesResponseType(StatusCodes.Status409Conflict)]
public async Task<IActionResult> AddProductPrice(
[FromRoute] int id,
[FromBody] AddProductPriceRequest request)
{
var command = new AddProductPriceCommand(
ProductId: id,
Price: request.Price,
PriceValidFrom: request.PriceValidFrom);
var validation = await _addValidator.ValidateAsync(command);
if (!validation.IsValid)
{
var errors = validation.Errors
.GroupBy(e => e.PropertyName)
.ToDictionary(g => g.Key, g => g.Select(e => e.ErrorMessage).ToArray());
return BadRequest(new { errors });
}
var result = await _dispatcher.Send<AddProductPriceCommand, AddProductPriceResponse>(command);
return CreatedAtAction(nameof(GetProductPrices), new { id }, result);
}
}
// ── Request body record ───────────────────────────────────────────────────────
/// <summary>PRD-003: Add ProductPrice request body.</summary>
public sealed record AddProductPriceRequest(
decimal Price,
DateOnly PriceValidFrom);

View File

@@ -1,184 +0,0 @@
using FluentValidation;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using SIGCM2.Api.Authorization;
using SIGCM2.Application.Abstractions;
using SIGCM2.Application.Common;
using SIGCM2.Application.ProductTypes.Create;
using SIGCM2.Application.ProductTypes.Deactivate;
using SIGCM2.Application.ProductTypes.GetById;
using SIGCM2.Application.ProductTypes.List;
using SIGCM2.Application.ProductTypes.Update;
namespace SIGCM2.Api.Controllers;
/// <summary>
/// PRD-001: ProductType catalog management.
/// Read endpoints at /api/v1/product-types — require authentication (any role).
/// Write endpoints at /api/v1/admin/product-types — require 'catalogo:tipos:gestionar'.
/// </summary>
[ApiController]
public sealed class ProductTypesController : ControllerBase
{
private readonly IDispatcher _dispatcher;
private readonly IValidator<CreateProductTypeCommand> _createValidator;
private readonly IValidator<UpdateProductTypeCommand> _updateValidator;
public ProductTypesController(
IDispatcher dispatcher,
IValidator<CreateProductTypeCommand> createValidator,
IValidator<UpdateProductTypeCommand> updateValidator)
{
_dispatcher = dispatcher;
_createValidator = createValidator;
_updateValidator = updateValidator;
}
// ── READ endpoints ─────────────────────────────────────────────────────────
/// <summary>Returns a paginated list of ProductTypes. Requires authentication.</summary>
[HttpGet("api/v1/product-types")]
[Authorize]
[ProducesResponseType(typeof(PagedResult<ProductTypeListItemDto>), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
public async Task<IActionResult> ListProductTypes(
[FromQuery] int page = 1,
[FromQuery] int pageSize = 20,
[FromQuery] bool? activo = true,
[FromQuery] string? search = null)
{
var query = new ListProductTypesQuery(page, pageSize, activo, search);
var result = await _dispatcher.Send<ListProductTypesQuery, PagedResult<ProductTypeListItemDto>>(query);
return Ok(result);
}
/// <summary>Returns a single ProductType by id. Requires authentication.</summary>
[HttpGet("api/v1/product-types/{id:int}")]
[Authorize]
[ProducesResponseType(typeof(ProductTypeDetailDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<IActionResult> GetProductTypeById([FromRoute] int id)
{
var query = new GetProductTypeByIdQuery(id);
var result = await _dispatcher.Send<GetProductTypeByIdQuery, ProductTypeDetailDto>(query);
return Ok(result);
}
// ── WRITE endpoints ────────────────────────────────────────────────────────
/// <summary>Creates a new ProductType. Requires catalogo:tipos:gestionar.</summary>
[HttpPost("api/v1/admin/product-types")]
[RequirePermission("catalogo:tipos:gestionar")]
[ProducesResponseType(typeof(ProductTypeCreatedDto), StatusCodes.Status201Created)]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
[ProducesResponseType(StatusCodes.Status403Forbidden)]
[ProducesResponseType(StatusCodes.Status409Conflict)]
public async Task<IActionResult> CreateProductType([FromBody] CreateProductTypeRequest request)
{
var command = new CreateProductTypeCommand(
Nombre: request.Nombre ?? string.Empty,
HasDuration: request.HasDuration,
RequiresText: request.RequiresText,
RequiresCategory: request.RequiresCategory,
IsBundle: request.IsBundle,
AllowImages: request.AllowImages,
MaxImages: request.MaxImages,
MaxImageSizeMB: request.MaxImageSizeMB,
MaxImageWidth: request.MaxImageWidth,
MaxImageHeight: request.MaxImageHeight);
var validation = await _createValidator.ValidateAsync(command);
if (!validation.IsValid)
{
var errors = validation.Errors
.GroupBy(e => e.PropertyName)
.ToDictionary(g => g.Key, g => g.Select(e => e.ErrorMessage).ToArray());
return BadRequest(new { errors });
}
var result = await _dispatcher.Send<CreateProductTypeCommand, ProductTypeCreatedDto>(command);
return CreatedAtAction(nameof(GetProductTypeById), new { id = result.Id }, result);
}
/// <summary>Updates a ProductType. Requires catalogo:tipos:gestionar.</summary>
[HttpPut("api/v1/admin/product-types/{id:int}")]
[RequirePermission("catalogo:tipos:gestionar")]
[ProducesResponseType(typeof(ProductTypeUpdatedDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
[ProducesResponseType(StatusCodes.Status403Forbidden)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
[ProducesResponseType(StatusCodes.Status409Conflict)]
public async Task<IActionResult> UpdateProductType([FromRoute] int id, [FromBody] UpdateProductTypeRequest request)
{
var command = new UpdateProductTypeCommand(
Id: id,
Nombre: request.Nombre ?? string.Empty,
HasDuration: request.HasDuration,
RequiresText: request.RequiresText,
RequiresCategory: request.RequiresCategory,
IsBundle: request.IsBundle,
AllowImages: request.AllowImages,
MaxImages: request.MaxImages,
MaxImageSizeMB: request.MaxImageSizeMB,
MaxImageWidth: request.MaxImageWidth,
MaxImageHeight: request.MaxImageHeight);
var validation = await _updateValidator.ValidateAsync(command);
if (!validation.IsValid)
{
var errors = validation.Errors
.GroupBy(e => e.PropertyName)
.ToDictionary(g => g.Key, g => g.Select(e => e.ErrorMessage).ToArray());
return BadRequest(new { errors });
}
var result = await _dispatcher.Send<UpdateProductTypeCommand, ProductTypeUpdatedDto>(command);
return Ok(result);
}
/// <summary>Soft-deletes (deactivates) a ProductType. Requires catalogo:tipos:gestionar.</summary>
[HttpDelete("api/v1/admin/product-types/{id:int}")]
[RequirePermission("catalogo:tipos:gestionar")]
[ProducesResponseType(StatusCodes.Status204NoContent)]
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
[ProducesResponseType(StatusCodes.Status403Forbidden)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
[ProducesResponseType(StatusCodes.Status409Conflict)]
public async Task<IActionResult> DeactivateProductType([FromRoute] int id)
{
var command = new DeactivateProductTypeCommand(id);
await _dispatcher.Send<DeactivateProductTypeCommand, ProductTypeStatusDto>(command);
return NoContent();
}
}
// ── Request body records ──────────────────────────────────────────────────────
/// <summary>PRD-001: Create ProductType request body.</summary>
public sealed record CreateProductTypeRequest(
string? Nombre,
bool HasDuration = false,
bool RequiresText = false,
bool RequiresCategory = false,
bool IsBundle = false,
bool AllowImages = false,
int? MaxImages = null,
decimal? MaxImageSizeMB = null,
int? MaxImageWidth = null,
int? MaxImageHeight = null);
/// <summary>PRD-001: Update ProductType request body.</summary>
public sealed record UpdateProductTypeRequest(
string? Nombre,
bool HasDuration = false,
bool RequiresText = false,
bool RequiresCategory = false,
bool IsBundle = false,
bool AllowImages = false,
int? MaxImages = null,
decimal? MaxImageSizeMB = null,
int? MaxImageWidth = null,
int? MaxImageHeight = null);

View File

@@ -1,169 +0,0 @@
using FluentValidation;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using SIGCM2.Api.Authorization;
using SIGCM2.Application.Abstractions;
using SIGCM2.Application.Common;
using SIGCM2.Application.Products.Create;
using SIGCM2.Application.Products.Deactivate;
using SIGCM2.Application.Products.GetById;
using SIGCM2.Application.Products.List;
using SIGCM2.Application.Products.Update;
namespace SIGCM2.Api.Controllers;
/// <summary>
/// PRD-002: Product catalog management.
/// Read endpoints at /api/v1/products — require authentication (any role).
/// Write endpoints at /api/v1/admin/products — require 'catalogo:productos:gestionar'.
/// </summary>
[ApiController]
public sealed class ProductsController : ControllerBase
{
private readonly IDispatcher _dispatcher;
private readonly IValidator<CreateProductCommand> _createValidator;
private readonly IValidator<UpdateProductCommand> _updateValidator;
public ProductsController(
IDispatcher dispatcher,
IValidator<CreateProductCommand> createValidator,
IValidator<UpdateProductCommand> updateValidator)
{
_dispatcher = dispatcher;
_createValidator = createValidator;
_updateValidator = updateValidator;
}
// ── READ endpoints ─────────────────────────────────────────────────────────
/// <summary>Returns a paginated list of Products. Requires authentication.</summary>
[HttpGet("api/v1/products")]
[Authorize]
[ProducesResponseType(typeof(PagedResult<ProductListItemDto>), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
public async Task<IActionResult> ListProducts(
[FromQuery] int page = 1,
[FromQuery] int pageSize = 20,
[FromQuery] bool? activo = true,
[FromQuery] string? search = null,
[FromQuery] int? medioId = null,
[FromQuery] int? productTypeId = null,
[FromQuery] int? rubroId = null)
{
var query = new ListProductsQuery(page, pageSize, activo, search, medioId, productTypeId, rubroId);
var result = await _dispatcher.Send<ListProductsQuery, PagedResult<ProductListItemDto>>(query);
return Ok(result);
}
/// <summary>Returns a single Product by id. Requires authentication.</summary>
[HttpGet("api/v1/products/{id:int}")]
[Authorize]
[ProducesResponseType(typeof(ProductDetailDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<IActionResult> GetProductById([FromRoute] int id)
{
var query = new GetProductByIdQuery(id);
var result = await _dispatcher.Send<GetProductByIdQuery, ProductDetailDto>(query);
return Ok(result);
}
// ── WRITE endpoints ────────────────────────────────────────────────────────
/// <summary>Creates a new Product. Requires catalogo:productos:gestionar.</summary>
[HttpPost("api/v1/admin/products")]
[RequirePermission("catalogo:productos:gestionar")]
[ProducesResponseType(typeof(ProductCreatedDto), StatusCodes.Status201Created)]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
[ProducesResponseType(StatusCodes.Status403Forbidden)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
[ProducesResponseType(StatusCodes.Status409Conflict)]
[ProducesResponseType(StatusCodes.Status422UnprocessableEntity)]
public async Task<IActionResult> CreateProduct([FromBody] CreateProductRequest request)
{
var command = new CreateProductCommand(
Nombre: request.Nombre ?? string.Empty,
MedioId: request.MedioId,
ProductTypeId: request.ProductTypeId,
RubroId: request.RubroId,
BasePrice: request.BasePrice,
PriceDurationDays: request.PriceDurationDays);
var validation = await _createValidator.ValidateAsync(command);
if (!validation.IsValid)
{
var errors = validation.Errors
.GroupBy(e => e.PropertyName)
.ToDictionary(g => g.Key, g => g.Select(e => e.ErrorMessage).ToArray());
return BadRequest(new { errors });
}
var result = await _dispatcher.Send<CreateProductCommand, ProductCreatedDto>(command);
return CreatedAtAction(nameof(GetProductById), new { id = result.Id }, result);
}
/// <summary>Updates a Product. Requires catalogo:productos:gestionar.</summary>
[HttpPut("api/v1/admin/products/{id:int}")]
[RequirePermission("catalogo:productos:gestionar")]
[ProducesResponseType(typeof(ProductUpdatedDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
[ProducesResponseType(StatusCodes.Status403Forbidden)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
[ProducesResponseType(StatusCodes.Status409Conflict)]
[ProducesResponseType(StatusCodes.Status422UnprocessableEntity)]
public async Task<IActionResult> UpdateProduct([FromRoute] int id, [FromBody] UpdateProductRequest request)
{
var command = new UpdateProductCommand(
Id: id,
Nombre: request.Nombre ?? string.Empty,
RubroId: request.RubroId,
BasePrice: request.BasePrice,
PriceDurationDays: request.PriceDurationDays);
var validation = await _updateValidator.ValidateAsync(command);
if (!validation.IsValid)
{
var errors = validation.Errors
.GroupBy(e => e.PropertyName)
.ToDictionary(g => g.Key, g => g.Select(e => e.ErrorMessage).ToArray());
return BadRequest(new { errors });
}
var result = await _dispatcher.Send<UpdateProductCommand, ProductUpdatedDto>(command);
return Ok(result);
}
/// <summary>Soft-deletes (deactivates) a Product. Requires catalogo:productos:gestionar.</summary>
[HttpDelete("api/v1/admin/products/{id:int}")]
[RequirePermission("catalogo:productos:gestionar")]
[ProducesResponseType(StatusCodes.Status204NoContent)]
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
[ProducesResponseType(StatusCodes.Status403Forbidden)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<IActionResult> DeactivateProduct([FromRoute] int id)
{
var command = new DeactivateProductCommand(id);
await _dispatcher.Send<DeactivateProductCommand, ProductStatusDto>(command);
return NoContent();
}
}
// ── Request body records ──────────────────────────────────────────────────────
/// <summary>PRD-002: Create Product request body.</summary>
public sealed record CreateProductRequest(
string? Nombre,
int MedioId = 0,
int ProductTypeId = 0,
int? RubroId = null,
decimal BasePrice = 0m,
int? PriceDurationDays = null);
/// <summary>PRD-002: Update Product request body.</summary>
public sealed record UpdateProductRequest(
string? Nombre,
int? RubroId = null,
decimal BasePrice = 0m,
int? PriceDurationDays = null);

View File

@@ -3,7 +3,6 @@ using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.Filters; using Microsoft.AspNetCore.Mvc.Filters;
using Microsoft.Data.SqlClient; using Microsoft.Data.SqlClient;
using SIGCM2.Domain.Exceptions; using SIGCM2.Domain.Exceptions;
using SIGCM2.Domain.Pricing.Exceptions;
namespace SIGCM2.Api.Filters; namespace SIGCM2.Api.Filters;
@@ -243,43 +242,6 @@ public sealed class ExceptionFilter : IExceptionFilter
context.ExceptionHandled = true; context.ExceptionHandled = true;
break; break;
// CAT-002: Rubro Regla de Oro (rama vs hoja)
case RubroPadreEsHojaConAvisosException rubroPadreHojaEx:
context.Result = new ObjectResult(new
{
error = "rubro_padre_es_hoja_con_avisos",
message = rubroPadreHojaEx.Message
})
{
StatusCode = StatusCodes.Status409Conflict
};
context.ExceptionHandled = true;
break;
case RubroEsRamaConHijosActivosException rubroRamaHijosEx:
context.Result = new ObjectResult(new
{
error = "rubro_es_rama_con_hijos_activos",
message = rubroRamaHijosEx.Message
})
{
StatusCode = StatusCodes.Status409Conflict
};
context.ExceptionHandled = true;
break;
case RubroConProductosActivosException rubroProductosEx:
context.Result = new ObjectResult(new
{
error = "rubro_con_productos_activos",
message = rubroProductosEx.Message
})
{
StatusCode = StatusCodes.Status409Conflict
};
context.ExceptionHandled = true;
break;
// ADM-001: Medio exceptions // ADM-001: Medio exceptions
case MedioCodigoDuplicadoException medioCodDupEx: case MedioCodigoDuplicadoException medioCodDupEx:
context.Result = new ObjectResult(new context.Result = new ObjectResult(new
@@ -427,156 +389,6 @@ public sealed class ExceptionFilter : IExceptionFilter
context.ExceptionHandled = true; context.ExceptionHandled = true;
break; break;
// PRD-001: ProductType exceptions
case ProductTypeNotFoundException productTypeNotFoundEx:
context.Result = new ObjectResult(new
{
error = "product_type_not_found",
message = productTypeNotFoundEx.Message
})
{
StatusCode = StatusCodes.Status404NotFound
};
context.ExceptionHandled = true;
break;
case ProductTypeNombreDuplicadoException productTypeDupEx:
context.Result = new ObjectResult(new
{
error = "product_type_nombre_duplicado",
message = productTypeDupEx.Message
})
{
StatusCode = StatusCodes.Status409Conflict
};
context.ExceptionHandled = true;
break;
case ProductTypeEnUsoException productTypeEnUsoEx:
context.Result = new ObjectResult(new
{
error = "product_type_en_uso",
message = productTypeEnUsoEx.Message
})
{
StatusCode = StatusCodes.Status409Conflict
};
context.ExceptionHandled = true;
break;
case ProductTypeFlagsIncoherentesException productTypeFlagsEx:
context.Result = new ObjectResult(new
{
error = "product_type_flags_incoherentes",
message = productTypeFlagsEx.Message
})
{
StatusCode = StatusCodes.Status422UnprocessableEntity
};
context.ExceptionHandled = true;
break;
// PRD-003: ProductPrices exceptions
case ProductPriceForwardOnlyException forwardOnlyEx:
context.Result = new ObjectResult(new
{
error = "product_price_forward_only",
message = forwardOnlyEx.Message,
productId = forwardOnlyEx.ProductId
})
{
StatusCode = StatusCodes.Status409Conflict
};
context.ExceptionHandled = true;
break;
case ProductPriceInvalidException priceInvalidEx:
context.Result = new ObjectResult(new
{
error = "product_price_invalid",
message = priceInvalidEx.Message,
field = priceInvalidEx.Field
})
{
StatusCode = StatusCodes.Status400BadRequest
};
context.ExceptionHandled = true;
break;
case ProductSinPrecioActivoException sinPrecioEx:
context.Result = new ObjectResult(new
{
error = "product_sin_precio_activo",
message = sinPrecioEx.Message
})
{
StatusCode = StatusCodes.Status404NotFound
};
context.ExceptionHandled = true;
break;
// PRD-002: Product exceptions
case ProductNotFoundException productNotFoundEx:
context.Result = new ObjectResult(new
{
error = "product_not_found",
message = productNotFoundEx.Message
})
{
StatusCode = StatusCodes.Status404NotFound
};
context.ExceptionHandled = true;
break;
case ProductNombreDuplicadoEnMedioTipoException productDupEx:
context.Result = new ObjectResult(new
{
error = "product_nombre_duplicado",
message = productDupEx.Message
})
{
StatusCode = StatusCodes.Status409Conflict
};
context.ExceptionHandled = true;
break;
case ProductTipoFlagsIncoherentesException productFlagsEx:
context.Result = new ObjectResult(new
{
error = "product_flags_incoherentes",
field = productFlagsEx.Field,
message = productFlagsEx.Message
})
{
StatusCode = StatusCodes.Status422UnprocessableEntity
};
context.ExceptionHandled = true;
break;
case ProductTypeInactivoException productTypeInactivoEx:
context.Result = new ObjectResult(new
{
error = "product_type_inactivo",
message = productTypeInactivoEx.Message
})
{
StatusCode = StatusCodes.Status409Conflict
};
context.ExceptionHandled = true;
break;
case RubroInactivoException rubroInactivoEx:
context.Result = new ObjectResult(new
{
error = "rubro_inactivo",
message = rubroInactivoEx.Message
})
{
StatusCode = StatusCodes.Status409Conflict
};
context.ExceptionHandled = true;
break;
// ADM-008: PuntoDeVenta exceptions // ADM-008: PuntoDeVenta exceptions
case PuntoDeVentaNotFoundException puntoDeVentaNotFoundEx: case PuntoDeVentaNotFoundException puntoDeVentaNotFoundEx:
context.Result = new ObjectResult(new context.Result = new ObjectResult(new
@@ -646,94 +458,6 @@ public sealed class ExceptionFilter : IExceptionFilter
context.ExceptionHandled = true; context.ExceptionHandled = true;
break; break;
// PRC-001: WordCounter + ChargeableCharConfig exceptions
case EmojiDetectedException emojiEx:
context.Result = new ObjectResult(new
{
error = "emoji_not_allowed",
code = "EMOJI_NOT_ALLOWED",
message = emojiEx.Message
})
{
StatusCode = StatusCodes.Status400BadRequest
};
context.ExceptionHandled = true;
break;
case WordCountValidationException wordEx:
context.Result = new ObjectResult(new
{
error = "word_count_validation",
code = "WORD_COUNT_VALIDATION",
field = wordEx.Field,
reason = wordEx.Reason,
message = wordEx.Message
})
{
StatusCode = StatusCodes.Status400BadRequest
};
context.ExceptionHandled = true;
break;
case ChargeableCharConfigInvalidException configInvalidEx:
context.Result = new ObjectResult(new
{
error = "chargeable_char_invalid",
code = "CHARGEABLE_CHAR_INVALID",
field = configInvalidEx.Field,
reason = configInvalidEx.Reason,
message = configInvalidEx.Message
})
{
StatusCode = StatusCodes.Status400BadRequest
};
context.ExceptionHandled = true;
break;
case ChargeableCharConfigForwardOnlyException forwardOnlyCharEx:
context.Result = new ObjectResult(new
{
error = "chargeable_char_forward_only",
code = "CHARGEABLE_CHAR_FORWARD_ONLY",
productTypeId = forwardOnlyCharEx.ProductTypeId,
symbol = forwardOnlyCharEx.Symbol,
newValidFrom = forwardOnlyCharEx.NewValidFrom,
activeValidFrom = forwardOnlyCharEx.ActiveValidFrom,
message = forwardOnlyCharEx.Message
})
{
StatusCode = StatusCodes.Status409Conflict
};
context.ExceptionHandled = true;
break;
case ChargeableCharConfigReactivationNotAllowedException reactivationEx:
context.Result = new ObjectResult(new
{
error = "chargeable_char_reactivation_not_allowed",
code = "CHARGEABLE_CHAR_REACTIVATION_NOT_ALLOWED",
id = reactivationEx.Id,
reason = reactivationEx.Reason,
message = reactivationEx.Message
})
{
StatusCode = StatusCodes.Status409Conflict
};
context.ExceptionHandled = true;
break;
case KeyNotFoundException keyNotFoundEx:
context.Result = new ObjectResult(new
{
error = "not_found",
message = keyNotFoundEx.Message
})
{
StatusCode = StatusCodes.Status404NotFound
};
context.ExceptionHandled = true;
break;
case ValidationException validationEx: case ValidationException validationEx:
var errors = validationEx.Errors var errors = validationEx.Errors
.GroupBy(e => e.PropertyName) .GroupBy(e => e.PropertyName)

View File

@@ -1,24 +0,0 @@
namespace SIGCM2.Application.Abstractions.Persistence;
/// <summary>
/// Query-only access to Aviso counts by Rubro.
/// CAT-002 introduces the contract. The real Dapper-based impl lands in PRD-002
/// (when dbo.Aviso exists). Until then, NullAvisoQueryRepository is the binding.
/// </summary>
public interface IAvisoQueryRepository
{
/// <summary>
/// Returns the count of avisos (active, non-archived) assigned to the given rubro.
/// </summary>
Task<int> CountAvisosEnRubroAsync(int rubroId, CancellationToken ct = default);
/// <summary>
/// Returns a dictionary of { rubroId → count } for the provided ids.
/// Used by GetRubroTreeQueryHandler to avoid N+1 when populating TieneAvisos per node.
/// The implementation MUST do a single query; the stub returns an empty dictionary
/// (every rubro gets 0 via dictionary.GetValueOrDefault).
/// </summary>
Task<IReadOnlyDictionary<int, int>> CountAvisosBatchAsync(
IReadOnlyCollection<int> rubroIds,
CancellationToken ct = default);
}

View File

@@ -1,104 +0,0 @@
using SIGCM2.Domain.Pricing.ChargeableChars;
namespace SIGCM2.Application.Abstractions.Persistence;
/// <summary>
/// PRC-001 — Write + query access to dbo.ChargeableCharConfig.
/// Implemented by ChargeableCharConfigRepository (Dapper) in Infrastructure.
///
/// InsertWithCloseAsync: invokes usp_ChargeableCharConfig_InsertWithClose which atomically
/// closes any active row for (ProductTypeId, Symbol) and inserts the new row.
///
/// GetActiveForProductTypeAsync: invokes usp_ChargeableCharConfig_GetActiveForProductType which
/// returns both per-ProductType rows AND global (ProductTypeId IS NULL) rows for the given asOfDate.
/// The Application service applies the per-ProductType > global priority rule.
/// </summary>
public interface IChargeableCharConfigRepository
{
/// <summary>
/// Invokes usp_ChargeableCharConfig_InsertWithClose inside the ambient TransactionScope.
/// Closes any active row matching (ProductTypeId, Symbol) and inserts a new one.
/// Returns the Id of the newly inserted row.
/// Throws:
/// - ChargeableCharConfigForwardOnlyException on SQL THROW 50409
/// - ChargeableCharConfigInvalidException on SQL THROW 50404 (not-found guard)
/// </summary>
Task<long> InsertWithCloseAsync(
long? productTypeId,
string symbol,
string category,
decimal price,
DateOnly validFrom,
CancellationToken ct = default);
/// <summary>
/// Returns all active rows whose [ValidFrom, ValidTo] window covers the given asOfDate
/// for the specified ProductType, including global rows (ProductTypeId IS NULL).
/// The SP returns both per-ProductType AND global rows — callers apply priority.
/// </summary>
Task<IReadOnlyList<ChargeableCharConfig>> GetActiveForProductTypeAsync(
long productTypeId,
DateOnly asOfDate,
CancellationToken ct = default);
/// <summary>
/// Returns paginated rows filtered by ProductTypeId and IsActive.
/// Skip = (page - 1) * pageSize computed by the caller.
/// </summary>
Task<IReadOnlyList<ChargeableCharConfig>> ListAsync(
long? productTypeId,
bool activeOnly,
int skip,
int take,
CancellationToken ct = default);
/// <summary>
/// Returns total row count for the given filters (used for pagination metadata).
/// </summary>
Task<int> CountAsync(
long? productTypeId,
bool activeOnly,
CancellationToken ct = default);
/// <summary>
/// Returns the row with the given Id, or null if not found.
/// </summary>
Task<ChargeableCharConfig?> GetByIdAsync(
long id,
CancellationToken ct = default);
/// <summary>
/// Deactivates the row with the given Id by setting IsActive = false and ValidTo = today.
/// Idempotent: no-op if already inactive.
/// Called inside the ambient TransactionScope of the handler.
/// </summary>
Task DeactivateAsync(
long id,
DateOnly today,
CancellationToken ct = default);
/// <summary>
/// Invokes usp_ChargeableCharConfig_ReactivateWithGuard.
/// Guard rules (enforced by SP):
/// 50410 → target row is already active → ChargeableCharConfigReactivationNotAllowedException(ALREADY_ACTIVE)
/// 50411 → a vigente active row exists for (ProductTypeId, Symbol) → ChargeableCharConfigReactivationNotAllowedException(VIGENTE_EXISTS)
/// 50412 → posterior rows exist after target row → ChargeableCharConfigReactivationNotAllowedException(POSTERIOR_ROWS_EXIST)
/// 50404 → row not found → ChargeableCharConfigInvalidException
/// On success: re-opens the row (IsActive=true, ValidTo=NULL) and returns the reactivated entity.
/// </summary>
Task<ChargeableCharConfig> ReactivateAsync(
long id,
CancellationToken ct = default);
/// <summary>
/// Physically deletes the row with the given Id from dbo.ChargeableCharConfig (current state).
/// NOTE: Since SYSTEM_VERSIONING is ON, SQL Server moves the row to the history table with
/// SysEndTime set to the delete time. The row disappears from all current-state queries but
/// remains queryable via FOR SYSTEM_TIME. Temporal audit trail is preserved.
/// Future guard for "used in invoicing" is deferred to FAC-001 followup issue.
/// Throws KeyNotFoundException if the row does not exist.
/// </summary>
Task DeleteAsync(
long id,
CancellationToken ct = default);
}

View File

@@ -1,44 +0,0 @@
using SIGCM2.Application.Common;
using SIGCM2.Domain.Entities;
namespace SIGCM2.Application.Abstractions.Persistence;
/// <summary>
/// PRD-003 — Write + query access to dbo.ProductPrices.
/// Implemented by ProductPriceRepository (Dapper) in Infrastructure.
/// </summary>
public interface IProductPriceRepository
{
/// <summary>
/// Invokes dbo.usp_AddProductPrice inside the ambient TransactionScope.
/// Returns (newId, closedId?). Throws:
/// - ProductPriceForwardOnlyException on SQL THROW 50409 or unique index violation (2601/2627).
/// - ProductNotFoundException on SQL THROW 50404.
/// </summary>
Task<(long NewId, long? ClosedId)> AddAsync(
int productId,
decimal price,
DateOnly priceValidFrom,
CancellationToken ct = default);
/// <summary>
/// Returns a paginated page of price rows for the product, ordered descending by PriceValidFrom.
/// Caller is responsible for clamping page (≥ 1) and pageSize (1100) 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);
}

View File

@@ -1,21 +0,0 @@
namespace SIGCM2.Application.Abstractions.Persistence;
/// <summary>
/// PRD-002 handoff contract — query-only access to Product data needed by ProductType handlers.
/// PRD-001 binds to NullProductQueryRepository (always returns false).
/// PRD-002 binds to Dapper impl against dbo.Product (when that table exists).
/// </summary>
public interface IProductQueryRepository
{
/// <summary>
/// Returns true if at least one active Product with the given ProductTypeId exists.
/// Used by DeactivateProductTypeCommandHandler to guard against orphaning active products.
/// </summary>
Task<bool> ExistsActiveByProductTypeAsync(int productTypeId, CancellationToken ct = default);
/// <summary>
/// Returns the count of active Products where RubroId = rubroId.
/// Used by DeactivateRubroCommandHandler to guard against orphaning active products. (issue #41)
/// </summary>
Task<int> CountActiveByRubroAsync(int rubroId, CancellationToken ct = default);
}

View File

@@ -1,29 +0,0 @@
using SIGCM2.Application.Common;
using SIGCM2.Domain.Entities;
namespace SIGCM2.Application.Abstractions.Persistence;
/// <summary>
/// Write-side repository for Product.
/// All reads needed by write handlers are included here.
/// </summary>
public interface IProductRepository
{
/// <summary>Inserts a new Product and returns the DB-assigned Id.</summary>
Task<int> AddAsync(Product product, CancellationToken ct = default);
/// <summary>Returns the Product with the given Id, or null if not found.</summary>
Task<Product?> GetByIdAsync(int id, CancellationToken ct = default);
/// <summary>Returns a paged result of Products matching the query.</summary>
Task<PagedResult<Product>> GetPagedAsync(ProductsQuery query, CancellationToken ct = default);
/// <summary>Persists all changes to an existing Product row.</summary>
Task UpdateAsync(Product product, CancellationToken ct = default);
/// <summary>
/// Returns true if an active Product with the same Nombre exists for the given MedioId+ProductTypeId combination.
/// Pass excludeId to skip the self-comparison during rename (update scenario).
/// </summary>
Task<bool> ExistsByNombreAsync(string nombre, int medioId, int productTypeId, int? excludeId = null, CancellationToken ct = default);
}

View File

@@ -1,31 +0,0 @@
using SIGCM2.Application.Common;
using SIGCM2.Domain.Entities;
namespace SIGCM2.Application.Abstractions.Persistence;
/// <summary>
/// Write-side repository for ProductType.
/// All reads needed by write handlers are included here.
/// Query-side (for listing, filtering) uses GetPagedAsync with ProductTypesQuery.
/// </summary>
public interface IProductTypeRepository
{
/// <summary>Inserts a new ProductType and returns the DB-assigned Id.</summary>
Task<int> AddAsync(ProductType productType, CancellationToken ct = default);
/// <summary>Returns the ProductType with the given Id, or null if not found.</summary>
Task<ProductType?> GetByIdAsync(int id, CancellationToken ct = default);
/// <summary>Returns a paged result of ProductTypes matching the query.</summary>
Task<PagedResult<ProductType>> GetPagedAsync(ProductTypesQuery query, CancellationToken ct = default);
/// <summary>Persists all changes to an existing ProductType row.</summary>
Task UpdateAsync(ProductType productType, CancellationToken ct = default);
/// <summary>
/// Returns true if an active ProductType with the given nombre exists.
/// Pass excludeId to skip the self-comparison during rename (update scenario).
/// Case-insensitive — delegates to DB collation (SQL_Latin1_General_CP1_CI_AI).
/// </summary>
Task<bool> ExistsByNombreAsync(string nombre, int? excludeId = null, CancellationToken ct = default);
}

View File

@@ -1,22 +0,0 @@
using SIGCM2.Application.Abstractions.Persistence;
namespace SIGCM2.Application.Avisos;
/// <summary>
/// STUB — PRD-002 reemplaza con AvisoQueryRepository contra dbo.Aviso.
/// Returns 0 / empty dictionary so every handler guard passes and every tree node shows TieneAvisos=false.
/// This is intentional for CAT-002: the mechanism is installed; the data feed arrives in PRD-002.
/// </summary>
public sealed class NullAvisoQueryRepository : IAvisoQueryRepository
{
private static readonly IReadOnlyDictionary<int, int> Empty =
new Dictionary<int, int>(capacity: 0);
public Task<int> CountAvisosEnRubroAsync(int rubroId, CancellationToken ct = default)
=> Task.FromResult(0);
public Task<IReadOnlyDictionary<int, int>> CountAvisosBatchAsync(
IReadOnlyCollection<int> rubroIds,
CancellationToken ct = default)
=> Task.FromResult(Empty);
}

View File

@@ -9,7 +9,7 @@ namespace SIGCM2.Application.Common;
/// </summary> /// </summary>
public sealed record PermisosOverride( public sealed record PermisosOverride(
[property: JsonPropertyName("grant")] IReadOnlyList<string> Grant, [property: JsonPropertyName("grant")] IReadOnlyList<string> Grant,
[property: JsonPropertyName("deny")] IReadOnlyList<string> Deny) [property: JsonPropertyName("deny")] IReadOnlyList<string> Deny)
{ {
/// <summary>No overrides — empty grant and deny.</summary> /// <summary>No overrides — empty grant and deny.</summary>
public static readonly PermisosOverride Empty = public static readonly PermisosOverride Empty =
@@ -46,7 +46,7 @@ public sealed record PermisosOverride(
return new PermisosOverride( return new PermisosOverride(
parsed.Grant ?? Array.Empty<string>(), parsed.Grant ?? Array.Empty<string>(),
parsed.Deny ?? Array.Empty<string>()); parsed.Deny ?? Array.Empty<string>());
} }
catch (JsonException) catch (JsonException)
{ {

View File

@@ -1,10 +0,0 @@
namespace SIGCM2.Application.Common;
/// <summary>
/// Query parameters for listing ProductTypes (used by IProductTypeRepository.GetPagedAsync).
/// </summary>
public sealed record ProductTypesQuery(
int Page = 1,
int PageSize = 20,
bool? Activo = true,
string? Search = null);

View File

@@ -1,13 +0,0 @@
namespace SIGCM2.Application.Common;
/// <summary>
/// Query parameters for listing Products (used by IProductRepository.GetPagedAsync).
/// </summary>
public sealed record ProductsQuery(
int Page = 1,
int PageSize = 20,
bool? Activo = true,
string? Search = null,
int? MedioId = null,
int? ProductTypeId = null,
int? RubroId = null);

View File

@@ -67,30 +67,6 @@ using SIGCM2.Application.Rubros.Move;
using SIGCM2.Application.Rubros.GetTree; using SIGCM2.Application.Rubros.GetTree;
using SIGCM2.Application.Rubros.GetById; using SIGCM2.Application.Rubros.GetById;
using SIGCM2.Application.Rubros.Dtos; using SIGCM2.Application.Rubros.Dtos;
using SIGCM2.Application.Abstractions.Persistence;
using SIGCM2.Application.Avisos;
using SIGCM2.Application.Products.Create;
using SIGCM2.Application.Products.Update;
using SIGCM2.Application.Products.Deactivate;
using SIGCM2.Application.Products.GetById;
using SIGCM2.Application.Products.List;
using SIGCM2.Application.Products.Prices;
using SIGCM2.Application.Products.Prices.AddPrice;
using SIGCM2.Application.Products.Prices.GetHistory;
using SIGCM2.Application.Products.Pricing;
using SIGCM2.Application.ProductTypes.Create;
using SIGCM2.Application.ProductTypes.Update;
using SIGCM2.Application.ProductTypes.Deactivate;
using SIGCM2.Application.ProductTypes.List;
using SIGCM2.Application.ProductTypes.GetById;
using SIGCM2.Application.Pricing.ChargeableChars;
using SIGCM2.Application.Pricing.ChargeableChars.Create;
using SIGCM2.Application.Pricing.ChargeableChars.SchedulePrice;
using SIGCM2.Application.Pricing.ChargeableChars.Deactivate;
using SIGCM2.Application.Pricing.ChargeableChars.Reactivate;
using SIGCM2.Application.Pricing.ChargeableChars.Delete;
using SIGCM2.Application.Pricing.ChargeableChars.List;
using SIGCM2.Application.Pricing.ChargeableChars.GetById;
namespace SIGCM2.Application; namespace SIGCM2.Application;
@@ -176,10 +152,7 @@ public static class DependencyInjection
services.AddScoped<ICommandHandler<ListIngresosBrutosQuery, PagedResult<IngresosBrutosDto>>, ListIngresosBrutosQueryHandler>(); services.AddScoped<ICommandHandler<ListIngresosBrutosQuery, PagedResult<IngresosBrutosDto>>, ListIngresosBrutosQueryHandler>();
services.AddScoped<ICommandHandler<GetHistorialIngresosBrutosQuery, IReadOnlyList<HistorialCadenaIibbDto>>, GetHistorialIngresosBrutosQueryHandler>(); services.AddScoped<ICommandHandler<GetHistorialIngresosBrutosQuery, IReadOnlyList<HistorialCadenaIibbDto>>, GetHistorialIngresosBrutosQueryHandler>();
// Rubros (CAT-001 + CAT-002) // Rubros (CAT-001)
// CAT-002: Regla de Oro Rama vs Hoja — stub binding until PRD-002 provides real impl
services.AddScoped<IAvisoQueryRepository, NullAvisoQueryRepository>();
services.AddScoped<ICommandHandler<CreateRubroCommand, RubroCreatedDto>, CreateRubroCommandHandler>(); services.AddScoped<ICommandHandler<CreateRubroCommand, RubroCreatedDto>, CreateRubroCommandHandler>();
services.AddScoped<ICommandHandler<UpdateRubroCommand, RubroUpdatedDto>, UpdateRubroCommandHandler>(); services.AddScoped<ICommandHandler<UpdateRubroCommand, RubroUpdatedDto>, UpdateRubroCommandHandler>();
services.AddScoped<ICommandHandler<DeactivateRubroCommand, RubroStatusDto>, DeactivateRubroCommandHandler>(); services.AddScoped<ICommandHandler<DeactivateRubroCommand, RubroStatusDto>, DeactivateRubroCommandHandler>();
@@ -187,37 +160,6 @@ public static class DependencyInjection
services.AddScoped<ICommandHandler<GetRubroTreeQuery, IReadOnlyList<RubroTreeNodeDto>>, GetRubroTreeQueryHandler>(); services.AddScoped<ICommandHandler<GetRubroTreeQuery, IReadOnlyList<RubroTreeNodeDto>>, GetRubroTreeQueryHandler>();
services.AddScoped<ICommandHandler<GetRubroByIdQuery, RubroDetailDto>, GetRubroByIdQueryHandler>(); services.AddScoped<ICommandHandler<GetRubroByIdQuery, RubroDetailDto>, GetRubroByIdQueryHandler>();
// Products (PRD-002)
services.AddScoped<ICommandHandler<CreateProductCommand, ProductCreatedDto>, CreateProductCommandHandler>();
services.AddScoped<ICommandHandler<UpdateProductCommand, ProductUpdatedDto>, UpdateProductCommandHandler>();
services.AddScoped<ICommandHandler<DeactivateProductCommand, ProductStatusDto>, DeactivateProductCommandHandler>();
services.AddScoped<ICommandHandler<GetProductByIdQuery, ProductDetailDto>, GetProductByIdQueryHandler>();
services.AddScoped<ICommandHandler<ListProductsQuery, PagedResult<ProductListItemDto>>, ListProductsQueryHandler>();
// ProductPrices (PRD-003)
services.AddScoped<ICommandHandler<AddProductPriceCommand, AddProductPriceResponse>, AddProductPriceCommandHandler>();
services.AddScoped<ICommandHandler<GetProductPricesQuery, PagedResult<ProductPriceDto>>, GetProductPricesQueryHandler>();
services.AddScoped<IProductPricingService, ProductPricingService>();
// ProductTypes (PRD-001)
// IProductQueryRepository is now bound in Infrastructure DI (PRD-002) against dbo.Product.
services.AddScoped<ICommandHandler<CreateProductTypeCommand, ProductTypeCreatedDto>, CreateProductTypeCommandHandler>();
services.AddScoped<ICommandHandler<UpdateProductTypeCommand, ProductTypeUpdatedDto>, UpdateProductTypeCommandHandler>();
services.AddScoped<ICommandHandler<DeactivateProductTypeCommand, ProductTypeStatusDto>, DeactivateProductTypeCommandHandler>();
services.AddScoped<ICommandHandler<ListProductTypesQuery, PagedResult<ProductTypeListItemDto>>, ListProductTypesQueryHandler>();
services.AddScoped<ICommandHandler<GetProductTypeByIdQuery, ProductTypeDetailDto>, GetProductTypeByIdQueryHandler>();
// ChargeableCharConfig (PRC-001)
services.AddScoped<ICommandHandler<CreateChargeableCharConfigCommand, CreateChargeableCharConfigResponse>, CreateChargeableCharConfigCommandHandler>();
services.AddScoped<ICommandHandler<SchedulePriceChangeCommand, SchedulePriceChangeResponse>, SchedulePriceChangeCommandHandler>();
services.AddScoped<ICommandHandler<DeactivateChargeableCharConfigCommand, DeactivateChargeableCharConfigResponse>, DeactivateChargeableCharConfigCommandHandler>();
services.AddScoped<ICommandHandler<ReactivateChargeableCharConfigCommand, ReactivateChargeableCharConfigResponse>, ReactivateChargeableCharConfigCommandHandler>();
services.AddScoped<ICommandHandler<DeleteChargeableCharConfigCommand, DeleteChargeableCharConfigResponse>, DeleteChargeableCharConfigCommandHandler>();
services.AddScoped<ICommandHandler<ListChargeableCharConfigQuery, PagedResult<ChargeableCharConfigDto>>, ListChargeableCharConfigQueryHandler>();
services.AddScoped<ICommandHandler<GetChargeableCharConfigByIdQuery, ChargeableCharConfigDto?>, GetChargeableCharConfigByIdQueryHandler>();
services.AddScoped<IChargeableCharConfigService, ChargeableCharConfigService>();
// FluentValidation validators (scans entire Application assembly) // FluentValidation validators (scans entire Application assembly)
services.AddValidatorsFromAssemblyContaining<LoginCommandValidator>(); services.AddValidatorsFromAssemblyContaining<LoginCommandValidator>();

View File

@@ -1,14 +0,0 @@
namespace SIGCM2.Application.Pricing.ChargeableChars;
/// <summary>
/// PRC-001 — DTO for ChargeableCharConfig rows returned in list / get-by-id responses.
/// </summary>
public sealed record ChargeableCharConfigDto(
long Id,
long? ProductTypeId,
string Symbol,
string Category,
decimal PricePerUnit,
DateOnly ValidFrom,
DateOnly? ValidTo,
bool IsActive);

View File

@@ -1,43 +0,0 @@
using SIGCM2.Application.Abstractions.Persistence;
namespace SIGCM2.Application.Pricing.ChargeableChars;
/// <summary>
/// PRC-001 — Implements IChargeableCharConfigService.
/// Delegates to IChargeableCharConfigRepository.GetActiveForProductTypeAsync, then applies
/// the per-ProductType > global priority rule in memory.
///
/// Priority rule: if the same Symbol appears as both global (ProductTypeId IS NULL) and
/// per-ProductType, the per-ProductType row wins. The SP returns both; we resolve in Application.
/// </summary>
public sealed class ChargeableCharConfigService : IChargeableCharConfigService
{
private readonly IChargeableCharConfigRepository _repo;
public ChargeableCharConfigService(IChargeableCharConfigRepository repo)
{
_repo = repo;
}
/// <inheritdoc />
public async Task<IReadOnlyDictionary<string, ChargeableCharSnapshot>> GetActiveConfigForProductTypeAsync(
long productTypeId,
DateOnly asOf,
CancellationToken ct = default)
{
var allRows = await _repo.GetActiveForProductTypeAsync(productTypeId, asOf, ct);
// Build a dictionary keyed by Symbol.
// Per-ProductType rows (ProductTypeId != null) take priority over global rows (ProductTypeId == null).
var result = new Dictionary<string, ChargeableCharSnapshot>(StringComparer.Ordinal);
// Two-pass: first add global rows, then overwrite with per-ProductType rows.
foreach (var row in allRows.Where(r => r.ProductTypeId is null))
result[row.Symbol] = new ChargeableCharSnapshot(row.Category, row.PricePerUnit);
foreach (var row in allRows.Where(r => r.ProductTypeId is not null))
result[row.Symbol] = new ChargeableCharSnapshot(row.Category, row.PricePerUnit);
return result;
}
}

View File

@@ -1,10 +0,0 @@
namespace SIGCM2.Application.Pricing.ChargeableChars;
/// <summary>
/// PRC-001 — Lightweight value snapshot for the active chargeable-char config
/// at the time of word counting. Used by IChargeableCharConfigService.
/// Keyed by Symbol in the returned dictionary.
/// </summary>
public sealed record ChargeableCharSnapshot(
string Category,
decimal PricePerUnit);

View File

@@ -1,12 +0,0 @@
namespace SIGCM2.Application.Pricing.ChargeableChars.Create;
/// <summary>
/// PRC-001 — Command to create a new ChargeableCharConfig.
/// ProductTypeId = null → global config. ProductTypeId set → per-ProductType config.
/// </summary>
public sealed record CreateChargeableCharConfigCommand(
long? ProductTypeId,
string Symbol,
string Category,
decimal PricePerUnit,
DateOnly ValidFrom);

View File

@@ -1,69 +0,0 @@
using System.Transactions;
using SIGCM2.Application.Abstractions;
using SIGCM2.Application.Abstractions.Persistence;
using SIGCM2.Application.Audit;
namespace SIGCM2.Application.Pricing.ChargeableChars.Create;
/// <summary>
/// PRC-001 — Handler for CreateChargeableCharConfigCommand.
/// Flow: opens TransactionScope → InsertWithCloseAsync (SP) → IAuditLogger.LogAsync (fail-closed) → tx.Complete().
/// </summary>
public sealed class CreateChargeableCharConfigCommandHandler
: ICommandHandler<CreateChargeableCharConfigCommand, CreateChargeableCharConfigResponse>
{
private readonly IChargeableCharConfigRepository _repo;
private readonly IAuditLogger _audit;
private readonly TimeProvider _timeProvider;
public CreateChargeableCharConfigCommandHandler(
IChargeableCharConfigRepository repo,
IAuditLogger audit,
TimeProvider timeProvider)
{
_repo = repo;
_audit = audit;
_timeProvider = timeProvider;
}
public async Task<CreateChargeableCharConfigResponse> Handle(CreateChargeableCharConfigCommand command)
{
long newId;
using (var tx = new TransactionScope(
TransactionScopeOption.Required,
new TransactionOptions { IsolationLevel = System.Transactions.IsolationLevel.ReadCommitted },
TransactionScopeAsyncFlowOption.Enabled))
{
newId = await _repo.InsertWithCloseAsync(
command.ProductTypeId,
command.Symbol,
command.Category,
command.PricePerUnit,
command.ValidFrom);
await _audit.LogAsync(
action: "tasacion.chargeable_char.create",
targetType: "ChargeableCharConfig",
targetId: newId.ToString(),
metadata: new
{
after = new
{
command.ProductTypeId,
command.Symbol,
command.Category,
command.PricePerUnit,
validFrom = command.ValidFrom.ToString("yyyy-MM-dd"),
}
});
tx.Complete();
}
return new CreateChargeableCharConfigResponse(
newId,
command.Symbol,
command.PricePerUnit,
command.ValidFrom);
}
}

View File

@@ -1,41 +0,0 @@
using FluentValidation;
using SIGCM2.Application.Common;
using SIGCM2.Domain.Pricing.ChargeableChars;
using SIGCM2.Domain.Pricing.WordCounter;
namespace SIGCM2.Application.Pricing.ChargeableChars.Create;
/// <summary>
/// PRC-001 — FluentValidation validator for CreateChargeableCharConfigCommand.
/// Injects TimeProvider for today_AR (Cat2, never DateTime.Now).
/// </summary>
public sealed class CreateChargeableCharConfigCommandValidator
: AbstractValidator<CreateChargeableCharConfigCommand>
{
public CreateChargeableCharConfigCommandValidator(TimeProvider timeProvider)
{
var today = timeProvider.GetArgentinaToday();
RuleFor(x => x.Symbol)
.NotEmpty()
.WithMessage("Symbol no puede estar vacío.")
.MaximumLength(4)
.WithMessage("Symbol no puede exceder 4 caracteres.")
.Must(s => !WordCounterService.ContainsEmoji(s))
.WithMessage("Symbol no puede contener emojis. Usá símbolos ASCII o latinos (ej: $, %, !, ¡).");
RuleFor(x => x.Category)
.NotEmpty()
.WithMessage("Category no puede estar vacío.")
.Must(ChargeableCharCategories.IsValid)
.WithMessage($"Category inválida. Valores válidos: {string.Join(", ", new[] { ChargeableCharCategories.Currency, ChargeableCharCategories.Percentage, ChargeableCharCategories.Exclamation, ChargeableCharCategories.Question, ChargeableCharCategories.Other })}.");
RuleFor(x => x.PricePerUnit)
.GreaterThanOrEqualTo(0m)
.WithMessage("PricePerUnit debe ser >= 0. Usá 0 para desactivar el cobro de este símbolo (opt-in billing).");
RuleFor(x => x.ValidFrom)
.GreaterThanOrEqualTo(today)
.WithMessage($"ValidFrom debe ser >= hoy ({today:yyyy-MM-dd} ART). No se permiten configuraciones con fecha retroactiva.");
}
}

View File

@@ -1,10 +0,0 @@
namespace SIGCM2.Application.Pricing.ChargeableChars.Create;
/// <summary>
/// PRC-001 — Response for CreateChargeableCharConfigCommand.
/// </summary>
public sealed record CreateChargeableCharConfigResponse(
long Id,
string Symbol,
decimal PricePerUnit,
DateOnly ValidFrom);

View File

@@ -1,6 +0,0 @@
namespace SIGCM2.Application.Pricing.ChargeableChars.Deactivate;
/// <summary>
/// PRC-001 — Command to deactivate an existing ChargeableCharConfig.
/// </summary>
public sealed record DeactivateChargeableCharConfigCommand(long Id);

View File

@@ -1,70 +0,0 @@
using System.Transactions;
using SIGCM2.Application.Abstractions;
using SIGCM2.Application.Abstractions.Persistence;
using SIGCM2.Application.Audit;
using SIGCM2.Application.Common;
namespace SIGCM2.Application.Pricing.ChargeableChars.Deactivate;
/// <summary>
/// PRC-001 — Handler for DeactivateChargeableCharConfigCommand.
/// Flow: load existing → open TX → DeactivateAsync → audit → tx.Complete().
/// </summary>
public sealed class DeactivateChargeableCharConfigCommandHandler
: ICommandHandler<DeactivateChargeableCharConfigCommand, DeactivateChargeableCharConfigResponse>
{
private readonly IChargeableCharConfigRepository _repo;
private readonly IAuditLogger _audit;
private readonly TimeProvider _timeProvider;
public DeactivateChargeableCharConfigCommandHandler(
IChargeableCharConfigRepository repo,
IAuditLogger audit,
TimeProvider timeProvider)
{
_repo = repo;
_audit = audit;
_timeProvider = timeProvider;
}
public async Task<DeactivateChargeableCharConfigResponse> Handle(
DeactivateChargeableCharConfigCommand command)
{
var today = _timeProvider.GetArgentinaToday();
// 1. Load existing — ensures the row exists.
var existing = await _repo.GetByIdAsync(command.Id)
?? throw new KeyNotFoundException($"ChargeableCharConfig con Id={command.Id} no existe.");
// 2. TX + deactivate + audit (fail-closed).
using (var tx = new TransactionScope(
TransactionScopeOption.Required,
new TransactionOptions { IsolationLevel = System.Transactions.IsolationLevel.ReadCommitted },
TransactionScopeAsyncFlowOption.Enabled))
{
await _repo.DeactivateAsync(command.Id, today);
await _audit.LogAsync(
action: "tasacion.chargeable_char.deactivate",
targetType: "ChargeableCharConfig",
targetId: command.Id.ToString(),
metadata: new
{
before = new
{
id = existing.Id,
symbol = existing.Symbol,
productTypeId = existing.ProductTypeId,
validFrom = existing.ValidFrom.ToString("yyyy-MM-dd"),
},
deactivatedOn = today.ToString("yyyy-MM-dd"),
});
tx.Complete();
}
return new DeactivateChargeableCharConfigResponse(
Id: command.Id,
ValidTo: today);
}
}

View File

@@ -1,9 +0,0 @@
namespace SIGCM2.Application.Pricing.ChargeableChars.Deactivate;
/// <summary>
/// PRC-001 — Response for DeactivateChargeableCharConfigCommand.
/// ValidTo is the date the config was deactivated (= today_AR at time of operation).
/// </summary>
public sealed record DeactivateChargeableCharConfigResponse(
long Id,
DateOnly ValidTo);

View File

@@ -1,10 +0,0 @@
namespace SIGCM2.Application.Pricing.ChargeableChars.Delete;
/// <summary>
/// PRC-001 — Command to physically delete a ChargeableCharConfig row.
/// NOTE: Since SYSTEM_VERSIONING is ON, the delete moves the row to the history table
/// (SysEndTime = delete time). The row disappears from all current-state queries but
/// the temporal audit trail is preserved. Guard for "used in invoicing" is deferred
/// to the FAC-001 followup issue.
/// </summary>
public sealed record DeleteChargeableCharConfigCommand(long Id);

View File

@@ -1,75 +0,0 @@
using System.Transactions;
using SIGCM2.Application.Abstractions;
using SIGCM2.Application.Abstractions.Persistence;
using SIGCM2.Application.Audit;
using SIGCM2.Application.Common;
namespace SIGCM2.Application.Pricing.ChargeableChars.Delete;
/// <summary>
/// PRC-001 — Handler for DeleteChargeableCharConfigCommand.
/// Flow: load existing → open TX → DeleteAsync → audit → tx.Complete().
///
/// NOTE on SYSTEM_VERSIONING: SQL Server moves the deleted row to the _History table with
/// SysEndTime = deletion timestamp. This means:
/// - Current-state queries (no FOR SYSTEM_TIME) return nothing — effectively "deleted".
/// - Historical queries (FOR SYSTEM_TIME ALL / AS OF) still return the row — temporal audit intact.
/// This is intentional. A "physical delete" (bypass SYSTEM_VERSIONING) is not supported here.
///
/// Future FAC-001 will add a guard to block delete if the row was used in invoicing.
/// </summary>
public sealed class DeleteChargeableCharConfigCommandHandler
: ICommandHandler<DeleteChargeableCharConfigCommand, DeleteChargeableCharConfigResponse>
{
private readonly IChargeableCharConfigRepository _repo;
private readonly IAuditLogger _audit;
private readonly TimeProvider _timeProvider;
public DeleteChargeableCharConfigCommandHandler(
IChargeableCharConfigRepository repo,
IAuditLogger audit,
TimeProvider timeProvider)
{
_repo = repo;
_audit = audit;
_timeProvider = timeProvider;
}
public async Task<DeleteChargeableCharConfigResponse> Handle(
DeleteChargeableCharConfigCommand command)
{
// 1. Load existing — ensures the row exists before opening TX.
var existing = await _repo.GetByIdAsync(command.Id)
?? throw new KeyNotFoundException($"ChargeableCharConfig con Id={command.Id} no existe.");
// 2. TX + delete + audit (fail-closed).
using (var tx = new TransactionScope(
TransactionScopeOption.Required,
new TransactionOptions { IsolationLevel = System.Transactions.IsolationLevel.ReadCommitted },
TransactionScopeAsyncFlowOption.Enabled))
{
await _repo.DeleteAsync(command.Id);
await _audit.LogAsync(
action: "tasacion.chargeable_char.delete",
targetType: "ChargeableCharConfig",
targetId: command.Id.ToString(),
metadata: new
{
before = new
{
id = existing.Id,
symbol = existing.Symbol,
productTypeId = existing.ProductTypeId,
isActive = existing.IsActive,
validFrom = existing.ValidFrom.ToString("yyyy-MM-dd"),
},
deletedOn = _timeProvider.GetArgentinaToday().ToString("yyyy-MM-dd"),
});
tx.Complete();
}
return new DeleteChargeableCharConfigResponse(Id: command.Id);
}
}

View File

@@ -1,6 +0,0 @@
namespace SIGCM2.Application.Pricing.ChargeableChars.Delete;
/// <summary>
/// PRC-001 — Response for a successful delete operation.
/// </summary>
public sealed record DeleteChargeableCharConfigResponse(long Id);

View File

@@ -1,7 +0,0 @@
namespace SIGCM2.Application.Pricing.ChargeableChars.GetById;
/// <summary>
/// PRC-001 — Query to fetch a single ChargeableCharConfig by Id.
/// Returns null if not found (caller decides whether to 404).
/// </summary>
public sealed record GetChargeableCharConfigByIdQuery(long Id);

View File

@@ -1,37 +0,0 @@
using SIGCM2.Application.Abstractions;
using SIGCM2.Application.Abstractions.Persistence;
using SIGCM2.Application.Pricing.ChargeableChars;
using SIGCM2.Domain.Pricing.ChargeableChars;
namespace SIGCM2.Application.Pricing.ChargeableChars.GetById;
/// <summary>
/// PRC-001 — Handler for GetChargeableCharConfigByIdQuery.
/// Returns null DTO when not found (API layer maps to 404).
/// </summary>
public sealed class GetChargeableCharConfigByIdQueryHandler
: ICommandHandler<GetChargeableCharConfigByIdQuery, ChargeableCharConfigDto?>
{
private readonly IChargeableCharConfigRepository _repo;
public GetChargeableCharConfigByIdQueryHandler(IChargeableCharConfigRepository repo)
{
_repo = repo;
}
public async Task<ChargeableCharConfigDto?> Handle(GetChargeableCharConfigByIdQuery query)
{
var entity = await _repo.GetByIdAsync(query.Id);
return entity is null ? null : ToDto(entity);
}
private static ChargeableCharConfigDto ToDto(ChargeableCharConfig c) => new(
c.Id,
c.ProductTypeId,
c.Symbol,
c.Category,
c.PricePerUnit,
c.ValidFrom,
c.ValidTo,
c.IsActive);
}

View File

@@ -1,21 +0,0 @@
namespace SIGCM2.Application.Pricing.ChargeableChars;
/// <summary>
/// PRC-001 — Application service for resolving active chargeable-char config for a ProductType.
///
/// Priority rule: per-ProductType row overrides global (ProductTypeId IS NULL) for the same Symbol.
/// Returns a dictionary keyed by Symbol for O(1) lookup during word-count pricing.
/// </summary>
public interface IChargeableCharConfigService
{
/// <summary>
/// Returns the resolved active config for the given ProductType as of the given date.
/// Per-ProductType rows take priority over global rows for the same Symbol.
/// Global rows are used as fallback when no per-ProductType row exists for that Symbol.
/// Returns an empty dictionary if no config exists at all.
/// </summary>
Task<IReadOnlyDictionary<string, ChargeableCharSnapshot>> GetActiveConfigForProductTypeAsync(
long productTypeId,
DateOnly asOf,
CancellationToken ct = default);
}

View File

@@ -1,11 +0,0 @@
namespace SIGCM2.Application.Pricing.ChargeableChars.List;
/// <summary>
/// PRC-001 — Paginated list query for ChargeableCharConfig rows.
/// Page/PageSize are clamped by the handler (page >= 1, pageSize in [1, 100]).
/// </summary>
public sealed record ListChargeableCharConfigQuery(
long? ProductTypeId,
bool ActiveOnly,
int Page = 1,
int PageSize = 20);

View File

@@ -1,46 +0,0 @@
using SIGCM2.Application.Abstractions;
using SIGCM2.Application.Abstractions.Persistence;
using SIGCM2.Application.Common;
using SIGCM2.Application.Pricing.ChargeableChars;
using SIGCM2.Domain.Pricing.ChargeableChars;
namespace SIGCM2.Application.Pricing.ChargeableChars.List;
/// <summary>
/// PRC-001 — Handler for ListChargeableCharConfigQuery.
/// Projects ChargeableCharConfig entities to ChargeableCharConfigDto.
/// </summary>
public sealed class ListChargeableCharConfigQueryHandler
: ICommandHandler<ListChargeableCharConfigQuery, PagedResult<ChargeableCharConfigDto>>
{
private readonly IChargeableCharConfigRepository _repo;
public ListChargeableCharConfigQueryHandler(IChargeableCharConfigRepository repo)
{
_repo = repo;
}
public async Task<PagedResult<ChargeableCharConfigDto>> Handle(ListChargeableCharConfigQuery query)
{
var page = Math.Max(1, query.Page);
var pageSize = Math.Clamp(query.PageSize, 1, 100);
var skip = (page - 1) * pageSize;
var items = await _repo.ListAsync(query.ProductTypeId, query.ActiveOnly, skip, pageSize);
var total = await _repo.CountAsync(query.ProductTypeId, query.ActiveOnly);
var dtos = items.Select(ToDto).ToList();
return new PagedResult<ChargeableCharConfigDto>(dtos, page, pageSize, total);
}
private static ChargeableCharConfigDto ToDto(ChargeableCharConfig c) => new(
c.Id,
c.ProductTypeId,
c.Symbol,
c.Category,
c.PricePerUnit,
c.ValidFrom,
c.ValidTo,
c.IsActive);
}

View File

@@ -1,7 +0,0 @@
namespace SIGCM2.Application.Pricing.ChargeableChars.Reactivate;
/// <summary>
/// PRC-001 — Command to reactivate a previously closed ChargeableCharConfig row.
/// Guard rules enforced by the SP (50410 ALREADY_ACTIVE / 50411 VIGENTE_EXISTS / 50412 POSTERIOR_ROWS_EXIST).
/// </summary>
public sealed record ReactivateChargeableCharConfigCommand(long Id);

View File

@@ -1,71 +0,0 @@
using System.Transactions;
using SIGCM2.Application.Abstractions;
using SIGCM2.Application.Abstractions.Persistence;
using SIGCM2.Application.Audit;
using SIGCM2.Application.Common;
namespace SIGCM2.Application.Pricing.ChargeableChars.Reactivate;
/// <summary>
/// PRC-001 — Handler for ReactivateChargeableCharConfigCommand.
/// Flow: open TransactionScope → ReactivateAsync (SP with guard) → audit → tx.Complete().
///
/// Guard failures (ALREADY_ACTIVE / VIGENTE_EXISTS / POSTERIOR_ROWS_EXIST) are thrown by the
/// repository as ChargeableCharConfigReactivationNotAllowedException and propagate to the
/// ExceptionFilter which maps them to HTTP 409.
/// </summary>
public sealed class ReactivateChargeableCharConfigCommandHandler
: ICommandHandler<ReactivateChargeableCharConfigCommand, ReactivateChargeableCharConfigResponse>
{
private readonly IChargeableCharConfigRepository _repo;
private readonly IAuditLogger _audit;
private readonly TimeProvider _timeProvider;
public ReactivateChargeableCharConfigCommandHandler(
IChargeableCharConfigRepository repo,
IAuditLogger audit,
TimeProvider timeProvider)
{
_repo = repo;
_audit = audit;
_timeProvider = timeProvider;
}
public async Task<ReactivateChargeableCharConfigResponse> Handle(
ReactivateChargeableCharConfigCommand command)
{
// Open TX before calling SP so that audit failure rolls back the SP work.
using var tx = new TransactionScope(
TransactionScopeOption.Required,
new TransactionOptions { IsolationLevel = System.Transactions.IsolationLevel.ReadCommitted },
TransactionScopeAsyncFlowOption.Enabled);
// SP enforces guard rules; throws ChargeableCharConfigReactivationNotAllowedException on failure.
// Returns the reactivated entity so we can populate the response and the audit log.
var reactivated = await _repo.ReactivateAsync(command.Id, CancellationToken.None);
await _audit.LogAsync(
action: "tasacion.chargeable_char.reactivate",
targetType: "ChargeableCharConfig",
targetId: command.Id.ToString(),
metadata: new
{
id = reactivated.Id,
symbol = reactivated.Symbol,
productTypeId = reactivated.ProductTypeId,
validFrom = reactivated.ValidFrom.ToString("yyyy-MM-dd"),
reactivatedOn = _timeProvider.GetArgentinaToday().ToString("yyyy-MM-dd"),
});
tx.Complete();
return new ReactivateChargeableCharConfigResponse(
Id: reactivated.Id,
ProductTypeId: reactivated.ProductTypeId,
Symbol: reactivated.Symbol,
Category: reactivated.Category,
PricePerUnit: reactivated.PricePerUnit,
ValidFrom: reactivated.ValidFrom,
IsActive: reactivated.IsActive);
}
}

View File

@@ -1,14 +0,0 @@
namespace SIGCM2.Application.Pricing.ChargeableChars.Reactivate;
/// <summary>
/// PRC-001 — Response for a successful reactivation.
/// Returns the current state of the row after it has been re-opened.
/// </summary>
public sealed record ReactivateChargeableCharConfigResponse(
long Id,
long? ProductTypeId,
string Symbol,
string Category,
decimal PricePerUnit,
DateOnly ValidFrom,
bool IsActive);

View File

@@ -1,11 +0,0 @@
namespace SIGCM2.Application.Pricing.ChargeableChars.SchedulePrice;
/// <summary>
/// PRC-001 — Command to schedule a new price for an existing ChargeableCharConfig.
/// Id: the existing row whose price should be superseded.
/// ValidFrom must be > existing row's ValidFrom (forward-only, enforced in handler).
/// </summary>
public sealed record SchedulePriceChangeCommand(
long Id,
decimal PricePerUnit,
DateOnly ValidFrom);

View File

@@ -1,80 +0,0 @@
using System.Transactions;
using SIGCM2.Application.Abstractions;
using SIGCM2.Application.Abstractions.Persistence;
using SIGCM2.Application.Audit;
namespace SIGCM2.Application.Pricing.ChargeableChars.SchedulePrice;
/// <summary>
/// PRC-001 — Handler for SchedulePriceChangeCommand.
/// Flow: load existing → validate forward-only via entity → open TX → InsertWithCloseAsync → audit → tx.Complete().
/// </summary>
public sealed class SchedulePriceChangeCommandHandler
: ICommandHandler<SchedulePriceChangeCommand, SchedulePriceChangeResponse>
{
private readonly IChargeableCharConfigRepository _repo;
private readonly IAuditLogger _audit;
private readonly TimeProvider _timeProvider;
public SchedulePriceChangeCommandHandler(
IChargeableCharConfigRepository repo,
IAuditLogger audit,
TimeProvider timeProvider)
{
_repo = repo;
_audit = audit;
_timeProvider = timeProvider;
}
public async Task<SchedulePriceChangeResponse> Handle(SchedulePriceChangeCommand command)
{
// 1. Load existing row — validates it exists and exposes ProductTypeId/Symbol/Category.
var existing = await _repo.GetByIdAsync(command.Id)
?? throw new KeyNotFoundException($"ChargeableCharConfig con Id={command.Id} no existe.");
// 2. Domain entity validates forward-only rule and builds the new entity value.
// ScheduleNewPrice throws ChargeableCharConfigForwardOnlyException if not strictly forward.
var newEntity = existing.ScheduleNewPrice(command.PricePerUnit, command.ValidFrom, _timeProvider);
// 3. TX + SP + audit (fail-closed).
long newId;
using (var tx = new TransactionScope(
TransactionScopeOption.Required,
new TransactionOptions { IsolationLevel = System.Transactions.IsolationLevel.ReadCommitted },
TransactionScopeAsyncFlowOption.Enabled))
{
newId = await _repo.InsertWithCloseAsync(
newEntity.ProductTypeId,
newEntity.Symbol,
newEntity.Category,
newEntity.PricePerUnit,
newEntity.ValidFrom);
await _audit.LogAsync(
action: "tasacion.chargeable_char.price_change",
targetType: "ChargeableCharConfig",
targetId: newId.ToString(),
metadata: new
{
before = new
{
id = existing.Id,
pricePerUnit = existing.PricePerUnit,
validFrom = existing.ValidFrom.ToString("yyyy-MM-dd"),
},
after = new
{
pricePerUnit = newEntity.PricePerUnit,
validFrom = newEntity.ValidFrom.ToString("yyyy-MM-dd"),
}
});
tx.Complete();
}
return new SchedulePriceChangeResponse(
NewId: newId,
PreviousValidFrom: existing.ValidFrom,
NewValidFrom: newEntity.ValidFrom);
}
}

View File

@@ -1,30 +0,0 @@
using FluentValidation;
using SIGCM2.Application.Common;
namespace SIGCM2.Application.Pricing.ChargeableChars.SchedulePrice;
/// <summary>
/// PRC-001 — FluentValidation validator for SchedulePriceChangeCommand.
/// Surface validation only (price > 0, validFrom >= today_AR, id > 0).
/// Forward-only check (ValidFrom > existing row's ValidFrom) is performed in the handler
/// where the existing entity is loaded.
/// </summary>
public sealed class SchedulePriceChangeCommandValidator : AbstractValidator<SchedulePriceChangeCommand>
{
public SchedulePriceChangeCommandValidator(TimeProvider timeProvider)
{
var today = timeProvider.GetArgentinaToday();
RuleFor(x => x.Id)
.GreaterThan(0L)
.WithMessage("Id debe ser un entero positivo.");
RuleFor(x => x.PricePerUnit)
.GreaterThan(0m)
.WithMessage("PricePerUnit debe ser > 0.");
RuleFor(x => x.ValidFrom)
.GreaterThanOrEqualTo(today)
.WithMessage($"ValidFrom debe ser >= hoy ({today:yyyy-MM-dd} ART).");
}
}

View File

@@ -1,9 +0,0 @@
namespace SIGCM2.Application.Pricing.ChargeableChars.SchedulePrice;
/// <summary>
/// PRC-001 — Response for SchedulePriceChangeCommand.
/// </summary>
public sealed record SchedulePriceChangeResponse(
long NewId,
DateOnly PreviousValidFrom,
DateOnly NewValidFrom);

View File

@@ -1,13 +0,0 @@
namespace SIGCM2.Application.ProductTypes.Create;
public sealed record CreateProductTypeCommand(
string Nombre,
bool HasDuration,
bool RequiresText,
bool RequiresCategory,
bool IsBundle,
bool AllowImages,
int? MaxImages,
decimal? MaxImageSizeMB,
int? MaxImageWidth,
int? MaxImageHeight);

View File

@@ -1,80 +0,0 @@
using System.Transactions;
using SIGCM2.Application.Abstractions;
using SIGCM2.Application.Abstractions.Persistence;
using SIGCM2.Application.Audit;
using SIGCM2.Domain.Entities;
using SIGCM2.Domain.Exceptions;
namespace SIGCM2.Application.ProductTypes.Create;
public sealed class CreateProductTypeCommandHandler
: ICommandHandler<CreateProductTypeCommand, ProductTypeCreatedDto>
{
private readonly IProductTypeRepository _repo;
private readonly IAuditLogger _audit;
private readonly TimeProvider _timeProvider;
public CreateProductTypeCommandHandler(
IProductTypeRepository repo,
IAuditLogger audit,
TimeProvider timeProvider)
{
_repo = repo;
_audit = audit;
_timeProvider = timeProvider;
}
public async Task<ProductTypeCreatedDto> Handle(CreateProductTypeCommand command)
{
// 1. Duplicate name check (before factory — avoids wasting domain allocation on error)
var exists = await _repo.ExistsByNombreAsync(command.Nombre, excludeId: null);
if (exists)
throw new ProductTypeNombreDuplicadoException(command.Nombre);
// 2. Build entity (factory normalizes multimedia if AllowImages=false)
var entity = ProductType.ForCreation(
command.Nombre,
command.HasDuration, command.RequiresText, command.RequiresCategory, command.IsBundle,
command.AllowImages,
command.MaxImages, command.MaxImageSizeMB, command.MaxImageWidth, command.MaxImageHeight,
_timeProvider);
// 3. Persist + audit (fail-closed: if audit throws, TX rolls back)
using var tx = new TransactionScope(
TransactionScopeOption.Required,
new TransactionOptions { IsolationLevel = System.Transactions.IsolationLevel.ReadCommitted },
TransactionScopeAsyncFlowOption.Enabled);
var newId = await _repo.AddAsync(entity);
await _audit.LogAsync(
action: "producto_tipo.created",
targetType: "ProductType",
targetId: newId.ToString(),
metadata: new
{
after = new
{
entity.Nombre,
entity.HasDuration,
entity.RequiresText,
entity.RequiresCategory,
entity.IsBundle,
entity.AllowImages,
entity.MaxImages,
entity.MaxImageSizeMB,
entity.MaxImageWidth,
entity.MaxImageHeight,
}
});
tx.Complete();
return new ProductTypeCreatedDto(
newId, entity.Nombre,
entity.HasDuration, entity.RequiresText, entity.RequiresCategory, entity.IsBundle,
entity.AllowImages,
entity.MaxImages, entity.MaxImageSizeMB, entity.MaxImageWidth, entity.MaxImageHeight,
entity.IsActive);
}
}

View File

@@ -1,29 +0,0 @@
using FluentValidation;
namespace SIGCM2.Application.ProductTypes.Create;
public sealed class CreateProductTypeCommandValidator : AbstractValidator<CreateProductTypeCommand>
{
public CreateProductTypeCommandValidator()
{
RuleFor(x => x.Nombre)
.NotEmpty().WithMessage("El nombre del tipo de producto es requerido.")
.MaximumLength(200).WithMessage("El nombre no puede superar los 200 caracteres.");
RuleFor(x => x.MaxImages)
.GreaterThan(0).When(x => x.MaxImages.HasValue)
.WithMessage("MaxImages debe ser mayor que 0 (o null para sin límite).");
RuleFor(x => x.MaxImageSizeMB)
.GreaterThan(0).When(x => x.MaxImageSizeMB.HasValue)
.WithMessage("MaxImageSizeMB debe ser mayor que 0 (o null para sin límite).");
RuleFor(x => x.MaxImageWidth)
.GreaterThan(0).When(x => x.MaxImageWidth.HasValue)
.WithMessage("MaxImageWidth debe ser mayor que 0 (o null para sin límite).");
RuleFor(x => x.MaxImageHeight)
.GreaterThan(0).When(x => x.MaxImageHeight.HasValue)
.WithMessage("MaxImageHeight debe ser mayor que 0 (o null para sin límite).");
}
}

View File

@@ -1,15 +0,0 @@
namespace SIGCM2.Application.ProductTypes.Create;
public sealed record ProductTypeCreatedDto(
int Id,
string Nombre,
bool HasDuration,
bool RequiresText,
bool RequiresCategory,
bool IsBundle,
bool AllowImages,
int? MaxImages,
decimal? MaxImageSizeMB,
int? MaxImageWidth,
int? MaxImageHeight,
bool IsActive);

View File

@@ -1,3 +0,0 @@
namespace SIGCM2.Application.ProductTypes.Deactivate;
public sealed record DeactivateProductTypeCommand(int Id);

View File

@@ -1,70 +0,0 @@
using System.Transactions;
using SIGCM2.Application.Abstractions;
using SIGCM2.Application.Abstractions.Persistence;
using SIGCM2.Application.Audit;
using SIGCM2.Domain.Entities;
using SIGCM2.Domain.Exceptions;
namespace SIGCM2.Application.ProductTypes.Deactivate;
public sealed class DeactivateProductTypeCommandHandler
: ICommandHandler<DeactivateProductTypeCommand, ProductTypeStatusDto>
{
private readonly IProductTypeRepository _repo;
private readonly IProductQueryRepository _productQuery;
private readonly IAuditLogger _audit;
private readonly TimeProvider _timeProvider;
public DeactivateProductTypeCommandHandler(
IProductTypeRepository repo,
IProductQueryRepository productQuery,
IAuditLogger audit,
TimeProvider timeProvider)
{
_repo = repo;
_productQuery = productQuery;
_audit = audit;
_timeProvider = timeProvider;
}
public async Task<ProductTypeStatusDto> Handle(DeactivateProductTypeCommand command)
{
// 1. Load entity
var target = await _repo.GetByIdAsync(command.Id)
?? throw new ProductTypeNotFoundException(command.Id);
// 2. Idempotent: already inactive → return without side effects (I7)
if (!target.IsActive)
return new ProductTypeStatusDto(command.Id, false);
// 3. Guard: check if any active product uses this type
var inUse = await _productQuery.ExistsActiveByProductTypeAsync(command.Id);
if (inUse)
throw new ProductTypeEnUsoException(command.Id, productsActivos: 1);
// 4. Deactivate (immutable — returns new instance)
var deactivated = target.WithDeactivated(_timeProvider);
// 5. Persist + audit (fail-closed)
using var tx = new TransactionScope(
TransactionScopeOption.Required,
new TransactionOptions { IsolationLevel = System.Transactions.IsolationLevel.ReadCommitted },
TransactionScopeAsyncFlowOption.Enabled);
await _repo.UpdateAsync(deactivated);
await _audit.LogAsync(
action: "producto_tipo.deactivated",
targetType: "ProductType",
targetId: command.Id.ToString(),
metadata: new
{
productTypeId = command.Id,
nombre = target.Nombre,
});
tx.Complete();
return new ProductTypeStatusDto(deactivated.Id, deactivated.IsActive);
}
}

View File

@@ -1,3 +0,0 @@
namespace SIGCM2.Application.ProductTypes.Deactivate;
public sealed record ProductTypeStatusDto(int Id, bool IsActive);

View File

@@ -1,3 +0,0 @@
namespace SIGCM2.Application.ProductTypes.GetById;
public sealed record GetProductTypeByIdQuery(int Id);

View File

@@ -1,30 +0,0 @@
using SIGCM2.Application.Abstractions;
using SIGCM2.Application.Abstractions.Persistence;
using SIGCM2.Domain.Exceptions;
namespace SIGCM2.Application.ProductTypes.GetById;
public sealed class GetProductTypeByIdQueryHandler
: ICommandHandler<GetProductTypeByIdQuery, ProductTypeDetailDto>
{
private readonly IProductTypeRepository _repo;
public GetProductTypeByIdQueryHandler(IProductTypeRepository repo)
{
_repo = repo;
}
public async Task<ProductTypeDetailDto> Handle(GetProductTypeByIdQuery query)
{
var pt = await _repo.GetByIdAsync(query.Id)
?? throw new ProductTypeNotFoundException(query.Id);
return new ProductTypeDetailDto(
pt.Id, pt.Nombre,
pt.HasDuration, pt.RequiresText, pt.RequiresCategory, pt.IsBundle,
pt.AllowImages,
pt.MaxImages, pt.MaxImageSizeMB, pt.MaxImageWidth, pt.MaxImageHeight,
pt.IsActive,
pt.FechaCreacion, pt.FechaModificacion);
}
}

View File

@@ -1,17 +0,0 @@
namespace SIGCM2.Application.ProductTypes.GetById;
public sealed record ProductTypeDetailDto(
int Id,
string Nombre,
bool HasDuration,
bool RequiresText,
bool RequiresCategory,
bool IsBundle,
bool AllowImages,
int? MaxImages,
decimal? MaxImageSizeMB,
int? MaxImageWidth,
int? MaxImageHeight,
bool IsActive,
DateTime FechaCreacion,
DateTime? FechaModificacion);

View File

@@ -1,7 +0,0 @@
namespace SIGCM2.Application.ProductTypes.List;
public sealed record ListProductTypesQuery(
int Page = 1,
int PageSize = 20,
bool? Activo = true,
string? Search = null);

View File

@@ -1,32 +0,0 @@
using SIGCM2.Application.Abstractions;
using SIGCM2.Application.Abstractions.Persistence;
using SIGCM2.Application.Common;
namespace SIGCM2.Application.ProductTypes.List;
public sealed class ListProductTypesQueryHandler
: ICommandHandler<ListProductTypesQuery, PagedResult<ProductTypeListItemDto>>
{
private readonly IProductTypeRepository _repo;
public ListProductTypesQueryHandler(IProductTypeRepository repo)
{
_repo = repo;
}
public async Task<PagedResult<ProductTypeListItemDto>> Handle(ListProductTypesQuery query)
{
var page = Math.Max(1, query.Page);
var pageSize = Math.Clamp(query.PageSize, 1, 100);
var repoQuery = new ProductTypesQuery(page, pageSize, query.Activo, query.Search);
var paged = await _repo.GetPagedAsync(repoQuery);
var items = paged.Items.Select(p => new ProductTypeListItemDto(
p.Id, p.Nombre,
p.HasDuration, p.RequiresText, p.RequiresCategory, p.IsBundle,
p.AllowImages, p.IsActive)).ToList();
return new PagedResult<ProductTypeListItemDto>(items, paged.Page, paged.PageSize, paged.Total);
}
}

View File

@@ -1,11 +0,0 @@
namespace SIGCM2.Application.ProductTypes.List;
public sealed record ProductTypeListItemDto(
int Id,
string Nombre,
bool HasDuration,
bool RequiresText,
bool RequiresCategory,
bool IsBundle,
bool AllowImages,
bool IsActive);

View File

@@ -1,16 +0,0 @@
namespace SIGCM2.Application.ProductTypes.Update;
public sealed record ProductTypeUpdatedDto(
int Id,
string Nombre,
bool HasDuration,
bool RequiresText,
bool RequiresCategory,
bool IsBundle,
bool AllowImages,
int? MaxImages,
decimal? MaxImageSizeMB,
int? MaxImageWidth,
int? MaxImageHeight,
bool IsActive,
DateTime? FechaModificacion);

View File

@@ -1,14 +0,0 @@
namespace SIGCM2.Application.ProductTypes.Update;
public sealed record UpdateProductTypeCommand(
int Id,
string Nombre,
bool HasDuration,
bool RequiresText,
bool RequiresCategory,
bool IsBundle,
bool AllowImages,
int? MaxImages,
decimal? MaxImageSizeMB,
int? MaxImageWidth,
int? MaxImageHeight);

View File

@@ -1,74 +0,0 @@
using System.Transactions;
using SIGCM2.Application.Abstractions;
using SIGCM2.Application.Abstractions.Persistence;
using SIGCM2.Application.Audit;
using SIGCM2.Domain.Entities;
using SIGCM2.Domain.Exceptions;
namespace SIGCM2.Application.ProductTypes.Update;
public sealed class UpdateProductTypeCommandHandler
: ICommandHandler<UpdateProductTypeCommand, ProductTypeUpdatedDto>
{
private readonly IProductTypeRepository _repo;
private readonly IAuditLogger _audit;
private readonly TimeProvider _timeProvider;
public UpdateProductTypeCommandHandler(
IProductTypeRepository repo,
IAuditLogger audit,
TimeProvider timeProvider)
{
_repo = repo;
_audit = audit;
_timeProvider = timeProvider;
}
public async Task<ProductTypeUpdatedDto> Handle(UpdateProductTypeCommand command)
{
// 1. Load entity (throws if not found)
var target = await _repo.GetByIdAsync(command.Id)
?? throw new ProductTypeNotFoundException(command.Id);
// 2. If nombre changed, check for duplicate (skip call when same name — optimization)
if (!string.Equals(command.Nombre, target.Nombre, StringComparison.OrdinalIgnoreCase))
{
var duplicateExists = await _repo.ExistsByNombreAsync(command.Nombre, excludeId: command.Id);
if (duplicateExists)
throw new ProductTypeNombreDuplicadoException(command.Nombre);
}
// 3. Build updated entity via With* methods (immutable, each returns new instance)
var updated = target
.WithRenamed(command.Nombre, _timeProvider)
.WithUpdatedFlags(command.HasDuration, command.RequiresText, command.RequiresCategory, command.IsBundle, _timeProvider)
.WithUpdatedMultimedia(command.AllowImages, command.MaxImages, command.MaxImageSizeMB, command.MaxImageWidth, command.MaxImageHeight, _timeProvider);
// 4. Persist + audit (fail-closed)
using var tx = new TransactionScope(
TransactionScopeOption.Required,
new TransactionOptions { IsolationLevel = System.Transactions.IsolationLevel.ReadCommitted },
TransactionScopeAsyncFlowOption.Enabled);
await _repo.UpdateAsync(updated);
await _audit.LogAsync(
action: "producto_tipo.updated",
targetType: "ProductType",
targetId: command.Id.ToString(),
metadata: new
{
before = new { target.Nombre, target.HasDuration, target.RequiresText, target.RequiresCategory, target.IsBundle, target.AllowImages },
after = new { updated.Nombre, updated.HasDuration, updated.RequiresText, updated.RequiresCategory, updated.IsBundle, updated.AllowImages }
});
tx.Complete();
return new ProductTypeUpdatedDto(
updated.Id, updated.Nombre,
updated.HasDuration, updated.RequiresText, updated.RequiresCategory, updated.IsBundle,
updated.AllowImages,
updated.MaxImages, updated.MaxImageSizeMB, updated.MaxImageWidth, updated.MaxImageHeight,
updated.IsActive, updated.FechaModificacion);
}
}

View File

@@ -1,32 +0,0 @@
using FluentValidation;
namespace SIGCM2.Application.ProductTypes.Update;
public sealed class UpdateProductTypeCommandValidator : AbstractValidator<UpdateProductTypeCommand>
{
public UpdateProductTypeCommandValidator()
{
RuleFor(x => x.Id)
.GreaterThan(0).WithMessage("El Id debe ser un entero positivo.");
RuleFor(x => x.Nombre)
.NotEmpty().WithMessage("El nombre del tipo de producto es requerido.")
.MaximumLength(200).WithMessage("El nombre no puede superar los 200 caracteres.");
RuleFor(x => x.MaxImages)
.GreaterThan(0).When(x => x.MaxImages.HasValue)
.WithMessage("MaxImages debe ser mayor que 0 (o null para sin límite).");
RuleFor(x => x.MaxImageSizeMB)
.GreaterThan(0).When(x => x.MaxImageSizeMB.HasValue)
.WithMessage("MaxImageSizeMB debe ser mayor que 0 (o null para sin límite).");
RuleFor(x => x.MaxImageWidth)
.GreaterThan(0).When(x => x.MaxImageWidth.HasValue)
.WithMessage("MaxImageWidth debe ser mayor que 0 (o null para sin límite).");
RuleFor(x => x.MaxImageHeight)
.GreaterThan(0).When(x => x.MaxImageHeight.HasValue)
.WithMessage("MaxImageHeight debe ser mayor que 0 (o null para sin límite).");
}
}

View File

@@ -1,9 +0,0 @@
namespace SIGCM2.Application.Products.Create;
public sealed record CreateProductCommand(
string Nombre,
int MedioId,
int ProductTypeId,
int? RubroId,
decimal BasePrice,
int? PriceDurationDays);

View File

@@ -1,112 +0,0 @@
using System.Transactions;
using SIGCM2.Application.Abstractions;
using SIGCM2.Application.Abstractions.Persistence;
using SIGCM2.Application.Audit;
using SIGCM2.Domain.Entities;
using SIGCM2.Domain.Exceptions;
namespace SIGCM2.Application.Products.Create;
public sealed class CreateProductCommandHandler
: ICommandHandler<CreateProductCommand, ProductCreatedDto>
{
private readonly IProductRepository _repo;
private readonly IProductTypeRepository _ptRepo;
private readonly IMedioRepository _medioRepo;
private readonly IRubroRepository _rubroRepo;
private readonly IAuditLogger _audit;
private readonly TimeProvider _timeProvider;
public CreateProductCommandHandler(
IProductRepository repo,
IProductTypeRepository ptRepo,
IMedioRepository medioRepo,
IRubroRepository rubroRepo,
IAuditLogger audit,
TimeProvider timeProvider)
{
_repo = repo;
_ptRepo = ptRepo;
_medioRepo = medioRepo;
_rubroRepo = rubroRepo;
_audit = audit;
_timeProvider = timeProvider;
}
public async Task<ProductCreatedDto> Handle(CreateProductCommand command)
{
// 1. Validate Medio exists and is active
var medio = await _medioRepo.GetByIdAsync(command.MedioId)
?? throw new MedioNotFoundException(command.MedioId);
if (!medio.Activo)
throw new MedioInactivoException(command.MedioId);
// 2. Validate ProductType exists and is active
var productType = await _ptRepo.GetByIdAsync(command.ProductTypeId)
?? throw new ProductTypeNotFoundException(command.ProductTypeId);
if (!productType.IsActive)
throw new ProductTypeInactivoException(command.ProductTypeId);
// 3. Flags coherence: RequiresCategory → RubroId required
if (productType.RequiresCategory && !command.RubroId.HasValue)
throw new ProductTipoFlagsIncoherentesException(
$"El tipo '{productType.Nombre}' requiere RubroId (RequiresCategory=true)", "rubroId");
// 4. Flags coherence: HasDuration → PriceDurationDays required
if (productType.HasDuration && !command.PriceDurationDays.HasValue)
throw new ProductTipoFlagsIncoherentesException(
$"El tipo '{productType.Nombre}' requiere PriceDurationDays (HasDuration=true)", "priceDurationDays");
// 5. Validate Rubro if provided: must be active
if (command.RubroId.HasValue)
{
var rubro = await _rubroRepo.GetByIdAsync(command.RubroId.Value);
if (rubro == null || !rubro.Activo)
throw new RubroInactivoException(command.RubroId.Value);
}
// 6. Duplicate nombre check (filtered on IsActive=1 — allows reuse after soft-delete)
var exists = await _repo.ExistsByNombreAsync(command.Nombre, command.MedioId, command.ProductTypeId, excludeId: null);
if (exists)
throw new ProductNombreDuplicadoEnMedioTipoException(command.MedioId, command.ProductTypeId, command.Nombre);
// 7. Build entity
var entity = Product.ForCreation(
command.Nombre, command.MedioId, command.ProductTypeId,
command.RubroId, command.BasePrice, command.PriceDurationDays,
_timeProvider);
// 8. Persist + audit (fail-closed)
using var tx = new TransactionScope(
TransactionScopeOption.Required,
new TransactionOptions { IsolationLevel = System.Transactions.IsolationLevel.ReadCommitted },
TransactionScopeAsyncFlowOption.Enabled);
var newId = await _repo.AddAsync(entity);
await _audit.LogAsync(
action: "producto.created",
targetType: "Product",
targetId: newId.ToString(),
metadata: new
{
after = new
{
entity.Nombre,
entity.MedioId,
entity.ProductTypeId,
entity.RubroId,
entity.BasePrice,
entity.PriceDurationDays,
}
});
tx.Complete();
return new ProductCreatedDto(
newId, entity.Nombre,
entity.MedioId, entity.ProductTypeId, entity.RubroId,
entity.BasePrice, entity.PriceDurationDays,
entity.IsActive, entity.FechaCreacion);
}
}

View File

@@ -1,26 +0,0 @@
using FluentValidation;
namespace SIGCM2.Application.Products.Create;
public sealed class CreateProductCommandValidator : AbstractValidator<CreateProductCommand>
{
public CreateProductCommandValidator()
{
RuleFor(x => x.Nombre)
.NotEmpty().WithMessage("El nombre del producto es requerido.")
.MaximumLength(300).WithMessage("El nombre no puede superar los 300 caracteres.");
RuleFor(x => x.MedioId)
.GreaterThan(0).WithMessage("MedioId debe ser un entero positivo.");
RuleFor(x => x.ProductTypeId)
.GreaterThan(0).WithMessage("ProductTypeId debe ser un entero positivo.");
RuleFor(x => x.BasePrice)
.GreaterThanOrEqualTo(0m).WithMessage("El precio base no puede ser negativo.");
RuleFor(x => x.PriceDurationDays)
.GreaterThan(0).When(x => x.PriceDurationDays.HasValue)
.WithMessage("PriceDurationDays debe ser >= 1 cuando se provee.");
}
}

View File

@@ -1,12 +0,0 @@
namespace SIGCM2.Application.Products.Create;
public sealed record ProductCreatedDto(
int Id,
string Nombre,
int MedioId,
int ProductTypeId,
int? RubroId,
decimal BasePrice,
int? PriceDurationDays,
bool IsActive,
DateTime FechaCreacion);

View File

@@ -1,3 +0,0 @@
namespace SIGCM2.Application.Products.Deactivate;
public sealed record DeactivateProductCommand(int Id);

View File

@@ -1,62 +0,0 @@
using System.Transactions;
using SIGCM2.Application.Abstractions;
using SIGCM2.Application.Abstractions.Persistence;
using SIGCM2.Application.Audit;
using SIGCM2.Domain.Entities;
using SIGCM2.Domain.Exceptions;
namespace SIGCM2.Application.Products.Deactivate;
public sealed class DeactivateProductCommandHandler
: ICommandHandler<DeactivateProductCommand, ProductStatusDto>
{
private readonly IProductRepository _repo;
private readonly IAuditLogger _audit;
private readonly TimeProvider _timeProvider;
public DeactivateProductCommandHandler(
IProductRepository repo,
IAuditLogger audit,
TimeProvider timeProvider)
{
_repo = repo;
_audit = audit;
_timeProvider = timeProvider;
}
public async Task<ProductStatusDto> Handle(DeactivateProductCommand command)
{
// 1. Load entity
var target = await _repo.GetByIdAsync(command.Id)
?? throw new ProductNotFoundException(command.Id);
// 2. Idempotent: already inactive → return without side effects
if (!target.IsActive)
return new ProductStatusDto(command.Id, false, target.FechaModificacion);
// 3. Deactivate (immutable)
var deactivated = target.WithDeactivated(_timeProvider);
// 4. Persist + audit (fail-closed)
using var tx = new TransactionScope(
TransactionScopeOption.Required,
new TransactionOptions { IsolationLevel = System.Transactions.IsolationLevel.ReadCommitted },
TransactionScopeAsyncFlowOption.Enabled);
await _repo.UpdateAsync(deactivated);
await _audit.LogAsync(
action: "producto.deactivated",
targetType: "Product",
targetId: command.Id.ToString(),
metadata: new
{
productId = command.Id,
nombre = target.Nombre,
});
tx.Complete();
return new ProductStatusDto(deactivated.Id, deactivated.IsActive, deactivated.FechaModificacion);
}
}

View File

@@ -1,6 +0,0 @@
namespace SIGCM2.Application.Products.Deactivate;
public sealed record ProductStatusDto(
int Id,
bool IsActive,
DateTime? FechaModificacion);

View File

@@ -1,3 +0,0 @@
namespace SIGCM2.Application.Products.GetById;
public sealed record GetProductByIdQuery(int Id);

View File

@@ -1,29 +0,0 @@
using SIGCM2.Application.Abstractions;
using SIGCM2.Application.Abstractions.Persistence;
using SIGCM2.Domain.Exceptions;
namespace SIGCM2.Application.Products.GetById;
public sealed class GetProductByIdQueryHandler
: ICommandHandler<GetProductByIdQuery, ProductDetailDto>
{
private readonly IProductRepository _repo;
public GetProductByIdQueryHandler(IProductRepository repo)
{
_repo = repo;
}
public async Task<ProductDetailDto> Handle(GetProductByIdQuery query)
{
var product = await _repo.GetByIdAsync(query.Id)
?? throw new ProductNotFoundException(query.Id);
return new ProductDetailDto(
product.Id, product.Nombre,
product.MedioId, product.ProductTypeId, product.RubroId,
product.BasePrice, product.PriceDurationDays,
product.IsActive,
product.FechaCreacion, product.FechaModificacion);
}
}

View File

@@ -1,13 +0,0 @@
namespace SIGCM2.Application.Products.GetById;
public sealed record ProductDetailDto(
int Id,
string Nombre,
int MedioId,
int ProductTypeId,
int? RubroId,
decimal BasePrice,
int? PriceDurationDays,
bool IsActive,
DateTime FechaCreacion,
DateTime? FechaModificacion);

View File

@@ -1,10 +0,0 @@
namespace SIGCM2.Application.Products.List;
public sealed record ListProductsQuery(
int Page = 1,
int PageSize = 20,
bool? Activo = true,
string? Search = null,
int? MedioId = null,
int? ProductTypeId = null,
int? RubroId = null);

View File

@@ -1,35 +0,0 @@
using SIGCM2.Application.Abstractions;
using SIGCM2.Application.Abstractions.Persistence;
using SIGCM2.Application.Common;
namespace SIGCM2.Application.Products.List;
public sealed class ListProductsQueryHandler
: ICommandHandler<ListProductsQuery, PagedResult<ProductListItemDto>>
{
private readonly IProductRepository _repo;
public ListProductsQueryHandler(IProductRepository repo)
{
_repo = repo;
}
public async Task<PagedResult<ProductListItemDto>> Handle(ListProductsQuery query)
{
var page = Math.Max(1, query.Page);
var pageSize = Math.Clamp(query.PageSize, 1, 100);
var repoQuery = new ProductsQuery(
page, pageSize, query.Activo, query.Search,
query.MedioId, query.ProductTypeId, query.RubroId);
var paged = await _repo.GetPagedAsync(repoQuery);
var items = paged.Items.Select(p => new ProductListItemDto(
p.Id, p.Nombre,
p.MedioId, p.ProductTypeId, p.RubroId,
p.BasePrice, p.PriceDurationDays,
p.IsActive)).ToList();
return new PagedResult<ProductListItemDto>(items, paged.Page, paged.PageSize, paged.Total);
}
}

View File

@@ -1,11 +0,0 @@
namespace SIGCM2.Application.Products.List;
public sealed record ProductListItemDto(
int Id,
string Nombre,
int MedioId,
int ProductTypeId,
int? RubroId,
decimal BasePrice,
int? PriceDurationDays,
bool IsActive);

View File

@@ -1,10 +0,0 @@
namespace SIGCM2.Application.Products.Prices.AddPrice;
/// <summary>
/// PRD-003 — Comando para registrar un nuevo precio histórico para un Product.
/// Price debe ser > 0. PriceValidFrom debe ser >= hoy_AR (Cat2, TimeProvider).
/// </summary>
public sealed record AddProductPriceCommand(
int ProductId,
decimal Price,
DateOnly PriceValidFrom);

View File

@@ -1,92 +0,0 @@
using System.Transactions;
using SIGCM2.Application.Abstractions;
using SIGCM2.Application.Abstractions.Persistence;
using SIGCM2.Application.Audit;
using SIGCM2.Domain.Entities;
using SIGCM2.Domain.Exceptions;
namespace SIGCM2.Application.Products.Prices.AddPrice;
/// <summary>
/// PRD-003 — Handler del comando AddProductPrice.
/// Flujo: verifica producto activo → abre TransactionScope (AsyncFlow) →
/// AddAsync (SP usp_AddProductPrice) → IAuditLogger.LogAsync (fail-closed) →
/// tx.Complete() → construye response con GetByProductIdAsync.
/// </summary>
public sealed class AddProductPriceCommandHandler
: ICommandHandler<AddProductPriceCommand, AddProductPriceResponse>
{
private readonly IProductPriceRepository _pricesRepo;
private readonly IProductRepository _productsRepo;
private readonly IAuditLogger _audit;
private readonly TimeProvider _timeProvider;
public AddProductPriceCommandHandler(
IProductPriceRepository pricesRepo,
IProductRepository productsRepo,
IAuditLogger audit,
TimeProvider timeProvider)
{
_pricesRepo = pricesRepo;
_productsRepo = productsRepo;
_audit = audit;
_timeProvider = timeProvider;
}
public async Task<AddProductPriceResponse> Handle(AddProductPriceCommand command)
{
// 1. Producto debe existir Y estar activo (defensa Application — el SP también valida en BD).
var product = await _productsRepo.GetByIdAsync(command.ProductId)
?? throw new ProductNotFoundException(command.ProductId);
if (!product.IsActive)
throw new ProductNotFoundException(command.ProductId); // inactivo = invisible para clientes
// 2. TX + SP + audit (fail-closed).
// El audit.LogAsync enlista en el mismo TransactionScope — si falla, rollback total.
// GetByProductIdAsync se ejecuta FUERA del scope (post-commit) para evitar
// "TransactionScope is already complete" al abrir una nueva conexión dentro del using.
long newId;
long? closedId;
using (var tx = new TransactionScope(
TransactionScopeOption.Required,
new TransactionOptions { IsolationLevel = System.Transactions.IsolationLevel.ReadCommitted },
TransactionScopeAsyncFlowOption.Enabled))
{
(newId, closedId) = await _pricesRepo.AddAsync(
command.ProductId, command.Price, command.PriceValidFrom);
await _audit.LogAsync(
action: "product_price.created",
targetType: "ProductPrice",
targetId: newId.ToString(),
metadata: new
{
after = new
{
command.ProductId,
command.Price,
priceValidFrom = command.PriceValidFrom.ToString("yyyy-MM-dd"),
},
closedPriceId = closedId
});
tx.Complete();
} // TX disposed (committed) here — BEFORE the post-commit read below.
// 3. Compongo la respuesta post-commit con lectura de historial actualizado.
// La primera página (pageSize=2) es suficiente: solo necesitamos el nuevo y el cerrado,
// que son siempre los más recientes (ORDER BY PriceValidFrom DESC).
var pricesPage = await _pricesRepo.GetByProductIdAsync(command.ProductId, page: 1, pageSize: 2);
var prices = pricesPage.Items;
var created = prices.Single(p => p.Id == newId);
var closed = closedId.HasValue
? prices.SingleOrDefault(p => p.Id == closedId.Value)
: null;
return new AddProductPriceResponse(ToDto(created), closed is null ? null : ToDto(closed));
}
private static ProductPriceDto ToDto(ProductPrice p)
=> new(p.Id, p.ProductId, p.Price, p.PriceValidFrom, p.PriceValidTo, p.IsActive);
}

View File

@@ -1,29 +0,0 @@
using FluentValidation;
using SIGCM2.Application.Common;
namespace SIGCM2.Application.Products.Prices.AddPrice;
/// <summary>
/// PRD-003 — FluentValidation validator para AddProductPriceCommand.
/// Inyecta TimeProvider para obtener hoy_AR (Cat2, nunca DateTime.Now).
/// FakeTimeProvider en tests garantiza determinismo.
/// </summary>
public sealed class AddProductPriceCommandValidator : AbstractValidator<AddProductPriceCommand>
{
public AddProductPriceCommandValidator(TimeProvider timeProvider)
{
var today = timeProvider.GetArgentinaToday();
RuleFor(x => x.ProductId)
.GreaterThan(0)
.WithMessage("ProductId debe ser un entero positivo.");
RuleFor(x => x.Price)
.GreaterThan(0m)
.WithMessage("El precio debe ser mayor a cero.");
RuleFor(x => x.PriceValidFrom)
.GreaterThanOrEqualTo(today)
.WithMessage($"PriceValidFrom debe ser >= hoy ({today:yyyy-MM-dd} ART). No se permiten precios con fecha retroactiva.");
}
}

View File

@@ -1,9 +0,0 @@
namespace SIGCM2.Application.Products.Prices.AddPrice;
/// <summary>
/// PRD-003 — Respuesta del comando AddProductPrice.
/// Closed es null si era el primer precio registrado para el producto.
/// </summary>
public sealed record AddProductPriceResponse(
ProductPriceDto Created,
ProductPriceDto? Closed);

View File

@@ -1,12 +0,0 @@
namespace SIGCM2.Application.Products.Prices.GetHistory;
/// <summary>
/// PRD-003 (paginated) — Query para obtener el historial de precios de un Product.
/// Devuelve PagedResult ordenado descending por PriceValidFrom (activo primero).
/// Lanza ProductNotFoundException si el producto no existe.
/// Page y PageSize son clampeados por el handler: page ≥ 1, pageSize ∈ [1, 100].
/// </summary>
public sealed record GetProductPricesQuery(
int ProductId,
int Page = 1,
int PageSize = 20);

View File

@@ -1,49 +0,0 @@
using SIGCM2.Application.Abstractions;
using SIGCM2.Application.Abstractions.Persistence;
using SIGCM2.Application.Common;
using SIGCM2.Application.Products.Prices;
using SIGCM2.Domain.Exceptions;
namespace SIGCM2.Application.Products.Prices.GetHistory;
/// <summary>
/// PRD-003 (paginated) — Handler de GetProductPricesQuery.
/// Verifica que el producto exista (404 si no), aplica clamping defensivo de
/// page/pageSize y retorna PagedResult ordenado descending por PriceValidFrom.
/// Lista vacía es válida (nuevo producto sin precios o página más allá del total).
/// </summary>
public sealed class GetProductPricesQueryHandler
: ICommandHandler<GetProductPricesQuery, PagedResult<ProductPriceDto>>
{
private readonly IProductPriceRepository _pricesRepo;
private readonly IProductRepository _productsRepo;
public GetProductPricesQueryHandler(
IProductPriceRepository pricesRepo,
IProductRepository productsRepo)
{
_pricesRepo = pricesRepo;
_productsRepo = productsRepo;
}
public async Task<PagedResult<ProductPriceDto>> Handle(GetProductPricesQuery query)
{
// Verifica existencia del producto (lanza 404 si no existe).
_ = await _productsRepo.GetByIdAsync(query.ProductId)
?? throw new ProductNotFoundException(query.ProductId);
// Clamping defensivo — igual al patrón de ListProductsQueryHandler.
var page = Math.Max(1, query.Page);
var pageSize = Math.Clamp(query.PageSize, 1, 100);
var paged = await _pricesRepo.GetByProductIdAsync(query.ProductId, page, pageSize);
var dtoItems = paged.Items
.Select(p => new ProductPriceDto(
p.Id, p.ProductId, p.Price,
p.PriceValidFrom, p.PriceValidTo, p.IsActive))
.ToList();
return new PagedResult<ProductPriceDto>(dtoItems, paged.Page, paged.PageSize, paged.Total);
}
}

Some files were not shown because too many files have changed in this diff Show More