Compare commits
147 Commits
d4b2183628
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 5a231c206e | |||
| bcb0c94fc5 | |||
| 2aae873a4b | |||
| 3a534f7ad3 | |||
| dfeb5fb7e1 | |||
| 3e7c4bfde9 | |||
| 0eab947975 | |||
| ee36d86b5a | |||
| 0e2e4c9c94 | |||
| 3a596080cb | |||
| d7c6cbd4ff | |||
| 40b5f3904a | |||
| 3eecb05634 | |||
| f7fb76219a | |||
| 5c1675e59a | |||
| 5175cc1ece | |||
| c2a0612a70 | |||
| 8fc7b363d5 | |||
| 3b1edfd696 | |||
| f1b38cd9ce | |||
| ded76fcdc7 | |||
| 8ac91a13aa | |||
| 9144c2e89e | |||
| dd4d4a1673 | |||
| e997409e95 | |||
| 34b07a1d55 | |||
| 0dce3ee4ac | |||
| da063ad677 | |||
| 7d06ac721b | |||
| 5a55fdaaae | |||
| 9f1a312bb9 | |||
| dd0e5e4fe8 | |||
| 7cabb677f3 | |||
| 6a9818b0ae | |||
| f6f24bc4be | |||
| 2d2e90fa3c | |||
| 4b0567d252 | |||
| 54b0265994 | |||
| 59f30cddfb | |||
| e735afb5b4 | |||
| 50a5118a78 | |||
| c974e824e0 | |||
| 900fd5e975 | |||
| e9d1e3237d | |||
| e33e9f332e | |||
| 0e363d1cfc | |||
| c5a8cd9edd | |||
| 616f6432d1 | |||
| 1730b0623e | |||
| d7fb3105fa | |||
| b4f17d6961 | |||
| a7cfcdb683 | |||
| 0f5455aba6 | |||
| 2b79b6f769 | |||
| d262454b28 | |||
| 08a4738daf | |||
| a41a4ea341 | |||
| 165abc8245 | |||
| 733ca0e2e2 | |||
| 8c9a50504d | |||
| bb455be745 | |||
| 8b555e1f8b | |||
| 16197cf242 | |||
| 0462970ea1 | |||
| d6ec618ff2 | |||
| 230405e056 | |||
| 9cb1e84ec0 | |||
| 3db4dedb91 | |||
| 170789886b | |||
| 936d1dc353 | |||
| 5c8f19bf39 | |||
| 3c9e852379 | |||
| 132d17c99f | |||
| de70152d3e | |||
| d8d1da8ea4 | |||
| a0a1874ac2 | |||
| 4f25233bab | |||
| bb5dde6e24 | |||
| f861dfa826 | |||
| c03aad8c5a | |||
| 216983623a | |||
| 9e50a929ae | |||
| 673194e249 | |||
| ddd28ea4d5 | |||
| 205f9c76ad | |||
| 389dda6e5e | |||
| bd2febf411 | |||
| 46ef3878de | |||
| 022a36a90c | |||
| f07802f769 | |||
| b22e9fe59a | |||
| 5e2323e0bc | |||
| f8e9d18379 | |||
| d9fc9a2867 | |||
| dcb2e5ada6 | |||
| 9f78425a93 | |||
| 0d50d4f3cc | |||
| 9886524645 | |||
| bcbba2c012 | |||
| 3cb89f80a3 | |||
| 18ce4f6841 | |||
| 8daadc8a77 | |||
| a0dcc7258b | |||
| e5b6c06f64 | |||
| e0b9cba948 | |||
| 03a695feb9 | |||
| e987228f14 | |||
| d4a2b3bc3e | |||
| 50a3c87b14 | |||
| 9957724c40 | |||
| 1cb69cbaf3 | |||
| 8353f73230 | |||
| 01ad4cbfbc | |||
| 67da544bb4 | |||
| b79dfb2f34 | |||
| ff912cc6a9 | |||
| 8d2618e6e5 | |||
| a5fd3e90fb | |||
| 50f713dc10 | |||
| b5ec0c25a9 | |||
| a39427865f | |||
| 202d267e16 | |||
| 8b369b69ee | |||
| d16da502f4 | |||
| 408c97559b | |||
| ef4b02be3b | |||
| 03a02c63d5 | |||
| 71d0928389 | |||
| 20b5863908 | |||
| 7e23a16062 | |||
| 2ea7678129 | |||
| bc3e5d99a1 | |||
| 9bc191c3ae | |||
| a9838427a4 | |||
| d69da5ff4c | |||
| 4e1d8f69ab | |||
| 3c264aa7a1 | |||
| a75d2f75a0 | |||
| 8dd668d5c5 | |||
| 54d2340bb9 | |||
| 4e70b0f847 | |||
| 03d51d4310 | |||
| 7e4a096f24 | |||
| cc4efe9ef2 | |||
| 7913dd8bb9 | |||
| a51a7bc07e | |||
| be6f76d107 |
@@ -19,6 +19,7 @@
|
||||
</ItemGroup>
|
||||
<!-- Test dependencies -->
|
||||
<ItemGroup>
|
||||
<PackageVersion Include="Microsoft.Extensions.TimeProvider.Testing" Version="9.1.0" />
|
||||
<PackageVersion Include="xunit" Version="2.9.3" />
|
||||
<PackageVersion Include="xunit.runner.visualstudio" Version="3.1.4" />
|
||||
<PackageVersion Include="Microsoft.NET.Test.Sdk" Version="17.14.1" />
|
||||
|
||||
21
README.md
21
README.md
@@ -73,6 +73,27 @@ dotnet test tests/SIGCM2.Api.Tests # integration (requiere SIGCM2_
|
||||
cd src/web && npx vitest run
|
||||
```
|
||||
|
||||
### 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
|
||||
|
||||
- Ramas: `feature/UDT-XXX` desde `main`.
|
||||
|
||||
48
coverlet.runsettings
Normal file
48
coverlet.runsettings
Normal file
@@ -0,0 +1,48 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<RunSettings>
|
||||
<!--
|
||||
Configuracion de coverage con coverlet.collector.
|
||||
Uso: dotnet test /collect:"XPlat Code Coverage" /settings:coverlet.runsettings /results-directory:./TestResults
|
||||
-->
|
||||
<RunConfiguration>
|
||||
<!-- Mantener ejecución secuencial (hereda política de tests.runsettings) -->
|
||||
<MaxCpuCount>1</MaxCpuCount>
|
||||
</RunConfiguration>
|
||||
|
||||
<DataCollectionRunSettings>
|
||||
<DataCollectors>
|
||||
<DataCollector friendlyName="XPlat Code Coverage">
|
||||
<Configuration>
|
||||
<!-- Formato de salida: cobertura (compatible con ReportGenerator y CI/CD) -->
|
||||
<Format>cobertura</Format>
|
||||
|
||||
<!-- Exclusiones por atributo generado -->
|
||||
<ExcludeByAttribute>GeneratedCodeAttribute,ExcludeFromCodeCoverageAttribute</ExcludeByAttribute>
|
||||
|
||||
<!-- Exclusiones por tipo/namespace -->
|
||||
<Exclude>
|
||||
<!-- Migrations embebidas (SQL scripts, no lógica de negocio) -->
|
||||
[*.Migrations]*,
|
||||
<!-- Los proyectos de test no se miden a sí mismos -->
|
||||
[*.Tests]*,
|
||||
[SIGCM2.TestSupport]*,
|
||||
<!-- Program.cs: host wiring, no testeable unitariamente -->
|
||||
[SIGCM2.Api]Program,
|
||||
<!-- Extension methods de DI: una línea por registro, ruido sin valor -->
|
||||
[*]*.Extensions.*Extensions,
|
||||
[*]*.DependencyInjection
|
||||
</Exclude>
|
||||
|
||||
<!-- No medir las propiedades auto-implementadas -->
|
||||
<SkipAutoProps>true</SkipAutoProps>
|
||||
|
||||
<!-- No incluir el assembly de tests en el reporte -->
|
||||
<IncludeTestAssembly>false</IncludeTestAssembly>
|
||||
|
||||
<!-- Permitir timestamps reales en el reporte (no forzar determinismo) -->
|
||||
<DeterministicReport>false</DeterministicReport>
|
||||
</Configuration>
|
||||
</DataCollector>
|
||||
</DataCollectors>
|
||||
</DataCollectionRunSettings>
|
||||
</RunSettings>
|
||||
@@ -29,6 +29,19 @@ database/
|
||||
| **V010** | **`V010__audit_infrastructure.sql`** | **UDT-010** | **Infra de auditoría + Temporal Tables. Ver nota abajo.** |
|
||||
| V011 | `V011__create_medio_seccion.sql` | ADM-001 | Medio + Seccion (temporal, retention 10y) + permiso `administracion:secciones:gestionar` |
|
||||
| V012 | `V012__seed_medios.sql` | ADM-001 | Seed idempotente de Medios ELDIA y ELPLATA |
|
||||
| V013 | `V013__create_puntos_de_venta.sql` | ADM-008 | PuntosDeVenta (temporal, retention 10y) + permiso `administracion:puntos_de_venta:gestionar` |
|
||||
| V014 | `V014__create_tablas_fiscales.sql` | ADM-009 | TiposDeIva + IngresosBrutos (versioning por cadena) + permisos fiscales |
|
||||
| V015 | `V015__create_local_timezone_views.sql` | UDT-011 | Vistas admin con OccurredAt convertido a hora Argentina |
|
||||
| **V016** | **`V016__create_rubro.sql`** | **CAT-001** | **Rubro (adjacency list, temporal 10y) + permiso `catalogo:rubros:gestionar`** |
|
||||
| **V017** | **`V017__create_product_type.sql`** | **PRD-001** | **ProductType (flags + multimedia limits, temporal 10y) + permiso `catalogo:tipos:gestionar`** |
|
||||
| V018 | `V018__create_product.sql` | PRD-002 | Product (temporal 10y) + permiso `catalogo:productos:gestionar` + índices |
|
||||
| V019 | `V019__create_product_prices.sql` | PRD-003 | ProductPrices (temporal 10y, forward-only) + SP `sp_ProductPrices_InsertWithClose` + permiso implícito |
|
||||
| V020 | `V020__add_chargeable_chars_permission.sql` | PRC-001 | Permiso `tasacion:caracteres_especiales:gestionar` + asignación a admin |
|
||||
| V021 | `V021__create_chargeable_char_config.sql` | PRC-001 | ChargeableCharConfig + ChargeableCharConfig_History (temporal 10y) + 2 SPs (`InsertWithClose`, `GetActiveForProductType`) + 2 índices |
|
||||
| V022 | `V022__seed_chargeable_char_config.sql` | PRC-001 | Seed 4 filas globales (`$`, `%`, `!`, `¡`) con PricePerUnit=1.0000 |
|
||||
| V023 | `V023__refactor_chargeable_char_config_to_product_type.sql` | PRC-001 (scope delta) | Refactor MedioId→ProductTypeId + nuevo SP `ReactivateWithGuard` + CK_Price_NonNegative (>= 0) |
|
||||
| V024 | `V024__reseed_global_with_zero_price.sql` | PRC-001 (scope delta) | Reseed 4 globales a PricePerUnit=0.0000 (opt-in billing) |
|
||||
| V025 | `V025__seed_chargeable_char_overrides_demo.sql` | PRC-001 (followup #54) | Seed demo de overrides ficticios per-ProductType (Clasificado/Notables/Fúnebres). Idempotente: no-op si los tipos no existen |
|
||||
|
||||
## Convenciones
|
||||
|
||||
@@ -36,23 +49,24 @@ database/
|
||||
- **Idempotentes**: cada migración usa `IF NOT EXISTS` / `MERGE` / `IF NOT EXISTS (SELECT 1 FROM sys....)`. Re-ejecutarlas es seguro.
|
||||
- **Boilerplate** inicial: `SET QUOTED_IDENTIFIER ON; SET ANSI_NULLS ON; SET NOCOUNT ON; GO`.
|
||||
- **Naming**: `PK_*`, `UQ_*`, `FK_*`, `DF_*`, `CK_*`, `IX_*`.
|
||||
- **Se aplican a AMBAS bases**: `SIGCM2` (dev) y `SIGCM2_Test` (integration tests). El orden debe ser idéntico.
|
||||
- **Se aplican a TRES bases**: `SIGCM2` (dev), `SIGCM2_Test_App` (Application.Tests) y `SIGCM2_Test_Api` (Api.Tests). El orden debe ser idéntico en las tres.
|
||||
|
||||
## Cómo aplicar migraciones
|
||||
|
||||
### En dev (manual)
|
||||
|
||||
```bash
|
||||
# Con sqlcmd:
|
||||
sqlcmd -S TECNICA3 -d SIGCM2 -U desarrollo -P desarrollo2026 -i database/migrations/V010__audit_infrastructure.sql
|
||||
sqlcmd -S TECNICA3 -d SIGCM2_Test -U desarrollo -P desarrollo2026 -i database/migrations/V010__audit_infrastructure.sql
|
||||
# Con sqlcmd (aplicar a las tres bases en orden):
|
||||
sqlcmd -S TECNICA3 -d SIGCM2 -U desarrollo -P desarrollo2026 -i database/migrations/V010__audit_infrastructure.sql
|
||||
sqlcmd -S TECNICA3 -d SIGCM2_Test_App -U desarrollo -P desarrollo2026 -i database/migrations/V010__audit_infrastructure.sql
|
||||
sqlcmd -S TECNICA3 -d SIGCM2_Test_Api -U desarrollo -P desarrollo2026 -i database/migrations/V010__audit_infrastructure.sql
|
||||
```
|
||||
|
||||
O desde SSMS: abrir el archivo, conectar a cada base, F5.
|
||||
|
||||
### En integration tests
|
||||
|
||||
`tests/SIGCM2.TestSupport/SqlTestFixture.cs` aplica automáticamente el schema necesario en `SIGCM2_Test` al inicializar el fixture (`EnsureV008SchemaAsync`, `EnsureV009SchemaAsync`, `EnsureV010SchemaAsync`…). **NO** hace falta correr el script manualmente.
|
||||
`tests/SIGCM2.TestSupport/SqlTestFixture.cs` aplica automáticamente el schema necesario en `SIGCM2_Test_App` al inicializar el fixture (`EnsureV008SchemaAsync`, `EnsureV009SchemaAsync`, `EnsureV010SchemaAsync`…). `TestWebAppFactory` hace lo mismo contra `SIGCM2_Test_Api`. **NO** hace falta correr los scripts manualmente si el fixture ya lo cubre.
|
||||
|
||||
### En producción (roadmap futuro)
|
||||
|
||||
@@ -90,6 +104,22 @@ O desde SSMS: abrir el archivo, conectar a cada base, F5.
|
||||
- `Seccion.Tipo` acepta `'clasificados' | 'notables' | 'suplementos'` (CHECK constraint). Config avanzada (par/impar, suplementos, cupos) se introduce en ADM-003.
|
||||
- Rollback: `V012_ROLLBACK.sql` (falla si hay Secciones vivas) → `V011_ROLLBACK.sql`. Rollback en prod NO soportado si ADM-008/009 o PRC-* ya aplicados.
|
||||
|
||||
## Bases de datos de integration tests
|
||||
|
||||
| Base | Propósito | Usada por |
|
||||
|---|---|---|
|
||||
| `SIGCM2_Test_App` | Tests de repositorios y Application layer | `SIGCM2.Application.Tests` vía `SqlTestFixture` (parameterless ctor) |
|
||||
| `SIGCM2_Test_Api` | Tests de endpoints HTTP / WebApplicationFactory | `SIGCM2.Api.Tests` vía `TestWebAppFactory` |
|
||||
|
||||
**Script de creación inicial** (idempotente): `database/init/create-test-api-db.sql`
|
||||
|
||||
Ambas bases deben tener **todas las migraciones V001–V015** aplicadas en orden. Al crear una base nueva o al agregar un desarrollador:
|
||||
1. Crear las bases con `create-test-api-db.sql`
|
||||
2. Aplicar V001–V015 en orden (ver tabla de arriba) contra cada base de test
|
||||
3. Las `EnsureV0XX` del fixture validan presencia; no aplican migraciones pesadas
|
||||
|
||||
Fuente única de connection strings: `tests/SIGCM2.TestSupport/TestConnectionStrings.cs`
|
||||
|
||||
## Recursos
|
||||
|
||||
- Design autoritativo: `Obsidian/02-ARQUITECTURA-y-TECH-STACK/2.5 📋 Auditoría.md`
|
||||
|
||||
30
database/init/create-test-api-db.sql
Normal file
30
database/init/create-test-api-db.sql
Normal file
@@ -0,0 +1,30 @@
|
||||
-- create-test-api-db.sql
|
||||
-- Creates test databases for integration tests (idempotent).
|
||||
-- Run once per environment on TECNICA3 before executing integration tests.
|
||||
--
|
||||
-- SIGCM2_Test_App -> used by SIGCM2.Application.Tests
|
||||
-- SIGCM2_Test_Api -> used by SIGCM2.Api.Tests
|
||||
-- SIGCM2_Test -> legacy (kept for old branches e.g. pre-merge CAT-001)
|
||||
--
|
||||
-- After creating the DBs, apply V010 to both new DBs:
|
||||
-- See database/README.md > "Test DBs" section for the PowerShell runbook.
|
||||
|
||||
IF DB_ID(N'SIGCM2_Test_App') IS NULL
|
||||
BEGIN
|
||||
CREATE DATABASE [SIGCM2_Test_App]
|
||||
COLLATE Modern_Spanish_CI_AS;
|
||||
PRINT 'Database SIGCM2_Test_App created.';
|
||||
END
|
||||
ELSE
|
||||
PRINT 'Database SIGCM2_Test_App already exists -- skip.';
|
||||
GO
|
||||
|
||||
IF DB_ID(N'SIGCM2_Test_Api') IS NULL
|
||||
BEGIN
|
||||
CREATE DATABASE [SIGCM2_Test_Api]
|
||||
COLLATE Modern_Spanish_CI_AS;
|
||||
PRINT 'Database SIGCM2_Test_Api created.';
|
||||
END
|
||||
ELSE
|
||||
PRINT 'Database SIGCM2_Test_Api already exists -- skip.';
|
||||
GO
|
||||
37
database/migrations/V015_ROLLBACK.sql
Normal file
37
database/migrations/V015_ROLLBACK.sql
Normal file
@@ -0,0 +1,37 @@
|
||||
-- V015_ROLLBACK.sql
|
||||
-- Reversa de V015__create_local_timezone_views.sql.
|
||||
--
|
||||
-- Elimina: dbo.v_AuditEvent_Local + dbo.v_SecurityEvent_Local
|
||||
-- No toca datos: las tablas base AuditEvent y SecurityEvent no se modifican.
|
||||
--
|
||||
-- Idempotente: seguro para re-ejecutar.
|
||||
-- Prerequisito: ningún objeto dependa de estas vistas (funciones, SPs, otras vistas).
|
||||
|
||||
SET QUOTED_IDENTIFIER ON;
|
||||
SET ANSI_NULLS ON;
|
||||
SET NOCOUNT ON;
|
||||
GO
|
||||
|
||||
IF OBJECT_ID('dbo.v_AuditEvent_Local', 'V') IS NOT NULL
|
||||
BEGIN
|
||||
DROP VIEW dbo.v_AuditEvent_Local;
|
||||
PRINT 'View dbo.v_AuditEvent_Local dropped.';
|
||||
END
|
||||
ELSE
|
||||
PRINT 'View dbo.v_AuditEvent_Local does not exist — skip.';
|
||||
GO
|
||||
|
||||
IF OBJECT_ID('dbo.v_SecurityEvent_Local', 'V') IS NOT NULL
|
||||
BEGIN
|
||||
DROP VIEW dbo.v_SecurityEvent_Local;
|
||||
PRINT 'View dbo.v_SecurityEvent_Local dropped.';
|
||||
END
|
||||
ELSE
|
||||
PRINT 'View dbo.v_SecurityEvent_Local does not exist — skip.';
|
||||
GO
|
||||
|
||||
PRINT '';
|
||||
PRINT 'V015 rolled back.';
|
||||
PRINT ' - dbo.v_AuditEvent_Local removed.';
|
||||
PRINT ' - dbo.v_SecurityEvent_Local removed.';
|
||||
GO
|
||||
88
database/migrations/V015__create_local_timezone_views.sql
Normal file
88
database/migrations/V015__create_local_timezone_views.sql
Normal file
@@ -0,0 +1,88 @@
|
||||
-- V015__create_local_timezone_views.sql
|
||||
-- UDT-011: Vistas admin con OccurredAt convertido a hora Argentina.
|
||||
--
|
||||
-- Crea:
|
||||
-- dbo.v_AuditEvent_Local — AuditEvent con OccurredAtLocal (offset -03:00)
|
||||
-- dbo.v_SecurityEvent_Local — SecurityEvent con OccurredAtLocal (offset -03:00)
|
||||
--
|
||||
-- Conversión: OccurredAt AT TIME ZONE 'UTC' AT TIME ZONE 'Argentina Standard Time'
|
||||
-- → offset fijo -03:00, sin DST (Argentina dejó el horario de verano en 2009).
|
||||
-- → Nombre 'Argentina Standard Time' es portable: Windows + SQL Server Linux 2022+ (via ICU).
|
||||
--
|
||||
-- Idempotente: re-ejecutable. Guard IF OBJECT_ID IS NULL en cada vista.
|
||||
-- No altera tablas base — rollback seguro sin pérdida de datos.
|
||||
-- Reversa: V015_ROLLBACK.sql.
|
||||
-- Run on: SIGCM2 (dev) y SIGCM2_Test (integration tests).
|
||||
--
|
||||
-- Covers: REQ-DB-VIEWS-001, REQ-DB-VIEWS-002, REQ-DB-VIEWS-003, REQ-DB-VIEWS-004
|
||||
|
||||
SET QUOTED_IDENTIFIER ON;
|
||||
SET ANSI_NULLS ON;
|
||||
SET NOCOUNT ON;
|
||||
GO
|
||||
|
||||
-- ═══════════════════════════════════════════════════════════════════════
|
||||
-- 1. dbo.v_AuditEvent_Local
|
||||
-- ═══════════════════════════════════════════════════════════════════════
|
||||
-- Nota: CREATE VIEW no permite IF...BEGIN...END directo — se usa EXEC('CREATE VIEW ...').
|
||||
|
||||
IF OBJECT_ID('dbo.v_AuditEvent_Local', 'V') IS NULL
|
||||
BEGIN
|
||||
EXEC('
|
||||
CREATE VIEW dbo.v_AuditEvent_Local AS
|
||||
SELECT
|
||||
Id,
|
||||
OccurredAt,
|
||||
OccurredAt AT TIME ZONE ''UTC'' AT TIME ZONE ''Argentina Standard Time'' AS OccurredAtLocal,
|
||||
ActorUserId,
|
||||
ActorRoleId,
|
||||
Action,
|
||||
TargetType,
|
||||
TargetId,
|
||||
CorrelationId,
|
||||
IpAddress,
|
||||
UserAgent,
|
||||
Metadata
|
||||
FROM dbo.AuditEvent;
|
||||
');
|
||||
PRINT 'View dbo.v_AuditEvent_Local created.';
|
||||
END
|
||||
ELSE
|
||||
PRINT 'View dbo.v_AuditEvent_Local already exists — skip.';
|
||||
GO
|
||||
|
||||
-- ═══════════════════════════════════════════════════════════════════════
|
||||
-- 2. dbo.v_SecurityEvent_Local
|
||||
-- ═══════════════════════════════════════════════════════════════════════
|
||||
|
||||
IF OBJECT_ID('dbo.v_SecurityEvent_Local', 'V') IS NULL
|
||||
BEGIN
|
||||
EXEC('
|
||||
CREATE VIEW dbo.v_SecurityEvent_Local AS
|
||||
SELECT
|
||||
Id,
|
||||
OccurredAt,
|
||||
OccurredAt AT TIME ZONE ''UTC'' AT TIME ZONE ''Argentina Standard Time'' AS OccurredAtLocal,
|
||||
ActorUserId,
|
||||
AttemptedUsername,
|
||||
SessionId,
|
||||
Action,
|
||||
Result,
|
||||
FailureReason,
|
||||
IpAddress,
|
||||
UserAgent,
|
||||
Metadata
|
||||
FROM dbo.SecurityEvent;
|
||||
');
|
||||
PRINT 'View dbo.v_SecurityEvent_Local created.';
|
||||
END
|
||||
ELSE
|
||||
PRINT 'View dbo.v_SecurityEvent_Local already exists — skip.';
|
||||
GO
|
||||
|
||||
PRINT '';
|
||||
PRINT 'V015 applied successfully.';
|
||||
PRINT ' - dbo.v_AuditEvent_Local (AuditEvent + OccurredAtLocal offset -03:00)';
|
||||
PRINT ' - dbo.v_SecurityEvent_Local (SecurityEvent + OccurredAtLocal offset -03:00)';
|
||||
PRINT ' - Argentina Standard Time = UTC-3 (fixed offset, no DST since 2009)';
|
||||
GO
|
||||
82
database/migrations/V016_ROLLBACK.sql
Normal file
82
database/migrations/V016_ROLLBACK.sql
Normal file
@@ -0,0 +1,82 @@
|
||||
-- V016_ROLLBACK.sql
|
||||
-- Reversa de V016__create_rubro.sql.
|
||||
--
|
||||
-- ⚠️ ADVERTENCIA: ejecutar ELIMINA dbo.Rubro, dbo.Rubro_History,
|
||||
-- el permiso 'catalogo:rubros:gestionar' y sus asignaciones.
|
||||
--
|
||||
-- Uso intended: ROLLBACK en entornos NO-productivos.
|
||||
-- Prerequisito: no deben existir FKs vivas apuntando a Rubro (p.ej., Producto, Tarifario).
|
||||
-- Si CAT-002..006 o PRC-001 ya están aplicados, agregar TarifarioBaseId FK,
|
||||
-- este rollback fallará — usar backup.
|
||||
|
||||
SET QUOTED_IDENTIFIER ON;
|
||||
SET ANSI_NULLS ON;
|
||||
SET NOCOUNT ON;
|
||||
GO
|
||||
|
||||
-- ═══════════════════════════════════════════════════════════════════════
|
||||
-- 1. Apagar SYSTEM_VERSIONING + remover PERIOD en Rubro
|
||||
-- ═══════════════════════════════════════════════════════════════════════
|
||||
|
||||
IF EXISTS (SELECT 1 FROM sys.tables WHERE object_id = OBJECT_ID('dbo.Rubro') AND temporal_type = 2)
|
||||
BEGIN
|
||||
ALTER TABLE dbo.Rubro SET (SYSTEM_VERSIONING = OFF);
|
||||
PRINT 'Rubro: SYSTEM_VERSIONING OFF.';
|
||||
END
|
||||
GO
|
||||
|
||||
IF EXISTS (SELECT 1 FROM sys.periods WHERE object_id = OBJECT_ID('dbo.Rubro'))
|
||||
BEGIN
|
||||
ALTER TABLE dbo.Rubro DROP PERIOD FOR SYSTEM_TIME;
|
||||
PRINT 'Rubro: PERIOD FOR SYSTEM_TIME dropped.';
|
||||
END
|
||||
GO
|
||||
|
||||
IF COL_LENGTH('dbo.Rubro', 'ValidFrom') IS NOT NULL
|
||||
BEGIN
|
||||
ALTER TABLE dbo.Rubro DROP CONSTRAINT IF EXISTS DF_Rubro_ValidFrom;
|
||||
ALTER TABLE dbo.Rubro DROP CONSTRAINT IF EXISTS DF_Rubro_ValidTo;
|
||||
ALTER TABLE dbo.Rubro DROP COLUMN ValidFrom, ValidTo;
|
||||
PRINT 'Rubro: ValidFrom/ValidTo dropped.';
|
||||
END
|
||||
GO
|
||||
|
||||
IF OBJECT_ID(N'dbo.Rubro_History', N'U') IS NOT NULL
|
||||
BEGIN
|
||||
DROP TABLE dbo.Rubro_History;
|
||||
PRINT 'Rubro_History dropped.';
|
||||
END
|
||||
GO
|
||||
|
||||
-- ═══════════════════════════════════════════════════════════════════════
|
||||
-- 2. Drop índices + tabla Rubro
|
||||
-- ═══════════════════════════════════════════════════════════════════════
|
||||
|
||||
-- Self-FK must be dropped before dropping the table (SQL Server handles it
|
||||
-- automatically when the table is dropped, but explicit is safer).
|
||||
|
||||
IF OBJECT_ID(N'dbo.Rubro', N'U') IS NOT NULL
|
||||
BEGIN
|
||||
DROP TABLE dbo.Rubro;
|
||||
PRINT 'Table dbo.Rubro dropped.';
|
||||
END
|
||||
GO
|
||||
|
||||
-- ═══════════════════════════════════════════════════════════════════════
|
||||
-- 3. Remover permiso 'catalogo:rubros:gestionar' + RolPermiso
|
||||
-- ═══════════════════════════════════════════════════════════════════════
|
||||
|
||||
DELETE rp
|
||||
FROM dbo.RolPermiso rp
|
||||
JOIN dbo.Permiso p ON p.Id = rp.PermisoId
|
||||
WHERE p.Codigo = 'catalogo:rubros:gestionar';
|
||||
GO
|
||||
|
||||
DELETE FROM dbo.Permiso
|
||||
WHERE Codigo = 'catalogo:rubros:gestionar';
|
||||
GO
|
||||
|
||||
PRINT '';
|
||||
PRINT 'V016 rolled back. dbo.Rubro and dbo.Rubro_History removed.';
|
||||
PRINT 'catalogo:rubros:gestionar permission and role assignment removed.';
|
||||
GO
|
||||
152
database/migrations/V016__create_rubro.sql
Normal file
152
database/migrations/V016__create_rubro.sql
Normal file
@@ -0,0 +1,152 @@
|
||||
-- V016__create_rubro.sql
|
||||
-- CAT-001: Árbol N-ario de Rubros — tabla fundacional del catálogo comercial.
|
||||
--
|
||||
-- Cambios:
|
||||
-- 1. dbo.Rubro (adjacency list, self-FK, soft-delete, SYSTEM_VERSIONING ON, retention 10 años).
|
||||
-- 2. Índice filtrado unique UQ_Rubro_ParentId_Nombre_Activo (unicidad CI por padre en activos).
|
||||
-- 3. Índice cubriente IX_Rubro_ParentId_Activo (child lookups ordenados).
|
||||
-- 4. Permiso 'catalogo:rubros:gestionar' + asignación a rol 'admin'.
|
||||
--
|
||||
-- Patrón: V011 (dbo.Medio con SYSTEM_VERSIONING + PAGE compression + MERGE permisos).
|
||||
-- Idempotente: seguro para re-ejecutar.
|
||||
-- Reversa: V016_ROLLBACK.sql.
|
||||
-- Run on: SIGCM2 (dev) y SIGCM2_Test (integration tests).
|
||||
--
|
||||
-- Notas:
|
||||
-- - TarifarioBaseId es INT NULL SIN FK — la FK se agrega en PRC-001.
|
||||
-- - UQ_Rubro_ParentId_Nombre_Activo cubre solo ParentId IS NOT NULL;
|
||||
-- para roots (ParentId IS NULL) la unicidad CI la garantiza Application
|
||||
-- via ExistsByNombreUnderParentAsync(null, ...) — SQL Server trata NULLs
|
||||
-- como distintos en índices únicos. Ver Design §9 Risk 1.
|
||||
-- - FechaCreacion / FechaModificacion: DATETIME2(3) alineado con Medio/Seccion.
|
||||
-- - ValidFrom / ValidTo: DATETIME2(3) GENERATED ALWAYS HIDDEN (idéntico a V011).
|
||||
--
|
||||
-- Source of truth: Obsidian/02-ARQUITECTURA-y-TECH-STACK/2.5 📋 Auditoría.md
|
||||
-- SDD Design: engram sdd/cat-001-arbol-nario-rubros/design
|
||||
|
||||
SET QUOTED_IDENTIFIER ON;
|
||||
SET ANSI_NULLS ON;
|
||||
SET NOCOUNT ON;
|
||||
GO
|
||||
|
||||
-- ═══════════════════════════════════════════════════════════════════════
|
||||
-- 1. dbo.Rubro
|
||||
-- ═══════════════════════════════════════════════════════════════════════
|
||||
|
||||
IF OBJECT_ID(N'dbo.Rubro', N'U') IS NULL
|
||||
BEGIN
|
||||
CREATE TABLE dbo.Rubro (
|
||||
Id INT IDENTITY(1,1) NOT NULL CONSTRAINT PK_Rubro PRIMARY KEY,
|
||||
ParentId INT NULL,
|
||||
Nombre NVARCHAR(200) COLLATE SQL_Latin1_General_CP1_CI_AI NOT NULL,
|
||||
Orden INT NOT NULL CONSTRAINT DF_Rubro_Orden DEFAULT(0),
|
||||
Activo BIT NOT NULL CONSTRAINT DF_Rubro_Activo DEFAULT(1),
|
||||
TarifarioBaseId INT NULL, -- FK reservada para PRC-001 (sin constraint por ahora)
|
||||
FechaCreacion DATETIME2(3) NOT NULL CONSTRAINT DF_Rubro_FechaCreacion DEFAULT(SYSUTCDATETIME()),
|
||||
FechaModificacion DATETIME2(3) NULL,
|
||||
CONSTRAINT FK_Rubro_Parent FOREIGN KEY (ParentId) REFERENCES dbo.Rubro(Id) ON DELETE NO ACTION
|
||||
);
|
||||
PRINT 'Table dbo.Rubro created.';
|
||||
END
|
||||
ELSE
|
||||
PRINT 'Table dbo.Rubro already exists — skip.';
|
||||
GO
|
||||
|
||||
-- ═══════════════════════════════════════════════════════════════════════
|
||||
-- 2. SYSTEM_VERSIONING — Rubro
|
||||
-- ═══════════════════════════════════════════════════════════════════════
|
||||
|
||||
IF COL_LENGTH('dbo.Rubro', 'ValidFrom') IS NULL
|
||||
BEGIN
|
||||
ALTER TABLE dbo.Rubro
|
||||
ADD
|
||||
ValidFrom DATETIME2(3) GENERATED ALWAYS AS ROW START HIDDEN NOT NULL
|
||||
CONSTRAINT DF_Rubro_ValidFrom DEFAULT(SYSUTCDATETIME()),
|
||||
ValidTo DATETIME2(3) GENERATED ALWAYS AS ROW END HIDDEN NOT NULL
|
||||
CONSTRAINT DF_Rubro_ValidTo DEFAULT(CONVERT(DATETIME2(3), '9999-12-31 23:59:59.999')),
|
||||
PERIOD FOR SYSTEM_TIME (ValidFrom, ValidTo);
|
||||
PRINT 'Rubro: PERIOD FOR SYSTEM_TIME added.';
|
||||
END
|
||||
GO
|
||||
|
||||
IF NOT EXISTS (SELECT 1 FROM sys.tables WHERE object_id = OBJECT_ID('dbo.Rubro') AND temporal_type = 2)
|
||||
BEGIN
|
||||
ALTER TABLE dbo.Rubro
|
||||
SET (SYSTEM_VERSIONING = ON (
|
||||
HISTORY_TABLE = dbo.Rubro_History,
|
||||
HISTORY_RETENTION_PERIOD = 10 YEARS
|
||||
));
|
||||
PRINT 'Rubro: SYSTEM_VERSIONING = ON (history: dbo.Rubro_History, retention: 10 years).';
|
||||
END
|
||||
ELSE
|
||||
PRINT 'Rubro: SYSTEM_VERSIONING already ON — skip.';
|
||||
GO
|
||||
|
||||
IF EXISTS (SELECT 1 FROM sys.tables WHERE name = 'Rubro_History' AND schema_id = SCHEMA_ID('dbo'))
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM sys.partitions p
|
||||
JOIN sys.tables t ON t.object_id = p.object_id
|
||||
WHERE t.name = 'Rubro_History' AND p.data_compression = 2
|
||||
)
|
||||
BEGIN
|
||||
ALTER TABLE dbo.Rubro_History REBUILD WITH (DATA_COMPRESSION = PAGE);
|
||||
PRINT 'Rubro_History: rebuilt with PAGE compression.';
|
||||
END
|
||||
GO
|
||||
|
||||
-- ═══════════════════════════════════════════════════════════════════════
|
||||
-- 3. Índices
|
||||
-- ═══════════════════════════════════════════════════════════════════════
|
||||
|
||||
-- Unicidad CI por nombre bajo el mismo padre (solo filas activas + ParentId NOT NULL).
|
||||
-- Para roots (ParentId IS NULL) la unicidad la garantiza Application layer.
|
||||
IF NOT EXISTS (SELECT 1 FROM sys.indexes WHERE name = 'UQ_Rubro_ParentId_Nombre_Activo' AND object_id = OBJECT_ID('dbo.Rubro'))
|
||||
BEGIN
|
||||
CREATE UNIQUE INDEX UQ_Rubro_ParentId_Nombre_Activo
|
||||
ON dbo.Rubro(ParentId, Nombre)
|
||||
WHERE Activo = 1 AND ParentId IS NOT NULL;
|
||||
PRINT 'Index UQ_Rubro_ParentId_Nombre_Activo created.';
|
||||
END
|
||||
GO
|
||||
|
||||
-- Cubriente para child lookups ordenados por Orden.
|
||||
IF NOT EXISTS (SELECT 1 FROM sys.indexes WHERE name = 'IX_Rubro_ParentId_Activo' AND object_id = OBJECT_ID('dbo.Rubro'))
|
||||
BEGIN
|
||||
CREATE INDEX IX_Rubro_ParentId_Activo
|
||||
ON dbo.Rubro(ParentId, Activo)
|
||||
INCLUDE (Nombre, Orden);
|
||||
PRINT 'Index IX_Rubro_ParentId_Activo created.';
|
||||
END
|
||||
GO
|
||||
|
||||
-- ═══════════════════════════════════════════════════════════════════════
|
||||
-- 4. Permiso: catalogo:rubros:gestionar + asignación a rol 'admin'
|
||||
-- ═══════════════════════════════════════════════════════════════════════
|
||||
|
||||
MERGE dbo.Permiso AS t
|
||||
USING (VALUES
|
||||
('catalogo:rubros:gestionar', N'Gestionar rubros del catálogo', N'Crear, editar, mover y desactivar rubros del árbol de catálogo comercial', 'catalogo')
|
||||
) AS s (Codigo, Nombre, Descripcion, Modulo)
|
||||
ON t.Codigo = s.Codigo
|
||||
WHEN NOT MATCHED BY TARGET THEN
|
||||
INSERT (Codigo, Nombre, Descripcion, Modulo)
|
||||
VALUES (s.Codigo, s.Nombre, s.Descripcion, s.Modulo);
|
||||
GO
|
||||
|
||||
MERGE dbo.RolPermiso AS t
|
||||
USING (
|
||||
SELECT r.Id AS RolId, p.Id AS PermisoId
|
||||
FROM (VALUES
|
||||
('admin', 'catalogo:rubros:gestionar')
|
||||
) AS x (RolCodigo, PermisoCodigo)
|
||||
JOIN dbo.Rol r ON r.Codigo = x.RolCodigo
|
||||
JOIN dbo.Permiso p ON p.Codigo = x.PermisoCodigo
|
||||
) AS s ON t.RolId = s.RolId AND t.PermisoId = s.PermisoId
|
||||
WHEN NOT MATCHED BY TARGET THEN
|
||||
INSERT (RolId, PermisoId) VALUES (s.RolId, s.PermisoId);
|
||||
GO
|
||||
|
||||
PRINT '';
|
||||
PRINT 'V016 applied successfully — dbo.Rubro (temporal, retention 10y) + permiso catalogo:rubros:gestionar.';
|
||||
PRINT 'Next: V017 (future — TBD by next UDT).';
|
||||
GO
|
||||
71
database/migrations/V017_ROLLBACK.sql
Normal file
71
database/migrations/V017_ROLLBACK.sql
Normal file
@@ -0,0 +1,71 @@
|
||||
-- V017_ROLLBACK.sql
|
||||
-- Reversa de V017__create_product_type.sql.
|
||||
-- PRD-001: ProductType rollback.
|
||||
--
|
||||
-- ADVERTENCIA: Si PRD-002 ya fue mergeado (IProductQueryRepository real), hacer rollback
|
||||
-- de PRD-002 primero (la interfaz es removida por esta rollback).
|
||||
--
|
||||
-- Idempotente: cada paso usa IF EXISTS guards.
|
||||
|
||||
SET QUOTED_IDENTIFIER ON;
|
||||
SET ANSI_NULLS ON;
|
||||
SET NOCOUNT ON;
|
||||
GO
|
||||
|
||||
-- 1. Desactivar SYSTEM_VERSIONING
|
||||
IF EXISTS (SELECT 1 FROM sys.tables WHERE object_id = OBJECT_ID('dbo.ProductType') AND temporal_type = 2)
|
||||
BEGIN
|
||||
ALTER TABLE dbo.ProductType SET (SYSTEM_VERSIONING = OFF);
|
||||
PRINT 'ProductType: SYSTEM_VERSIONING = OFF.';
|
||||
END
|
||||
GO
|
||||
|
||||
-- 2. Remover PERIOD FOR SYSTEM_TIME
|
||||
IF EXISTS (SELECT 1 FROM sys.periods WHERE object_id = OBJECT_ID('dbo.ProductType'))
|
||||
BEGIN
|
||||
ALTER TABLE dbo.ProductType DROP PERIOD FOR SYSTEM_TIME;
|
||||
PRINT 'ProductType: PERIOD FOR SYSTEM_TIME dropped.';
|
||||
END
|
||||
GO
|
||||
|
||||
-- 3. Remover columnas HIDDEN + default constraints
|
||||
IF COL_LENGTH('dbo.ProductType', 'ValidFrom') IS NOT NULL
|
||||
BEGIN
|
||||
ALTER TABLE dbo.ProductType DROP CONSTRAINT IF EXISTS DF_ProductType_ValidFrom;
|
||||
ALTER TABLE dbo.ProductType DROP CONSTRAINT IF EXISTS DF_ProductType_ValidTo;
|
||||
ALTER TABLE dbo.ProductType DROP COLUMN ValidFrom, ValidTo;
|
||||
PRINT 'ProductType: ValidFrom/ValidTo columns dropped.';
|
||||
END
|
||||
GO
|
||||
|
||||
-- 4. Drop history table
|
||||
IF OBJECT_ID(N'dbo.ProductType_History', N'U') IS NOT NULL
|
||||
BEGIN
|
||||
DROP TABLE dbo.ProductType_History;
|
||||
PRINT 'Table dbo.ProductType_History dropped.';
|
||||
END
|
||||
GO
|
||||
|
||||
-- 5. Drop main table
|
||||
IF OBJECT_ID(N'dbo.ProductType', N'U') IS NOT NULL
|
||||
BEGIN
|
||||
DROP TABLE dbo.ProductType;
|
||||
PRINT 'Table dbo.ProductType dropped.';
|
||||
END
|
||||
GO
|
||||
|
||||
-- 6. Remover RolPermiso para catalogo:tipos:gestionar
|
||||
DELETE rp FROM dbo.RolPermiso rp
|
||||
JOIN dbo.Permiso p ON p.Id = rp.PermisoId
|
||||
WHERE p.Codigo = 'catalogo:tipos:gestionar';
|
||||
PRINT 'RolPermiso rows for catalogo:tipos:gestionar deleted.';
|
||||
GO
|
||||
|
||||
-- 7. Remover Permiso
|
||||
DELETE FROM dbo.Permiso WHERE Codigo = 'catalogo:tipos:gestionar';
|
||||
PRINT 'Permiso catalogo:tipos:gestionar deleted.';
|
||||
GO
|
||||
|
||||
PRINT '';
|
||||
PRINT 'V017 rolled back successfully.';
|
||||
GO
|
||||
158
database/migrations/V017__create_product_type.sql
Normal file
158
database/migrations/V017__create_product_type.sql
Normal file
@@ -0,0 +1,158 @@
|
||||
-- V017__create_product_type.sql
|
||||
-- PRD-001: ProductType — tipología dinámica de productos con flags de comportamiento + límites multimedia.
|
||||
--
|
||||
-- Cambios:
|
||||
-- 1. dbo.ProductType (flags + multimedia limits, SYSTEM_VERSIONING ON, retention 10 años).
|
||||
-- 2. Índice filtrado unique UQ_ProductType_Nombre_Activo (unicidad CI entre activos).
|
||||
-- 3. Índice cubriente IX_ProductType_IsActive_Cover.
|
||||
-- 4. Permiso 'catalogo:tipos:gestionar' + asignación a rol 'admin'.
|
||||
--
|
||||
-- Patrón: V016 (dbo.Rubro con SYSTEM_VERSIONING + PAGE compression + MERGE permisos).
|
||||
-- Idempotente: seguro para re-ejecutar.
|
||||
-- Reversa: V017_ROLLBACK.sql.
|
||||
-- Run on: SIGCM2 (dev) y SIGCM2_Test (integration tests).
|
||||
--
|
||||
-- Notas:
|
||||
-- - SIN seed de datos — PRD-008 (V018) seedea los 12 tipos legacy.
|
||||
-- - SIN FK desde dbo.Product — PRD-002 agrega ALTER TABLE con FK.
|
||||
-- - Invariante aplicada en Application: si AllowImages=0, los 4 campos multimedia son NULL (handler normaliza).
|
||||
-- - MaxImages/MaxImageSizeMB/MaxImageWidth/MaxImageHeight: NULL = sin límite; >=1 = tope (validator rechaza <=0).
|
||||
-- - Desviación del UDT: "0 = ilimitado" → usamos NULL (convención canónica). Ver PRD-001 archive-report.
|
||||
--
|
||||
-- SDD Design: engram sdd/prd-001-product-type-flags-multimedia/design
|
||||
|
||||
SET QUOTED_IDENTIFIER ON;
|
||||
SET ANSI_NULLS ON;
|
||||
SET NOCOUNT ON;
|
||||
GO
|
||||
|
||||
-- ═══════════════════════════════════════════════════════════════════════
|
||||
-- 1. dbo.ProductType
|
||||
-- ═══════════════════════════════════════════════════════════════════════
|
||||
|
||||
IF OBJECT_ID(N'dbo.ProductType', N'U') IS NULL
|
||||
BEGIN
|
||||
CREATE TABLE dbo.ProductType (
|
||||
Id INT IDENTITY(1,1) NOT NULL CONSTRAINT PK_ProductType PRIMARY KEY,
|
||||
Nombre NVARCHAR(200) COLLATE SQL_Latin1_General_CP1_CI_AI NOT NULL,
|
||||
|
||||
-- Flags de comportamiento
|
||||
HasDuration BIT NOT NULL CONSTRAINT DF_ProductType_HasDuration DEFAULT(0),
|
||||
RequiresText BIT NOT NULL CONSTRAINT DF_ProductType_RequiresText DEFAULT(0),
|
||||
RequiresCategory BIT NOT NULL CONSTRAINT DF_ProductType_RequiresCategory DEFAULT(0),
|
||||
IsBundle BIT NOT NULL CONSTRAINT DF_ProductType_IsBundle DEFAULT(0),
|
||||
|
||||
-- Multimedia (AllowImages=0 => handler normaliza los 4 siguientes a NULL)
|
||||
AllowImages BIT NOT NULL CONSTRAINT DF_ProductType_AllowImages DEFAULT(0),
|
||||
MaxImages INT NULL, -- NULL = sin límite; >=1 tope (validator rechaza <=0)
|
||||
MaxImageSizeMB DECIMAL(10,2) NULL, -- NULL = sin límite; DECIMAL(10,2) permite 0.5 MB, 2.75 MB
|
||||
MaxImageWidth INT NULL, -- NULL = sin límite; >=1 px
|
||||
MaxImageHeight INT NULL, -- NULL = sin límite; >=1 px
|
||||
|
||||
-- Lifecycle
|
||||
IsActive BIT NOT NULL CONSTRAINT DF_ProductType_IsActive DEFAULT(1),
|
||||
FechaCreacion DATETIME2(3) NOT NULL CONSTRAINT DF_ProductType_FechaCreacion DEFAULT(SYSUTCDATETIME()),
|
||||
FechaModificacion DATETIME2(3) NULL
|
||||
);
|
||||
PRINT 'Table dbo.ProductType created.';
|
||||
END
|
||||
ELSE
|
||||
PRINT 'Table dbo.ProductType already exists — skip.';
|
||||
GO
|
||||
|
||||
-- ═══════════════════════════════════════════════════════════════════════
|
||||
-- 2. SYSTEM_VERSIONING — ProductType
|
||||
-- ═══════════════════════════════════════════════════════════════════════
|
||||
|
||||
IF COL_LENGTH('dbo.ProductType', 'ValidFrom') IS NULL
|
||||
BEGIN
|
||||
ALTER TABLE dbo.ProductType
|
||||
ADD
|
||||
ValidFrom DATETIME2(3) GENERATED ALWAYS AS ROW START HIDDEN NOT NULL
|
||||
CONSTRAINT DF_ProductType_ValidFrom DEFAULT(SYSUTCDATETIME()),
|
||||
ValidTo DATETIME2(3) GENERATED ALWAYS AS ROW END HIDDEN NOT NULL
|
||||
CONSTRAINT DF_ProductType_ValidTo DEFAULT(CONVERT(DATETIME2(3), '9999-12-31 23:59:59.999')),
|
||||
PERIOD FOR SYSTEM_TIME (ValidFrom, ValidTo);
|
||||
PRINT 'ProductType: PERIOD FOR SYSTEM_TIME added.';
|
||||
END
|
||||
GO
|
||||
|
||||
IF NOT EXISTS (SELECT 1 FROM sys.tables WHERE object_id = OBJECT_ID('dbo.ProductType') AND temporal_type = 2)
|
||||
BEGIN
|
||||
ALTER TABLE dbo.ProductType
|
||||
SET (SYSTEM_VERSIONING = ON (
|
||||
HISTORY_TABLE = dbo.ProductType_History,
|
||||
HISTORY_RETENTION_PERIOD = 10 YEARS
|
||||
));
|
||||
PRINT 'ProductType: SYSTEM_VERSIONING = ON (history: dbo.ProductType_History, retention: 10 years).';
|
||||
END
|
||||
ELSE
|
||||
PRINT 'ProductType: SYSTEM_VERSIONING already ON — skip.';
|
||||
GO
|
||||
|
||||
IF EXISTS (SELECT 1 FROM sys.tables WHERE name = 'ProductType_History' AND schema_id = SCHEMA_ID('dbo'))
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM sys.partitions p
|
||||
JOIN sys.tables t ON t.object_id = p.object_id
|
||||
WHERE t.name = 'ProductType_History' AND p.data_compression = 2
|
||||
)
|
||||
BEGIN
|
||||
ALTER TABLE dbo.ProductType_History REBUILD WITH (DATA_COMPRESSION = PAGE);
|
||||
PRINT 'ProductType_History: rebuilt with PAGE compression.';
|
||||
END
|
||||
GO
|
||||
|
||||
-- ═══════════════════════════════════════════════════════════════════════
|
||||
-- 3. Índices
|
||||
-- ═══════════════════════════════════════════════════════════════════════
|
||||
|
||||
IF NOT EXISTS (SELECT 1 FROM sys.indexes WHERE name = 'UQ_ProductType_Nombre_Activo' AND object_id = OBJECT_ID('dbo.ProductType'))
|
||||
BEGIN
|
||||
CREATE UNIQUE INDEX UQ_ProductType_Nombre_Activo
|
||||
ON dbo.ProductType(Nombre)
|
||||
WHERE IsActive = 1;
|
||||
PRINT 'Index UQ_ProductType_Nombre_Activo created.';
|
||||
END
|
||||
GO
|
||||
|
||||
IF NOT EXISTS (SELECT 1 FROM sys.indexes WHERE name = 'IX_ProductType_IsActive_Cover' AND object_id = OBJECT_ID('dbo.ProductType'))
|
||||
BEGIN
|
||||
CREATE INDEX IX_ProductType_IsActive_Cover
|
||||
ON dbo.ProductType(IsActive)
|
||||
INCLUDE (Nombre, HasDuration, RequiresText, RequiresCategory, IsBundle, AllowImages);
|
||||
PRINT 'Index IX_ProductType_IsActive_Cover created.';
|
||||
END
|
||||
GO
|
||||
|
||||
-- ═══════════════════════════════════════════════════════════════════════
|
||||
-- 4. Permiso: catalogo:tipos:gestionar + asignación a rol 'admin'
|
||||
-- ═══════════════════════════════════════════════════════════════════════
|
||||
|
||||
MERGE dbo.Permiso AS t
|
||||
USING (VALUES
|
||||
('catalogo:tipos:gestionar',
|
||||
N'Gestionar tipos de producto',
|
||||
N'Crear, editar y desactivar ProductTypes del catálogo (flags + límites multimedia)',
|
||||
'catalogo')
|
||||
) AS s (Codigo, Nombre, Descripcion, Modulo)
|
||||
ON t.Codigo = s.Codigo
|
||||
WHEN NOT MATCHED BY TARGET THEN
|
||||
INSERT (Codigo, Nombre, Descripcion, Modulo)
|
||||
VALUES (s.Codigo, s.Nombre, s.Descripcion, s.Modulo);
|
||||
GO
|
||||
|
||||
MERGE dbo.RolPermiso AS t
|
||||
USING (
|
||||
SELECT r.Id AS RolId, p.Id AS PermisoId
|
||||
FROM (VALUES ('admin', 'catalogo:tipos:gestionar')) AS x (RolCodigo, PermisoCodigo)
|
||||
JOIN dbo.Rol r ON r.Codigo = x.RolCodigo
|
||||
JOIN dbo.Permiso p ON p.Codigo = x.PermisoCodigo
|
||||
) AS s ON t.RolId = s.RolId AND t.PermisoId = s.PermisoId
|
||||
WHEN NOT MATCHED BY TARGET THEN
|
||||
INSERT (RolId, PermisoId) VALUES (s.RolId, s.PermisoId);
|
||||
GO
|
||||
|
||||
PRINT '';
|
||||
PRINT 'V017 applied — dbo.ProductType (temporal, retention 10y) + permiso catalogo:tipos:gestionar.';
|
||||
PRINT 'Next: V018 (PRD-008 — seed 12 tipos legacy).';
|
||||
GO
|
||||
67
database/migrations/V018_ROLLBACK.sql
Normal file
67
database/migrations/V018_ROLLBACK.sql
Normal file
@@ -0,0 +1,67 @@
|
||||
-- V018_ROLLBACK.sql
|
||||
-- Reversa de V018__create_product.sql — PRD-002.
|
||||
--
|
||||
-- Idempotente: cada paso usa IF EXISTS guards.
|
||||
-- ADVERTENCIA: Ejecutar antes de V017_ROLLBACK (FK desde Product hacia ProductType).
|
||||
|
||||
SET QUOTED_IDENTIFIER ON;
|
||||
SET ANSI_NULLS ON;
|
||||
SET NOCOUNT ON;
|
||||
GO
|
||||
|
||||
-- 1. SYSTEM_VERSIONING OFF
|
||||
IF EXISTS (SELECT 1 FROM sys.tables WHERE object_id = OBJECT_ID('dbo.Product') AND temporal_type = 2)
|
||||
BEGIN
|
||||
ALTER TABLE dbo.Product SET (SYSTEM_VERSIONING = OFF);
|
||||
PRINT 'Product: SYSTEM_VERSIONING = OFF.';
|
||||
END
|
||||
GO
|
||||
|
||||
-- 2. DROP PERIOD
|
||||
IF EXISTS (SELECT 1 FROM sys.periods WHERE object_id = OBJECT_ID('dbo.Product'))
|
||||
BEGIN
|
||||
ALTER TABLE dbo.Product DROP PERIOD FOR SYSTEM_TIME;
|
||||
PRINT 'Product: PERIOD FOR SYSTEM_TIME dropped.';
|
||||
END
|
||||
GO
|
||||
|
||||
-- 3. Drop HIDDEN columns + default constraints
|
||||
IF COL_LENGTH('dbo.Product', 'ValidFrom') IS NOT NULL
|
||||
BEGIN
|
||||
ALTER TABLE dbo.Product DROP CONSTRAINT IF EXISTS DF_Product_ValidFrom;
|
||||
ALTER TABLE dbo.Product DROP CONSTRAINT IF EXISTS DF_Product_ValidTo;
|
||||
ALTER TABLE dbo.Product DROP COLUMN ValidFrom, ValidTo;
|
||||
PRINT 'Product: ValidFrom/ValidTo columns dropped.';
|
||||
END
|
||||
GO
|
||||
|
||||
-- 4. Drop history
|
||||
IF OBJECT_ID(N'dbo.Product_History', N'U') IS NOT NULL
|
||||
BEGIN
|
||||
DROP TABLE dbo.Product_History;
|
||||
PRINT 'Table dbo.Product_History dropped.';
|
||||
END
|
||||
GO
|
||||
|
||||
-- 5. Drop main
|
||||
IF OBJECT_ID(N'dbo.Product', N'U') IS NOT NULL
|
||||
BEGIN
|
||||
DROP TABLE dbo.Product;
|
||||
PRINT 'Table dbo.Product dropped.';
|
||||
END
|
||||
GO
|
||||
|
||||
-- 6. Remove RolPermiso / Permiso
|
||||
DELETE rp FROM dbo.RolPermiso rp
|
||||
JOIN dbo.Permiso p ON p.Id = rp.PermisoId
|
||||
WHERE p.Codigo = 'catalogo:productos:gestionar';
|
||||
PRINT 'RolPermiso rows for catalogo:productos:gestionar deleted.';
|
||||
GO
|
||||
|
||||
DELETE FROM dbo.Permiso WHERE Codigo = 'catalogo:productos:gestionar';
|
||||
PRINT 'Permiso catalogo:productos:gestionar deleted.';
|
||||
GO
|
||||
|
||||
PRINT '';
|
||||
PRINT 'V018 rolled back successfully.';
|
||||
GO
|
||||
172
database/migrations/V018__create_product.sql
Normal file
172
database/migrations/V018__create_product.sql
Normal file
@@ -0,0 +1,172 @@
|
||||
-- V018__create_product.sql
|
||||
-- PRD-002: Product — entidad vendible concreta del catálogo comercial.
|
||||
--
|
||||
-- Cambios:
|
||||
-- 1. dbo.Product (FK Medio/ProductType/Rubro, SYSTEM_VERSIONING ON, retention 10 años).
|
||||
-- 2. Índices: filtered UQ por (MedioId, ProductTypeId, Nombre) activos; cover por ProductTypeId
|
||||
-- (para IProductQueryRepository); cover por MedioId; cover filtrado por RubroId.
|
||||
-- 3. Permiso 'catalogo:productos:gestionar' + asignación a rol 'admin'.
|
||||
--
|
||||
-- Patrón: V017 (dbo.ProductType con SYSTEM_VERSIONING + PAGE compression + MERGE permisos).
|
||||
-- Idempotente: seguro para re-ejecutar.
|
||||
-- Reversa: V018_ROLLBACK.sql.
|
||||
-- Run on: SIGCM2 (dev) y SIGCM2_Test (integration tests).
|
||||
--
|
||||
-- Notas:
|
||||
-- - SIN seed de datos — PRD-008 (V019) seedea los 12 productos legacy.
|
||||
-- - Validación de flags (RequiresCategory, HasDuration) vive en Application layer:
|
||||
-- un ProductType puede cambiar flags; la Product queda en estado snapshot.
|
||||
-- - UQ filtered WHERE IsActive=1: permite reusar nombres tras soft-delete.
|
||||
--
|
||||
-- SDD Design: engram sdd/prd-002-product-crud/design
|
||||
|
||||
SET QUOTED_IDENTIFIER ON;
|
||||
SET ANSI_NULLS ON;
|
||||
SET NOCOUNT ON;
|
||||
GO
|
||||
|
||||
-- ═══════════════════════════════════════════════════════════════════════
|
||||
-- 1. dbo.Product
|
||||
-- ═══════════════════════════════════════════════════════════════════════
|
||||
|
||||
IF OBJECT_ID(N'dbo.Product', N'U') IS NULL
|
||||
BEGIN
|
||||
CREATE TABLE dbo.Product (
|
||||
Id INT IDENTITY(1,1) NOT NULL CONSTRAINT PK_Product PRIMARY KEY,
|
||||
Nombre NVARCHAR(300) COLLATE SQL_Latin1_General_CP1_CI_AI NOT NULL,
|
||||
MedioId INT NOT NULL,
|
||||
ProductTypeId INT NOT NULL,
|
||||
RubroId INT NULL,
|
||||
BasePrice DECIMAL(18,4) NOT NULL,
|
||||
PriceDurationDays INT NULL,
|
||||
IsActive BIT NOT NULL CONSTRAINT DF_Product_IsActive DEFAULT(1),
|
||||
FechaCreacion DATETIME2(3) NOT NULL CONSTRAINT DF_Product_FechaCreacion DEFAULT(SYSUTCDATETIME()),
|
||||
FechaModificacion DATETIME2(3) NULL,
|
||||
CONSTRAINT FK_Product_Medio FOREIGN KEY (MedioId) REFERENCES dbo.Medio(Id) ON DELETE NO ACTION,
|
||||
CONSTRAINT FK_Product_ProductType FOREIGN KEY (ProductTypeId) REFERENCES dbo.ProductType(Id) ON DELETE NO ACTION,
|
||||
CONSTRAINT FK_Product_Rubro FOREIGN KEY (RubroId) REFERENCES dbo.Rubro(Id) ON DELETE NO ACTION,
|
||||
CONSTRAINT CK_Product_BasePrice_NonNegative CHECK (BasePrice >= 0),
|
||||
CONSTRAINT CK_Product_PriceDurationDays_Positive CHECK (PriceDurationDays IS NULL OR PriceDurationDays >= 1)
|
||||
);
|
||||
PRINT 'Table dbo.Product created.';
|
||||
END
|
||||
ELSE
|
||||
PRINT 'Table dbo.Product already exists — skip.';
|
||||
GO
|
||||
|
||||
-- ═══════════════════════════════════════════════════════════════════════
|
||||
-- 2. SYSTEM_VERSIONING — Product
|
||||
-- ═══════════════════════════════════════════════════════════════════════
|
||||
|
||||
IF COL_LENGTH('dbo.Product', 'ValidFrom') IS NULL
|
||||
BEGIN
|
||||
ALTER TABLE dbo.Product
|
||||
ADD
|
||||
ValidFrom DATETIME2(3) GENERATED ALWAYS AS ROW START HIDDEN NOT NULL
|
||||
CONSTRAINT DF_Product_ValidFrom DEFAULT(SYSUTCDATETIME()),
|
||||
ValidTo DATETIME2(3) GENERATED ALWAYS AS ROW END HIDDEN NOT NULL
|
||||
CONSTRAINT DF_Product_ValidTo DEFAULT(CONVERT(DATETIME2(3), '9999-12-31 23:59:59.999')),
|
||||
PERIOD FOR SYSTEM_TIME (ValidFrom, ValidTo);
|
||||
PRINT 'Product: PERIOD FOR SYSTEM_TIME added.';
|
||||
END
|
||||
GO
|
||||
|
||||
IF NOT EXISTS (SELECT 1 FROM sys.tables WHERE object_id = OBJECT_ID('dbo.Product') AND temporal_type = 2)
|
||||
BEGIN
|
||||
ALTER TABLE dbo.Product
|
||||
SET (SYSTEM_VERSIONING = ON (
|
||||
HISTORY_TABLE = dbo.Product_History,
|
||||
HISTORY_RETENTION_PERIOD = 10 YEARS
|
||||
));
|
||||
PRINT 'Product: SYSTEM_VERSIONING = ON (history: dbo.Product_History, retention: 10 years).';
|
||||
END
|
||||
ELSE
|
||||
PRINT 'Product: SYSTEM_VERSIONING already ON — skip.';
|
||||
GO
|
||||
|
||||
IF EXISTS (SELECT 1 FROM sys.tables WHERE name = 'Product_History' AND schema_id = SCHEMA_ID('dbo'))
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM sys.partitions p
|
||||
JOIN sys.tables t ON t.object_id = p.object_id
|
||||
WHERE t.name = 'Product_History' AND p.data_compression = 2
|
||||
)
|
||||
BEGIN
|
||||
ALTER TABLE dbo.Product_History REBUILD WITH (DATA_COMPRESSION = PAGE);
|
||||
PRINT 'Product_History: rebuilt with PAGE compression.';
|
||||
END
|
||||
GO
|
||||
|
||||
-- ═══════════════════════════════════════════════════════════════════════
|
||||
-- 3. Índices
|
||||
-- ═══════════════════════════════════════════════════════════════════════
|
||||
|
||||
-- Filtered UQ: unicidad activa por (Medio, Tipo, Nombre). Permite reusar nombres tras soft-delete.
|
||||
IF NOT EXISTS (SELECT 1 FROM sys.indexes WHERE name = 'UQ_Product_MedioId_ProductTypeId_Nombre_Active' AND object_id = OBJECT_ID('dbo.Product'))
|
||||
BEGIN
|
||||
CREATE UNIQUE INDEX UQ_Product_MedioId_ProductTypeId_Nombre_Active
|
||||
ON dbo.Product (MedioId, ProductTypeId, Nombre)
|
||||
WHERE IsActive = 1;
|
||||
PRINT 'Index UQ_Product_MedioId_ProductTypeId_Nombre_Active created.';
|
||||
END
|
||||
GO
|
||||
|
||||
-- Cover para IProductQueryRepository.ExistsActiveByProductTypeAsync
|
||||
IF NOT EXISTS (SELECT 1 FROM sys.indexes WHERE name = 'IX_Product_ProductTypeId_IsActive' AND object_id = OBJECT_ID('dbo.Product'))
|
||||
BEGIN
|
||||
CREATE INDEX IX_Product_ProductTypeId_IsActive
|
||||
ON dbo.Product (ProductTypeId, IsActive);
|
||||
PRINT 'Index IX_Product_ProductTypeId_IsActive created.';
|
||||
END
|
||||
GO
|
||||
|
||||
-- Cover para list filtered by MedioId
|
||||
IF NOT EXISTS (SELECT 1 FROM sys.indexes WHERE name = 'IX_Product_MedioId_IsActive' AND object_id = OBJECT_ID('dbo.Product'))
|
||||
BEGIN
|
||||
CREATE INDEX IX_Product_MedioId_IsActive
|
||||
ON dbo.Product (MedioId, IsActive);
|
||||
PRINT 'Index IX_Product_MedioId_IsActive created.';
|
||||
END
|
||||
GO
|
||||
|
||||
-- Cover para list filtered by RubroId
|
||||
IF NOT EXISTS (SELECT 1 FROM sys.indexes WHERE name = 'IX_Product_RubroId_IsActive' AND object_id = OBJECT_ID('dbo.Product'))
|
||||
BEGIN
|
||||
CREATE INDEX IX_Product_RubroId_IsActive
|
||||
ON dbo.Product (RubroId, IsActive)
|
||||
WHERE RubroId IS NOT NULL;
|
||||
PRINT 'Index IX_Product_RubroId_IsActive created.';
|
||||
END
|
||||
GO
|
||||
|
||||
-- ═══════════════════════════════════════════════════════════════════════
|
||||
-- 4. Permiso: catalogo:productos:gestionar + asignación a rol 'admin'
|
||||
-- ═══════════════════════════════════════════════════════════════════════
|
||||
|
||||
MERGE dbo.Permiso AS t
|
||||
USING (VALUES
|
||||
('catalogo:productos:gestionar',
|
||||
N'Gestionar productos del catálogo',
|
||||
N'Crear, editar y desactivar productos del catálogo comercial',
|
||||
'catalogo')
|
||||
) AS s (Codigo, Nombre, Descripcion, Modulo)
|
||||
ON t.Codigo = s.Codigo
|
||||
WHEN NOT MATCHED BY TARGET THEN
|
||||
INSERT (Codigo, Nombre, Descripcion, Modulo)
|
||||
VALUES (s.Codigo, s.Nombre, s.Descripcion, s.Modulo);
|
||||
GO
|
||||
|
||||
MERGE dbo.RolPermiso AS t
|
||||
USING (
|
||||
SELECT r.Id AS RolId, p.Id AS PermisoId
|
||||
FROM (VALUES ('admin', 'catalogo:productos:gestionar')) AS x (RolCodigo, PermisoCodigo)
|
||||
JOIN dbo.Rol r ON r.Codigo = x.RolCodigo
|
||||
JOIN dbo.Permiso p ON p.Codigo = x.PermisoCodigo
|
||||
) AS s ON t.RolId = s.RolId AND t.PermisoId = s.PermisoId
|
||||
WHEN NOT MATCHED BY TARGET THEN
|
||||
INSERT (RolId, PermisoId) VALUES (s.RolId, s.PermisoId);
|
||||
GO
|
||||
|
||||
PRINT '';
|
||||
PRINT 'V018 applied — dbo.Product (temporal, retention 10y) + permiso catalogo:productos:gestionar.';
|
||||
PRINT 'Next: V019 (PRD-008 — seed 12 productos legacy).';
|
||||
GO
|
||||
71
database/migrations/V019_ROLLBACK.sql
Normal file
71
database/migrations/V019_ROLLBACK.sql
Normal file
@@ -0,0 +1,71 @@
|
||||
-- V019_ROLLBACK.sql
|
||||
-- PRD-003: Reversa de V019__create_product_prices.sql.
|
||||
--
|
||||
-- Pasos:
|
||||
-- 1. Deshabilita SYSTEM_VERSIONING en dbo.ProductPrices (requerido antes de DROP TABLE).
|
||||
-- 2. Elimina el PERIOD FOR SYSTEM_TIME y las columnas hidden SysStartTime/SysEndTime.
|
||||
-- 3. Drop de dbo.ProductPrices_History.
|
||||
-- 4. Drop de dbo.ProductPrices (y sus constraints + índices en cascada).
|
||||
-- 5. Drop de dbo.usp_AddProductPrice.
|
||||
--
|
||||
-- ADVERTENCIA: destruye todo el historial de precios. Ejecutar sólo en DEV o TEST.
|
||||
-- Run on: SIGCM2 (dev), SIGCM2_Test_App, SIGCM2_Test_Api.
|
||||
|
||||
SET QUOTED_IDENTIFIER ON;
|
||||
SET ANSI_NULLS ON;
|
||||
SET NOCOUNT ON;
|
||||
GO
|
||||
|
||||
-- 1. Deshabilita SYSTEM_VERSIONING (imprescindible antes de DROP TABLE temporal).
|
||||
IF EXISTS (SELECT 1 FROM sys.tables WHERE object_id = OBJECT_ID('dbo.ProductPrices') AND temporal_type = 2)
|
||||
BEGIN
|
||||
ALTER TABLE dbo.ProductPrices SET (SYSTEM_VERSIONING = OFF);
|
||||
PRINT 'ProductPrices: SYSTEM_VERSIONING = OFF.';
|
||||
END
|
||||
GO
|
||||
|
||||
-- 2. Elimina el PERIOD y las hidden cols (si existen, independientemente del versioning).
|
||||
IF COL_LENGTH('dbo.ProductPrices', 'SysStartTime') IS NOT NULL
|
||||
BEGIN
|
||||
ALTER TABLE dbo.ProductPrices
|
||||
DROP PERIOD FOR SYSTEM_TIME;
|
||||
|
||||
-- Drop default constraints antes de drop de columnas.
|
||||
IF EXISTS (SELECT 1 FROM sys.default_constraints WHERE name = 'DF_ProductPrices_SysStartTime')
|
||||
ALTER TABLE dbo.ProductPrices DROP CONSTRAINT DF_ProductPrices_SysStartTime;
|
||||
IF EXISTS (SELECT 1 FROM sys.default_constraints WHERE name = 'DF_ProductPrices_SysEndTime')
|
||||
ALTER TABLE dbo.ProductPrices DROP CONSTRAINT DF_ProductPrices_SysEndTime;
|
||||
|
||||
ALTER TABLE dbo.ProductPrices DROP COLUMN SysStartTime;
|
||||
ALTER TABLE dbo.ProductPrices DROP COLUMN SysEndTime;
|
||||
PRINT 'ProductPrices: PERIOD + hidden cols dropped.';
|
||||
END
|
||||
GO
|
||||
|
||||
-- 3. Drop de la history table.
|
||||
IF OBJECT_ID(N'dbo.ProductPrices_History', N'U') IS NOT NULL
|
||||
BEGIN
|
||||
DROP TABLE dbo.ProductPrices_History;
|
||||
PRINT 'Table dbo.ProductPrices_History dropped.';
|
||||
END
|
||||
GO
|
||||
|
||||
-- 4. Drop de la tabla principal (constraints + índices en cascada).
|
||||
IF OBJECT_ID(N'dbo.ProductPrices', N'U') IS NOT NULL
|
||||
BEGIN
|
||||
DROP TABLE dbo.ProductPrices;
|
||||
PRINT 'Table dbo.ProductPrices dropped.';
|
||||
END
|
||||
GO
|
||||
|
||||
-- 5. Drop del SP.
|
||||
IF OBJECT_ID(N'dbo.usp_AddProductPrice', N'P') IS NOT NULL
|
||||
BEGIN
|
||||
DROP PROCEDURE dbo.usp_AddProductPrice;
|
||||
PRINT 'Procedure dbo.usp_AddProductPrice dropped.';
|
||||
END
|
||||
GO
|
||||
|
||||
PRINT '';
|
||||
PRINT 'V019 rollback complete — dbo.ProductPrices, dbo.ProductPrices_History, dbo.usp_AddProductPrice removed.';
|
||||
GO
|
||||
196
database/migrations/V019__create_product_prices.sql
Normal file
196
database/migrations/V019__create_product_prices.sql
Normal file
@@ -0,0 +1,196 @@
|
||||
-- V019__create_product_prices.sql
|
||||
-- PRD-003: ProductPrices — historial de precios por Producto con vigencia civil (Cat2).
|
||||
--
|
||||
-- Cambios:
|
||||
-- 1. dbo.ProductPrices (FK Product, SYSTEM_VERSIONING ON, retention 10 años).
|
||||
-- 2. Índices: filtered UQ un único activo; cover compuesto para GetPriceAt.
|
||||
-- 3. SP dbo.usp_AddProductPrice (SERIALIZABLE + UPDLOCK, cierre atómico forward-only).
|
||||
--
|
||||
-- Patrón: V018 (SYSTEM_VERSIONING + PAGE compression).
|
||||
-- Idempotente: seguro para re-ejecutar.
|
||||
-- Reversa: V019_ROLLBACK.sql.
|
||||
-- Run on: SIGCM2 (dev), SIGCM2_Test_App, SIGCM2_Test_Api.
|
||||
--
|
||||
-- Notas:
|
||||
-- - SysStartTime/SysEndTime como nombres de cols HIDDEN (no ValidFrom/ValidTo):
|
||||
-- evita colisión con las business cols PriceValidFrom/PriceValidTo (D1).
|
||||
-- - DECIMAL(12,2) para Price (distinto de Product.BasePrice DECIMAL(18,4)) — precios retail
|
||||
-- en pesos con 2 decimales; la diferencia es intencional (D6).
|
||||
-- - Sin seed inicial — Product.BasePrice queda ortogonal como fallback (OQ-B, D8).
|
||||
-- - Forward-only estricto en SP: THROW 50409 si new PVF <= active PVF (no solo <).
|
||||
--
|
||||
-- SDD Design: engram sdd/prd-003-product-prices-historicos/design
|
||||
|
||||
SET QUOTED_IDENTIFIER ON;
|
||||
SET ANSI_NULLS ON;
|
||||
SET NOCOUNT ON;
|
||||
GO
|
||||
|
||||
-- ═══════════════════════════════════════════════════════════════════════
|
||||
-- 1. dbo.ProductPrices
|
||||
-- ═══════════════════════════════════════════════════════════════════════
|
||||
|
||||
IF OBJECT_ID(N'dbo.ProductPrices', N'U') IS NULL
|
||||
BEGIN
|
||||
CREATE TABLE dbo.ProductPrices (
|
||||
Id BIGINT IDENTITY(1,1) NOT NULL
|
||||
CONSTRAINT PK_ProductPrices PRIMARY KEY,
|
||||
ProductId INT NOT NULL,
|
||||
Price DECIMAL(12,2) NOT NULL,
|
||||
PriceValidFrom DATE NOT NULL,
|
||||
PriceValidTo DATE NULL,
|
||||
FechaCreacion DATETIME2(3) NOT NULL
|
||||
CONSTRAINT DF_ProductPrices_FechaCreacion DEFAULT(SYSUTCDATETIME()),
|
||||
CONSTRAINT FK_ProductPrices_Product
|
||||
FOREIGN KEY (ProductId) REFERENCES dbo.Product(Id) ON DELETE NO ACTION,
|
||||
CONSTRAINT CK_ProductPrices_Price_Positive
|
||||
CHECK (Price > 0),
|
||||
CONSTRAINT CK_ProductPrices_ValidRange
|
||||
CHECK (PriceValidTo IS NULL OR PriceValidTo >= PriceValidFrom)
|
||||
);
|
||||
PRINT 'Table dbo.ProductPrices created.';
|
||||
END
|
||||
ELSE
|
||||
PRINT 'Table dbo.ProductPrices already exists — skip.';
|
||||
GO
|
||||
|
||||
-- ═══════════════════════════════════════════════════════════════════════
|
||||
-- 2. SYSTEM_VERSIONING — ProductPrices
|
||||
-- Las hidden cols se llaman SysStartTime/SysEndTime para evitar
|
||||
-- colisión con las business cols PriceValidFrom/PriceValidTo (D1).
|
||||
-- ═══════════════════════════════════════════════════════════════════════
|
||||
|
||||
IF COL_LENGTH('dbo.ProductPrices', 'SysStartTime') IS NULL
|
||||
BEGIN
|
||||
ALTER TABLE dbo.ProductPrices
|
||||
ADD
|
||||
SysStartTime DATETIME2(3) GENERATED ALWAYS AS ROW START HIDDEN NOT NULL
|
||||
CONSTRAINT DF_ProductPrices_SysStartTime DEFAULT(SYSUTCDATETIME()),
|
||||
SysEndTime DATETIME2(3) GENERATED ALWAYS AS ROW END HIDDEN NOT NULL
|
||||
CONSTRAINT DF_ProductPrices_SysEndTime DEFAULT(CONVERT(DATETIME2(3),'9999-12-31 23:59:59.999')),
|
||||
PERIOD FOR SYSTEM_TIME (SysStartTime, SysEndTime);
|
||||
PRINT 'ProductPrices: PERIOD FOR SYSTEM_TIME added (SysStartTime/SysEndTime).';
|
||||
END
|
||||
GO
|
||||
|
||||
IF NOT EXISTS (SELECT 1 FROM sys.tables WHERE object_id = OBJECT_ID('dbo.ProductPrices') AND temporal_type = 2)
|
||||
BEGIN
|
||||
ALTER TABLE dbo.ProductPrices
|
||||
SET (SYSTEM_VERSIONING = ON (
|
||||
HISTORY_TABLE = dbo.ProductPrices_History,
|
||||
HISTORY_RETENTION_PERIOD = 10 YEARS
|
||||
));
|
||||
PRINT 'ProductPrices: SYSTEM_VERSIONING = ON (history: dbo.ProductPrices_History, retention: 10 years).';
|
||||
END
|
||||
ELSE
|
||||
PRINT 'ProductPrices: SYSTEM_VERSIONING already ON — skip.';
|
||||
GO
|
||||
|
||||
IF EXISTS (SELECT 1 FROM sys.tables WHERE name = 'ProductPrices_History' AND schema_id = SCHEMA_ID('dbo'))
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM sys.partitions p
|
||||
JOIN sys.tables t ON t.object_id = p.object_id
|
||||
WHERE t.name = 'ProductPrices_History' AND p.data_compression = 2
|
||||
)
|
||||
BEGIN
|
||||
ALTER TABLE dbo.ProductPrices_History REBUILD WITH (DATA_COMPRESSION = PAGE);
|
||||
PRINT 'ProductPrices_History: rebuilt with PAGE compression.';
|
||||
END
|
||||
GO
|
||||
|
||||
-- ═══════════════════════════════════════════════════════════════════════
|
||||
-- 3. Índices
|
||||
-- ═══════════════════════════════════════════════════════════════════════
|
||||
|
||||
-- Un único activo por producto (imposibilita violar a nivel BD).
|
||||
IF NOT EXISTS (SELECT 1 FROM sys.indexes WHERE name = 'UX_ProductPrices_Active' AND object_id = OBJECT_ID('dbo.ProductPrices'))
|
||||
BEGIN
|
||||
CREATE UNIQUE INDEX UX_ProductPrices_Active
|
||||
ON dbo.ProductPrices (ProductId)
|
||||
WHERE PriceValidTo IS NULL;
|
||||
PRINT 'Index UX_ProductPrices_Active created.';
|
||||
END
|
||||
GO
|
||||
|
||||
-- Cover para GetPriceAt / GetByProductIdAsync (ProductId + PriceValidFrom con INCLUDEs).
|
||||
IF NOT EXISTS (SELECT 1 FROM sys.indexes WHERE name = 'IX_ProductPrices_Lookup' AND object_id = OBJECT_ID('dbo.ProductPrices'))
|
||||
BEGIN
|
||||
CREATE INDEX IX_ProductPrices_Lookup
|
||||
ON dbo.ProductPrices (ProductId, PriceValidFrom DESC)
|
||||
INCLUDE (Price, PriceValidTo);
|
||||
PRINT 'Index IX_ProductPrices_Lookup created.';
|
||||
END
|
||||
GO
|
||||
|
||||
-- ═══════════════════════════════════════════════════════════════════════
|
||||
-- 4. SP — dbo.usp_AddProductPrice
|
||||
-- Cierre atómico forward-only: SERIALIZABLE + UPDLOCK + HOLDLOCK.
|
||||
-- Params de salida: @NewId (BIGINT), @ClosedId (BIGINT — NULL si primer precio).
|
||||
-- ═══════════════════════════════════════════════════════════════════════
|
||||
GO
|
||||
CREATE OR ALTER PROCEDURE dbo.usp_AddProductPrice
|
||||
@ProductId INT,
|
||||
@Price DECIMAL(12,2),
|
||||
@PriceValidFrom DATE,
|
||||
@NewId BIGINT OUTPUT,
|
||||
@ClosedId BIGINT OUTPUT
|
||||
AS
|
||||
BEGIN
|
||||
SET NOCOUNT ON;
|
||||
SET XACT_ABORT ON;
|
||||
SET TRANSACTION ISOLATION LEVEL SERIALIZABLE;
|
||||
|
||||
BEGIN TRY
|
||||
BEGIN TRANSACTION;
|
||||
|
||||
-- Validación: producto debe existir y estar activo.
|
||||
IF NOT EXISTS (SELECT 1 FROM dbo.Product WITH (NOLOCK) WHERE Id = @ProductId AND IsActive = 1)
|
||||
BEGIN
|
||||
ROLLBACK;
|
||||
THROW 50404, 'Product not found or inactive', 1;
|
||||
END
|
||||
|
||||
-- Lee activo con UPDLOCK + HOLDLOCK — bloquea el range key del filtered index.
|
||||
DECLARE @ActiveId BIGINT, @ActivePVF DATE;
|
||||
SELECT TOP 1
|
||||
@ActiveId = Id,
|
||||
@ActivePVF = PriceValidFrom
|
||||
FROM dbo.ProductPrices WITH (UPDLOCK, HOLDLOCK, ROWLOCK)
|
||||
WHERE ProductId = @ProductId AND PriceValidTo IS NULL;
|
||||
|
||||
-- Forward-only estricto: el nuevo PVF debe ser ESTRICTAMENTE mayor al activo.
|
||||
IF @ActiveId IS NOT NULL AND @PriceValidFrom <= @ActivePVF
|
||||
BEGIN
|
||||
ROLLBACK;
|
||||
THROW 50409, 'ProductPriceForwardOnly: new PriceValidFrom must be > active.PriceValidFrom', 1;
|
||||
END
|
||||
|
||||
-- Cierra el activo previo: PVT = PVF(nuevo) - 1 día.
|
||||
IF @ActiveId IS NOT NULL
|
||||
BEGIN
|
||||
UPDATE dbo.ProductPrices
|
||||
SET PriceValidTo = DATEADD(DAY, -1, @PriceValidFrom)
|
||||
WHERE Id = @ActiveId;
|
||||
SET @ClosedId = @ActiveId;
|
||||
END
|
||||
ELSE
|
||||
SET @ClosedId = NULL;
|
||||
|
||||
-- Inserta el nuevo activo.
|
||||
INSERT INTO dbo.ProductPrices (ProductId, Price, PriceValidFrom, PriceValidTo)
|
||||
VALUES (@ProductId, @Price, @PriceValidFrom, NULL);
|
||||
SET @NewId = SCOPE_IDENTITY();
|
||||
|
||||
COMMIT TRANSACTION;
|
||||
END TRY
|
||||
BEGIN CATCH
|
||||
IF XACT_STATE() <> 0 ROLLBACK TRANSACTION;
|
||||
THROW;
|
||||
END CATCH
|
||||
END
|
||||
GO
|
||||
|
||||
PRINT '';
|
||||
PRINT 'V019 applied — dbo.ProductPrices (temporal, retention 10y) + UX_ProductPrices_Active + IX_ProductPrices_Lookup + usp_AddProductPrice.';
|
||||
PRINT 'Next migration: V020 (TBD).';
|
||||
GO
|
||||
33
database/migrations/V020_ROLLBACK.sql
Normal file
33
database/migrations/V020_ROLLBACK.sql
Normal file
@@ -0,0 +1,33 @@
|
||||
-- V020_ROLLBACK.sql
|
||||
-- PRC-001: Reversa de V020__add_chargeable_chars_permission.sql.
|
||||
--
|
||||
-- Pasos:
|
||||
-- 1. Elimina la asignación del permiso al rol 'admin'.
|
||||
-- 2. Elimina el permiso del catálogo.
|
||||
--
|
||||
-- ADVERTENCIA: si algún usuario o rol tiene este permiso asignado explícitamente,
|
||||
-- la FK de RolPermiso causará error. Limpiar RolPermiso primero.
|
||||
-- Run on: SIGCM2 (dev), SIGCM2_Test_App, SIGCM2_Test_Api.
|
||||
|
||||
SET QUOTED_IDENTIFIER ON;
|
||||
SET ANSI_NULLS ON;
|
||||
SET NOCOUNT ON;
|
||||
GO
|
||||
|
||||
-- 1. Eliminar asignaciones del permiso a cualquier rol.
|
||||
DELETE rp
|
||||
FROM dbo.RolPermiso rp
|
||||
JOIN dbo.Permiso p ON p.Id = rp.PermisoId
|
||||
WHERE p.Codigo = 'tasacion:caracteres_especiales:gestionar';
|
||||
PRINT 'V020 rollback: RolPermiso entries for tasacion:caracteres_especiales:gestionar removed.';
|
||||
GO
|
||||
|
||||
-- 2. Eliminar el permiso del catálogo.
|
||||
DELETE FROM dbo.Permiso
|
||||
WHERE Codigo = 'tasacion:caracteres_especiales:gestionar';
|
||||
PRINT 'V020 rollback: Permiso tasacion:caracteres_especiales:gestionar removed.';
|
||||
GO
|
||||
|
||||
PRINT '';
|
||||
PRINT 'V020 rollback complete.';
|
||||
GO
|
||||
@@ -0,0 +1,54 @@
|
||||
-- V020__add_chargeable_chars_permission.sql
|
||||
-- PRC-001: permiso RBAC para ABM de caracteres tasables.
|
||||
--
|
||||
-- Cambios:
|
||||
-- 1. Agrega permiso 'tasacion:caracteres_especiales:gestionar' al catálogo.
|
||||
-- 2. Asigna el permiso al rol 'admin'.
|
||||
--
|
||||
-- Convención RBAC: modulo:recurso:accion.
|
||||
-- Patrón: V007 (MERGE idempotente).
|
||||
-- Idempotente: seguro para re-ejecutar.
|
||||
-- Reversa: V020_ROLLBACK.sql.
|
||||
-- Run on: SIGCM2 (dev), SIGCM2_Test_App, SIGCM2_Test_Api.
|
||||
--
|
||||
-- NOTA: V020 se ejecuta ANTES de V021 (tabla) porque el permiso debe existir
|
||||
-- antes de que la API arranque con [RequirePermission(...)].
|
||||
-- V021 crea la tabla dbo.ChargeableCharConfig.
|
||||
-- V022 siembra las 4 filas globales por defecto.
|
||||
--
|
||||
-- SDD Design: engram sdd/prc-001-word-counter-spike/design (D16/D17)
|
||||
|
||||
SET QUOTED_IDENTIFIER ON;
|
||||
SET ANSI_NULLS ON;
|
||||
SET NOCOUNT ON;
|
||||
GO
|
||||
|
||||
-- Agregar permiso al catálogo (idempotente via MERGE).
|
||||
MERGE dbo.Permiso AS t
|
||||
USING (VALUES
|
||||
('tasacion:caracteres_especiales:gestionar',
|
||||
N'Gestionar caracteres tasables',
|
||||
N'Crear, editar precio y desactivar la configuración de caracteres especiales para tasación.',
|
||||
'tasacion')
|
||||
) AS s (Codigo, Nombre, Descripcion, Modulo)
|
||||
ON t.Codigo = s.Codigo
|
||||
WHEN NOT MATCHED BY TARGET THEN
|
||||
INSERT (Codigo, Nombre, Descripcion, Modulo)
|
||||
VALUES (s.Codigo, s.Nombre, s.Descripcion, s.Modulo);
|
||||
GO
|
||||
|
||||
-- Asignar a rol 'admin' (idempotente via MERGE).
|
||||
MERGE dbo.RolPermiso AS t
|
||||
USING (
|
||||
SELECT r.Id AS RolId, p.Id AS PermisoId
|
||||
FROM dbo.Rol r
|
||||
CROSS JOIN dbo.Permiso p
|
||||
WHERE r.Codigo = 'admin'
|
||||
AND p.Codigo = 'tasacion:caracteres_especiales:gestionar'
|
||||
) AS s ON t.RolId = s.RolId AND t.PermisoId = s.PermisoId
|
||||
WHEN NOT MATCHED BY TARGET THEN
|
||||
INSERT (RolId, PermisoId) VALUES (s.RolId, s.PermisoId);
|
||||
GO
|
||||
|
||||
PRINT 'V020 applied — tasacion:caracteres_especiales:gestionar added to catalog and assigned to admin.';
|
||||
GO
|
||||
79
database/migrations/V021_ROLLBACK.sql
Normal file
79
database/migrations/V021_ROLLBACK.sql
Normal file
@@ -0,0 +1,79 @@
|
||||
-- V021_ROLLBACK.sql
|
||||
-- PRC-001: Reversa de V021__create_chargeable_char_config.sql.
|
||||
--
|
||||
-- Pasos:
|
||||
-- 1. Deshabilita SYSTEM_VERSIONING en dbo.ChargeableCharConfig (requerido antes de DROP TABLE).
|
||||
-- 2. Elimina el PERIOD FOR SYSTEM_TIME y las columnas hidden SysStartTime/SysEndTime.
|
||||
-- 3. Drop de dbo.ChargeableCharConfig_History.
|
||||
-- 4. Drop de dbo.ChargeableCharConfig (constraints + índices en cascada).
|
||||
-- 5. Drop de dbo.usp_ChargeableCharConfig_InsertWithClose.
|
||||
-- 6. Drop de dbo.usp_ChargeableCharConfig_GetActiveForMedio.
|
||||
--
|
||||
-- ADVERTENCIA: destruye toda la configuración de caracteres tasables. Solo DEV/TEST.
|
||||
-- Run on: SIGCM2 (dev), SIGCM2_Test_App, SIGCM2_Test_Api.
|
||||
|
||||
SET QUOTED_IDENTIFIER ON;
|
||||
SET ANSI_NULLS ON;
|
||||
SET NOCOUNT ON;
|
||||
GO
|
||||
|
||||
-- 1. Deshabilita SYSTEM_VERSIONING (imprescindible antes de DROP TABLE temporal).
|
||||
IF EXISTS (SELECT 1 FROM sys.tables WHERE object_id = OBJECT_ID('dbo.ChargeableCharConfig') AND temporal_type = 2)
|
||||
BEGIN
|
||||
ALTER TABLE dbo.ChargeableCharConfig SET (SYSTEM_VERSIONING = OFF);
|
||||
PRINT 'ChargeableCharConfig: SYSTEM_VERSIONING = OFF.';
|
||||
END
|
||||
GO
|
||||
|
||||
-- 2. Elimina el PERIOD y las hidden cols.
|
||||
IF COL_LENGTH('dbo.ChargeableCharConfig', 'SysStartTime') IS NOT NULL
|
||||
BEGIN
|
||||
ALTER TABLE dbo.ChargeableCharConfig
|
||||
DROP PERIOD FOR SYSTEM_TIME;
|
||||
|
||||
IF EXISTS (SELECT 1 FROM sys.default_constraints WHERE name = 'DF_ChargeableCharConfig_SysStartTime')
|
||||
ALTER TABLE dbo.ChargeableCharConfig DROP CONSTRAINT DF_ChargeableCharConfig_SysStartTime;
|
||||
IF EXISTS (SELECT 1 FROM sys.default_constraints WHERE name = 'DF_ChargeableCharConfig_SysEndTime')
|
||||
ALTER TABLE dbo.ChargeableCharConfig DROP CONSTRAINT DF_ChargeableCharConfig_SysEndTime;
|
||||
|
||||
ALTER TABLE dbo.ChargeableCharConfig DROP COLUMN SysStartTime;
|
||||
ALTER TABLE dbo.ChargeableCharConfig DROP COLUMN SysEndTime;
|
||||
PRINT 'ChargeableCharConfig: PERIOD + hidden cols dropped.';
|
||||
END
|
||||
GO
|
||||
|
||||
-- 3. Drop de la history table.
|
||||
IF OBJECT_ID(N'dbo.ChargeableCharConfig_History', N'U') IS NOT NULL
|
||||
BEGIN
|
||||
DROP TABLE dbo.ChargeableCharConfig_History;
|
||||
PRINT 'Table dbo.ChargeableCharConfig_History dropped.';
|
||||
END
|
||||
GO
|
||||
|
||||
-- 4. Drop de la tabla principal.
|
||||
IF OBJECT_ID(N'dbo.ChargeableCharConfig', N'U') IS NOT NULL
|
||||
BEGIN
|
||||
DROP TABLE dbo.ChargeableCharConfig;
|
||||
PRINT 'Table dbo.ChargeableCharConfig dropped.';
|
||||
END
|
||||
GO
|
||||
|
||||
-- 5. Drop del SP InsertWithClose.
|
||||
IF OBJECT_ID(N'dbo.usp_ChargeableCharConfig_InsertWithClose', N'P') IS NOT NULL
|
||||
BEGIN
|
||||
DROP PROCEDURE dbo.usp_ChargeableCharConfig_InsertWithClose;
|
||||
PRINT 'Procedure dbo.usp_ChargeableCharConfig_InsertWithClose dropped.';
|
||||
END
|
||||
GO
|
||||
|
||||
-- 6. Drop del SP GetActiveForMedio.
|
||||
IF OBJECT_ID(N'dbo.usp_ChargeableCharConfig_GetActiveForMedio', N'P') IS NOT NULL
|
||||
BEGIN
|
||||
DROP PROCEDURE dbo.usp_ChargeableCharConfig_GetActiveForMedio;
|
||||
PRINT 'Procedure dbo.usp_ChargeableCharConfig_GetActiveForMedio dropped.';
|
||||
END
|
||||
GO
|
||||
|
||||
PRINT '';
|
||||
PRINT 'V021 rollback complete — dbo.ChargeableCharConfig, dbo.ChargeableCharConfig_History, usp_ChargeableCharConfig_InsertWithClose, usp_ChargeableCharConfig_GetActiveForMedio removed.';
|
||||
GO
|
||||
256
database/migrations/V021__create_chargeable_char_config.sql
Normal file
256
database/migrations/V021__create_chargeable_char_config.sql
Normal file
@@ -0,0 +1,256 @@
|
||||
-- V021__create_chargeable_char_config.sql
|
||||
-- PRC-001: ChargeableCharConfig — configuración de caracteres especiales tasables con vigencia civil.
|
||||
--
|
||||
-- Cambios:
|
||||
-- 1. dbo.ChargeableCharConfig (FK Medios NULL=global, SYSTEM_VERSIONING ON, retention 10 años).
|
||||
-- 2. Índices: filtered UX vigente por (MedioId,Symbol); cover IX para GetActiveForMedio.
|
||||
-- 3. SP dbo.usp_ChargeableCharConfig_InsertWithClose (SERIALIZABLE + UPDLOCK, forward-only).
|
||||
-- 4. SP dbo.usp_ChargeableCharConfig_GetActiveForMedio (CTE + ROW_NUMBER per-medio/global).
|
||||
--
|
||||
-- Patrón: V019 (SYSTEM_VERSIONING + PAGE compression + SERIALIZABLE SP).
|
||||
-- Idempotente: seguro para re-ejecutar.
|
||||
-- Reversa: V021_ROLLBACK.sql.
|
||||
-- Run on: SIGCM2 (dev), SIGCM2_Test_App, SIGCM2_Test_Api.
|
||||
--
|
||||
-- Notas:
|
||||
-- - SysStartTime/SysEndTime como hidden cols: evita colisión con business cols ValidFrom/ValidTo (D4).
|
||||
-- - DECIMAL(18,4) para PricePerUnit (mayor granularidad que ProductPrices) (D8).
|
||||
-- - MedioId NULL = global fallback; per-medio overrides global in GetActiveForMedio (D2/D6).
|
||||
-- - Forward-only estricto: THROW 50409 si new ValidFrom <= activo.ValidFrom (D9).
|
||||
-- - UX filtered WHERE ValidTo IS NULL: SQL Server trata (NULL,'$') como valor igual → enforza 1 vigente global (D7).
|
||||
-- - dbo.ChargeableCharConfig_History debe agregarse a TablesToIgnore en SqlTestFixture.cs (Respawn).
|
||||
--
|
||||
-- SDD Design: engram sdd/prc-001-word-counter-spike/design
|
||||
|
||||
SET QUOTED_IDENTIFIER ON;
|
||||
SET ANSI_NULLS ON;
|
||||
SET NOCOUNT ON;
|
||||
GO
|
||||
|
||||
-- ═══════════════════════════════════════════════════════════════════════
|
||||
-- 1. dbo.ChargeableCharConfig
|
||||
-- ═══════════════════════════════════════════════════════════════════════
|
||||
|
||||
IF OBJECT_ID(N'dbo.ChargeableCharConfig', N'U') IS NULL
|
||||
BEGIN
|
||||
CREATE TABLE dbo.ChargeableCharConfig (
|
||||
Id BIGINT IDENTITY(1,1) NOT NULL
|
||||
CONSTRAINT PK_ChargeableCharConfig PRIMARY KEY,
|
||||
MedioId INT NULL, -- NULL = global fallback
|
||||
Symbol NVARCHAR(4) NOT NULL,
|
||||
Category NVARCHAR(32) NOT NULL, -- enum-as-string: Currency/Percentage/Exclamation/Question/Other
|
||||
PricePerUnit DECIMAL(18,4) NOT NULL,
|
||||
ValidFrom DATE NOT NULL,
|
||||
ValidTo DATE NULL,
|
||||
IsActive BIT NOT NULL
|
||||
CONSTRAINT DF_ChargeableCharConfig_IsActive DEFAULT(1),
|
||||
FechaCreacion DATETIME2(3) NOT NULL
|
||||
CONSTRAINT DF_ChargeableCharConfig_FechaCreacion DEFAULT(SYSUTCDATETIME()),
|
||||
CONSTRAINT FK_ChargeableCharConfig_Medio
|
||||
FOREIGN KEY (MedioId) REFERENCES dbo.Medio(Id) ON DELETE NO ACTION,
|
||||
CONSTRAINT CK_ChargeableCharConfig_Price_Positive
|
||||
CHECK (PricePerUnit > 0),
|
||||
CONSTRAINT CK_ChargeableCharConfig_Symbol_NotEmpty
|
||||
CHECK (LEN(Symbol) > 0),
|
||||
CONSTRAINT CK_ChargeableCharConfig_ValidRange
|
||||
CHECK (ValidTo IS NULL OR ValidTo >= ValidFrom)
|
||||
);
|
||||
PRINT 'Table dbo.ChargeableCharConfig created.';
|
||||
END
|
||||
ELSE
|
||||
PRINT 'Table dbo.ChargeableCharConfig already exists — skip.';
|
||||
GO
|
||||
|
||||
-- ═══════════════════════════════════════════════════════════════════════
|
||||
-- 2. SYSTEM_VERSIONING — ChargeableCharConfig
|
||||
-- SysStartTime/SysEndTime para no colisionar con business cols ValidFrom/ValidTo (D4).
|
||||
-- ═══════════════════════════════════════════════════════════════════════
|
||||
|
||||
IF COL_LENGTH('dbo.ChargeableCharConfig', 'SysStartTime') IS NULL
|
||||
BEGIN
|
||||
ALTER TABLE dbo.ChargeableCharConfig
|
||||
ADD
|
||||
SysStartTime DATETIME2(3) GENERATED ALWAYS AS ROW START HIDDEN NOT NULL
|
||||
CONSTRAINT DF_ChargeableCharConfig_SysStartTime DEFAULT(SYSUTCDATETIME()),
|
||||
SysEndTime DATETIME2(3) GENERATED ALWAYS AS ROW END HIDDEN NOT NULL
|
||||
CONSTRAINT DF_ChargeableCharConfig_SysEndTime DEFAULT(CONVERT(DATETIME2(3),'9999-12-31 23:59:59.999')),
|
||||
PERIOD FOR SYSTEM_TIME (SysStartTime, SysEndTime);
|
||||
PRINT 'ChargeableCharConfig: PERIOD FOR SYSTEM_TIME added (SysStartTime/SysEndTime).';
|
||||
END
|
||||
GO
|
||||
|
||||
IF NOT EXISTS (SELECT 1 FROM sys.tables WHERE object_id = OBJECT_ID('dbo.ChargeableCharConfig') AND temporal_type = 2)
|
||||
BEGIN
|
||||
ALTER TABLE dbo.ChargeableCharConfig
|
||||
SET (SYSTEM_VERSIONING = ON (
|
||||
HISTORY_TABLE = dbo.ChargeableCharConfig_History,
|
||||
HISTORY_RETENTION_PERIOD = 10 YEARS
|
||||
));
|
||||
PRINT 'ChargeableCharConfig: SYSTEM_VERSIONING = ON (history: dbo.ChargeableCharConfig_History, retention: 10 years).';
|
||||
END
|
||||
ELSE
|
||||
PRINT 'ChargeableCharConfig: SYSTEM_VERSIONING already ON — skip.';
|
||||
GO
|
||||
|
||||
IF EXISTS (SELECT 1 FROM sys.tables WHERE name = 'ChargeableCharConfig_History' AND schema_id = SCHEMA_ID('dbo'))
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM sys.partitions p
|
||||
JOIN sys.tables t ON t.object_id = p.object_id
|
||||
WHERE t.name = 'ChargeableCharConfig_History' AND p.data_compression = 2
|
||||
)
|
||||
BEGIN
|
||||
ALTER TABLE dbo.ChargeableCharConfig_History REBUILD WITH (DATA_COMPRESSION = PAGE);
|
||||
PRINT 'ChargeableCharConfig_History: rebuilt with PAGE compression.';
|
||||
END
|
||||
GO
|
||||
|
||||
-- ═══════════════════════════════════════════════════════════════════════
|
||||
-- 3. Índices
|
||||
-- ═══════════════════════════════════════════════════════════════════════
|
||||
|
||||
-- Un único vigente por (MedioId, Symbol).
|
||||
-- SQL Server trata NULL como "distinto" en índices únicos: (NULL,'$') colisiona consigo mismo
|
||||
-- → enforza exactamente 1 vigente global por símbolo (D7).
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM sys.indexes
|
||||
WHERE name = 'UX_ChargeableCharConfig_Vigente'
|
||||
AND object_id = OBJECT_ID('dbo.ChargeableCharConfig')
|
||||
)
|
||||
BEGIN
|
||||
CREATE UNIQUE INDEX UX_ChargeableCharConfig_Vigente
|
||||
ON dbo.ChargeableCharConfig (MedioId, Symbol)
|
||||
WHERE ValidTo IS NULL;
|
||||
PRINT 'Index UX_ChargeableCharConfig_Vigente created.';
|
||||
END
|
||||
GO
|
||||
|
||||
-- Cover para GetActiveForMedio y List.
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM sys.indexes
|
||||
WHERE name = 'IX_ChargeableCharConfig_Query'
|
||||
AND object_id = OBJECT_ID('dbo.ChargeableCharConfig')
|
||||
)
|
||||
BEGIN
|
||||
CREATE INDEX IX_ChargeableCharConfig_Query
|
||||
ON dbo.ChargeableCharConfig (MedioId, Symbol, ValidFrom, ValidTo)
|
||||
INCLUDE (PricePerUnit, IsActive, Category);
|
||||
PRINT 'Index IX_ChargeableCharConfig_Query created.';
|
||||
END
|
||||
GO
|
||||
|
||||
-- ═══════════════════════════════════════════════════════════════════════
|
||||
-- 4. SP — dbo.usp_ChargeableCharConfig_InsertWithClose
|
||||
-- Cierre atómico forward-only: SERIALIZABLE + UPDLOCK + HOLDLOCK.
|
||||
-- @MedioId NULL = global; existencia validada sólo cuando NOT NULL.
|
||||
-- THROW 50404: Medio not found.
|
||||
-- THROW 50409: ForwardOnly — new ValidFrom must be > active.ValidFrom.
|
||||
-- Params de salida: @NewId (BIGINT), @ClosedId (BIGINT — NULL si primer precio).
|
||||
-- ═══════════════════════════════════════════════════════════════════════
|
||||
GO
|
||||
CREATE OR ALTER PROCEDURE dbo.usp_ChargeableCharConfig_InsertWithClose
|
||||
@MedioId INT = NULL,
|
||||
@Symbol NVARCHAR(4),
|
||||
@Category NVARCHAR(32),
|
||||
@PricePerUnit DECIMAL(18,4),
|
||||
@ValidFrom DATE,
|
||||
@NewId BIGINT OUTPUT,
|
||||
@ClosedId BIGINT OUTPUT
|
||||
AS
|
||||
BEGIN
|
||||
SET NOCOUNT ON;
|
||||
SET XACT_ABORT ON;
|
||||
SET TRANSACTION ISOLATION LEVEL SERIALIZABLE;
|
||||
|
||||
BEGIN TRY
|
||||
BEGIN TRANSACTION;
|
||||
|
||||
-- Validar MedioId sólo cuando se proporciona (NULL = global fallback siempre válido).
|
||||
IF @MedioId IS NOT NULL
|
||||
AND NOT EXISTS (SELECT 1 FROM dbo.Medio WITH (NOLOCK) WHERE Id = @MedioId)
|
||||
BEGIN
|
||||
ROLLBACK;
|
||||
THROW 50404, 'Medio not found', 1;
|
||||
END
|
||||
|
||||
-- Lee el vigente actual con bloqueo de rango para serialización.
|
||||
DECLARE @ActiveId BIGINT, @ActiveValidFrom DATE;
|
||||
SELECT TOP 1
|
||||
@ActiveId = Id,
|
||||
@ActiveValidFrom = ValidFrom
|
||||
FROM dbo.ChargeableCharConfig WITH (UPDLOCK, HOLDLOCK, ROWLOCK)
|
||||
WHERE ((@MedioId IS NULL AND MedioId IS NULL)
|
||||
OR (@MedioId IS NOT NULL AND MedioId = @MedioId))
|
||||
AND Symbol = @Symbol
|
||||
AND ValidTo IS NULL;
|
||||
|
||||
-- Forward-only estricto: new ValidFrom debe ser ESTRICTAMENTE mayor al activo.
|
||||
IF @ActiveId IS NOT NULL AND @ValidFrom <= @ActiveValidFrom
|
||||
BEGIN
|
||||
ROLLBACK;
|
||||
THROW 50409, 'ChargeableCharConfigForwardOnly: new ValidFrom must be > active.ValidFrom', 1;
|
||||
END
|
||||
|
||||
-- Cierra el vigente previo: ValidTo = ValidFrom(nuevo) - 1 día.
|
||||
IF @ActiveId IS NOT NULL
|
||||
BEGIN
|
||||
UPDATE dbo.ChargeableCharConfig
|
||||
SET ValidTo = DATEADD(DAY, -1, @ValidFrom)
|
||||
WHERE Id = @ActiveId;
|
||||
SET @ClosedId = @ActiveId;
|
||||
END
|
||||
ELSE
|
||||
SET @ClosedId = NULL;
|
||||
|
||||
-- Inserta el nuevo vigente.
|
||||
INSERT INTO dbo.ChargeableCharConfig
|
||||
(MedioId, Symbol, Category, PricePerUnit, ValidFrom, ValidTo, IsActive)
|
||||
VALUES
|
||||
(@MedioId, @Symbol, @Category, @PricePerUnit, @ValidFrom, NULL, 1);
|
||||
SET @NewId = SCOPE_IDENTITY();
|
||||
|
||||
COMMIT TRANSACTION;
|
||||
END TRY
|
||||
BEGIN CATCH
|
||||
IF XACT_STATE() <> 0 ROLLBACK TRANSACTION;
|
||||
THROW;
|
||||
END CATCH
|
||||
END
|
||||
GO
|
||||
|
||||
-- ═══════════════════════════════════════════════════════════════════════
|
||||
-- 5. SP — dbo.usp_ChargeableCharConfig_GetActiveForMedio
|
||||
-- Resolución per-medio + global fallback: 1 fila por Symbol.
|
||||
-- CTE + ROW_NUMBER PARTITION BY Symbol ORDER BY per-medio(0) vs global(1).
|
||||
-- ═══════════════════════════════════════════════════════════════════════
|
||||
GO
|
||||
CREATE OR ALTER PROCEDURE dbo.usp_ChargeableCharConfig_GetActiveForMedio
|
||||
@MedioId INT,
|
||||
@AsOfDate DATE
|
||||
AS
|
||||
BEGIN
|
||||
SET NOCOUNT ON;
|
||||
WITH Candidates AS (
|
||||
SELECT
|
||||
Id, MedioId, Symbol, Category, PricePerUnit, ValidFrom, ValidTo, IsActive,
|
||||
ROW_NUMBER() OVER (
|
||||
PARTITION BY Symbol
|
||||
ORDER BY
|
||||
CASE WHEN MedioId = @MedioId THEN 0 ELSE 1 END, -- prefer specific over global
|
||||
ValidFrom DESC
|
||||
) AS rn
|
||||
FROM dbo.ChargeableCharConfig
|
||||
WHERE IsActive = 1
|
||||
AND ValidFrom <= @AsOfDate
|
||||
AND (ValidTo IS NULL OR ValidTo >= @AsOfDate)
|
||||
AND (MedioId = @MedioId OR MedioId IS NULL)
|
||||
)
|
||||
SELECT Id, MedioId, Symbol, Category, PricePerUnit, ValidFrom, ValidTo, IsActive
|
||||
FROM Candidates
|
||||
WHERE rn = 1;
|
||||
END
|
||||
GO
|
||||
|
||||
PRINT '';
|
||||
PRINT 'V021 applied — dbo.ChargeableCharConfig (temporal, retention 10y) + UX_ChargeableCharConfig_Vigente + IX_ChargeableCharConfig_Query + usp_ChargeableCharConfig_InsertWithClose + usp_ChargeableCharConfig_GetActiveForMedio.';
|
||||
PRINT 'Next migration: V022 (seed ChargeableCharConfig).';
|
||||
GO
|
||||
23
database/migrations/V022_ROLLBACK.sql
Normal file
23
database/migrations/V022_ROLLBACK.sql
Normal file
@@ -0,0 +1,23 @@
|
||||
-- V022_ROLLBACK.sql
|
||||
-- PRC-001: Reversa de V022__seed_chargeable_char_config.sql.
|
||||
--
|
||||
-- Elimina las 4 filas globales de seed (MedioId NULL, símbolos $/%/!/¡, ValidTo NULL).
|
||||
-- Solo elimina las filas vigentes (ValidTo IS NULL) para no romper el historial temporal.
|
||||
--
|
||||
-- ADVERTENCIA: si alguna de estas filas fue cerrada (ValidTo SET), el rollback las ignora
|
||||
-- (ya no son vigentes). La historia temporal queda intacta.
|
||||
-- Run on: SIGCM2 (dev), SIGCM2_Test_App, SIGCM2_Test_Api.
|
||||
|
||||
SET QUOTED_IDENTIFIER ON;
|
||||
SET ANSI_NULLS ON;
|
||||
SET NOCOUNT ON;
|
||||
GO
|
||||
|
||||
DELETE FROM dbo.ChargeableCharConfig
|
||||
WHERE MedioId IS NULL
|
||||
AND Symbol IN (N'$', N'%', N'!', N'¡')
|
||||
AND ValidTo IS NULL;
|
||||
GO
|
||||
|
||||
PRINT 'V022 rollback complete — global seed rows ($, %, !, ¡) removed.';
|
||||
GO
|
||||
44
database/migrations/V022__seed_chargeable_char_config.sql
Normal file
44
database/migrations/V022__seed_chargeable_char_config.sql
Normal file
@@ -0,0 +1,44 @@
|
||||
-- V022__seed_chargeable_char_config.sql
|
||||
-- PRC-001: seed de las 4 configuraciones globales de caracteres tasables por defecto.
|
||||
--
|
||||
-- Cambios:
|
||||
-- 1. Inserta 4 filas globales (MedioId NULL): $, %, !, ¡ — precios placeholder 1.0000.
|
||||
-- El equipo de negocio seteará los valores reales desde el CMS.
|
||||
--
|
||||
-- Patrón: MERGE idempotente ON (MedioId IS NULL AND Symbol AND ValidTo IS NULL).
|
||||
-- Idempotente: seguro para re-ejecutar.
|
||||
-- Reversa: V022_ROLLBACK.sql.
|
||||
-- Run on: SIGCM2 (dev), SIGCM2_Test_App, SIGCM2_Test_Api.
|
||||
--
|
||||
-- Depends on: V021 (dbo.ChargeableCharConfig must exist).
|
||||
--
|
||||
-- Notas:
|
||||
-- - MedioId NULL = global fallback; aplica a todos los medios a menos que exista
|
||||
-- una fila per-medio más específica (resolución en usp_ChargeableCharConfig_GetActiveForMedio).
|
||||
-- - ValidFrom = 2026-01-01: retroactivo al inicio del año fiscal 2026.
|
||||
-- - ValidTo NULL = vigente (sin fecha de cierre).
|
||||
-- - PricePerUnit 1.0000 son placeholders — CONFIRMAR con el área de tasación.
|
||||
--
|
||||
-- SDD Design: engram sdd/prc-001-word-counter-spike/design (§3.3)
|
||||
|
||||
SET QUOTED_IDENTIFIER ON;
|
||||
SET ANSI_NULLS ON;
|
||||
SET NOCOUNT ON;
|
||||
GO
|
||||
|
||||
MERGE dbo.ChargeableCharConfig AS t
|
||||
USING (VALUES
|
||||
(NULL, N'$', N'Currency', CAST(1.0000 AS DECIMAL(18,4)), CAST('2026-01-01' AS DATE)),
|
||||
(NULL, N'%', N'Percentage', CAST(1.0000 AS DECIMAL(18,4)), CAST('2026-01-01' AS DATE)),
|
||||
(NULL, N'!', N'Exclamation', CAST(1.0000 AS DECIMAL(18,4)), CAST('2026-01-01' AS DATE)),
|
||||
(NULL, N'¡', N'Exclamation', CAST(1.0000 AS DECIMAL(18,4)), CAST('2026-01-01' AS DATE))
|
||||
) AS s (MedioId, Symbol, Category, PricePerUnit, ValidFrom)
|
||||
ON (t.MedioId IS NULL AND s.MedioId IS NULL AND t.Symbol = s.Symbol AND t.ValidTo IS NULL)
|
||||
WHEN NOT MATCHED THEN
|
||||
INSERT (MedioId, Symbol, Category, PricePerUnit, ValidFrom, ValidTo, IsActive)
|
||||
VALUES (s.MedioId, s.Symbol, s.Category, s.PricePerUnit, s.ValidFrom, NULL, 1);
|
||||
GO
|
||||
|
||||
PRINT 'V022 applied — 4 global ChargeableCharConfig defaults seeded ($, %, !, ¡).';
|
||||
PRINT 'NOTE: PricePerUnit values are placeholders (1.0000). Update via CMS before going live.';
|
||||
GO
|
||||
246
database/migrations/V023_ROLLBACK.sql
Normal file
246
database/migrations/V023_ROLLBACK.sql
Normal file
@@ -0,0 +1,246 @@
|
||||
-- V023_ROLLBACK.sql
|
||||
-- PRC-001: Reversa de V023__refactor_chargeable_char_config_to_product_type.sql.
|
||||
--
|
||||
-- ADVERTENCIA: rollback destructivo — elimina ProductTypeId y restaura MedioId.
|
||||
-- - Todos los datos de ProductTypeId se pierden.
|
||||
-- - Las filas globales (ProductTypeId NULL) se preservan como globales (MedioId NULL).
|
||||
-- - El historial temporal puede quedar inconsistente si la tabla fue modificada después.
|
||||
--
|
||||
-- Solo para uso en DEV/TEST. No ejecutar en producción si hay datos de ProductTypeId.
|
||||
-- Run on: SIGCM2 (dev), SIGCM2_Test_App, SIGCM2_Test_Api.
|
||||
|
||||
SET QUOTED_IDENTIFIER ON;
|
||||
SET ANSI_NULLS ON;
|
||||
SET NOCOUNT ON;
|
||||
GO
|
||||
|
||||
-- ─── 1. Drop new SPs ────────────────────────────────────────────────────────
|
||||
|
||||
IF OBJECT_ID('dbo.usp_ChargeableCharConfig_ReactivateWithGuard', 'P') IS NOT NULL
|
||||
BEGIN
|
||||
DROP PROCEDURE dbo.usp_ChargeableCharConfig_ReactivateWithGuard;
|
||||
PRINT 'V023 ROLLBACK: usp_ChargeableCharConfig_ReactivateWithGuard dropped.';
|
||||
END
|
||||
GO
|
||||
|
||||
IF OBJECT_ID('dbo.usp_ChargeableCharConfig_GetActiveForProductType', 'P') IS NOT NULL
|
||||
BEGIN
|
||||
DROP PROCEDURE dbo.usp_ChargeableCharConfig_GetActiveForProductType;
|
||||
PRINT 'V023 ROLLBACK: usp_ChargeableCharConfig_GetActiveForProductType dropped.';
|
||||
END
|
||||
GO
|
||||
|
||||
IF OBJECT_ID('dbo.usp_ChargeableCharConfig_InsertWithClose', 'P') IS NOT NULL
|
||||
BEGIN
|
||||
DROP PROCEDURE dbo.usp_ChargeableCharConfig_InsertWithClose;
|
||||
PRINT 'V023 ROLLBACK: usp_ChargeableCharConfig_InsertWithClose dropped.';
|
||||
END
|
||||
GO
|
||||
|
||||
-- ─── 2. Reverse table alterations if ProductTypeId column exists ─────────────
|
||||
|
||||
IF EXISTS (SELECT 1 FROM sys.tables WHERE name = 'ChargeableCharConfig' AND schema_id = SCHEMA_ID('dbo'))
|
||||
AND EXISTS (SELECT 1 FROM sys.columns
|
||||
WHERE object_id = OBJECT_ID('dbo.ChargeableCharConfig')
|
||||
AND name = 'ProductTypeId')
|
||||
BEGIN
|
||||
-- 2a. Turn off SYSTEM_VERSIONING
|
||||
ALTER TABLE dbo.ChargeableCharConfig SET (SYSTEM_VERSIONING = OFF);
|
||||
PRINT 'V023 ROLLBACK: SYSTEM_VERSIONING = OFF.';
|
||||
|
||||
-- 2b. Drop indexes on ProductTypeId
|
||||
IF EXISTS (SELECT 1 FROM sys.indexes WHERE name = 'UX_ChargeableCharConfig_Vigente'
|
||||
AND object_id = OBJECT_ID('dbo.ChargeableCharConfig'))
|
||||
BEGIN
|
||||
DROP INDEX UX_ChargeableCharConfig_Vigente ON dbo.ChargeableCharConfig;
|
||||
PRINT 'V023 ROLLBACK: UX_ChargeableCharConfig_Vigente dropped.';
|
||||
END
|
||||
|
||||
IF EXISTS (SELECT 1 FROM sys.indexes WHERE name = 'IX_ChargeableCharConfig_Query'
|
||||
AND object_id = OBJECT_ID('dbo.ChargeableCharConfig'))
|
||||
BEGIN
|
||||
DROP INDEX IX_ChargeableCharConfig_Query ON dbo.ChargeableCharConfig;
|
||||
PRINT 'V023 ROLLBACK: IX_ChargeableCharConfig_Query dropped.';
|
||||
END
|
||||
|
||||
-- 2c. Drop FK to ProductType
|
||||
DECLARE @fk_pt sysname;
|
||||
SELECT @fk_pt = name
|
||||
FROM sys.foreign_keys
|
||||
WHERE parent_object_id = OBJECT_ID('dbo.ChargeableCharConfig')
|
||||
AND referenced_object_id = OBJECT_ID('dbo.ProductType');
|
||||
IF @fk_pt IS NOT NULL
|
||||
BEGIN
|
||||
EXEC('ALTER TABLE dbo.ChargeableCharConfig DROP CONSTRAINT ' + @fk_pt);
|
||||
PRINT 'V023 ROLLBACK: FK_ChargeableCharConfig_ProductType dropped.';
|
||||
END
|
||||
|
||||
-- 2d. Drop NonNegative price check; restore Positive check
|
||||
IF EXISTS (SELECT 1 FROM sys.check_constraints
|
||||
WHERE name = 'CK_ChargeableCharConfig_Price_NonNegative'
|
||||
AND parent_object_id = OBJECT_ID('dbo.ChargeableCharConfig'))
|
||||
BEGIN
|
||||
ALTER TABLE dbo.ChargeableCharConfig DROP CONSTRAINT CK_ChargeableCharConfig_Price_NonNegative;
|
||||
PRINT 'V023 ROLLBACK: CK_ChargeableCharConfig_Price_NonNegative dropped.';
|
||||
END
|
||||
|
||||
IF NOT EXISTS (SELECT 1 FROM sys.check_constraints
|
||||
WHERE name = 'CK_ChargeableCharConfig_Price_Positive'
|
||||
AND parent_object_id = OBJECT_ID('dbo.ChargeableCharConfig'))
|
||||
BEGIN
|
||||
ALTER TABLE dbo.ChargeableCharConfig
|
||||
ADD CONSTRAINT CK_ChargeableCharConfig_Price_Positive CHECK (PricePerUnit > 0);
|
||||
PRINT 'V023 ROLLBACK: CK_ChargeableCharConfig_Price_Positive restored.';
|
||||
END
|
||||
|
||||
-- 2e. Drop ProductTypeId column from main + history
|
||||
ALTER TABLE dbo.ChargeableCharConfig DROP COLUMN ProductTypeId;
|
||||
PRINT 'V023 ROLLBACK: ProductTypeId dropped from ChargeableCharConfig.';
|
||||
|
||||
IF EXISTS (SELECT 1 FROM sys.columns
|
||||
WHERE object_id = OBJECT_ID('dbo.ChargeableCharConfig_History')
|
||||
AND name = 'ProductTypeId')
|
||||
BEGIN
|
||||
ALTER TABLE dbo.ChargeableCharConfig_History DROP COLUMN ProductTypeId;
|
||||
PRINT 'V023 ROLLBACK: ProductTypeId dropped from ChargeableCharConfig_History.';
|
||||
END
|
||||
|
||||
-- 2f. Restore MedioId column
|
||||
ALTER TABLE dbo.ChargeableCharConfig ADD MedioId INT NULL;
|
||||
ALTER TABLE dbo.ChargeableCharConfig_History ADD MedioId INT NULL;
|
||||
PRINT 'V023 ROLLBACK: MedioId restored.';
|
||||
|
||||
-- 2g. Restore FK to Medio
|
||||
ALTER TABLE dbo.ChargeableCharConfig
|
||||
ADD CONSTRAINT FK_ChargeableCharConfig_Medio
|
||||
FOREIGN KEY (MedioId) REFERENCES dbo.Medio(Id) ON DELETE NO ACTION;
|
||||
PRINT 'V023 ROLLBACK: FK_ChargeableCharConfig_Medio restored.';
|
||||
|
||||
-- 2h. Restore indexes on MedioId
|
||||
CREATE UNIQUE NONCLUSTERED INDEX UX_ChargeableCharConfig_Vigente
|
||||
ON dbo.ChargeableCharConfig (MedioId, Symbol)
|
||||
WHERE ValidTo IS NULL;
|
||||
PRINT 'V023 ROLLBACK: UX_ChargeableCharConfig_Vigente restored (MedioId).';
|
||||
|
||||
CREATE NONCLUSTERED INDEX IX_ChargeableCharConfig_Query
|
||||
ON dbo.ChargeableCharConfig (MedioId, Symbol, ValidFrom, ValidTo)
|
||||
INCLUDE (PricePerUnit, IsActive, Category);
|
||||
PRINT 'V023 ROLLBACK: IX_ChargeableCharConfig_Query restored (MedioId).';
|
||||
|
||||
-- 2i. Restore SYSTEM_VERSIONING
|
||||
ALTER TABLE dbo.ChargeableCharConfig
|
||||
SET (SYSTEM_VERSIONING = ON (
|
||||
HISTORY_TABLE = dbo.ChargeableCharConfig_History,
|
||||
HISTORY_RETENTION_PERIOD = 10 YEARS
|
||||
));
|
||||
PRINT 'V023 ROLLBACK: SYSTEM_VERSIONING = ON restored.';
|
||||
END
|
||||
ELSE
|
||||
PRINT 'V023 ROLLBACK: ProductTypeId column not found — table already in MedioId state or missing, skipping.';
|
||||
GO
|
||||
|
||||
-- ─── 3. Restore original SPs ────────────────────────────────────────────────
|
||||
|
||||
IF OBJECT_ID('dbo.usp_ChargeableCharConfig_InsertWithClose', 'P') IS NULL
|
||||
EXEC('CREATE PROCEDURE dbo.usp_ChargeableCharConfig_InsertWithClose AS RETURN 0');
|
||||
GO
|
||||
|
||||
ALTER PROCEDURE dbo.usp_ChargeableCharConfig_InsertWithClose
|
||||
@MedioId INT = NULL,
|
||||
@Symbol NVARCHAR(4),
|
||||
@Category NVARCHAR(32),
|
||||
@PricePerUnit DECIMAL(18,4),
|
||||
@ValidFrom DATE,
|
||||
@NewId BIGINT OUTPUT,
|
||||
@ClosedId BIGINT OUTPUT
|
||||
AS
|
||||
BEGIN
|
||||
SET NOCOUNT ON;
|
||||
SET XACT_ABORT ON;
|
||||
SET TRANSACTION ISOLATION LEVEL SERIALIZABLE;
|
||||
|
||||
BEGIN TRY
|
||||
BEGIN TRANSACTION;
|
||||
|
||||
IF @MedioId IS NOT NULL
|
||||
AND NOT EXISTS (SELECT 1 FROM dbo.Medio WITH (NOLOCK) WHERE Id = @MedioId)
|
||||
BEGIN
|
||||
ROLLBACK;
|
||||
THROW 50404, 'Medio not found', 1;
|
||||
END
|
||||
|
||||
DECLARE @ActiveId BIGINT, @ActiveValidFrom DATE;
|
||||
SELECT TOP 1
|
||||
@ActiveId = Id,
|
||||
@ActiveValidFrom = ValidFrom
|
||||
FROM dbo.ChargeableCharConfig WITH (UPDLOCK, HOLDLOCK, ROWLOCK)
|
||||
WHERE ((@MedioId IS NULL AND MedioId IS NULL)
|
||||
OR (@MedioId IS NOT NULL AND MedioId = @MedioId))
|
||||
AND Symbol = @Symbol
|
||||
AND ValidTo IS NULL;
|
||||
|
||||
IF @ActiveId IS NOT NULL AND @ValidFrom <= @ActiveValidFrom
|
||||
BEGIN
|
||||
ROLLBACK;
|
||||
THROW 50409, 'ChargeableCharConfigForwardOnly: new ValidFrom must be > active.ValidFrom', 1;
|
||||
END
|
||||
|
||||
IF @ActiveId IS NOT NULL
|
||||
BEGIN
|
||||
UPDATE dbo.ChargeableCharConfig
|
||||
SET ValidTo = DATEADD(DAY, -1, @ValidFrom)
|
||||
WHERE Id = @ActiveId;
|
||||
SET @ClosedId = @ActiveId;
|
||||
END
|
||||
ELSE
|
||||
SET @ClosedId = NULL;
|
||||
|
||||
INSERT INTO dbo.ChargeableCharConfig
|
||||
(MedioId, Symbol, Category, PricePerUnit, ValidFrom, ValidTo, IsActive)
|
||||
VALUES
|
||||
(@MedioId, @Symbol, @Category, @PricePerUnit, @ValidFrom, NULL, 1);
|
||||
SET @NewId = SCOPE_IDENTITY();
|
||||
|
||||
COMMIT TRANSACTION;
|
||||
END TRY
|
||||
BEGIN CATCH
|
||||
IF XACT_STATE() <> 0 ROLLBACK TRANSACTION;
|
||||
THROW;
|
||||
END CATCH
|
||||
END
|
||||
GO
|
||||
|
||||
IF OBJECT_ID('dbo.usp_ChargeableCharConfig_GetActiveForMedio', 'P') IS NULL
|
||||
EXEC('CREATE PROCEDURE dbo.usp_ChargeableCharConfig_GetActiveForMedio AS RETURN 0');
|
||||
GO
|
||||
|
||||
ALTER PROCEDURE dbo.usp_ChargeableCharConfig_GetActiveForMedio
|
||||
@MedioId INT,
|
||||
@AsOfDate DATE
|
||||
AS
|
||||
BEGIN
|
||||
SET NOCOUNT ON;
|
||||
WITH Candidates AS (
|
||||
SELECT
|
||||
Id, MedioId, Symbol, Category, PricePerUnit, ValidFrom, ValidTo, IsActive,
|
||||
ROW_NUMBER() OVER (
|
||||
PARTITION BY Symbol
|
||||
ORDER BY
|
||||
CASE WHEN MedioId = @MedioId THEN 0 ELSE 1 END,
|
||||
ValidFrom DESC
|
||||
) AS rn
|
||||
FROM dbo.ChargeableCharConfig
|
||||
WHERE IsActive = 1
|
||||
AND ValidFrom <= @AsOfDate
|
||||
AND (ValidTo IS NULL OR ValidTo >= @AsOfDate)
|
||||
AND (MedioId = @MedioId OR MedioId IS NULL)
|
||||
)
|
||||
SELECT Id, MedioId, Symbol, Category, PricePerUnit, ValidFrom, ValidTo, IsActive
|
||||
FROM Candidates
|
||||
WHERE rn = 1;
|
||||
END
|
||||
GO
|
||||
|
||||
PRINT '';
|
||||
PRINT 'V023 ROLLBACK complete — ChargeableCharConfig restored to MedioId model.';
|
||||
GO
|
||||
@@ -0,0 +1,414 @@
|
||||
-- V023__refactor_chargeable_char_config_to_product_type.sql
|
||||
-- PRC-001 scope delta: ChargeableCharConfig per ProductType (reemplaza per-Medio).
|
||||
--
|
||||
-- Cambios:
|
||||
-- 1. DROP MedioId + FK_ChargeableCharConfig_Medio + índices que lo referencian.
|
||||
-- 2. ADD ProductTypeId (nullable = global fallback) + FK_ChargeableCharConfig_ProductType.
|
||||
-- 3. Recrea índices con ProductTypeId (UX_Vigente + IX_Query).
|
||||
-- 4. DROP+CREATE usp_ChargeableCharConfig_InsertWithClose (@MedioId → @ProductTypeId).
|
||||
-- 5. DROP usp_ChargeableCharConfig_GetActiveForMedio + CREATE usp_ChargeableCharConfig_GetActiveForProductType.
|
||||
-- 6. NEW SP usp_ChargeableCharConfig_ReactivateWithGuard (opción A+guard para feature 3).
|
||||
-- 7. DROP CK_ChargeableCharConfig_Price_Positive (se permite 0.0000 para opt-in billing).
|
||||
-- Reemplaza con CK_ChargeableCharConfig_Price_NonNegative (>= 0).
|
||||
--
|
||||
-- Patrón: idempotente con IF EXISTS guards. Bloque principal protegido por la presencia
|
||||
-- de la columna MedioId — si no existe ya fue refactorizada, el bloque no ejecuta.
|
||||
-- SYSTEM_VERSIONING: OFF al inicio del ALTER block, ON al final (con history table + retention).
|
||||
-- Depende de: V017 (dbo.ProductType debe existir).
|
||||
-- Reversa: V023_ROLLBACK.sql.
|
||||
-- Run on: SIGCM2 (dev), SIGCM2_Test_App, SIGCM2_Test_Api.
|
||||
--
|
||||
-- SDD Design: engram sdd/prc-001-word-counter-spike/design
|
||||
-- Scope delta: engram sdd/prc-001-word-counter-spike/scope-delta-1
|
||||
|
||||
SET QUOTED_IDENTIFIER ON;
|
||||
SET ANSI_NULLS ON;
|
||||
SET NOCOUNT ON;
|
||||
GO
|
||||
|
||||
-- ═══════════════════════════════════════════════════════════════════════
|
||||
-- Bloque principal: solo ejecuta si la tabla existe Y todavía tiene MedioId
|
||||
-- (guard idempotente: si ya fue refactorizada, el bloque se saltea completo).
|
||||
-- ═══════════════════════════════════════════════════════════════════════
|
||||
|
||||
IF EXISTS (SELECT 1 FROM sys.tables WHERE name = 'ChargeableCharConfig' AND schema_id = SCHEMA_ID('dbo'))
|
||||
AND EXISTS (SELECT 1 FROM sys.columns
|
||||
WHERE object_id = OBJECT_ID('dbo.ChargeableCharConfig')
|
||||
AND name = 'MedioId')
|
||||
BEGIN
|
||||
PRINT 'V023: MedioId column found — proceeding with refactor.';
|
||||
|
||||
-- ─── 1. Turn OFF SYSTEM_VERSIONING (idempotent — skip if already OFF) ───
|
||||
IF EXISTS (SELECT 1 FROM sys.tables
|
||||
WHERE name = 'ChargeableCharConfig'
|
||||
AND schema_id = SCHEMA_ID('dbo')
|
||||
AND temporal_type = 2) -- 2 = SYSTEM_VERSIONED_TEMPORAL_TABLE
|
||||
BEGIN
|
||||
ALTER TABLE dbo.ChargeableCharConfig SET (SYSTEM_VERSIONING = OFF);
|
||||
PRINT 'V023: SYSTEM_VERSIONING = OFF.';
|
||||
END
|
||||
ELSE
|
||||
PRINT 'V023: SYSTEM_VERSIONING already OFF — skipping.';
|
||||
|
||||
-- ─── 2. Drop indexes that reference MedioId ────────────────────────
|
||||
IF EXISTS (SELECT 1 FROM sys.indexes WHERE name = 'UX_ChargeableCharConfig_Vigente'
|
||||
AND object_id = OBJECT_ID('dbo.ChargeableCharConfig'))
|
||||
BEGIN
|
||||
DROP INDEX UX_ChargeableCharConfig_Vigente ON dbo.ChargeableCharConfig;
|
||||
PRINT 'V023: UX_ChargeableCharConfig_Vigente dropped.';
|
||||
END
|
||||
|
||||
IF EXISTS (SELECT 1 FROM sys.indexes WHERE name = 'IX_ChargeableCharConfig_Query'
|
||||
AND object_id = OBJECT_ID('dbo.ChargeableCharConfig'))
|
||||
BEGIN
|
||||
DROP INDEX IX_ChargeableCharConfig_Query ON dbo.ChargeableCharConfig;
|
||||
PRINT 'V023: IX_ChargeableCharConfig_Query dropped.';
|
||||
END
|
||||
|
||||
-- ─── 3. Drop FK to Medio ────────────────────────────────────────────
|
||||
DECLARE @fk_name sysname;
|
||||
SELECT @fk_name = name
|
||||
FROM sys.foreign_keys
|
||||
WHERE parent_object_id = OBJECT_ID('dbo.ChargeableCharConfig')
|
||||
AND referenced_object_id = OBJECT_ID('dbo.Medio');
|
||||
IF @fk_name IS NOT NULL
|
||||
BEGIN
|
||||
EXEC('ALTER TABLE dbo.ChargeableCharConfig DROP CONSTRAINT ' + @fk_name);
|
||||
PRINT 'V023: FK_ChargeableCharConfig_Medio dropped.';
|
||||
END
|
||||
|
||||
-- ─── 4. Drop MedioId column (drop DF constraint first if present) ───
|
||||
DECLARE @df_medio sysname;
|
||||
SELECT @df_medio = dc.name
|
||||
FROM sys.default_constraints dc
|
||||
JOIN sys.columns c ON c.default_object_id = dc.object_id
|
||||
WHERE c.object_id = OBJECT_ID('dbo.ChargeableCharConfig')
|
||||
AND c.name = 'MedioId';
|
||||
IF @df_medio IS NOT NULL
|
||||
BEGIN
|
||||
EXEC('ALTER TABLE dbo.ChargeableCharConfig DROP CONSTRAINT ' + @df_medio);
|
||||
PRINT 'V023: Default constraint on MedioId dropped.';
|
||||
END
|
||||
|
||||
ALTER TABLE dbo.ChargeableCharConfig DROP COLUMN MedioId;
|
||||
PRINT 'V023: MedioId column dropped from ChargeableCharConfig.';
|
||||
|
||||
-- Drop MedioId from history table if present
|
||||
IF EXISTS (SELECT 1 FROM sys.columns
|
||||
WHERE object_id = OBJECT_ID('dbo.ChargeableCharConfig_History')
|
||||
AND name = 'MedioId')
|
||||
BEGIN
|
||||
-- Drop default constraint on history MedioId if any
|
||||
DECLARE @df_hist_medio sysname;
|
||||
SELECT @df_hist_medio = dc.name
|
||||
FROM sys.default_constraints dc
|
||||
JOIN sys.columns c ON c.default_object_id = dc.object_id
|
||||
WHERE c.object_id = OBJECT_ID('dbo.ChargeableCharConfig_History')
|
||||
AND c.name = 'MedioId';
|
||||
IF @df_hist_medio IS NOT NULL
|
||||
EXEC('ALTER TABLE dbo.ChargeableCharConfig_History DROP CONSTRAINT ' + @df_hist_medio);
|
||||
|
||||
ALTER TABLE dbo.ChargeableCharConfig_History DROP COLUMN MedioId;
|
||||
PRINT 'V023: MedioId column dropped from ChargeableCharConfig_History.';
|
||||
END
|
||||
|
||||
-- ─── 5. Drop CK_Price_Positive, replace with CK_Price_NonNegative ──
|
||||
-- V024 seeds PricePerUnit = 0.0000 (opt-in billing). Old check (> 0) would block it.
|
||||
IF EXISTS (SELECT 1 FROM sys.check_constraints
|
||||
WHERE name = 'CK_ChargeableCharConfig_Price_Positive'
|
||||
AND parent_object_id = OBJECT_ID('dbo.ChargeableCharConfig'))
|
||||
BEGIN
|
||||
ALTER TABLE dbo.ChargeableCharConfig DROP CONSTRAINT CK_ChargeableCharConfig_Price_Positive;
|
||||
PRINT 'V023: CK_ChargeableCharConfig_Price_Positive dropped.';
|
||||
END
|
||||
|
||||
IF NOT EXISTS (SELECT 1 FROM sys.check_constraints
|
||||
WHERE name = 'CK_ChargeableCharConfig_Price_NonNegative'
|
||||
AND parent_object_id = OBJECT_ID('dbo.ChargeableCharConfig'))
|
||||
BEGIN
|
||||
ALTER TABLE dbo.ChargeableCharConfig
|
||||
ADD CONSTRAINT CK_ChargeableCharConfig_Price_NonNegative
|
||||
CHECK (PricePerUnit >= 0);
|
||||
PRINT 'V023: CK_ChargeableCharConfig_Price_NonNegative added (>= 0, opt-in billing).';
|
||||
END
|
||||
|
||||
-- ─── 6. Add ProductTypeId column ────────────────────────────────────
|
||||
ALTER TABLE dbo.ChargeableCharConfig
|
||||
ADD ProductTypeId INT NULL; -- NULL = global fallback
|
||||
PRINT 'V023: ProductTypeId column added to ChargeableCharConfig.';
|
||||
|
||||
ALTER TABLE dbo.ChargeableCharConfig_History
|
||||
ADD ProductTypeId INT NULL;
|
||||
PRINT 'V023: ProductTypeId column added to ChargeableCharConfig_History.';
|
||||
|
||||
-- ─── 7. Add FK to ProductType ────────────────────────────────────────
|
||||
ALTER TABLE dbo.ChargeableCharConfig
|
||||
ADD CONSTRAINT FK_ChargeableCharConfig_ProductType
|
||||
FOREIGN KEY (ProductTypeId) REFERENCES dbo.ProductType(Id) ON DELETE NO ACTION;
|
||||
PRINT 'V023: FK_ChargeableCharConfig_ProductType added.';
|
||||
|
||||
-- ─── 8. Recreate filtered unique index with ProductTypeId ────────────
|
||||
-- 1 vigente per (ProductTypeId, Symbol). NULL ProductTypeId = global fallback.
|
||||
-- SQL Server trata NULL como "distinto" en unique indexes → enforza 1 vigente global.
|
||||
CREATE UNIQUE NONCLUSTERED INDEX UX_ChargeableCharConfig_Vigente
|
||||
ON dbo.ChargeableCharConfig (ProductTypeId, Symbol)
|
||||
WHERE ValidTo IS NULL;
|
||||
PRINT 'V023: UX_ChargeableCharConfig_Vigente recreated (ProductTypeId, Symbol).';
|
||||
|
||||
-- ─── 9. Recreate cover index with ProductTypeId ──────────────────────
|
||||
CREATE NONCLUSTERED INDEX IX_ChargeableCharConfig_Query
|
||||
ON dbo.ChargeableCharConfig (ProductTypeId, Symbol, ValidFrom, ValidTo)
|
||||
INCLUDE (PricePerUnit, IsActive, Category);
|
||||
PRINT 'V023: IX_ChargeableCharConfig_Query recreated (ProductTypeId).';
|
||||
|
||||
-- ─── 10. Turn SYSTEM_VERSIONING back ON ──────────────────────────────
|
||||
ALTER TABLE dbo.ChargeableCharConfig
|
||||
SET (SYSTEM_VERSIONING = ON (
|
||||
HISTORY_TABLE = dbo.ChargeableCharConfig_History,
|
||||
HISTORY_RETENTION_PERIOD = 10 YEARS
|
||||
));
|
||||
PRINT 'V023: SYSTEM_VERSIONING = ON (history: dbo.ChargeableCharConfig_History, retention: 10 years).';
|
||||
END
|
||||
ELSE
|
||||
BEGIN
|
||||
IF NOT EXISTS (SELECT 1 FROM sys.tables WHERE name = 'ChargeableCharConfig' AND schema_id = SCHEMA_ID('dbo'))
|
||||
PRINT 'V023: dbo.ChargeableCharConfig does not exist — skipping table refactor.';
|
||||
ELSE
|
||||
PRINT 'V023: MedioId column not found — table already refactored, skipping.';
|
||||
END
|
||||
GO
|
||||
|
||||
-- ═══════════════════════════════════════════════════════════════════════
|
||||
-- SP: usp_ChargeableCharConfig_InsertWithClose (@ProductTypeId replaces @MedioId)
|
||||
-- Cierre atómico forward-only: SERIALIZABLE + UPDLOCK + HOLDLOCK.
|
||||
-- @ProductTypeId NULL = global; FK validada solo cuando NOT NULL (via referential integrity).
|
||||
-- THROW 50404: ProductType not found.
|
||||
-- THROW 50409: ForwardOnly — new ValidFrom must be > active.ValidFrom.
|
||||
-- Output: @NewId (BIGINT), @ClosedId (BIGINT — NULL if first price for symbol).
|
||||
-- ═══════════════════════════════════════════════════════════════════════
|
||||
|
||||
IF OBJECT_ID('dbo.usp_ChargeableCharConfig_InsertWithClose', 'P') IS NOT NULL
|
||||
DROP PROCEDURE dbo.usp_ChargeableCharConfig_InsertWithClose;
|
||||
GO
|
||||
|
||||
CREATE PROCEDURE dbo.usp_ChargeableCharConfig_InsertWithClose
|
||||
@ProductTypeId INT = NULL,
|
||||
@Symbol NVARCHAR(4),
|
||||
@Category NVARCHAR(32),
|
||||
@PricePerUnit DECIMAL(18,4),
|
||||
@ValidFrom DATE,
|
||||
@NewId BIGINT OUTPUT,
|
||||
@ClosedId BIGINT OUTPUT
|
||||
AS
|
||||
BEGIN
|
||||
SET NOCOUNT ON;
|
||||
SET XACT_ABORT ON;
|
||||
SET TRANSACTION ISOLATION LEVEL SERIALIZABLE;
|
||||
|
||||
BEGIN TRY
|
||||
BEGIN TRANSACTION;
|
||||
|
||||
-- Validate ProductTypeId only when provided (NULL = global fallback, always valid).
|
||||
-- FK constraint handles referential integrity; we throw 50404 explicitly for better UX.
|
||||
IF @ProductTypeId IS NOT NULL
|
||||
AND NOT EXISTS (SELECT 1 FROM dbo.ProductType WITH (NOLOCK) WHERE Id = @ProductTypeId)
|
||||
BEGIN
|
||||
ROLLBACK;
|
||||
THROW 50404, 'ProductType not found', 1;
|
||||
END
|
||||
|
||||
-- Read current vigente with range lock for serialization.
|
||||
DECLARE @ActiveId BIGINT, @ActiveValidFrom DATE;
|
||||
SELECT TOP 1
|
||||
@ActiveId = Id,
|
||||
@ActiveValidFrom = ValidFrom
|
||||
FROM dbo.ChargeableCharConfig WITH (UPDLOCK, HOLDLOCK, ROWLOCK)
|
||||
WHERE ((@ProductTypeId IS NULL AND ProductTypeId IS NULL)
|
||||
OR (@ProductTypeId IS NOT NULL AND ProductTypeId = @ProductTypeId))
|
||||
AND Symbol = @Symbol
|
||||
AND ValidTo IS NULL;
|
||||
|
||||
-- Forward-only strict: new ValidFrom must be STRICTLY greater than active.ValidFrom.
|
||||
IF @ActiveId IS NOT NULL AND @ValidFrom <= @ActiveValidFrom
|
||||
BEGIN
|
||||
ROLLBACK;
|
||||
THROW 50409, 'ChargeableCharConfigForwardOnly: new ValidFrom must be > active.ValidFrom', 1;
|
||||
END
|
||||
|
||||
-- Close the current vigente: ValidTo = new ValidFrom - 1 day.
|
||||
IF @ActiveId IS NOT NULL
|
||||
BEGIN
|
||||
UPDATE dbo.ChargeableCharConfig
|
||||
SET ValidTo = DATEADD(DAY, -1, @ValidFrom)
|
||||
WHERE Id = @ActiveId;
|
||||
SET @ClosedId = @ActiveId;
|
||||
END
|
||||
ELSE
|
||||
SET @ClosedId = NULL;
|
||||
|
||||
-- Insert the new vigente.
|
||||
INSERT INTO dbo.ChargeableCharConfig
|
||||
(ProductTypeId, Symbol, Category, PricePerUnit, ValidFrom, ValidTo, IsActive)
|
||||
VALUES
|
||||
(@ProductTypeId, @Symbol, @Category, @PricePerUnit, @ValidFrom, NULL, 1);
|
||||
SET @NewId = SCOPE_IDENTITY();
|
||||
|
||||
COMMIT TRANSACTION;
|
||||
END TRY
|
||||
BEGIN CATCH
|
||||
IF XACT_STATE() <> 0 ROLLBACK TRANSACTION;
|
||||
THROW;
|
||||
END CATCH
|
||||
END
|
||||
GO
|
||||
|
||||
-- ═══════════════════════════════════════════════════════════════════════
|
||||
-- SP: drop old GetActiveForMedio (renamed to GetActiveForProductType)
|
||||
-- ═══════════════════════════════════════════════════════════════════════
|
||||
|
||||
IF OBJECT_ID('dbo.usp_ChargeableCharConfig_GetActiveForMedio', 'P') IS NOT NULL
|
||||
BEGIN
|
||||
DROP PROCEDURE dbo.usp_ChargeableCharConfig_GetActiveForMedio;
|
||||
PRINT 'V023: usp_ChargeableCharConfig_GetActiveForMedio dropped.';
|
||||
END
|
||||
GO
|
||||
|
||||
-- ═══════════════════════════════════════════════════════════════════════
|
||||
-- SP: usp_ChargeableCharConfig_GetActiveForProductType
|
||||
-- Resolución per-ProductType + global fallback: 1 fila por Symbol.
|
||||
-- CTE + ROW_NUMBER PARTITION BY Symbol ORDER BY per-PT(0) vs global(1).
|
||||
-- @ProductTypeId: the specific product type to resolve for.
|
||||
-- @AsOfDate: resolve active rows as of this date (for pricing snapshot).
|
||||
-- ═══════════════════════════════════════════════════════════════════════
|
||||
|
||||
CREATE PROCEDURE dbo.usp_ChargeableCharConfig_GetActiveForProductType
|
||||
@ProductTypeId INT,
|
||||
@AsOfDate DATE
|
||||
AS
|
||||
BEGIN
|
||||
SET NOCOUNT ON;
|
||||
WITH Candidates AS (
|
||||
SELECT
|
||||
Id, ProductTypeId, Symbol, Category, PricePerUnit, ValidFrom, ValidTo, IsActive,
|
||||
ROW_NUMBER() OVER (
|
||||
PARTITION BY Symbol
|
||||
ORDER BY
|
||||
CASE WHEN ProductTypeId = @ProductTypeId THEN 0 ELSE 1 END, -- prefer specific over global
|
||||
ValidFrom DESC
|
||||
) AS rn
|
||||
FROM dbo.ChargeableCharConfig
|
||||
WHERE IsActive = 1
|
||||
AND ValidFrom <= @AsOfDate
|
||||
AND (ValidTo IS NULL OR ValidTo >= @AsOfDate)
|
||||
AND (ProductTypeId = @ProductTypeId OR ProductTypeId IS NULL)
|
||||
)
|
||||
SELECT Id, ProductTypeId, Symbol, Category, PricePerUnit, ValidFrom, ValidTo, IsActive
|
||||
FROM Candidates
|
||||
WHERE rn = 1;
|
||||
END
|
||||
GO
|
||||
|
||||
-- ═══════════════════════════════════════════════════════════════════════
|
||||
-- SP: usp_ChargeableCharConfig_ReactivateWithGuard (NEW — feature 3 of scope delta)
|
||||
-- Opción A+guard: literal undo of the last close for (ProductTypeId, Symbol).
|
||||
-- Guards:
|
||||
-- - Row must exist → THROW 50404
|
||||
-- - Row must be closed (ValidTo IS NOT NULL, IsActive = 0) → THROW 50410 if already active
|
||||
-- - No vigente currently exists for (ProductTypeId, Symbol) → THROW 50411
|
||||
-- - No posterior rows exist for (ProductTypeId, Symbol) → THROW 50412
|
||||
-- On success: UPDATE IsActive = 1, ValidTo = NULL (literal undo).
|
||||
-- Preserves forward-only invariant and maintains clean history.
|
||||
-- ═══════════════════════════════════════════════════════════════════════
|
||||
|
||||
IF OBJECT_ID('dbo.usp_ChargeableCharConfig_ReactivateWithGuard', 'P') IS NOT NULL
|
||||
DROP PROCEDURE dbo.usp_ChargeableCharConfig_ReactivateWithGuard;
|
||||
GO
|
||||
|
||||
CREATE PROCEDURE dbo.usp_ChargeableCharConfig_ReactivateWithGuard
|
||||
@Id BIGINT
|
||||
AS
|
||||
BEGIN
|
||||
SET NOCOUNT ON;
|
||||
SET XACT_ABORT ON;
|
||||
SET TRANSACTION ISOLATION LEVEL SERIALIZABLE;
|
||||
|
||||
BEGIN TRY
|
||||
BEGIN TRANSACTION;
|
||||
|
||||
-- Step 1: Lock + load target row.
|
||||
DECLARE @ProductTypeId INT, @Symbol NVARCHAR(4), @ValidTo DATE, @IsActive BIT;
|
||||
SELECT @ProductTypeId = ProductTypeId,
|
||||
@Symbol = Symbol,
|
||||
@ValidTo = ValidTo,
|
||||
@IsActive = IsActive
|
||||
FROM dbo.ChargeableCharConfig WITH (UPDLOCK, HOLDLOCK)
|
||||
WHERE Id = @Id;
|
||||
|
||||
IF @@ROWCOUNT = 0
|
||||
BEGIN
|
||||
ROLLBACK TRANSACTION;
|
||||
THROW 50404, 'ChargeableCharConfig row not found', 1;
|
||||
END
|
||||
|
||||
-- Step 2: Row must be closed (ValidTo IS NOT NULL and IsActive = 0).
|
||||
-- If it is currently active (ValidTo IS NULL), reactivation is nonsensical.
|
||||
IF @ValidTo IS NULL
|
||||
BEGIN
|
||||
ROLLBACK TRANSACTION;
|
||||
THROW 50410, 'Row is already active — reactivation not needed', 1;
|
||||
END
|
||||
|
||||
-- Step 3: GUARD — no vigente currently for (ProductTypeId, Symbol).
|
||||
-- Prevents re-opening a row while another is already vigente.
|
||||
IF EXISTS (
|
||||
SELECT 1 FROM dbo.ChargeableCharConfig
|
||||
WHERE ((ProductTypeId = @ProductTypeId) OR (ProductTypeId IS NULL AND @ProductTypeId IS NULL))
|
||||
AND Symbol = @Symbol
|
||||
AND ValidTo IS NULL
|
||||
)
|
||||
BEGIN
|
||||
ROLLBACK TRANSACTION;
|
||||
THROW 50411, 'A current active row already exists for this ProductType/Symbol — cannot reactivate', 1;
|
||||
END
|
||||
|
||||
-- Step 4: GUARD — no posterior rows exist for (ProductTypeId, Symbol) after @ValidTo.
|
||||
-- Ensures this is the LAST closed row; reactivating an older row would violate
|
||||
-- forward-only ordering of the temporal chain.
|
||||
IF EXISTS (
|
||||
SELECT 1 FROM dbo.ChargeableCharConfig
|
||||
WHERE ((ProductTypeId = @ProductTypeId) OR (ProductTypeId IS NULL AND @ProductTypeId IS NULL))
|
||||
AND Symbol = @Symbol
|
||||
AND ValidFrom > @ValidTo
|
||||
AND Id <> @Id
|
||||
)
|
||||
BEGIN
|
||||
ROLLBACK TRANSACTION;
|
||||
THROW 50412, 'Posterior rows exist for this ProductType/Symbol — reactivation not allowed', 1;
|
||||
END
|
||||
|
||||
-- Step 5: Literal undo — re-open the row.
|
||||
UPDATE dbo.ChargeableCharConfig
|
||||
SET IsActive = 1,
|
||||
ValidTo = NULL
|
||||
WHERE Id = @Id;
|
||||
|
||||
COMMIT TRANSACTION;
|
||||
END TRY
|
||||
BEGIN CATCH
|
||||
IF XACT_STATE() <> 0 ROLLBACK TRANSACTION;
|
||||
THROW;
|
||||
END CATCH
|
||||
END
|
||||
GO
|
||||
|
||||
PRINT '';
|
||||
PRINT 'V023 applied — ChargeableCharConfig refactored to ProductType model:';
|
||||
PRINT ' - MedioId dropped, ProductTypeId added (FK to dbo.ProductType)';
|
||||
PRINT ' - UX_ChargeableCharConfig_Vigente + IX_ChargeableCharConfig_Query recreated';
|
||||
PRINT ' - usp_ChargeableCharConfig_InsertWithClose: @MedioId → @ProductTypeId';
|
||||
PRINT ' - usp_ChargeableCharConfig_GetActiveForMedio dropped';
|
||||
PRINT ' - usp_ChargeableCharConfig_GetActiveForProductType created';
|
||||
PRINT ' - usp_ChargeableCharConfig_ReactivateWithGuard created (NEW)';
|
||||
PRINT ' - CK_Price_Positive replaced by CK_Price_NonNegative (>= 0 for opt-in billing)';
|
||||
PRINT 'Next migration: V024 (reseed global rows with PricePerUnit = 0.0000).';
|
||||
GO
|
||||
22
database/migrations/V024_ROLLBACK.sql
Normal file
22
database/migrations/V024_ROLLBACK.sql
Normal file
@@ -0,0 +1,22 @@
|
||||
-- V024_ROLLBACK.sql
|
||||
-- PRC-001: Reversa de V024__reseed_global_with_zero_price.sql.
|
||||
--
|
||||
-- Restaura las 4 filas globales de seed a PricePerUnit = 1.0000 (valor original de V022).
|
||||
-- Solo ejecutar si V024 fue aplicado y se desea volver al estado previo.
|
||||
--
|
||||
-- Run on: SIGCM2 (dev), SIGCM2_Test_App, SIGCM2_Test_Api.
|
||||
|
||||
SET QUOTED_IDENTIFIER ON;
|
||||
SET ANSI_NULLS ON;
|
||||
SET NOCOUNT ON;
|
||||
GO
|
||||
|
||||
UPDATE dbo.ChargeableCharConfig
|
||||
SET PricePerUnit = CAST(1.0000 AS DECIMAL(18,4))
|
||||
WHERE ProductTypeId IS NULL
|
||||
AND Symbol IN (N'$', N'%', N'!', N'¡')
|
||||
AND ValidTo IS NULL;
|
||||
|
||||
PRINT 'V024 ROLLBACK complete — global ChargeableCharConfig prices restored to 1.0000.';
|
||||
PRINT 'Rows updated: ' + CAST(@@ROWCOUNT AS NVARCHAR(10));
|
||||
GO
|
||||
34
database/migrations/V024__reseed_global_with_zero_price.sql
Normal file
34
database/migrations/V024__reseed_global_with_zero_price.sql
Normal file
@@ -0,0 +1,34 @@
|
||||
-- V024__reseed_global_with_zero_price.sql
|
||||
-- PRC-001 scope delta: actualiza las 4 filas globales de seed a PricePerUnit = 0.0000.
|
||||
--
|
||||
-- Cambios:
|
||||
-- 1. UPDATE directo de las 4 filas globales vigentes ($, %, !, ¡) a PricePerUnit = 0.0000.
|
||||
--
|
||||
-- Decisión: UPDATE directo (no forward-only close+insert) porque:
|
||||
-- - V022 seed price 1.0000 era siempre un placeholder nunca usado en lógica de negocio.
|
||||
-- - No existe historial de facturación con el valor 1.0000.
|
||||
-- - La semántica correcta es "opt-in billing": por defecto ningún tipo cobra especiales.
|
||||
-- - La forward-only invariante aplica a cambios de precio en producción; este es un fix
|
||||
-- de seed pre-go-live dentro de la misma branch feature (no mergeada a main aún).
|
||||
-- See: scope-delta-1 en engram sdd/prc-001-word-counter-spike/scope-delta-1.
|
||||
--
|
||||
-- Patrón: UPDATE simple WHERE ProductTypeId IS NULL AND Symbol IN (...) AND ValidTo IS NULL.
|
||||
-- Idempotente: UPDATE idempotente (re-ejecutar no cambia el resultado).
|
||||
-- Reversa: V024_ROLLBACK.sql.
|
||||
-- Depends on: V023 (ProductTypeId column must exist; CK_Price_NonNegative >= 0 required).
|
||||
-- Run on: SIGCM2 (dev), SIGCM2_Test_App, SIGCM2_Test_Api.
|
||||
|
||||
SET QUOTED_IDENTIFIER ON;
|
||||
SET ANSI_NULLS ON;
|
||||
SET NOCOUNT ON;
|
||||
GO
|
||||
|
||||
UPDATE dbo.ChargeableCharConfig
|
||||
SET PricePerUnit = CAST(0.0000 AS DECIMAL(18,4))
|
||||
WHERE ProductTypeId IS NULL
|
||||
AND Symbol IN (N'$', N'%', N'!', N'¡')
|
||||
AND ValidTo IS NULL;
|
||||
|
||||
PRINT 'V024 applied — global ChargeableCharConfig prices reset to 0.0000 (opt-in billing).';
|
||||
PRINT 'Rows updated: ' + CAST(@@ROWCOUNT AS NVARCHAR(10));
|
||||
GO
|
||||
20
database/migrations/V025_ROLLBACK.sql
Normal file
20
database/migrations/V025_ROLLBACK.sql
Normal file
@@ -0,0 +1,20 @@
|
||||
-- V025_ROLLBACK.sql
|
||||
-- Reversa de V025 — elimina los overrides demo de ChargeableCharConfig.
|
||||
-- Los globales V022/V024 (ProductTypeId IS NULL) NO se tocan.
|
||||
|
||||
SET QUOTED_IDENTIFIER ON;
|
||||
SET ANSI_NULLS ON;
|
||||
SET NOCOUNT ON;
|
||||
GO
|
||||
|
||||
DELETE FROM dbo.ChargeableCharConfig
|
||||
WHERE ProductTypeId IS NOT NULL
|
||||
AND ValidTo IS NULL
|
||||
AND Symbol IN (N'$', N'%', N'!', N'¡')
|
||||
AND ProductTypeId IN (
|
||||
SELECT Id FROM dbo.ProductType
|
||||
WHERE Nombre IN ('Clasificado', 'Notables', 'Fúnebres', 'Funebres')
|
||||
);
|
||||
|
||||
PRINT 'V025 rolled back — demo overrides eliminated. Globales V022/V024 preservados.';
|
||||
GO
|
||||
@@ -0,0 +1,101 @@
|
||||
-- V025__seed_chargeable_char_overrides_demo.sql
|
||||
-- PRC-001 followup #54: seeders de demo con valores ficticios per-ProductType.
|
||||
--
|
||||
-- Estrategia:
|
||||
-- 1. Los 4 globales de V022+V024 quedan en 0.0000 (opt-in billing baseline).
|
||||
-- 2. Para ProductTypes conocidos del roadmap (Clasificado, Notables, Fúnebres),
|
||||
-- inserta overrides con precios ficticios coherentes con datos de demo del resto
|
||||
-- del proyecto. Si el ProductType no existe, el bloque correspondiente no hace nada.
|
||||
-- 3. Cuando PRD-008 seede los 12 tipos legacy, V025 puede re-aplicarse y creará
|
||||
-- los overrides que falten (MERGE idempotente).
|
||||
--
|
||||
-- Precios ficticios (placeholders de demo — NO son tarifas reales):
|
||||
-- Clasificado: $ = 5.0000, % = 3.0000, ! = 2.0000, ¡ = 2.0000
|
||||
-- Notables: $ = 8.0000, % = 5.0000, ! = 4.0000, ¡ = 4.0000
|
||||
-- Fúnebres: $ = 6.0000, % = 4.0000, ! = 3.5000, ¡ = 3.5000
|
||||
--
|
||||
-- Reversa: V025_ROLLBACK.sql (elimina los overrides demo dejando solo los globales V022/V024).
|
||||
-- Run on: SIGCM2 (dev), SIGCM2_Test_App, SIGCM2_Test_Api.
|
||||
-- Idempotente: usa MERGE por (ProductTypeId, Symbol, ValidTo IS NULL).
|
||||
|
||||
SET QUOTED_IDENTIFIER ON;
|
||||
SET ANSI_NULLS ON;
|
||||
SET NOCOUNT ON;
|
||||
GO
|
||||
|
||||
DECLARE @ClasificadoId INT = (SELECT TOP 1 Id FROM dbo.ProductType WHERE Nombre = 'Clasificado' AND IsActive = 1);
|
||||
DECLARE @NotablesId INT = (SELECT TOP 1 Id FROM dbo.ProductType WHERE Nombre = 'Notables' AND IsActive = 1);
|
||||
DECLARE @FunebresId INT = (SELECT TOP 1 Id FROM dbo.ProductType WHERE Nombre IN ('Fúnebres','Funebres') AND IsActive = 1);
|
||||
|
||||
DECLARE @DemoValidFrom DATE = '2026-01-01';
|
||||
|
||||
-- Clasificado overrides
|
||||
IF @ClasificadoId IS NOT NULL
|
||||
BEGIN
|
||||
MERGE dbo.ChargeableCharConfig AS t
|
||||
USING (VALUES
|
||||
(@ClasificadoId, N'$', N'Currency', CAST(5.0000 AS DECIMAL(18,4)), @DemoValidFrom),
|
||||
(@ClasificadoId, N'%', N'Percentage', CAST(3.0000 AS DECIMAL(18,4)), @DemoValidFrom),
|
||||
(@ClasificadoId, N'!', N'Exclamation', CAST(2.0000 AS DECIMAL(18,4)), @DemoValidFrom),
|
||||
(@ClasificadoId, N'¡', N'Exclamation', CAST(2.0000 AS DECIMAL(18,4)), @DemoValidFrom)
|
||||
) AS s (ProductTypeId, Symbol, Category, PricePerUnit, ValidFrom)
|
||||
ON (t.ProductTypeId = s.ProductTypeId AND t.Symbol = s.Symbol AND t.ValidTo IS NULL)
|
||||
WHEN NOT MATCHED THEN
|
||||
INSERT (ProductTypeId, Symbol, Category, PricePerUnit, ValidFrom, ValidTo, IsActive)
|
||||
VALUES (s.ProductTypeId, s.Symbol, s.Category, s.PricePerUnit, s.ValidFrom, NULL, 1);
|
||||
PRINT 'V025: Clasificado overrides seeded (ProductTypeId=' + CAST(@ClasificadoId AS NVARCHAR(10)) + ').';
|
||||
END
|
||||
ELSE
|
||||
PRINT 'V025: ProductType "Clasificado" not found — skipping Clasificado overrides.';
|
||||
GO
|
||||
|
||||
DECLARE @NotablesId INT = (SELECT TOP 1 Id FROM dbo.ProductType WHERE Nombre = 'Notables' AND IsActive = 1);
|
||||
DECLARE @DemoValidFrom DATE = '2026-01-01';
|
||||
|
||||
-- Notables overrides
|
||||
IF @NotablesId IS NOT NULL
|
||||
BEGIN
|
||||
MERGE dbo.ChargeableCharConfig AS t
|
||||
USING (VALUES
|
||||
(@NotablesId, N'$', N'Currency', CAST(8.0000 AS DECIMAL(18,4)), @DemoValidFrom),
|
||||
(@NotablesId, N'%', N'Percentage', CAST(5.0000 AS DECIMAL(18,4)), @DemoValidFrom),
|
||||
(@NotablesId, N'!', N'Exclamation', CAST(4.0000 AS DECIMAL(18,4)), @DemoValidFrom),
|
||||
(@NotablesId, N'¡', N'Exclamation', CAST(4.0000 AS DECIMAL(18,4)), @DemoValidFrom)
|
||||
) AS s (ProductTypeId, Symbol, Category, PricePerUnit, ValidFrom)
|
||||
ON (t.ProductTypeId = s.ProductTypeId AND t.Symbol = s.Symbol AND t.ValidTo IS NULL)
|
||||
WHEN NOT MATCHED THEN
|
||||
INSERT (ProductTypeId, Symbol, Category, PricePerUnit, ValidFrom, ValidTo, IsActive)
|
||||
VALUES (s.ProductTypeId, s.Symbol, s.Category, s.PricePerUnit, s.ValidFrom, NULL, 1);
|
||||
PRINT 'V025: Notables overrides seeded (ProductTypeId=' + CAST(@NotablesId AS NVARCHAR(10)) + ').';
|
||||
END
|
||||
ELSE
|
||||
PRINT 'V025: ProductType "Notables" not found — skipping Notables overrides.';
|
||||
GO
|
||||
|
||||
DECLARE @FunebresId INT = (SELECT TOP 1 Id FROM dbo.ProductType WHERE Nombre IN ('Fúnebres','Funebres') AND IsActive = 1);
|
||||
DECLARE @DemoValidFrom DATE = '2026-01-01';
|
||||
|
||||
-- Fúnebres overrides
|
||||
IF @FunebresId IS NOT NULL
|
||||
BEGIN
|
||||
MERGE dbo.ChargeableCharConfig AS t
|
||||
USING (VALUES
|
||||
(@FunebresId, N'$', N'Currency', CAST(6.0000 AS DECIMAL(18,4)), @DemoValidFrom),
|
||||
(@FunebresId, N'%', N'Percentage', CAST(4.0000 AS DECIMAL(18,4)), @DemoValidFrom),
|
||||
(@FunebresId, N'!', N'Exclamation', CAST(3.5000 AS DECIMAL(18,4)), @DemoValidFrom),
|
||||
(@FunebresId, N'¡', N'Exclamation', CAST(3.5000 AS DECIMAL(18,4)), @DemoValidFrom)
|
||||
) AS s (ProductTypeId, Symbol, Category, PricePerUnit, ValidFrom)
|
||||
ON (t.ProductTypeId = s.ProductTypeId AND t.Symbol = s.Symbol AND t.ValidTo IS NULL)
|
||||
WHEN NOT MATCHED THEN
|
||||
INSERT (ProductTypeId, Symbol, Category, PricePerUnit, ValidFrom, ValidTo, IsActive)
|
||||
VALUES (s.ProductTypeId, s.Symbol, s.Category, s.PricePerUnit, s.ValidFrom, NULL, 1);
|
||||
PRINT 'V025: Fúnebres overrides seeded (ProductTypeId=' + CAST(@FunebresId AS NVARCHAR(10)) + ').';
|
||||
END
|
||||
ELSE
|
||||
PRINT 'V025: ProductType "Fúnebres/Funebres" not found — skipping Fúnebres overrides.';
|
||||
GO
|
||||
|
||||
PRINT '';
|
||||
PRINT 'V025 applied — demo overrides (fictitious prices) seeded for ProductTypes: Clasificado, Notables, Fúnebres (only where they exist).';
|
||||
PRINT 'NOTE: Los 4 globales (V022/V024) quedan intactos en 0.0000. Estos overrides son PLACEHOLDERS DE DEMO — reemplazar antes de go-live.';
|
||||
GO
|
||||
247
src/api/SIGCM2.Api/Controllers/ChargeableCharConfigController.cs
Normal file
247
src/api/SIGCM2.Api/Controllers/ChargeableCharConfigController.cs
Normal file
@@ -0,0 +1,247 @@
|
||||
using FluentValidation;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using SIGCM2.Api.Authorization;
|
||||
using SIGCM2.Application.Abstractions;
|
||||
using SIGCM2.Application.Common;
|
||||
using SIGCM2.Application.Pricing.ChargeableChars;
|
||||
using SIGCM2.Application.Pricing.ChargeableChars.Create;
|
||||
using SIGCM2.Application.Pricing.ChargeableChars.Deactivate;
|
||||
using SIGCM2.Application.Pricing.ChargeableChars.Delete;
|
||||
using SIGCM2.Application.Pricing.ChargeableChars.GetById;
|
||||
using SIGCM2.Application.Pricing.ChargeableChars.List;
|
||||
using SIGCM2.Application.Pricing.ChargeableChars.Reactivate;
|
||||
using SIGCM2.Application.Pricing.ChargeableChars.SchedulePrice;
|
||||
|
||||
namespace SIGCM2.Api.Controllers;
|
||||
|
||||
/// <summary>
|
||||
/// PRC-001: Admin endpoints for ChargeableCharConfig management.
|
||||
/// All endpoints require 'tasacion:caracteres_especiales:gestionar'.
|
||||
/// Route base: api/v1/admin/chargeable-chars
|
||||
/// </summary>
|
||||
[ApiController]
|
||||
[Route("api/v1/admin/chargeable-chars")]
|
||||
public sealed class ChargeableCharConfigController : ControllerBase
|
||||
{
|
||||
private readonly IDispatcher _dispatcher;
|
||||
private readonly IValidator<CreateChargeableCharConfigCommand> _createValidator;
|
||||
private readonly IValidator<SchedulePriceChangeCommand> _scheduleValidator;
|
||||
|
||||
public ChargeableCharConfigController(
|
||||
IDispatcher dispatcher,
|
||||
IValidator<CreateChargeableCharConfigCommand> createValidator,
|
||||
IValidator<SchedulePriceChangeCommand> scheduleValidator)
|
||||
{
|
||||
_dispatcher = dispatcher;
|
||||
_createValidator = createValidator;
|
||||
_scheduleValidator = scheduleValidator;
|
||||
}
|
||||
|
||||
// ── GET /api/v1/admin/chargeable-chars ────────────────────────────────────
|
||||
|
||||
/// <summary>
|
||||
/// Returns a paginated list of ChargeableCharConfig rows.
|
||||
/// Filters: productTypeId (optional, long?), activeOnly (bool, default true).
|
||||
/// Pagination: skip/take model mapped to page/pageSize — or use page/pageSize directly.
|
||||
/// Defaults: page=1, pageSize=20. Clamped: pageSize max 200.
|
||||
/// </summary>
|
||||
[HttpGet]
|
||||
[RequirePermission("tasacion:caracteres_especiales:gestionar")]
|
||||
[ProducesResponseType(typeof(PagedResult<ChargeableCharConfigDto>), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
|
||||
[ProducesResponseType(StatusCodes.Status403Forbidden)]
|
||||
public async Task<IActionResult> List(
|
||||
[FromQuery] long? productTypeId,
|
||||
[FromQuery] bool activeOnly = true,
|
||||
[FromQuery] int? page = null,
|
||||
[FromQuery] int? pageSize = null,
|
||||
[FromQuery] int? skip = null,
|
||||
[FromQuery] int? take = null)
|
||||
{
|
||||
// Support both page/pageSize and skip/take query patterns
|
||||
int resolvedPage;
|
||||
int resolvedPageSize;
|
||||
|
||||
if (skip is not null || take is not null)
|
||||
{
|
||||
// Convert skip/take to page/pageSize
|
||||
resolvedPageSize = Math.Min(take ?? 50, 200);
|
||||
resolvedPage = resolvedPageSize > 0
|
||||
? ((skip ?? 0) / resolvedPageSize) + 1
|
||||
: 1;
|
||||
}
|
||||
else
|
||||
{
|
||||
resolvedPage = page ?? 1;
|
||||
resolvedPageSize = Math.Min(pageSize ?? 20, 200);
|
||||
}
|
||||
|
||||
var query = new ListChargeableCharConfigQuery(productTypeId, activeOnly, resolvedPage, resolvedPageSize);
|
||||
var result = await _dispatcher.Send<ListChargeableCharConfigQuery, PagedResult<ChargeableCharConfigDto>>(query);
|
||||
return Ok(result);
|
||||
}
|
||||
|
||||
// ── GET /api/v1/admin/chargeable-chars/{id} ───────────────────────────────
|
||||
|
||||
/// <summary>
|
||||
/// Returns a single ChargeableCharConfig by Id. Returns 404 if not found.
|
||||
/// </summary>
|
||||
[HttpGet("{id:long}")]
|
||||
[RequirePermission("tasacion:caracteres_especiales:gestionar")]
|
||||
[ProducesResponseType(typeof(ChargeableCharConfigDto), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
|
||||
[ProducesResponseType(StatusCodes.Status403Forbidden)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
public async Task<IActionResult> GetById([FromRoute] long id)
|
||||
{
|
||||
var result = await _dispatcher.Send<GetChargeableCharConfigByIdQuery, ChargeableCharConfigDto?>(
|
||||
new GetChargeableCharConfigByIdQuery(id));
|
||||
return result is null ? NotFound() : Ok(result);
|
||||
}
|
||||
|
||||
// ── POST /api/v1/admin/chargeable-chars ───────────────────────────────────
|
||||
|
||||
/// <summary>
|
||||
/// Creates a new ChargeableCharConfig row. Closes the current active row for (ProductTypeId, Symbol) if one exists.
|
||||
/// Returns 201 Created with Location header pointing to GET /{id}.
|
||||
/// </summary>
|
||||
[HttpPost]
|
||||
[RequirePermission("tasacion:caracteres_especiales:gestionar")]
|
||||
[ProducesResponseType(typeof(CreateChargeableCharConfigResponse), StatusCodes.Status201Created)]
|
||||
[ProducesResponseType(StatusCodes.Status400BadRequest)]
|
||||
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
|
||||
[ProducesResponseType(StatusCodes.Status403Forbidden)]
|
||||
[ProducesResponseType(StatusCodes.Status409Conflict)]
|
||||
public async Task<IActionResult> Create([FromBody] CreateChargeableCharConfigRequest request)
|
||||
{
|
||||
var command = new CreateChargeableCharConfigCommand(
|
||||
request.ProductTypeId,
|
||||
request.Symbol,
|
||||
request.Category,
|
||||
request.PricePerUnit,
|
||||
request.ValidFrom);
|
||||
|
||||
var validation = await _createValidator.ValidateAsync(command);
|
||||
if (!validation.IsValid)
|
||||
{
|
||||
var errors = validation.Errors
|
||||
.GroupBy(e => e.PropertyName)
|
||||
.ToDictionary(g => g.Key, g => g.Select(e => e.ErrorMessage).ToArray());
|
||||
return BadRequest(new { errors });
|
||||
}
|
||||
|
||||
var result = await _dispatcher.Send<CreateChargeableCharConfigCommand, CreateChargeableCharConfigResponse>(command);
|
||||
return CreatedAtAction(nameof(GetById), new { id = result.Id }, result);
|
||||
}
|
||||
|
||||
// ── PUT /api/v1/admin/chargeable-chars/{id}/price ────────────────────────
|
||||
|
||||
/// <summary>
|
||||
/// Schedules a price change for an existing ChargeableCharConfig.
|
||||
/// Closes the current active row and opens a new one with the new price + ValidFrom.
|
||||
/// ValidFrom must be strictly greater than the existing row's ValidFrom (forward-only).
|
||||
/// </summary>
|
||||
[HttpPut("{id:long}/price")]
|
||||
[RequirePermission("tasacion:caracteres_especiales:gestionar")]
|
||||
[ProducesResponseType(typeof(SchedulePriceChangeResponse), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status400BadRequest)]
|
||||
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
|
||||
[ProducesResponseType(StatusCodes.Status403Forbidden)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
[ProducesResponseType(StatusCodes.Status409Conflict)]
|
||||
public async Task<IActionResult> SchedulePriceChange(
|
||||
[FromRoute] long id,
|
||||
[FromBody] SchedulePriceChangeRequest request)
|
||||
{
|
||||
var command = new SchedulePriceChangeCommand(id, request.PricePerUnit, request.ValidFrom);
|
||||
|
||||
var validation = await _scheduleValidator.ValidateAsync(command);
|
||||
if (!validation.IsValid)
|
||||
{
|
||||
var errors = validation.Errors
|
||||
.GroupBy(e => e.PropertyName)
|
||||
.ToDictionary(g => g.Key, g => g.Select(e => e.ErrorMessage).ToArray());
|
||||
return BadRequest(new { errors });
|
||||
}
|
||||
|
||||
var result = await _dispatcher.Send<SchedulePriceChangeCommand, SchedulePriceChangeResponse>(command);
|
||||
return Ok(result);
|
||||
}
|
||||
|
||||
// ── PATCH /api/v1/admin/chargeable-chars/{id}/deactivate ─────────────────
|
||||
|
||||
/// <summary>
|
||||
/// Deactivates a ChargeableCharConfig row (sets IsActive=false, ValidTo=today_AR).
|
||||
/// Idempotent: calling on an already-inactive row is a no-op.
|
||||
/// </summary>
|
||||
[HttpPatch("{id:long}/deactivate")]
|
||||
[RequirePermission("tasacion:caracteres_especiales:gestionar")]
|
||||
[ProducesResponseType(typeof(DeactivateChargeableCharConfigResponse), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
|
||||
[ProducesResponseType(StatusCodes.Status403Forbidden)]
|
||||
public async Task<IActionResult> Deactivate([FromRoute] long id)
|
||||
{
|
||||
var result = await _dispatcher.Send<DeactivateChargeableCharConfigCommand, DeactivateChargeableCharConfigResponse>(
|
||||
new DeactivateChargeableCharConfigCommand(id));
|
||||
return Ok(result);
|
||||
}
|
||||
|
||||
// ── PATCH /api/v1/admin/chargeable-chars/{id}/reactivate ─────────────────
|
||||
|
||||
/// <summary>
|
||||
/// Reactivates a previously closed ChargeableCharConfig row (undo last deactivation).
|
||||
/// Guard rules (enforced by SP):
|
||||
/// - ALREADY_ACTIVE: target row is already active → 409
|
||||
/// - VIGENTE_EXISTS: a different active row exists for (ProductTypeId, Symbol) → 409
|
||||
/// - POSTERIOR_ROWS_EXIST: rows with higher ValidFrom exist after the target → 409
|
||||
/// </summary>
|
||||
[HttpPatch("{id:long}/reactivate")]
|
||||
[RequirePermission("tasacion:caracteres_especiales:gestionar")]
|
||||
[ProducesResponseType(typeof(ReactivateChargeableCharConfigResponse), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
|
||||
[ProducesResponseType(StatusCodes.Status403Forbidden)]
|
||||
[ProducesResponseType(StatusCodes.Status409Conflict)]
|
||||
public async Task<IActionResult> Reactivate([FromRoute] long id)
|
||||
{
|
||||
var result = await _dispatcher.Send<ReactivateChargeableCharConfigCommand, ReactivateChargeableCharConfigResponse>(
|
||||
new ReactivateChargeableCharConfigCommand(id));
|
||||
return Ok(result);
|
||||
}
|
||||
|
||||
// ── DELETE /api/v1/admin/chargeable-chars/{id} ───────────────────────────
|
||||
|
||||
/// <summary>
|
||||
/// Deletes a ChargeableCharConfig row.
|
||||
/// NOTE: With SYSTEM_VERSIONING ON, the row is moved to the history table (temporal audit preserved).
|
||||
/// The row disappears from all current-state queries.
|
||||
/// Guard for "used in invoicing" is deferred to FAC-001 followup issue.
|
||||
/// Returns 200 + { id } consistent with the Deactivate pattern.
|
||||
/// </summary>
|
||||
[HttpDelete("{id:long}")]
|
||||
[RequirePermission("tasacion:caracteres_especiales:gestionar")]
|
||||
[ProducesResponseType(typeof(DeleteChargeableCharConfigResponse), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
|
||||
[ProducesResponseType(StatusCodes.Status403Forbidden)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
public async Task<IActionResult> Delete([FromRoute] long id)
|
||||
{
|
||||
var result = await _dispatcher.Send<DeleteChargeableCharConfigCommand, DeleteChargeableCharConfigResponse>(
|
||||
new DeleteChargeableCharConfigCommand(id));
|
||||
return Ok(result);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Request body records ──────────────────────────────────────────────────────
|
||||
|
||||
/// <summary>PRC-001: Create ChargeableCharConfig request body.</summary>
|
||||
public sealed record CreateChargeableCharConfigRequest(
|
||||
long? ProductTypeId,
|
||||
string Symbol,
|
||||
string Category,
|
||||
decimal PricePerUnit,
|
||||
DateOnly ValidFrom);
|
||||
|
||||
/// <summary>PRC-001: Schedule price change request body.</summary>
|
||||
public sealed record SchedulePriceChangeRequest(
|
||||
decimal PricePerUnit,
|
||||
DateOnly ValidFrom);
|
||||
@@ -53,12 +53,12 @@ public sealed class FiscalController : ControllerBase
|
||||
IValidator<UpdateIngresosBrutosCommand> updateIibbValidator,
|
||||
IValidator<NuevaVersionIngresosBrutosCommand> nuevaVersionIibbValidator)
|
||||
{
|
||||
_dispatcher = dispatcher;
|
||||
_createIvaValidator = createIvaValidator;
|
||||
_updateIvaValidator = updateIvaValidator;
|
||||
_dispatcher = dispatcher;
|
||||
_createIvaValidator = createIvaValidator;
|
||||
_updateIvaValidator = updateIvaValidator;
|
||||
_nuevaVersionIvaValidator = nuevaVersionIvaValidator;
|
||||
_createIibbValidator = createIibbValidator;
|
||||
_updateIibbValidator = updateIibbValidator;
|
||||
_createIibbValidator = createIibbValidator;
|
||||
_updateIibbValidator = updateIibbValidator;
|
||||
_nuevaVersionIibbValidator = nuevaVersionIibbValidator;
|
||||
}
|
||||
|
||||
@@ -78,15 +78,15 @@ public sealed class FiscalController : ControllerBase
|
||||
[FromQuery] bool? activo = 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" });
|
||||
|
||||
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);
|
||||
|
||||
return Ok(new
|
||||
{
|
||||
Items = result.Items.Select(FiscalContractMapper.ToIvaResponse).ToList(),
|
||||
Items = result.Items.Select(FiscalContractMapper.ToIvaResponse).ToList(),
|
||||
result.Page,
|
||||
result.PageSize,
|
||||
result.Total
|
||||
@@ -102,7 +102,7 @@ public sealed class FiscalController : ControllerBase
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
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);
|
||||
return Ok(FiscalContractMapper.ToIvaResponse(result));
|
||||
}
|
||||
@@ -115,7 +115,7 @@ public sealed class FiscalController : ControllerBase
|
||||
[ProducesResponseType(StatusCodes.Status403Forbidden)]
|
||||
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);
|
||||
return Ok(result.Select(FiscalContractMapper.ToHistorialIvaResponse).ToList());
|
||||
}
|
||||
@@ -143,10 +143,10 @@ public sealed class FiscalController : ControllerBase
|
||||
}
|
||||
|
||||
var command = new CreateTipoDeIvaCommand(
|
||||
Codigo: request.Codigo ?? string.Empty,
|
||||
Descripcion: request.Descripcion ?? string.Empty,
|
||||
Porcentaje: request.Porcentaje ?? 0m,
|
||||
AplicaIVA: request.AplicaIVA ?? false,
|
||||
Codigo: request.Codigo ?? string.Empty,
|
||||
Descripcion: request.Descripcion ?? string.Empty,
|
||||
Porcentaje: request.Porcentaje ?? 0m,
|
||||
AplicaIVA: request.AplicaIVA ?? false,
|
||||
VigenciaDesde: vigenciaDesde.Value,
|
||||
VigenciaHasta: vigenciaHasta);
|
||||
|
||||
@@ -202,11 +202,11 @@ public sealed class FiscalController : ControllerBase
|
||||
return BadRequest(new { error = "Request body is required" });
|
||||
|
||||
var command = new UpdateTipoDeIvaCommand(
|
||||
Id: id,
|
||||
Codigo: request.Codigo ?? string.Empty,
|
||||
Id: id,
|
||||
Codigo: request.Codigo ?? string.Empty,
|
||||
Descripcion: request.Descripcion ?? string.Empty,
|
||||
AplicaIVA: request.AplicaIVA ?? false,
|
||||
Activo: request.Activo ?? true);
|
||||
AplicaIVA: request.AplicaIVA ?? false,
|
||||
Activo: request.Activo ?? true);
|
||||
|
||||
var validation = await _updateIvaValidator.ValidateAsync(command);
|
||||
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)" });
|
||||
|
||||
var command = new NuevaVersionTipoDeIvaCommand(
|
||||
PredecesoraId: id,
|
||||
PredecesoraId: id,
|
||||
NuevoPorcentaje: request.Porcentaje ?? 0m,
|
||||
VigenciaDesde: vigenciaDesde.Value);
|
||||
VigenciaDesde: vigenciaDesde.Value);
|
||||
|
||||
var validation = await _nuevaVersionIvaValidator.ValidateAsync(command);
|
||||
if (!validation.IsValid)
|
||||
@@ -269,7 +269,7 @@ public sealed class FiscalController : ControllerBase
|
||||
public async Task<IActionResult> DeactivateIva([FromRoute] int 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));
|
||||
}
|
||||
|
||||
@@ -283,7 +283,7 @@ public sealed class FiscalController : ControllerBase
|
||||
public async Task<IActionResult> ReactivateIva([FromRoute] int 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));
|
||||
}
|
||||
|
||||
@@ -303,7 +303,7 @@ public sealed class FiscalController : ControllerBase
|
||||
[FromQuery] bool? activo = 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" });
|
||||
|
||||
ProvinciaArgentina? provinciaEnum = null;
|
||||
@@ -314,12 +314,12 @@ public sealed class FiscalController : ControllerBase
|
||||
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);
|
||||
|
||||
return Ok(new
|
||||
{
|
||||
Items = result.Items.Select(FiscalContractMapper.ToIibbResponse).ToList(),
|
||||
Items = result.Items.Select(FiscalContractMapper.ToIibbResponse).ToList(),
|
||||
result.Page,
|
||||
result.PageSize,
|
||||
result.Total
|
||||
@@ -335,7 +335,7 @@ public sealed class FiscalController : ControllerBase
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
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);
|
||||
return Ok(FiscalContractMapper.ToIibbResponse(result));
|
||||
}
|
||||
@@ -348,7 +348,7 @@ public sealed class FiscalController : ControllerBase
|
||||
[ProducesResponseType(StatusCodes.Status403Forbidden)]
|
||||
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);
|
||||
return Ok(result.Select(FiscalContractMapper.ToHistorialIibbResponse).ToList());
|
||||
}
|
||||
@@ -397,9 +397,9 @@ public sealed class FiscalController : ControllerBase
|
||||
}
|
||||
|
||||
var command = new CreateIngresosBrutosCommand(
|
||||
Provincia: provinciaEnum,
|
||||
Descripcion: request.Descripcion ?? string.Empty,
|
||||
Alicuota: request.Alicuota ?? 0m,
|
||||
Provincia: provinciaEnum,
|
||||
Descripcion: request.Descripcion ?? string.Empty,
|
||||
Alicuota: request.Alicuota ?? 0m,
|
||||
VigenciaDesde: vigenciaDesde.Value,
|
||||
VigenciaHasta: vigenciaHasta);
|
||||
|
||||
@@ -453,9 +453,9 @@ public sealed class FiscalController : ControllerBase
|
||||
return BadRequest(new { error = "Request body is required" });
|
||||
|
||||
var command = new UpdateIngresosBrutosCommand(
|
||||
Id: id,
|
||||
Id: id,
|
||||
Descripcion: request.Descripcion ?? string.Empty,
|
||||
Activo: request.Activo ?? true);
|
||||
Activo: request.Activo ?? true);
|
||||
|
||||
var validation = await _updateIibbValidator.ValidateAsync(command);
|
||||
if (!validation.IsValid)
|
||||
@@ -518,7 +518,7 @@ public sealed class FiscalController : ControllerBase
|
||||
public async Task<IActionResult> DeactivateIibb([FromRoute] int 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));
|
||||
}
|
||||
|
||||
@@ -532,7 +532,7 @@ public sealed class FiscalController : ControllerBase
|
||||
public async Task<IActionResult> ReactivateIibb([FromRoute] int 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));
|
||||
}
|
||||
|
||||
|
||||
102
src/api/SIGCM2.Api/Controllers/ProductPricesController.cs
Normal file
102
src/api/SIGCM2.Api/Controllers/ProductPricesController.cs
Normal file
@@ -0,0 +1,102 @@
|
||||
using FluentValidation;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using SIGCM2.Api.Authorization;
|
||||
using SIGCM2.Application.Abstractions;
|
||||
using SIGCM2.Application.Common;
|
||||
using SIGCM2.Application.Products.Prices;
|
||||
using SIGCM2.Application.Products.Prices.AddPrice;
|
||||
using SIGCM2.Application.Products.Prices.GetHistory;
|
||||
|
||||
namespace SIGCM2.Api.Controllers;
|
||||
|
||||
/// <summary>
|
||||
/// PRD-003: ProductPrices historic pricing management.
|
||||
/// Read endpoint at GET /api/v1/products/{id}/prices — requires 'catalogo:productos:gestionar'.
|
||||
/// Write endpoint at POST /api/v1/admin/products/{id}/prices — requires 'catalogo:productos:gestionar'.
|
||||
/// </summary>
|
||||
[ApiController]
|
||||
public sealed class ProductPricesController : ControllerBase
|
||||
{
|
||||
private readonly IDispatcher _dispatcher;
|
||||
private readonly IValidator<AddProductPriceCommand> _addValidator;
|
||||
|
||||
public ProductPricesController(
|
||||
IDispatcher dispatcher,
|
||||
IValidator<AddProductPriceCommand> addValidator)
|
||||
{
|
||||
_dispatcher = dispatcher;
|
||||
_addValidator = addValidator;
|
||||
}
|
||||
|
||||
// ── READ endpoint ──────────────────────────────────────────────────────────
|
||||
|
||||
/// <summary>
|
||||
/// Returns a paginated page of price history for a Product, ordered descending by PriceValidFrom.
|
||||
/// Defaults: page=1, pageSize=20. Clamping: page ≥ 1, pageSize ∈ [1, 100].
|
||||
/// Returns 200 with empty items if the product has no prices yet or page is beyond total.
|
||||
/// Returns 404 if the product does not exist.
|
||||
/// Returns 401 if not authenticated, 403 if missing 'catalogo:productos:gestionar' permission.
|
||||
/// </summary>
|
||||
[HttpGet("api/v1/products/{id:int}/prices")]
|
||||
[RequirePermission("catalogo:productos:gestionar")]
|
||||
[ProducesResponseType(typeof(PagedResult<ProductPriceDto>), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
|
||||
[ProducesResponseType(StatusCodes.Status403Forbidden)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
public async Task<IActionResult> GetProductPrices(
|
||||
[FromRoute] int id,
|
||||
[FromQuery] int? page,
|
||||
[FromQuery] int? pageSize)
|
||||
{
|
||||
var query = new GetProductPricesQuery(
|
||||
ProductId: id,
|
||||
Page: page ?? 1,
|
||||
PageSize: pageSize ?? 20);
|
||||
var result = await _dispatcher.Send<GetProductPricesQuery, PagedResult<ProductPriceDto>>(query);
|
||||
return Ok(result);
|
||||
}
|
||||
|
||||
// ── WRITE endpoint ─────────────────────────────────────────────────────────
|
||||
|
||||
/// <summary>
|
||||
/// Adds a new price to a Product. Closes the current active price if one exists.
|
||||
/// PriceValidFrom must be >= today_AR and strictly greater than the active price's PriceValidFrom.
|
||||
/// Returns 201 Created with Location header pointing to GET /api/v1/products/{id}/prices.
|
||||
/// </summary>
|
||||
[HttpPost("api/v1/admin/products/{id:int}/prices")]
|
||||
[RequirePermission("catalogo:productos:gestionar")]
|
||||
[ProducesResponseType(typeof(AddProductPriceResponse), StatusCodes.Status201Created)]
|
||||
[ProducesResponseType(StatusCodes.Status400BadRequest)]
|
||||
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
|
||||
[ProducesResponseType(StatusCodes.Status403Forbidden)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
[ProducesResponseType(StatusCodes.Status409Conflict)]
|
||||
public async Task<IActionResult> AddProductPrice(
|
||||
[FromRoute] int id,
|
||||
[FromBody] AddProductPriceRequest request)
|
||||
{
|
||||
var command = new AddProductPriceCommand(
|
||||
ProductId: id,
|
||||
Price: request.Price,
|
||||
PriceValidFrom: request.PriceValidFrom);
|
||||
|
||||
var validation = await _addValidator.ValidateAsync(command);
|
||||
if (!validation.IsValid)
|
||||
{
|
||||
var errors = validation.Errors
|
||||
.GroupBy(e => e.PropertyName)
|
||||
.ToDictionary(g => g.Key, g => g.Select(e => e.ErrorMessage).ToArray());
|
||||
return BadRequest(new { errors });
|
||||
}
|
||||
|
||||
var result = await _dispatcher.Send<AddProductPriceCommand, AddProductPriceResponse>(command);
|
||||
return CreatedAtAction(nameof(GetProductPrices), new { id }, result);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Request body record ───────────────────────────────────────────────────────
|
||||
|
||||
/// <summary>PRD-003: Add ProductPrice request body.</summary>
|
||||
public sealed record AddProductPriceRequest(
|
||||
decimal Price,
|
||||
DateOnly PriceValidFrom);
|
||||
184
src/api/SIGCM2.Api/Controllers/ProductTypesController.cs
Normal file
184
src/api/SIGCM2.Api/Controllers/ProductTypesController.cs
Normal file
@@ -0,0 +1,184 @@
|
||||
using FluentValidation;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using SIGCM2.Api.Authorization;
|
||||
using SIGCM2.Application.Abstractions;
|
||||
using SIGCM2.Application.Common;
|
||||
using SIGCM2.Application.ProductTypes.Create;
|
||||
using SIGCM2.Application.ProductTypes.Deactivate;
|
||||
using SIGCM2.Application.ProductTypes.GetById;
|
||||
using SIGCM2.Application.ProductTypes.List;
|
||||
using SIGCM2.Application.ProductTypes.Update;
|
||||
|
||||
namespace SIGCM2.Api.Controllers;
|
||||
|
||||
/// <summary>
|
||||
/// PRD-001: ProductType catalog management.
|
||||
/// Read endpoints at /api/v1/product-types — require authentication (any role).
|
||||
/// Write endpoints at /api/v1/admin/product-types — require 'catalogo:tipos:gestionar'.
|
||||
/// </summary>
|
||||
[ApiController]
|
||||
public sealed class ProductTypesController : ControllerBase
|
||||
{
|
||||
private readonly IDispatcher _dispatcher;
|
||||
private readonly IValidator<CreateProductTypeCommand> _createValidator;
|
||||
private readonly IValidator<UpdateProductTypeCommand> _updateValidator;
|
||||
|
||||
public ProductTypesController(
|
||||
IDispatcher dispatcher,
|
||||
IValidator<CreateProductTypeCommand> createValidator,
|
||||
IValidator<UpdateProductTypeCommand> updateValidator)
|
||||
{
|
||||
_dispatcher = dispatcher;
|
||||
_createValidator = createValidator;
|
||||
_updateValidator = updateValidator;
|
||||
}
|
||||
|
||||
// ── READ endpoints ─────────────────────────────────────────────────────────
|
||||
|
||||
/// <summary>Returns a paginated list of ProductTypes. Requires authentication.</summary>
|
||||
[HttpGet("api/v1/product-types")]
|
||||
[Authorize]
|
||||
[ProducesResponseType(typeof(PagedResult<ProductTypeListItemDto>), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
|
||||
public async Task<IActionResult> ListProductTypes(
|
||||
[FromQuery] int page = 1,
|
||||
[FromQuery] int pageSize = 20,
|
||||
[FromQuery] bool? activo = true,
|
||||
[FromQuery] string? search = null)
|
||||
{
|
||||
var query = new ListProductTypesQuery(page, pageSize, activo, search);
|
||||
var result = await _dispatcher.Send<ListProductTypesQuery, PagedResult<ProductTypeListItemDto>>(query);
|
||||
return Ok(result);
|
||||
}
|
||||
|
||||
/// <summary>Returns a single ProductType by id. Requires authentication.</summary>
|
||||
[HttpGet("api/v1/product-types/{id:int}")]
|
||||
[Authorize]
|
||||
[ProducesResponseType(typeof(ProductTypeDetailDto), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
public async Task<IActionResult> GetProductTypeById([FromRoute] int id)
|
||||
{
|
||||
var query = new GetProductTypeByIdQuery(id);
|
||||
var result = await _dispatcher.Send<GetProductTypeByIdQuery, ProductTypeDetailDto>(query);
|
||||
return Ok(result);
|
||||
}
|
||||
|
||||
// ── WRITE endpoints ────────────────────────────────────────────────────────
|
||||
|
||||
/// <summary>Creates a new ProductType. Requires catalogo:tipos:gestionar.</summary>
|
||||
[HttpPost("api/v1/admin/product-types")]
|
||||
[RequirePermission("catalogo:tipos:gestionar")]
|
||||
[ProducesResponseType(typeof(ProductTypeCreatedDto), StatusCodes.Status201Created)]
|
||||
[ProducesResponseType(StatusCodes.Status400BadRequest)]
|
||||
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
|
||||
[ProducesResponseType(StatusCodes.Status403Forbidden)]
|
||||
[ProducesResponseType(StatusCodes.Status409Conflict)]
|
||||
public async Task<IActionResult> CreateProductType([FromBody] CreateProductTypeRequest request)
|
||||
{
|
||||
var command = new CreateProductTypeCommand(
|
||||
Nombre: request.Nombre ?? string.Empty,
|
||||
HasDuration: request.HasDuration,
|
||||
RequiresText: request.RequiresText,
|
||||
RequiresCategory: request.RequiresCategory,
|
||||
IsBundle: request.IsBundle,
|
||||
AllowImages: request.AllowImages,
|
||||
MaxImages: request.MaxImages,
|
||||
MaxImageSizeMB: request.MaxImageSizeMB,
|
||||
MaxImageWidth: request.MaxImageWidth,
|
||||
MaxImageHeight: request.MaxImageHeight);
|
||||
|
||||
var validation = await _createValidator.ValidateAsync(command);
|
||||
if (!validation.IsValid)
|
||||
{
|
||||
var errors = validation.Errors
|
||||
.GroupBy(e => e.PropertyName)
|
||||
.ToDictionary(g => g.Key, g => g.Select(e => e.ErrorMessage).ToArray());
|
||||
return BadRequest(new { errors });
|
||||
}
|
||||
|
||||
var result = await _dispatcher.Send<CreateProductTypeCommand, ProductTypeCreatedDto>(command);
|
||||
return CreatedAtAction(nameof(GetProductTypeById), new { id = result.Id }, result);
|
||||
}
|
||||
|
||||
/// <summary>Updates a ProductType. Requires catalogo:tipos:gestionar.</summary>
|
||||
[HttpPut("api/v1/admin/product-types/{id:int}")]
|
||||
[RequirePermission("catalogo:tipos:gestionar")]
|
||||
[ProducesResponseType(typeof(ProductTypeUpdatedDto), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status400BadRequest)]
|
||||
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
|
||||
[ProducesResponseType(StatusCodes.Status403Forbidden)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
[ProducesResponseType(StatusCodes.Status409Conflict)]
|
||||
public async Task<IActionResult> UpdateProductType([FromRoute] int id, [FromBody] UpdateProductTypeRequest request)
|
||||
{
|
||||
var command = new UpdateProductTypeCommand(
|
||||
Id: id,
|
||||
Nombre: request.Nombre ?? string.Empty,
|
||||
HasDuration: request.HasDuration,
|
||||
RequiresText: request.RequiresText,
|
||||
RequiresCategory: request.RequiresCategory,
|
||||
IsBundle: request.IsBundle,
|
||||
AllowImages: request.AllowImages,
|
||||
MaxImages: request.MaxImages,
|
||||
MaxImageSizeMB: request.MaxImageSizeMB,
|
||||
MaxImageWidth: request.MaxImageWidth,
|
||||
MaxImageHeight: request.MaxImageHeight);
|
||||
|
||||
var validation = await _updateValidator.ValidateAsync(command);
|
||||
if (!validation.IsValid)
|
||||
{
|
||||
var errors = validation.Errors
|
||||
.GroupBy(e => e.PropertyName)
|
||||
.ToDictionary(g => g.Key, g => g.Select(e => e.ErrorMessage).ToArray());
|
||||
return BadRequest(new { errors });
|
||||
}
|
||||
|
||||
var result = await _dispatcher.Send<UpdateProductTypeCommand, ProductTypeUpdatedDto>(command);
|
||||
return Ok(result);
|
||||
}
|
||||
|
||||
/// <summary>Soft-deletes (deactivates) a ProductType. Requires catalogo:tipos:gestionar.</summary>
|
||||
[HttpDelete("api/v1/admin/product-types/{id:int}")]
|
||||
[RequirePermission("catalogo:tipos:gestionar")]
|
||||
[ProducesResponseType(StatusCodes.Status204NoContent)]
|
||||
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
|
||||
[ProducesResponseType(StatusCodes.Status403Forbidden)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
[ProducesResponseType(StatusCodes.Status409Conflict)]
|
||||
public async Task<IActionResult> DeactivateProductType([FromRoute] int id)
|
||||
{
|
||||
var command = new DeactivateProductTypeCommand(id);
|
||||
await _dispatcher.Send<DeactivateProductTypeCommand, ProductTypeStatusDto>(command);
|
||||
return NoContent();
|
||||
}
|
||||
}
|
||||
|
||||
// ── Request body records ──────────────────────────────────────────────────────
|
||||
|
||||
/// <summary>PRD-001: Create ProductType request body.</summary>
|
||||
public sealed record CreateProductTypeRequest(
|
||||
string? Nombre,
|
||||
bool HasDuration = false,
|
||||
bool RequiresText = false,
|
||||
bool RequiresCategory = false,
|
||||
bool IsBundle = false,
|
||||
bool AllowImages = false,
|
||||
int? MaxImages = null,
|
||||
decimal? MaxImageSizeMB = null,
|
||||
int? MaxImageWidth = null,
|
||||
int? MaxImageHeight = null);
|
||||
|
||||
/// <summary>PRD-001: Update ProductType request body.</summary>
|
||||
public sealed record UpdateProductTypeRequest(
|
||||
string? Nombre,
|
||||
bool HasDuration = false,
|
||||
bool RequiresText = false,
|
||||
bool RequiresCategory = false,
|
||||
bool IsBundle = false,
|
||||
bool AllowImages = false,
|
||||
int? MaxImages = null,
|
||||
decimal? MaxImageSizeMB = null,
|
||||
int? MaxImageWidth = null,
|
||||
int? MaxImageHeight = null);
|
||||
169
src/api/SIGCM2.Api/Controllers/ProductsController.cs
Normal file
169
src/api/SIGCM2.Api/Controllers/ProductsController.cs
Normal file
@@ -0,0 +1,169 @@
|
||||
using FluentValidation;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using SIGCM2.Api.Authorization;
|
||||
using SIGCM2.Application.Abstractions;
|
||||
using SIGCM2.Application.Common;
|
||||
using SIGCM2.Application.Products.Create;
|
||||
using SIGCM2.Application.Products.Deactivate;
|
||||
using SIGCM2.Application.Products.GetById;
|
||||
using SIGCM2.Application.Products.List;
|
||||
using SIGCM2.Application.Products.Update;
|
||||
|
||||
namespace SIGCM2.Api.Controllers;
|
||||
|
||||
/// <summary>
|
||||
/// PRD-002: Product catalog management.
|
||||
/// Read endpoints at /api/v1/products — require authentication (any role).
|
||||
/// Write endpoints at /api/v1/admin/products — require 'catalogo:productos:gestionar'.
|
||||
/// </summary>
|
||||
[ApiController]
|
||||
public sealed class ProductsController : ControllerBase
|
||||
{
|
||||
private readonly IDispatcher _dispatcher;
|
||||
private readonly IValidator<CreateProductCommand> _createValidator;
|
||||
private readonly IValidator<UpdateProductCommand> _updateValidator;
|
||||
|
||||
public ProductsController(
|
||||
IDispatcher dispatcher,
|
||||
IValidator<CreateProductCommand> createValidator,
|
||||
IValidator<UpdateProductCommand> updateValidator)
|
||||
{
|
||||
_dispatcher = dispatcher;
|
||||
_createValidator = createValidator;
|
||||
_updateValidator = updateValidator;
|
||||
}
|
||||
|
||||
// ── READ endpoints ─────────────────────────────────────────────────────────
|
||||
|
||||
/// <summary>Returns a paginated list of Products. Requires authentication.</summary>
|
||||
[HttpGet("api/v1/products")]
|
||||
[Authorize]
|
||||
[ProducesResponseType(typeof(PagedResult<ProductListItemDto>), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
|
||||
public async Task<IActionResult> ListProducts(
|
||||
[FromQuery] int page = 1,
|
||||
[FromQuery] int pageSize = 20,
|
||||
[FromQuery] bool? activo = true,
|
||||
[FromQuery] string? search = null,
|
||||
[FromQuery] int? medioId = null,
|
||||
[FromQuery] int? productTypeId = null,
|
||||
[FromQuery] int? rubroId = null)
|
||||
{
|
||||
var query = new ListProductsQuery(page, pageSize, activo, search, medioId, productTypeId, rubroId);
|
||||
var result = await _dispatcher.Send<ListProductsQuery, PagedResult<ProductListItemDto>>(query);
|
||||
return Ok(result);
|
||||
}
|
||||
|
||||
/// <summary>Returns a single Product by id. Requires authentication.</summary>
|
||||
[HttpGet("api/v1/products/{id:int}")]
|
||||
[Authorize]
|
||||
[ProducesResponseType(typeof(ProductDetailDto), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
public async Task<IActionResult> GetProductById([FromRoute] int id)
|
||||
{
|
||||
var query = new GetProductByIdQuery(id);
|
||||
var result = await _dispatcher.Send<GetProductByIdQuery, ProductDetailDto>(query);
|
||||
return Ok(result);
|
||||
}
|
||||
|
||||
// ── WRITE endpoints ────────────────────────────────────────────────────────
|
||||
|
||||
/// <summary>Creates a new Product. Requires catalogo:productos:gestionar.</summary>
|
||||
[HttpPost("api/v1/admin/products")]
|
||||
[RequirePermission("catalogo:productos:gestionar")]
|
||||
[ProducesResponseType(typeof(ProductCreatedDto), StatusCodes.Status201Created)]
|
||||
[ProducesResponseType(StatusCodes.Status400BadRequest)]
|
||||
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
|
||||
[ProducesResponseType(StatusCodes.Status403Forbidden)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
[ProducesResponseType(StatusCodes.Status409Conflict)]
|
||||
[ProducesResponseType(StatusCodes.Status422UnprocessableEntity)]
|
||||
public async Task<IActionResult> CreateProduct([FromBody] CreateProductRequest request)
|
||||
{
|
||||
var command = new CreateProductCommand(
|
||||
Nombre: request.Nombre ?? string.Empty,
|
||||
MedioId: request.MedioId,
|
||||
ProductTypeId: request.ProductTypeId,
|
||||
RubroId: request.RubroId,
|
||||
BasePrice: request.BasePrice,
|
||||
PriceDurationDays: request.PriceDurationDays);
|
||||
|
||||
var validation = await _createValidator.ValidateAsync(command);
|
||||
if (!validation.IsValid)
|
||||
{
|
||||
var errors = validation.Errors
|
||||
.GroupBy(e => e.PropertyName)
|
||||
.ToDictionary(g => g.Key, g => g.Select(e => e.ErrorMessage).ToArray());
|
||||
return BadRequest(new { errors });
|
||||
}
|
||||
|
||||
var result = await _dispatcher.Send<CreateProductCommand, ProductCreatedDto>(command);
|
||||
return CreatedAtAction(nameof(GetProductById), new { id = result.Id }, result);
|
||||
}
|
||||
|
||||
/// <summary>Updates a Product. Requires catalogo:productos:gestionar.</summary>
|
||||
[HttpPut("api/v1/admin/products/{id:int}")]
|
||||
[RequirePermission("catalogo:productos:gestionar")]
|
||||
[ProducesResponseType(typeof(ProductUpdatedDto), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status400BadRequest)]
|
||||
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
|
||||
[ProducesResponseType(StatusCodes.Status403Forbidden)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
[ProducesResponseType(StatusCodes.Status409Conflict)]
|
||||
[ProducesResponseType(StatusCodes.Status422UnprocessableEntity)]
|
||||
public async Task<IActionResult> UpdateProduct([FromRoute] int id, [FromBody] UpdateProductRequest request)
|
||||
{
|
||||
var command = new UpdateProductCommand(
|
||||
Id: id,
|
||||
Nombre: request.Nombre ?? string.Empty,
|
||||
RubroId: request.RubroId,
|
||||
BasePrice: request.BasePrice,
|
||||
PriceDurationDays: request.PriceDurationDays);
|
||||
|
||||
var validation = await _updateValidator.ValidateAsync(command);
|
||||
if (!validation.IsValid)
|
||||
{
|
||||
var errors = validation.Errors
|
||||
.GroupBy(e => e.PropertyName)
|
||||
.ToDictionary(g => g.Key, g => g.Select(e => e.ErrorMessage).ToArray());
|
||||
return BadRequest(new { errors });
|
||||
}
|
||||
|
||||
var result = await _dispatcher.Send<UpdateProductCommand, ProductUpdatedDto>(command);
|
||||
return Ok(result);
|
||||
}
|
||||
|
||||
/// <summary>Soft-deletes (deactivates) a Product. Requires catalogo:productos:gestionar.</summary>
|
||||
[HttpDelete("api/v1/admin/products/{id:int}")]
|
||||
[RequirePermission("catalogo:productos:gestionar")]
|
||||
[ProducesResponseType(StatusCodes.Status204NoContent)]
|
||||
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
|
||||
[ProducesResponseType(StatusCodes.Status403Forbidden)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
public async Task<IActionResult> DeactivateProduct([FromRoute] int id)
|
||||
{
|
||||
var command = new DeactivateProductCommand(id);
|
||||
await _dispatcher.Send<DeactivateProductCommand, ProductStatusDto>(command);
|
||||
return NoContent();
|
||||
}
|
||||
}
|
||||
|
||||
// ── Request body records ──────────────────────────────────────────────────────
|
||||
|
||||
/// <summary>PRD-002: Create Product request body.</summary>
|
||||
public sealed record CreateProductRequest(
|
||||
string? Nombre,
|
||||
int MedioId = 0,
|
||||
int ProductTypeId = 0,
|
||||
int? RubroId = null,
|
||||
decimal BasePrice = 0m,
|
||||
int? PriceDurationDays = null);
|
||||
|
||||
/// <summary>PRD-002: Update Product request body.</summary>
|
||||
public sealed record UpdateProductRequest(
|
||||
string? Nombre,
|
||||
int? RubroId = null,
|
||||
decimal BasePrice = 0m,
|
||||
int? PriceDurationDays = null);
|
||||
151
src/api/SIGCM2.Api/Controllers/RubrosController.cs
Normal file
151
src/api/SIGCM2.Api/Controllers/RubrosController.cs
Normal file
@@ -0,0 +1,151 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using SIGCM2.Api.Authorization;
|
||||
using SIGCM2.Application.Abstractions;
|
||||
using SIGCM2.Application.Rubros.Create;
|
||||
using SIGCM2.Application.Rubros.Deactivate;
|
||||
using SIGCM2.Application.Rubros.Dtos;
|
||||
using SIGCM2.Application.Rubros.GetById;
|
||||
using SIGCM2.Application.Rubros.GetTree;
|
||||
using SIGCM2.Application.Rubros.Move;
|
||||
using SIGCM2.Application.Rubros.Update;
|
||||
|
||||
namespace SIGCM2.Api.Controllers;
|
||||
|
||||
/// <summary>
|
||||
/// CAT-001: Rubro N-ary tree management.
|
||||
/// Read endpoints at /api/v1/rubros — require authentication (any role).
|
||||
/// Write endpoints at /api/v1/admin/rubros — require 'catalogo:rubros:gestionar'.
|
||||
/// </summary>
|
||||
[ApiController]
|
||||
public sealed class RubrosController : ControllerBase
|
||||
{
|
||||
private readonly IDispatcher _dispatcher;
|
||||
|
||||
public RubrosController(IDispatcher dispatcher)
|
||||
{
|
||||
_dispatcher = dispatcher;
|
||||
}
|
||||
|
||||
// ── READ endpoints ─────────────────────────────────────────────────────────
|
||||
|
||||
/// <summary>Returns the full Rubro tree. Requires authentication.</summary>
|
||||
[HttpGet("api/v1/rubros/tree")]
|
||||
[Authorize]
|
||||
[ProducesResponseType(typeof(IReadOnlyList<RubroTreeNodeDto>), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
|
||||
public async Task<IActionResult> GetRubroTree([FromQuery] bool incluirInactivos = false)
|
||||
{
|
||||
var query = new GetRubroTreeQuery(incluirInactivos);
|
||||
var result = await _dispatcher.Send<GetRubroTreeQuery, IReadOnlyList<RubroTreeNodeDto>>(query);
|
||||
return Ok(result);
|
||||
}
|
||||
|
||||
/// <summary>Returns a single Rubro by id. Requires authentication.</summary>
|
||||
[HttpGet("api/v1/rubros/{id:int}")]
|
||||
[Authorize]
|
||||
[ProducesResponseType(typeof(RubroDetailDto), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
public async Task<IActionResult> GetRubroById([FromRoute] int id)
|
||||
{
|
||||
var query = new GetRubroByIdQuery(id);
|
||||
var result = await _dispatcher.Send<GetRubroByIdQuery, RubroDetailDto>(query);
|
||||
return Ok(result);
|
||||
}
|
||||
|
||||
// ── WRITE endpoints ────────────────────────────────────────────────────────
|
||||
|
||||
/// <summary>Creates a new Rubro. Requires catalogo:rubros:gestionar.</summary>
|
||||
[HttpPost("api/v1/admin/rubros")]
|
||||
[RequirePermission("catalogo:rubros:gestionar")]
|
||||
[ProducesResponseType(typeof(RubroCreatedDto), StatusCodes.Status201Created)]
|
||||
[ProducesResponseType(StatusCodes.Status400BadRequest)]
|
||||
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
|
||||
[ProducesResponseType(StatusCodes.Status403Forbidden)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
[ProducesResponseType(StatusCodes.Status409Conflict)]
|
||||
[ProducesResponseType(StatusCodes.Status422UnprocessableEntity)]
|
||||
public async Task<IActionResult> CreateRubro([FromBody] CreateRubroRequest request)
|
||||
{
|
||||
var command = new CreateRubroCommand(
|
||||
Nombre: request.Nombre ?? string.Empty,
|
||||
ParentId: request.ParentId,
|
||||
TarifarioBaseId: request.TarifarioBaseId);
|
||||
|
||||
var result = await _dispatcher.Send<CreateRubroCommand, RubroCreatedDto>(command);
|
||||
return CreatedAtAction(nameof(GetRubroById), new { id = result.Id }, result);
|
||||
}
|
||||
|
||||
/// <summary>Updates a Rubro's nombre. Requires catalogo:rubros:gestionar.</summary>
|
||||
[HttpPut("api/v1/admin/rubros/{id:int}")]
|
||||
[RequirePermission("catalogo:rubros:gestionar")]
|
||||
[ProducesResponseType(typeof(RubroUpdatedDto), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status400BadRequest)]
|
||||
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
|
||||
[ProducesResponseType(StatusCodes.Status403Forbidden)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
[ProducesResponseType(StatusCodes.Status409Conflict)]
|
||||
public async Task<IActionResult> UpdateRubro([FromRoute] int id, [FromBody] UpdateRubroRequest request)
|
||||
{
|
||||
var command = new UpdateRubroCommand(
|
||||
Id: id,
|
||||
Nombre: request.Nombre ?? string.Empty);
|
||||
|
||||
var result = await _dispatcher.Send<UpdateRubroCommand, RubroUpdatedDto>(command);
|
||||
return Ok(result);
|
||||
}
|
||||
|
||||
/// <summary>Soft-deletes (deactivates) a Rubro. Requires catalogo:rubros:gestionar.</summary>
|
||||
[HttpDelete("api/v1/admin/rubros/{id:int}")]
|
||||
[RequirePermission("catalogo:rubros:gestionar")]
|
||||
[ProducesResponseType(StatusCodes.Status204NoContent)]
|
||||
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
|
||||
[ProducesResponseType(StatusCodes.Status403Forbidden)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
[ProducesResponseType(StatusCodes.Status409Conflict)]
|
||||
public async Task<IActionResult> DeactivateRubro([FromRoute] int id)
|
||||
{
|
||||
var command = new DeactivateRubroCommand(id);
|
||||
await _dispatcher.Send<DeactivateRubroCommand, RubroStatusDto>(command);
|
||||
return NoContent();
|
||||
}
|
||||
|
||||
/// <summary>Moves a Rubro to a new parent. Requires catalogo:rubros:gestionar.</summary>
|
||||
[HttpPatch("api/v1/admin/rubros/{id:int}/mover")]
|
||||
[RequirePermission("catalogo:rubros:gestionar")]
|
||||
[ProducesResponseType(typeof(RubroMovedDto), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status400BadRequest)]
|
||||
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
|
||||
[ProducesResponseType(StatusCodes.Status403Forbidden)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
[ProducesResponseType(StatusCodes.Status409Conflict)]
|
||||
[ProducesResponseType(StatusCodes.Status422UnprocessableEntity)]
|
||||
public async Task<IActionResult> MoveRubro([FromRoute] int id, [FromBody] MoveRubroRequest request)
|
||||
{
|
||||
var command = new MoveRubroCommand(
|
||||
Id: id,
|
||||
NuevoParentId: request.NuevoParentId,
|
||||
NuevoOrden: request.NuevoOrden);
|
||||
|
||||
var result = await _dispatcher.Send<MoveRubroCommand, RubroMovedDto>(command);
|
||||
return Ok(result);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Request body records ──────────────────────────────────────────────────────
|
||||
|
||||
/// <summary>CAT-001: Create rubro request body.</summary>
|
||||
public sealed record CreateRubroRequest(
|
||||
string? Nombre,
|
||||
int? ParentId,
|
||||
int? TarifarioBaseId);
|
||||
|
||||
/// <summary>CAT-001: Update rubro request body.</summary>
|
||||
public sealed record UpdateRubroRequest(
|
||||
string? Nombre);
|
||||
|
||||
/// <summary>CAT-001: Move rubro request body.</summary>
|
||||
public sealed record MoveRubroRequest(
|
||||
int? NuevoParentId,
|
||||
int NuevoOrden);
|
||||
@@ -3,6 +3,7 @@ using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.AspNetCore.Mvc.Filters;
|
||||
using Microsoft.Data.SqlClient;
|
||||
using SIGCM2.Domain.Exceptions;
|
||||
using SIGCM2.Domain.Pricing.Exceptions;
|
||||
|
||||
namespace SIGCM2.Api.Filters;
|
||||
|
||||
@@ -169,6 +170,116 @@ public sealed class ExceptionFilter : IExceptionFilter
|
||||
context.ExceptionHandled = true;
|
||||
break;
|
||||
|
||||
// CAT-001: Rubro exceptions
|
||||
case RubroNotFoundException rubroNotFoundEx:
|
||||
context.Result = new ObjectResult(new
|
||||
{
|
||||
error = "rubro_not_found",
|
||||
message = rubroNotFoundEx.Message
|
||||
})
|
||||
{
|
||||
StatusCode = StatusCodes.Status404NotFound
|
||||
};
|
||||
context.ExceptionHandled = true;
|
||||
break;
|
||||
|
||||
case RubroNombreDuplicadoEnPadreException rubroDupEx:
|
||||
context.Result = new ObjectResult(new
|
||||
{
|
||||
error = "rubro_nombre_duplicado",
|
||||
message = rubroDupEx.Message
|
||||
})
|
||||
{
|
||||
StatusCode = StatusCodes.Status409Conflict
|
||||
};
|
||||
context.ExceptionHandled = true;
|
||||
break;
|
||||
|
||||
case RubroTieneHijosActivosException rubroHijosEx:
|
||||
context.Result = new ObjectResult(new
|
||||
{
|
||||
error = "rubro_tiene_hijos_activos",
|
||||
message = rubroHijosEx.Message
|
||||
})
|
||||
{
|
||||
StatusCode = StatusCodes.Status409Conflict
|
||||
};
|
||||
context.ExceptionHandled = true;
|
||||
break;
|
||||
|
||||
case RubroPadreInactivoException rubroPadreEx:
|
||||
context.Result = new ObjectResult(new
|
||||
{
|
||||
error = "rubro_padre_inactivo",
|
||||
message = rubroPadreEx.Message
|
||||
})
|
||||
{
|
||||
StatusCode = StatusCodes.Status400BadRequest
|
||||
};
|
||||
context.ExceptionHandled = true;
|
||||
break;
|
||||
|
||||
case RubroMaxDepthExceededException rubroDepthEx:
|
||||
context.Result = new ObjectResult(new
|
||||
{
|
||||
error = "rubro_max_depth_exceeded",
|
||||
message = rubroDepthEx.Message
|
||||
})
|
||||
{
|
||||
StatusCode = StatusCodes.Status422UnprocessableEntity
|
||||
};
|
||||
context.ExceptionHandled = true;
|
||||
break;
|
||||
|
||||
case RubroCycleDetectedException rubroCycleEx:
|
||||
context.Result = new ObjectResult(new
|
||||
{
|
||||
error = "rubro_cycle_detected",
|
||||
message = rubroCycleEx.Message
|
||||
})
|
||||
{
|
||||
StatusCode = StatusCodes.Status400BadRequest
|
||||
};
|
||||
context.ExceptionHandled = true;
|
||||
break;
|
||||
|
||||
// CAT-002: Rubro Regla de Oro (rama vs hoja)
|
||||
case RubroPadreEsHojaConAvisosException rubroPadreHojaEx:
|
||||
context.Result = new ObjectResult(new
|
||||
{
|
||||
error = "rubro_padre_es_hoja_con_avisos",
|
||||
message = rubroPadreHojaEx.Message
|
||||
})
|
||||
{
|
||||
StatusCode = StatusCodes.Status409Conflict
|
||||
};
|
||||
context.ExceptionHandled = true;
|
||||
break;
|
||||
|
||||
case RubroEsRamaConHijosActivosException rubroRamaHijosEx:
|
||||
context.Result = new ObjectResult(new
|
||||
{
|
||||
error = "rubro_es_rama_con_hijos_activos",
|
||||
message = rubroRamaHijosEx.Message
|
||||
})
|
||||
{
|
||||
StatusCode = StatusCodes.Status409Conflict
|
||||
};
|
||||
context.ExceptionHandled = true;
|
||||
break;
|
||||
|
||||
case RubroConProductosActivosException rubroProductosEx:
|
||||
context.Result = new ObjectResult(new
|
||||
{
|
||||
error = "rubro_con_productos_activos",
|
||||
message = rubroProductosEx.Message
|
||||
})
|
||||
{
|
||||
StatusCode = StatusCodes.Status409Conflict
|
||||
};
|
||||
context.ExceptionHandled = true;
|
||||
break;
|
||||
|
||||
// ADM-001: Medio exceptions
|
||||
case MedioCodigoDuplicadoException medioCodDupEx:
|
||||
context.Result = new ObjectResult(new
|
||||
@@ -316,6 +427,156 @@ public sealed class ExceptionFilter : IExceptionFilter
|
||||
context.ExceptionHandled = true;
|
||||
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
|
||||
case PuntoDeVentaNotFoundException puntoDeVentaNotFoundEx:
|
||||
context.Result = new ObjectResult(new
|
||||
@@ -385,6 +646,94 @@ public sealed class ExceptionFilter : IExceptionFilter
|
||||
context.ExceptionHandled = true;
|
||||
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:
|
||||
var errors = validationEx.Errors
|
||||
.GroupBy(e => e.PropertyName)
|
||||
|
||||
31
src/api/SIGCM2.Api/Json/DateOnlyJsonConverter.cs
Normal file
31
src/api/SIGCM2.Api/Json/DateOnlyJsonConverter.cs
Normal file
@@ -0,0 +1,31 @@
|
||||
using System.Globalization;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace SIGCM2.Api.Json;
|
||||
|
||||
/// <summary>
|
||||
/// JSON converter for <see cref="DateOnly"/> that uses the "yyyy-MM-dd" ISO format.
|
||||
///
|
||||
/// UDT-011: Ensures Cat2 date fields (VigenciaDesde, etc.) never serialize as
|
||||
/// "2026-05-01T00:00:00" or with a UTC suffix "Z", which would mislead consumers
|
||||
/// into treating civil Argentine dates as absolute UTC instants.
|
||||
/// </summary>
|
||||
public sealed class DateOnlyJsonConverter : JsonConverter<DateOnly>
|
||||
{
|
||||
private const string DateFormat = "yyyy-MM-dd";
|
||||
|
||||
public override DateOnly Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
|
||||
{
|
||||
var str = reader.GetString();
|
||||
if (str is null)
|
||||
throw new JsonException("DateOnly value cannot be null.");
|
||||
|
||||
return DateOnly.ParseExact(str, DateFormat, CultureInfo.InvariantCulture);
|
||||
}
|
||||
|
||||
public override void Write(Utf8JsonWriter writer, DateOnly value, JsonSerializerOptions options)
|
||||
{
|
||||
writer.WriteStringValue(value.ToString(DateFormat, CultureInfo.InvariantCulture));
|
||||
}
|
||||
}
|
||||
@@ -2,12 +2,13 @@ using Microsoft.AspNetCore.Authorization;
|
||||
using Serilog;
|
||||
using Scalar.AspNetCore;
|
||||
using SIGCM2.Api.Authorization;
|
||||
using SIGCM2.Api.Filters;
|
||||
using SIGCM2.Api.HealthChecks;
|
||||
using SIGCM2.Api.Json;
|
||||
using SIGCM2.Api.Middleware;
|
||||
using SIGCM2.Application;
|
||||
using SIGCM2.Infrastructure;
|
||||
using SIGCM2.Infrastructure.Audit.Jobs;
|
||||
using SIGCM2.Api.Filters;
|
||||
|
||||
// Bootstrap logger — before DI is built
|
||||
Log.Logger = new LoggerConfiguration()
|
||||
@@ -36,10 +37,15 @@ builder.Services.AddAuthorization();
|
||||
builder.Services.AddScoped<IAuthorizationHandler, PermissionAuthorizationHandler>();
|
||||
builder.Services.AddSingleton<IAuthorizationMiddlewareResultHandler, ForbiddenProblemDetailsHandler>();
|
||||
|
||||
// Controllers with exception filter
|
||||
// Controllers with exception filter + JSON options
|
||||
// UDT-011: DateOnlyJsonConverter ensures Cat2 date fields serialize as "yyyy-MM-dd"
|
||||
// and never as "2026-05-01T00:00:00" or with a UTC "Z" suffix.
|
||||
builder.Services.AddControllers(opts =>
|
||||
{
|
||||
opts.Filters.Add<ExceptionFilter>();
|
||||
}).AddJsonOptions(jsonOpts =>
|
||||
{
|
||||
jsonOpts.JsonSerializerOptions.Converters.Add(new DateOnlyJsonConverter());
|
||||
});
|
||||
|
||||
// OpenAPI / Scalar
|
||||
|
||||
@@ -32,5 +32,8 @@
|
||||
],
|
||||
"Enrich": [ "FromLogContext", "WithMachineName", "WithThreadId" ]
|
||||
},
|
||||
"Rubros": {
|
||||
"MaxDepth": 10
|
||||
},
|
||||
"AllowedHosts": "*"
|
||||
}
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
namespace SIGCM2.Application.Abstractions.Persistence;
|
||||
|
||||
/// <summary>
|
||||
/// Query-only access to Aviso counts by Rubro.
|
||||
/// CAT-002 introduces the contract. The real Dapper-based impl lands in PRD-002
|
||||
/// (when dbo.Aviso exists). Until then, NullAvisoQueryRepository is the binding.
|
||||
/// </summary>
|
||||
public interface IAvisoQueryRepository
|
||||
{
|
||||
/// <summary>
|
||||
/// Returns the count of avisos (active, non-archived) assigned to the given rubro.
|
||||
/// </summary>
|
||||
Task<int> CountAvisosEnRubroAsync(int rubroId, CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// Returns a dictionary of { rubroId → count } for the provided ids.
|
||||
/// Used by GetRubroTreeQueryHandler to avoid N+1 when populating TieneAvisos per node.
|
||||
/// The implementation MUST do a single query; the stub returns an empty dictionary
|
||||
/// (every rubro gets 0 via dictionary.GetValueOrDefault).
|
||||
/// </summary>
|
||||
Task<IReadOnlyDictionary<int, int>> CountAvisosBatchAsync(
|
||||
IReadOnlyCollection<int> rubroIds,
|
||||
CancellationToken ct = default);
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
using SIGCM2.Domain.Pricing.ChargeableChars;
|
||||
|
||||
namespace SIGCM2.Application.Abstractions.Persistence;
|
||||
|
||||
/// <summary>
|
||||
/// PRC-001 — Write + query access to dbo.ChargeableCharConfig.
|
||||
/// Implemented by ChargeableCharConfigRepository (Dapper) in Infrastructure.
|
||||
///
|
||||
/// InsertWithCloseAsync: invokes usp_ChargeableCharConfig_InsertWithClose which atomically
|
||||
/// closes any active row for (ProductTypeId, Symbol) and inserts the new row.
|
||||
///
|
||||
/// GetActiveForProductTypeAsync: invokes usp_ChargeableCharConfig_GetActiveForProductType which
|
||||
/// returns both per-ProductType rows AND global (ProductTypeId IS NULL) rows for the given asOfDate.
|
||||
/// The Application service applies the per-ProductType > global priority rule.
|
||||
/// </summary>
|
||||
public interface IChargeableCharConfigRepository
|
||||
{
|
||||
/// <summary>
|
||||
/// Invokes usp_ChargeableCharConfig_InsertWithClose inside the ambient TransactionScope.
|
||||
/// Closes any active row matching (ProductTypeId, Symbol) and inserts a new one.
|
||||
/// Returns the Id of the newly inserted row.
|
||||
/// Throws:
|
||||
/// - ChargeableCharConfigForwardOnlyException on SQL THROW 50409
|
||||
/// - ChargeableCharConfigInvalidException on SQL THROW 50404 (not-found guard)
|
||||
/// </summary>
|
||||
Task<long> InsertWithCloseAsync(
|
||||
long? productTypeId,
|
||||
string symbol,
|
||||
string category,
|
||||
decimal price,
|
||||
DateOnly validFrom,
|
||||
CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// Returns all active rows whose [ValidFrom, ValidTo] window covers the given asOfDate
|
||||
/// for the specified ProductType, including global rows (ProductTypeId IS NULL).
|
||||
/// The SP returns both per-ProductType AND global rows — callers apply priority.
|
||||
/// </summary>
|
||||
Task<IReadOnlyList<ChargeableCharConfig>> GetActiveForProductTypeAsync(
|
||||
long productTypeId,
|
||||
DateOnly asOfDate,
|
||||
CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// Returns paginated rows filtered by ProductTypeId and IsActive.
|
||||
/// Skip = (page - 1) * pageSize computed by the caller.
|
||||
/// </summary>
|
||||
Task<IReadOnlyList<ChargeableCharConfig>> ListAsync(
|
||||
long? productTypeId,
|
||||
bool activeOnly,
|
||||
int skip,
|
||||
int take,
|
||||
CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// Returns total row count for the given filters (used for pagination metadata).
|
||||
/// </summary>
|
||||
Task<int> CountAsync(
|
||||
long? productTypeId,
|
||||
bool activeOnly,
|
||||
CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// Returns the row with the given Id, or null if not found.
|
||||
/// </summary>
|
||||
Task<ChargeableCharConfig?> GetByIdAsync(
|
||||
long id,
|
||||
CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// Deactivates the row with the given Id by setting IsActive = false and ValidTo = today.
|
||||
/// Idempotent: no-op if already inactive.
|
||||
/// Called inside the ambient TransactionScope of the handler.
|
||||
/// </summary>
|
||||
Task DeactivateAsync(
|
||||
long id,
|
||||
DateOnly today,
|
||||
CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// Invokes usp_ChargeableCharConfig_ReactivateWithGuard.
|
||||
/// Guard rules (enforced by SP):
|
||||
/// 50410 → target row is already active → ChargeableCharConfigReactivationNotAllowedException(ALREADY_ACTIVE)
|
||||
/// 50411 → a vigente active row exists for (ProductTypeId, Symbol) → ChargeableCharConfigReactivationNotAllowedException(VIGENTE_EXISTS)
|
||||
/// 50412 → posterior rows exist after target row → ChargeableCharConfigReactivationNotAllowedException(POSTERIOR_ROWS_EXIST)
|
||||
/// 50404 → row not found → ChargeableCharConfigInvalidException
|
||||
/// On success: re-opens the row (IsActive=true, ValidTo=NULL) and returns the reactivated entity.
|
||||
/// </summary>
|
||||
Task<ChargeableCharConfig> ReactivateAsync(
|
||||
long id,
|
||||
CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// Physically deletes the row with the given Id from dbo.ChargeableCharConfig (current state).
|
||||
/// NOTE: Since SYSTEM_VERSIONING is ON, SQL Server moves the row to the history table with
|
||||
/// SysEndTime set to the delete time. The row disappears from all current-state queries but
|
||||
/// remains queryable via FOR SYSTEM_TIME. Temporal audit trail is preserved.
|
||||
/// Future guard for "used in invoicing" is deferred to FAC-001 followup issue.
|
||||
/// Throws KeyNotFoundException if the row does not exist.
|
||||
/// </summary>
|
||||
Task DeleteAsync(
|
||||
long id,
|
||||
CancellationToken ct = default);
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
using SIGCM2.Application.Common;
|
||||
using SIGCM2.Domain.Entities;
|
||||
|
||||
namespace SIGCM2.Application.Abstractions.Persistence;
|
||||
|
||||
/// <summary>
|
||||
/// PRD-003 — Write + query access to dbo.ProductPrices.
|
||||
/// Implemented by ProductPriceRepository (Dapper) in Infrastructure.
|
||||
/// </summary>
|
||||
public interface IProductPriceRepository
|
||||
{
|
||||
/// <summary>
|
||||
/// Invokes dbo.usp_AddProductPrice inside the ambient TransactionScope.
|
||||
/// Returns (newId, closedId?). Throws:
|
||||
/// - ProductPriceForwardOnlyException on SQL THROW 50409 or unique index violation (2601/2627).
|
||||
/// - ProductNotFoundException on SQL THROW 50404.
|
||||
/// </summary>
|
||||
Task<(long NewId, long? ClosedId)> AddAsync(
|
||||
int productId,
|
||||
decimal price,
|
||||
DateOnly priceValidFrom,
|
||||
CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// Returns a paginated page of price rows for the product, ordered descending by PriceValidFrom.
|
||||
/// Caller is responsible for clamping page (≥ 1) and pageSize (1–100) before calling.
|
||||
/// Returns PagedResult with empty Items when the product has no price history or page is beyond total.
|
||||
/// </summary>
|
||||
Task<PagedResult<ProductPrice>> GetByProductIdAsync(
|
||||
int productId,
|
||||
int page,
|
||||
int pageSize,
|
||||
CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// Returns the ProductPrice row whose window [PriceValidFrom, PriceValidTo] covers the given
|
||||
/// civil date, or null if no row matches (no history, or date is before any recorded price).
|
||||
/// Used by ProductPricingService.GetPriceAtAsync.
|
||||
/// </summary>
|
||||
Task<ProductPrice?> GetActiveAsync(
|
||||
int productId,
|
||||
DateOnly date,
|
||||
CancellationToken ct = default);
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
namespace SIGCM2.Application.Abstractions.Persistence;
|
||||
|
||||
/// <summary>
|
||||
/// PRD-002 handoff contract — query-only access to Product data needed by ProductType handlers.
|
||||
/// PRD-001 binds to NullProductQueryRepository (always returns false).
|
||||
/// PRD-002 binds to Dapper impl against dbo.Product (when that table exists).
|
||||
/// </summary>
|
||||
public interface IProductQueryRepository
|
||||
{
|
||||
/// <summary>
|
||||
/// Returns true if at least one active Product with the given ProductTypeId exists.
|
||||
/// Used by DeactivateProductTypeCommandHandler to guard against orphaning active products.
|
||||
/// </summary>
|
||||
Task<bool> ExistsActiveByProductTypeAsync(int productTypeId, CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// Returns the count of active Products where RubroId = rubroId.
|
||||
/// Used by DeactivateRubroCommandHandler to guard against orphaning active products. (issue #41)
|
||||
/// </summary>
|
||||
Task<int> CountActiveByRubroAsync(int rubroId, CancellationToken ct = default);
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
using SIGCM2.Application.Common;
|
||||
using SIGCM2.Domain.Entities;
|
||||
|
||||
namespace SIGCM2.Application.Abstractions.Persistence;
|
||||
|
||||
/// <summary>
|
||||
/// Write-side repository for Product.
|
||||
/// All reads needed by write handlers are included here.
|
||||
/// </summary>
|
||||
public interface IProductRepository
|
||||
{
|
||||
/// <summary>Inserts a new Product and returns the DB-assigned Id.</summary>
|
||||
Task<int> AddAsync(Product product, CancellationToken ct = default);
|
||||
|
||||
/// <summary>Returns the Product with the given Id, or null if not found.</summary>
|
||||
Task<Product?> GetByIdAsync(int id, CancellationToken ct = default);
|
||||
|
||||
/// <summary>Returns a paged result of Products matching the query.</summary>
|
||||
Task<PagedResult<Product>> GetPagedAsync(ProductsQuery query, CancellationToken ct = default);
|
||||
|
||||
/// <summary>Persists all changes to an existing Product row.</summary>
|
||||
Task UpdateAsync(Product product, CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// Returns true if an active Product with the same Nombre exists for the given MedioId+ProductTypeId combination.
|
||||
/// Pass excludeId to skip the self-comparison during rename (update scenario).
|
||||
/// </summary>
|
||||
Task<bool> ExistsByNombreAsync(string nombre, int medioId, int productTypeId, int? excludeId = null, CancellationToken ct = default);
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
using SIGCM2.Application.Common;
|
||||
using SIGCM2.Domain.Entities;
|
||||
|
||||
namespace SIGCM2.Application.Abstractions.Persistence;
|
||||
|
||||
/// <summary>
|
||||
/// Write-side repository for ProductType.
|
||||
/// All reads needed by write handlers are included here.
|
||||
/// Query-side (for listing, filtering) uses GetPagedAsync with ProductTypesQuery.
|
||||
/// </summary>
|
||||
public interface IProductTypeRepository
|
||||
{
|
||||
/// <summary>Inserts a new ProductType and returns the DB-assigned Id.</summary>
|
||||
Task<int> AddAsync(ProductType productType, CancellationToken ct = default);
|
||||
|
||||
/// <summary>Returns the ProductType with the given Id, or null if not found.</summary>
|
||||
Task<ProductType?> GetByIdAsync(int id, CancellationToken ct = default);
|
||||
|
||||
/// <summary>Returns a paged result of ProductTypes matching the query.</summary>
|
||||
Task<PagedResult<ProductType>> GetPagedAsync(ProductTypesQuery query, CancellationToken ct = default);
|
||||
|
||||
/// <summary>Persists all changes to an existing ProductType row.</summary>
|
||||
Task UpdateAsync(ProductType productType, CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// Returns true if an active ProductType with the given nombre exists.
|
||||
/// Pass excludeId to skip the self-comparison during rename (update scenario).
|
||||
/// Case-insensitive — delegates to DB collation (SQL_Latin1_General_CP1_CI_AI).
|
||||
/// </summary>
|
||||
Task<bool> ExistsByNombreAsync(string nombre, int? excludeId = null, CancellationToken ct = default);
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
using SIGCM2.Domain.Entities;
|
||||
|
||||
namespace SIGCM2.Application.Abstractions.Persistence;
|
||||
|
||||
public interface IRubroRepository
|
||||
{
|
||||
Task<int> AddAsync(Rubro rubro, CancellationToken ct = default);
|
||||
Task<Rubro?> GetByIdAsync(int id, CancellationToken ct = default);
|
||||
Task<IReadOnlyList<Rubro>> GetAllAsync(bool incluirInactivos, CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// Returns all descendants of rootId via recursive CTE (used only by MoveRubro for cycle detection).
|
||||
/// </summary>
|
||||
Task<IReadOnlyList<Rubro>> GetDescendantsAsync(int rootId, CancellationToken ct = default);
|
||||
|
||||
Task UpdateAsync(Rubro rubro, CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// Returns the count of active children for the given parentId.
|
||||
/// Used by soft-delete to guard against deleting non-leaf rubros.
|
||||
/// </summary>
|
||||
Task<int> CountActiveChildrenAsync(int id, CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// Returns MAX(Orden)+1 among siblings of the given parentId (0 if no siblings).
|
||||
/// Used for append-on-create ordering.
|
||||
/// </summary>
|
||||
Task<int> GetMaxOrdenAsync(int? parentId, CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// Returns true if an active Rubro with the same Nombre (CI) exists under the same parentId,
|
||||
/// optionally excluding the Rubro with the given id (for rename operations).
|
||||
/// </summary>
|
||||
Task<bool> ExistsByNombreUnderParentAsync(int? parentId, string nombre, int? excludeId, CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// Returns the depth of the given parentId (0 if parentId is null = root level).
|
||||
/// Uses a recursive CTE going upward through ancestors.
|
||||
/// </summary>
|
||||
Task<int> GetDepthAsync(int? parentId, CancellationToken ct = default);
|
||||
}
|
||||
@@ -22,6 +22,7 @@ public sealed class LoginCommandHandler : ICommandHandler<LoginCommand, LoginRes
|
||||
private readonly IRolPermisoRepository _rolPermisoRepository;
|
||||
private readonly ISecurityEventLogger _security;
|
||||
private readonly ILogger<LoginCommandHandler> _logger;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
|
||||
public LoginCommandHandler(
|
||||
IUsuarioRepository repository,
|
||||
@@ -33,7 +34,8 @@ public sealed class LoginCommandHandler : ICommandHandler<LoginCommand, LoginRes
|
||||
AuthOptions authOptions,
|
||||
IRolPermisoRepository rolPermisoRepository,
|
||||
ISecurityEventLogger security,
|
||||
ILogger<LoginCommandHandler> logger)
|
||||
ILogger<LoginCommandHandler> logger,
|
||||
TimeProvider timeProvider)
|
||||
{
|
||||
_repository = repository;
|
||||
_hasher = hasher;
|
||||
@@ -45,6 +47,7 @@ public sealed class LoginCommandHandler : ICommandHandler<LoginCommand, LoginRes
|
||||
_rolPermisoRepository = rolPermisoRepository;
|
||||
_security = security;
|
||||
_logger = logger;
|
||||
_timeProvider = timeProvider;
|
||||
}
|
||||
|
||||
public async Task<LoginResponseDto> Handle(LoginCommand command)
|
||||
@@ -81,7 +84,7 @@ public sealed class LoginCommandHandler : ICommandHandler<LoginCommand, LoginRes
|
||||
// Generate and persist refresh token — only the hash hits the DB
|
||||
var rawRefresh = _refreshGenerator.Generate();
|
||||
var hash = TokenHasher.Sha256Base64Url(rawRefresh);
|
||||
var now = DateTime.UtcNow;
|
||||
var now = _timeProvider.GetUtcNow().UtcDateTime;
|
||||
var ttl = TimeSpan.FromDays(_authOptions.RefreshTokenDays);
|
||||
var entity = RefreshToken.IssueForNewFamily(
|
||||
usuario.Id, hash, now, ttl,
|
||||
|
||||
@@ -8,18 +8,24 @@ public sealed class LogoutCommandHandler : ICommandHandler<LogoutCommand, Logout
|
||||
{
|
||||
private readonly IRefreshTokenRepository _refreshRepo;
|
||||
private readonly ISecurityEventLogger _security;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
|
||||
public LogoutCommandHandler(IRefreshTokenRepository refreshRepo, ISecurityEventLogger security)
|
||||
public LogoutCommandHandler(
|
||||
IRefreshTokenRepository refreshRepo,
|
||||
ISecurityEventLogger security,
|
||||
TimeProvider timeProvider)
|
||||
{
|
||||
_refreshRepo = refreshRepo;
|
||||
_security = security;
|
||||
_timeProvider = timeProvider;
|
||||
}
|
||||
|
||||
public async Task<LogoutResponseDto> Handle(LogoutCommand command)
|
||||
{
|
||||
// Revoke all active tokens for the user across all families.
|
||||
// Idempotent: 0 rows affected is not an error.
|
||||
await _refreshRepo.RevokeAllActiveForUserAsync(command.UsuarioId, DateTime.UtcNow);
|
||||
var now = _timeProvider.GetUtcNow().UtcDateTime;
|
||||
await _refreshRepo.RevokeAllActiveForUserAsync(command.UsuarioId, now);
|
||||
await _security.LogAsync("logout", "success", actorUserId: command.UsuarioId);
|
||||
return new LogoutResponseDto(true, "Sesión cerrada correctamente");
|
||||
}
|
||||
|
||||
@@ -17,6 +17,7 @@ public sealed class RefreshCommandHandler : ICommandHandler<RefreshCommand, Refr
|
||||
private readonly IClientContext _clientCtx;
|
||||
private readonly AuthOptions _authOptions;
|
||||
private readonly ISecurityEventLogger _security;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
|
||||
public RefreshCommandHandler(
|
||||
IRefreshTokenRepository refreshRepo,
|
||||
@@ -25,7 +26,8 @@ public sealed class RefreshCommandHandler : ICommandHandler<RefreshCommand, Refr
|
||||
IRefreshTokenGenerator refreshGenerator,
|
||||
IClientContext clientCtx,
|
||||
AuthOptions authOptions,
|
||||
ISecurityEventLogger security)
|
||||
ISecurityEventLogger security,
|
||||
TimeProvider timeProvider)
|
||||
{
|
||||
_refreshRepo = refreshRepo;
|
||||
_usuarioRepo = usuarioRepo;
|
||||
@@ -34,6 +36,7 @@ public sealed class RefreshCommandHandler : ICommandHandler<RefreshCommand, Refr
|
||||
_clientCtx = clientCtx;
|
||||
_authOptions = authOptions;
|
||||
_security = security;
|
||||
_timeProvider = timeProvider;
|
||||
}
|
||||
|
||||
public async Task<RefreshResponseDto> Handle(RefreshCommand command)
|
||||
@@ -60,7 +63,7 @@ public sealed class RefreshCommandHandler : ICommandHandler<RefreshCommand, Refr
|
||||
if (stored is null)
|
||||
throw new InvalidRefreshTokenException();
|
||||
|
||||
var now = DateTime.UtcNow;
|
||||
var now = _timeProvider.GetUtcNow().UtcDateTime;
|
||||
|
||||
// 4. Reuse detection: already revoked → chain revocation and throw
|
||||
if (stored.IsRevoked)
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
using SIGCM2.Application.Abstractions.Persistence;
|
||||
|
||||
namespace SIGCM2.Application.Avisos;
|
||||
|
||||
/// <summary>
|
||||
/// STUB — PRD-002 reemplaza con AvisoQueryRepository contra dbo.Aviso.
|
||||
/// Returns 0 / empty dictionary so every handler guard passes and every tree node shows TieneAvisos=false.
|
||||
/// This is intentional for CAT-002: the mechanism is installed; the data feed arrives in PRD-002.
|
||||
/// </summary>
|
||||
public sealed class NullAvisoQueryRepository : IAvisoQueryRepository
|
||||
{
|
||||
private static readonly IReadOnlyDictionary<int, int> Empty =
|
||||
new Dictionary<int, int>(capacity: 0);
|
||||
|
||||
public Task<int> CountAvisosEnRubroAsync(int rubroId, CancellationToken ct = default)
|
||||
=> Task.FromResult(0);
|
||||
|
||||
public Task<IReadOnlyDictionary<int, int>> CountAvisosBatchAsync(
|
||||
IReadOnlyCollection<int> rubroIds,
|
||||
CancellationToken ct = default)
|
||||
=> Task.FromResult(Empty);
|
||||
}
|
||||
@@ -9,7 +9,7 @@ namespace SIGCM2.Application.Common;
|
||||
/// </summary>
|
||||
public sealed record PermisosOverride(
|
||||
[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>
|
||||
public static readonly PermisosOverride Empty =
|
||||
@@ -46,7 +46,7 @@ public sealed record PermisosOverride(
|
||||
|
||||
return new PermisosOverride(
|
||||
parsed.Grant ?? Array.Empty<string>(),
|
||||
parsed.Deny ?? Array.Empty<string>());
|
||||
parsed.Deny ?? Array.Empty<string>());
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
|
||||
10
src/api/SIGCM2.Application/Common/ProductTypesQuery.cs
Normal file
10
src/api/SIGCM2.Application/Common/ProductTypesQuery.cs
Normal file
@@ -0,0 +1,10 @@
|
||||
namespace SIGCM2.Application.Common;
|
||||
|
||||
/// <summary>
|
||||
/// Query parameters for listing ProductTypes (used by IProductTypeRepository.GetPagedAsync).
|
||||
/// </summary>
|
||||
public sealed record ProductTypesQuery(
|
||||
int Page = 1,
|
||||
int PageSize = 20,
|
||||
bool? Activo = true,
|
||||
string? Search = null);
|
||||
13
src/api/SIGCM2.Application/Common/ProductsQuery.cs
Normal file
13
src/api/SIGCM2.Application/Common/ProductsQuery.cs
Normal file
@@ -0,0 +1,13 @@
|
||||
namespace SIGCM2.Application.Common;
|
||||
|
||||
/// <summary>
|
||||
/// Query parameters for listing Products (used by IProductRepository.GetPagedAsync).
|
||||
/// </summary>
|
||||
public sealed record ProductsQuery(
|
||||
int Page = 1,
|
||||
int PageSize = 20,
|
||||
bool? Activo = true,
|
||||
string? Search = null,
|
||||
int? MedioId = null,
|
||||
int? ProductTypeId = null,
|
||||
int? RubroId = null);
|
||||
@@ -0,0 +1,45 @@
|
||||
namespace SIGCM2.Application.Common;
|
||||
|
||||
/// <summary>
|
||||
/// Extension methods for <see cref="TimeProvider"/> that expose Argentina-localized
|
||||
/// date helpers. Handles the UTC-3 offset cross-platform (IANA / Windows TZ IDs).
|
||||
///
|
||||
/// UDT-011: Cat2 fields (VigenciaDesde, etc.) must use civil Argentine date, never
|
||||
/// raw UTC, to avoid date-creep during the 22:00–23:59 window.
|
||||
/// </summary>
|
||||
public static class TimeProviderArgentinaExtensions
|
||||
{
|
||||
// IANA TZ id — Linux / macOS / .NET 8+ on Windows with ICU
|
||||
public const string ArgentinaTimeZoneId = "America/Argentina/Buenos_Aires";
|
||||
|
||||
// Windows built-in TZ id — fallback for environments without ICU
|
||||
public const string ArgentinaTimeZoneIdWindows = "Argentina Standard Time";
|
||||
|
||||
private static readonly TimeZoneInfo ArgentinaTz = LoadArgentinaTz();
|
||||
|
||||
/// <summary>
|
||||
/// Returns today's civil date in Argentina timezone, computed from the
|
||||
/// <see cref="TimeProvider"/> UTC clock.
|
||||
/// Safe in tests via <c>FakeTimeProvider</c>; safe in production via
|
||||
/// <c>TimeProvider.System</c>.
|
||||
/// </summary>
|
||||
public static DateOnly GetArgentinaToday(this TimeProvider timeProvider)
|
||||
{
|
||||
var utcNow = timeProvider.GetUtcNow();
|
||||
var argentinaNow = TimeZoneInfo.ConvertTime(utcNow, ArgentinaTz);
|
||||
return DateOnly.FromDateTime(argentinaNow.DateTime);
|
||||
}
|
||||
|
||||
private static TimeZoneInfo LoadArgentinaTz()
|
||||
{
|
||||
try
|
||||
{
|
||||
return TimeZoneInfo.FindSystemTimeZoneById(ArgentinaTimeZoneId);
|
||||
}
|
||||
catch (TimeZoneNotFoundException)
|
||||
{
|
||||
// Windows without ICU: fall back to built-in Windows TZ name
|
||||
return TimeZoneInfo.FindSystemTimeZoneById(ArgentinaTimeZoneIdWindows);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -60,6 +60,37 @@ using SIGCM2.Application.Usuarios.Reactivate;
|
||||
using SIGCM2.Application.Usuarios.ResetPassword;
|
||||
using SIGCM2.Application.Usuarios.Permisos;
|
||||
using SIGCM2.Application.Usuarios.Update;
|
||||
using SIGCM2.Application.Rubros.Create;
|
||||
using SIGCM2.Application.Rubros.Update;
|
||||
using SIGCM2.Application.Rubros.Deactivate;
|
||||
using SIGCM2.Application.Rubros.Move;
|
||||
using SIGCM2.Application.Rubros.GetTree;
|
||||
using SIGCM2.Application.Rubros.GetById;
|
||||
using SIGCM2.Application.Rubros.Dtos;
|
||||
using SIGCM2.Application.Abstractions.Persistence;
|
||||
using SIGCM2.Application.Avisos;
|
||||
using SIGCM2.Application.Products.Create;
|
||||
using SIGCM2.Application.Products.Update;
|
||||
using SIGCM2.Application.Products.Deactivate;
|
||||
using SIGCM2.Application.Products.GetById;
|
||||
using SIGCM2.Application.Products.List;
|
||||
using SIGCM2.Application.Products.Prices;
|
||||
using SIGCM2.Application.Products.Prices.AddPrice;
|
||||
using SIGCM2.Application.Products.Prices.GetHistory;
|
||||
using SIGCM2.Application.Products.Pricing;
|
||||
using SIGCM2.Application.ProductTypes.Create;
|
||||
using SIGCM2.Application.ProductTypes.Update;
|
||||
using SIGCM2.Application.ProductTypes.Deactivate;
|
||||
using SIGCM2.Application.ProductTypes.List;
|
||||
using SIGCM2.Application.ProductTypes.GetById;
|
||||
using SIGCM2.Application.Pricing.ChargeableChars;
|
||||
using SIGCM2.Application.Pricing.ChargeableChars.Create;
|
||||
using SIGCM2.Application.Pricing.ChargeableChars.SchedulePrice;
|
||||
using SIGCM2.Application.Pricing.ChargeableChars.Deactivate;
|
||||
using SIGCM2.Application.Pricing.ChargeableChars.Reactivate;
|
||||
using SIGCM2.Application.Pricing.ChargeableChars.Delete;
|
||||
using SIGCM2.Application.Pricing.ChargeableChars.List;
|
||||
using SIGCM2.Application.Pricing.ChargeableChars.GetById;
|
||||
|
||||
namespace SIGCM2.Application;
|
||||
|
||||
@@ -67,6 +98,9 @@ public static class DependencyInjection
|
||||
{
|
||||
public static IServiceCollection AddApplication(this IServiceCollection services)
|
||||
{
|
||||
// UDT-011: TimeProvider singleton — available to all handlers for Cat2 date computation
|
||||
services.AddSingleton(TimeProvider.System);
|
||||
|
||||
// Command handlers
|
||||
services.AddScoped<ICommandHandler<LoginCommand, LoginResponseDto>, LoginCommandHandler>();
|
||||
services.AddScoped<ICommandHandler<RefreshCommand, RefreshResponseDto>, RefreshCommandHandler>();
|
||||
@@ -142,6 +176,48 @@ public static class DependencyInjection
|
||||
services.AddScoped<ICommandHandler<ListIngresosBrutosQuery, PagedResult<IngresosBrutosDto>>, ListIngresosBrutosQueryHandler>();
|
||||
services.AddScoped<ICommandHandler<GetHistorialIngresosBrutosQuery, IReadOnlyList<HistorialCadenaIibbDto>>, GetHistorialIngresosBrutosQueryHandler>();
|
||||
|
||||
// Rubros (CAT-001 + CAT-002)
|
||||
// CAT-002: Regla de Oro Rama vs Hoja — stub binding until PRD-002 provides real impl
|
||||
services.AddScoped<IAvisoQueryRepository, NullAvisoQueryRepository>();
|
||||
|
||||
services.AddScoped<ICommandHandler<CreateRubroCommand, RubroCreatedDto>, CreateRubroCommandHandler>();
|
||||
services.AddScoped<ICommandHandler<UpdateRubroCommand, RubroUpdatedDto>, UpdateRubroCommandHandler>();
|
||||
services.AddScoped<ICommandHandler<DeactivateRubroCommand, RubroStatusDto>, DeactivateRubroCommandHandler>();
|
||||
services.AddScoped<ICommandHandler<MoveRubroCommand, RubroMovedDto>, MoveRubroCommandHandler>();
|
||||
services.AddScoped<ICommandHandler<GetRubroTreeQuery, IReadOnlyList<RubroTreeNodeDto>>, GetRubroTreeQueryHandler>();
|
||||
services.AddScoped<ICommandHandler<GetRubroByIdQuery, RubroDetailDto>, GetRubroByIdQueryHandler>();
|
||||
|
||||
// Products (PRD-002)
|
||||
services.AddScoped<ICommandHandler<CreateProductCommand, ProductCreatedDto>, CreateProductCommandHandler>();
|
||||
services.AddScoped<ICommandHandler<UpdateProductCommand, ProductUpdatedDto>, UpdateProductCommandHandler>();
|
||||
services.AddScoped<ICommandHandler<DeactivateProductCommand, ProductStatusDto>, DeactivateProductCommandHandler>();
|
||||
services.AddScoped<ICommandHandler<GetProductByIdQuery, ProductDetailDto>, GetProductByIdQueryHandler>();
|
||||
services.AddScoped<ICommandHandler<ListProductsQuery, PagedResult<ProductListItemDto>>, ListProductsQueryHandler>();
|
||||
|
||||
// ProductPrices (PRD-003)
|
||||
services.AddScoped<ICommandHandler<AddProductPriceCommand, AddProductPriceResponse>, AddProductPriceCommandHandler>();
|
||||
services.AddScoped<ICommandHandler<GetProductPricesQuery, PagedResult<ProductPriceDto>>, GetProductPricesQueryHandler>();
|
||||
services.AddScoped<IProductPricingService, ProductPricingService>();
|
||||
|
||||
// ProductTypes (PRD-001)
|
||||
// IProductQueryRepository is now bound in Infrastructure DI (PRD-002) against dbo.Product.
|
||||
|
||||
services.AddScoped<ICommandHandler<CreateProductTypeCommand, ProductTypeCreatedDto>, CreateProductTypeCommandHandler>();
|
||||
services.AddScoped<ICommandHandler<UpdateProductTypeCommand, ProductTypeUpdatedDto>, UpdateProductTypeCommandHandler>();
|
||||
services.AddScoped<ICommandHandler<DeactivateProductTypeCommand, ProductTypeStatusDto>, DeactivateProductTypeCommandHandler>();
|
||||
services.AddScoped<ICommandHandler<ListProductTypesQuery, PagedResult<ProductTypeListItemDto>>, ListProductTypesQueryHandler>();
|
||||
services.AddScoped<ICommandHandler<GetProductTypeByIdQuery, ProductTypeDetailDto>, GetProductTypeByIdQueryHandler>();
|
||||
|
||||
// ChargeableCharConfig (PRC-001)
|
||||
services.AddScoped<ICommandHandler<CreateChargeableCharConfigCommand, CreateChargeableCharConfigResponse>, CreateChargeableCharConfigCommandHandler>();
|
||||
services.AddScoped<ICommandHandler<SchedulePriceChangeCommand, SchedulePriceChangeResponse>, SchedulePriceChangeCommandHandler>();
|
||||
services.AddScoped<ICommandHandler<DeactivateChargeableCharConfigCommand, DeactivateChargeableCharConfigResponse>, DeactivateChargeableCharConfigCommandHandler>();
|
||||
services.AddScoped<ICommandHandler<ReactivateChargeableCharConfigCommand, ReactivateChargeableCharConfigResponse>, ReactivateChargeableCharConfigCommandHandler>();
|
||||
services.AddScoped<ICommandHandler<DeleteChargeableCharConfigCommand, DeleteChargeableCharConfigResponse>, DeleteChargeableCharConfigCommandHandler>();
|
||||
services.AddScoped<ICommandHandler<ListChargeableCharConfigQuery, PagedResult<ChargeableCharConfigDto>>, ListChargeableCharConfigQueryHandler>();
|
||||
services.AddScoped<ICommandHandler<GetChargeableCharConfigByIdQuery, ChargeableCharConfigDto?>, GetChargeableCharConfigByIdQueryHandler>();
|
||||
services.AddScoped<IChargeableCharConfigService, ChargeableCharConfigService>();
|
||||
|
||||
// FluentValidation validators (scans entire Application assembly)
|
||||
services.AddValidatorsFromAssemblyContaining<LoginCommandValidator>();
|
||||
|
||||
|
||||
@@ -11,11 +11,13 @@ public sealed class CreateIngresosBrutosCommandHandler
|
||||
{
|
||||
private readonly IIngresosBrutosRepository _repo;
|
||||
private readonly IAuditLogger _audit;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
|
||||
public CreateIngresosBrutosCommandHandler(IIngresosBrutosRepository repo, IAuditLogger audit)
|
||||
public CreateIngresosBrutosCommandHandler(IIngresosBrutosRepository repo, IAuditLogger audit, TimeProvider timeProvider)
|
||||
{
|
||||
_repo = repo;
|
||||
_audit = audit;
|
||||
_timeProvider = timeProvider;
|
||||
}
|
||||
|
||||
public async Task<IngresosBrutosDto> Handle(CreateIngresosBrutosCommand command)
|
||||
@@ -51,7 +53,7 @@ public sealed class CreateIngresosBrutosCommandHandler
|
||||
vigenciaDesde: entity.VigenciaDesde,
|
||||
vigenciaHasta: entity.VigenciaHasta,
|
||||
predecesorId: entity.PredecesorId,
|
||||
fechaCreacion: DateTime.UtcNow,
|
||||
fechaCreacion: _timeProvider.GetUtcNow().UtcDateTime,
|
||||
fechaModificacion: null));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,11 +12,13 @@ public sealed class DeactivateIngresosBrutosCommandHandler
|
||||
{
|
||||
private readonly IIngresosBrutosRepository _repo;
|
||||
private readonly IAuditLogger _audit;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
|
||||
public DeactivateIngresosBrutosCommandHandler(IIngresosBrutosRepository repo, IAuditLogger audit)
|
||||
public DeactivateIngresosBrutosCommandHandler(IIngresosBrutosRepository repo, IAuditLogger audit, TimeProvider timeProvider)
|
||||
{
|
||||
_repo = repo;
|
||||
_audit = audit;
|
||||
_timeProvider = timeProvider;
|
||||
}
|
||||
|
||||
public async Task<IngresosBrutosDto> Handle(DeactivateIngresosBrutosCommand command)
|
||||
@@ -41,6 +43,7 @@ public sealed class DeactivateIngresosBrutosCommandHandler
|
||||
|
||||
tx.Complete();
|
||||
|
||||
return IngresosBrutosMapper.ToDto(entity.Deactivate());
|
||||
var now = _timeProvider.GetUtcNow().UtcDateTime;
|
||||
return IngresosBrutosMapper.ToDto(entity.Deactivate(now));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,11 +12,13 @@ public sealed class NuevaVersionIngresosBrutosCommandHandler
|
||||
{
|
||||
private readonly IIngresosBrutosRepository _repo;
|
||||
private readonly IAuditLogger _audit;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
|
||||
public NuevaVersionIngresosBrutosCommandHandler(IIngresosBrutosRepository repo, IAuditLogger audit)
|
||||
public NuevaVersionIngresosBrutosCommandHandler(IIngresosBrutosRepository repo, IAuditLogger audit, TimeProvider timeProvider)
|
||||
{
|
||||
_repo = repo;
|
||||
_audit = audit;
|
||||
_timeProvider = timeProvider;
|
||||
}
|
||||
|
||||
public async Task<NuevaVersionIibbResultDto> Handle(NuevaVersionIngresosBrutosCommand command)
|
||||
@@ -29,10 +31,13 @@ public sealed class NuevaVersionIngresosBrutosCommandHandler
|
||||
if (!predecesora.Activo || predecesora.VigenciaHasta is not null)
|
||||
throw new PredecesorYaCerradoException(command.PredecesoraId);
|
||||
|
||||
var now = _timeProvider.GetUtcNow().UtcDateTime;
|
||||
|
||||
// Steps 3–4: domain validation + tuple creation (throws ArgumentException if vigencia invalid)
|
||||
var (predecesoraCerrada, nuevaVersion) = predecesora.NuevaVersion(
|
||||
command.NuevaAlicuota,
|
||||
command.VigenciaDesde);
|
||||
command.VigenciaDesde,
|
||||
now);
|
||||
|
||||
using var tx = new TransactionScope(
|
||||
TransactionScopeOption.Required,
|
||||
|
||||
@@ -12,11 +12,13 @@ public sealed class ReactivateIngresosBrutosCommandHandler
|
||||
{
|
||||
private readonly IIngresosBrutosRepository _repo;
|
||||
private readonly IAuditLogger _audit;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
|
||||
public ReactivateIngresosBrutosCommandHandler(IIngresosBrutosRepository repo, IAuditLogger audit)
|
||||
public ReactivateIngresosBrutosCommandHandler(IIngresosBrutosRepository repo, IAuditLogger audit, TimeProvider timeProvider)
|
||||
{
|
||||
_repo = repo;
|
||||
_audit = audit;
|
||||
_timeProvider = timeProvider;
|
||||
}
|
||||
|
||||
public async Task<IngresosBrutosDto> Handle(ReactivateIngresosBrutosCommand command)
|
||||
@@ -41,6 +43,7 @@ public sealed class ReactivateIngresosBrutosCommandHandler
|
||||
|
||||
tx.Complete();
|
||||
|
||||
return IngresosBrutosMapper.ToDto(entity.Reactivate());
|
||||
var now = _timeProvider.GetUtcNow().UtcDateTime;
|
||||
return IngresosBrutosMapper.ToDto(entity.Reactivate(now));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,11 +12,13 @@ public sealed class UpdateIngresosBrutosCommandHandler
|
||||
{
|
||||
private readonly IIngresosBrutosRepository _repo;
|
||||
private readonly IAuditLogger _audit;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
|
||||
public UpdateIngresosBrutosCommandHandler(IIngresosBrutosRepository repo, IAuditLogger audit)
|
||||
public UpdateIngresosBrutosCommandHandler(IIngresosBrutosRepository repo, IAuditLogger audit, TimeProvider timeProvider)
|
||||
{
|
||||
_repo = repo;
|
||||
_audit = audit;
|
||||
_timeProvider = timeProvider;
|
||||
}
|
||||
|
||||
public async Task<IngresosBrutosDto> Handle(UpdateIngresosBrutosCommand command)
|
||||
@@ -24,8 +26,9 @@ public sealed class UpdateIngresosBrutosCommandHandler
|
||||
var entity = await _repo.GetByIdAsync(command.Id)
|
||||
?? throw new IngresosBrutosNotFoundException(command.Id);
|
||||
|
||||
var updated = entity.WithDescripcion(command.Descripcion);
|
||||
updated = command.Activo ? updated.Reactivate() : updated.Deactivate();
|
||||
var now = _timeProvider.GetUtcNow().UtcDateTime;
|
||||
var updated = entity.WithDescripcion(command.Descripcion, now);
|
||||
updated = command.Activo ? updated.Reactivate(now) : updated.Deactivate(now);
|
||||
|
||||
using var tx = new TransactionScope(
|
||||
TransactionScopeOption.Required,
|
||||
|
||||
@@ -11,11 +11,13 @@ public sealed class DeactivateMedioCommandHandler : ICommandHandler<DeactivateMe
|
||||
{
|
||||
private readonly IMedioRepository _repo;
|
||||
private readonly IAuditLogger _audit;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
|
||||
public DeactivateMedioCommandHandler(IMedioRepository repo, IAuditLogger audit)
|
||||
public DeactivateMedioCommandHandler(IMedioRepository repo, IAuditLogger audit, TimeProvider timeProvider)
|
||||
{
|
||||
_repo = repo;
|
||||
_audit = audit;
|
||||
_timeProvider = timeProvider;
|
||||
}
|
||||
|
||||
public async Task<MedioStatusDto> Handle(DeactivateMedioCommand command)
|
||||
@@ -27,7 +29,8 @@ public sealed class DeactivateMedioCommandHandler : ICommandHandler<DeactivateMe
|
||||
if (!target.Activo)
|
||||
return new MedioStatusDto(target.Id, target.Codigo, target.Activo);
|
||||
|
||||
var updated = target.WithActivo(false);
|
||||
var now = _timeProvider.GetUtcNow().UtcDateTime;
|
||||
var updated = target.WithActivo(false, now);
|
||||
|
||||
using var tx = new TransactionScope(
|
||||
TransactionScopeOption.Required,
|
||||
|
||||
@@ -12,11 +12,13 @@ public sealed class ReactivateMedioCommandHandler : ICommandHandler<ReactivateMe
|
||||
{
|
||||
private readonly IMedioRepository _repo;
|
||||
private readonly IAuditLogger _audit;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
|
||||
public ReactivateMedioCommandHandler(IMedioRepository repo, IAuditLogger audit)
|
||||
public ReactivateMedioCommandHandler(IMedioRepository repo, IAuditLogger audit, TimeProvider timeProvider)
|
||||
{
|
||||
_repo = repo;
|
||||
_audit = audit;
|
||||
_timeProvider = timeProvider;
|
||||
}
|
||||
|
||||
public async Task<MedioStatusDto> Handle(ReactivateMedioCommand command)
|
||||
@@ -28,7 +30,8 @@ public sealed class ReactivateMedioCommandHandler : ICommandHandler<ReactivateMe
|
||||
if (target.Activo)
|
||||
return new MedioStatusDto(target.Id, target.Codigo, target.Activo);
|
||||
|
||||
var updated = target.WithActivo(true);
|
||||
var now = _timeProvider.GetUtcNow().UtcDateTime;
|
||||
var updated = target.WithActivo(true, now);
|
||||
|
||||
using var tx = new TransactionScope(
|
||||
TransactionScopeOption.Required,
|
||||
|
||||
@@ -11,11 +11,13 @@ public sealed class UpdateMedioCommandHandler : ICommandHandler<UpdateMedioComma
|
||||
{
|
||||
private readonly IMedioRepository _repo;
|
||||
private readonly IAuditLogger _audit;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
|
||||
public UpdateMedioCommandHandler(IMedioRepository repo, IAuditLogger audit)
|
||||
public UpdateMedioCommandHandler(IMedioRepository repo, IAuditLogger audit, TimeProvider timeProvider)
|
||||
{
|
||||
_repo = repo;
|
||||
_audit = audit;
|
||||
_timeProvider = timeProvider;
|
||||
}
|
||||
|
||||
public async Task<MedioUpdatedDto> Handle(UpdateMedioCommand command)
|
||||
@@ -23,7 +25,8 @@ public sealed class UpdateMedioCommandHandler : ICommandHandler<UpdateMedioComma
|
||||
var target = await _repo.GetByIdAsync(command.Id)
|
||||
?? throw new MedioNotFoundException(command.Id);
|
||||
|
||||
var updated = target.WithUpdatedProfile(command.Nombre, command.Tipo, command.PlataformaEmpresaId);
|
||||
var now = _timeProvider.GetUtcNow().UtcDateTime;
|
||||
var updated = target.WithUpdatedProfile(command.Nombre, command.Tipo, command.PlataformaEmpresaId, now);
|
||||
|
||||
using var tx = new TransactionScope(
|
||||
TransactionScopeOption.Required,
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
namespace SIGCM2.Application.Pricing.ChargeableChars;
|
||||
|
||||
/// <summary>
|
||||
/// PRC-001 — DTO for ChargeableCharConfig rows returned in list / get-by-id responses.
|
||||
/// </summary>
|
||||
public sealed record ChargeableCharConfigDto(
|
||||
long Id,
|
||||
long? ProductTypeId,
|
||||
string Symbol,
|
||||
string Category,
|
||||
decimal PricePerUnit,
|
||||
DateOnly ValidFrom,
|
||||
DateOnly? ValidTo,
|
||||
bool IsActive);
|
||||
@@ -0,0 +1,43 @@
|
||||
using SIGCM2.Application.Abstractions.Persistence;
|
||||
|
||||
namespace SIGCM2.Application.Pricing.ChargeableChars;
|
||||
|
||||
/// <summary>
|
||||
/// PRC-001 — Implements IChargeableCharConfigService.
|
||||
/// Delegates to IChargeableCharConfigRepository.GetActiveForProductTypeAsync, then applies
|
||||
/// the per-ProductType > global priority rule in memory.
|
||||
///
|
||||
/// Priority rule: if the same Symbol appears as both global (ProductTypeId IS NULL) and
|
||||
/// per-ProductType, the per-ProductType row wins. The SP returns both; we resolve in Application.
|
||||
/// </summary>
|
||||
public sealed class ChargeableCharConfigService : IChargeableCharConfigService
|
||||
{
|
||||
private readonly IChargeableCharConfigRepository _repo;
|
||||
|
||||
public ChargeableCharConfigService(IChargeableCharConfigRepository repo)
|
||||
{
|
||||
_repo = repo;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<IReadOnlyDictionary<string, ChargeableCharSnapshot>> GetActiveConfigForProductTypeAsync(
|
||||
long productTypeId,
|
||||
DateOnly asOf,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
var allRows = await _repo.GetActiveForProductTypeAsync(productTypeId, asOf, ct);
|
||||
|
||||
// Build a dictionary keyed by Symbol.
|
||||
// Per-ProductType rows (ProductTypeId != null) take priority over global rows (ProductTypeId == null).
|
||||
var result = new Dictionary<string, ChargeableCharSnapshot>(StringComparer.Ordinal);
|
||||
|
||||
// Two-pass: first add global rows, then overwrite with per-ProductType rows.
|
||||
foreach (var row in allRows.Where(r => r.ProductTypeId is null))
|
||||
result[row.Symbol] = new ChargeableCharSnapshot(row.Category, row.PricePerUnit);
|
||||
|
||||
foreach (var row in allRows.Where(r => r.ProductTypeId is not null))
|
||||
result[row.Symbol] = new ChargeableCharSnapshot(row.Category, row.PricePerUnit);
|
||||
|
||||
return result;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
namespace SIGCM2.Application.Pricing.ChargeableChars;
|
||||
|
||||
/// <summary>
|
||||
/// PRC-001 — Lightweight value snapshot for the active chargeable-char config
|
||||
/// at the time of word counting. Used by IChargeableCharConfigService.
|
||||
/// Keyed by Symbol in the returned dictionary.
|
||||
/// </summary>
|
||||
public sealed record ChargeableCharSnapshot(
|
||||
string Category,
|
||||
decimal PricePerUnit);
|
||||
@@ -0,0 +1,12 @@
|
||||
namespace SIGCM2.Application.Pricing.ChargeableChars.Create;
|
||||
|
||||
/// <summary>
|
||||
/// PRC-001 — Command to create a new ChargeableCharConfig.
|
||||
/// ProductTypeId = null → global config. ProductTypeId set → per-ProductType config.
|
||||
/// </summary>
|
||||
public sealed record CreateChargeableCharConfigCommand(
|
||||
long? ProductTypeId,
|
||||
string Symbol,
|
||||
string Category,
|
||||
decimal PricePerUnit,
|
||||
DateOnly ValidFrom);
|
||||
@@ -0,0 +1,69 @@
|
||||
using System.Transactions;
|
||||
using SIGCM2.Application.Abstractions;
|
||||
using SIGCM2.Application.Abstractions.Persistence;
|
||||
using SIGCM2.Application.Audit;
|
||||
|
||||
namespace SIGCM2.Application.Pricing.ChargeableChars.Create;
|
||||
|
||||
/// <summary>
|
||||
/// PRC-001 — Handler for CreateChargeableCharConfigCommand.
|
||||
/// Flow: opens TransactionScope → InsertWithCloseAsync (SP) → IAuditLogger.LogAsync (fail-closed) → tx.Complete().
|
||||
/// </summary>
|
||||
public sealed class CreateChargeableCharConfigCommandHandler
|
||||
: ICommandHandler<CreateChargeableCharConfigCommand, CreateChargeableCharConfigResponse>
|
||||
{
|
||||
private readonly IChargeableCharConfigRepository _repo;
|
||||
private readonly IAuditLogger _audit;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
|
||||
public CreateChargeableCharConfigCommandHandler(
|
||||
IChargeableCharConfigRepository repo,
|
||||
IAuditLogger audit,
|
||||
TimeProvider timeProvider)
|
||||
{
|
||||
_repo = repo;
|
||||
_audit = audit;
|
||||
_timeProvider = timeProvider;
|
||||
}
|
||||
|
||||
public async Task<CreateChargeableCharConfigResponse> Handle(CreateChargeableCharConfigCommand command)
|
||||
{
|
||||
long newId;
|
||||
using (var tx = new TransactionScope(
|
||||
TransactionScopeOption.Required,
|
||||
new TransactionOptions { IsolationLevel = System.Transactions.IsolationLevel.ReadCommitted },
|
||||
TransactionScopeAsyncFlowOption.Enabled))
|
||||
{
|
||||
newId = await _repo.InsertWithCloseAsync(
|
||||
command.ProductTypeId,
|
||||
command.Symbol,
|
||||
command.Category,
|
||||
command.PricePerUnit,
|
||||
command.ValidFrom);
|
||||
|
||||
await _audit.LogAsync(
|
||||
action: "tasacion.chargeable_char.create",
|
||||
targetType: "ChargeableCharConfig",
|
||||
targetId: newId.ToString(),
|
||||
metadata: new
|
||||
{
|
||||
after = new
|
||||
{
|
||||
command.ProductTypeId,
|
||||
command.Symbol,
|
||||
command.Category,
|
||||
command.PricePerUnit,
|
||||
validFrom = command.ValidFrom.ToString("yyyy-MM-dd"),
|
||||
}
|
||||
});
|
||||
|
||||
tx.Complete();
|
||||
}
|
||||
|
||||
return new CreateChargeableCharConfigResponse(
|
||||
newId,
|
||||
command.Symbol,
|
||||
command.PricePerUnit,
|
||||
command.ValidFrom);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
using FluentValidation;
|
||||
using SIGCM2.Application.Common;
|
||||
using SIGCM2.Domain.Pricing.ChargeableChars;
|
||||
using SIGCM2.Domain.Pricing.WordCounter;
|
||||
|
||||
namespace SIGCM2.Application.Pricing.ChargeableChars.Create;
|
||||
|
||||
/// <summary>
|
||||
/// PRC-001 — FluentValidation validator for CreateChargeableCharConfigCommand.
|
||||
/// Injects TimeProvider for today_AR (Cat2, never DateTime.Now).
|
||||
/// </summary>
|
||||
public sealed class CreateChargeableCharConfigCommandValidator
|
||||
: AbstractValidator<CreateChargeableCharConfigCommand>
|
||||
{
|
||||
public CreateChargeableCharConfigCommandValidator(TimeProvider timeProvider)
|
||||
{
|
||||
var today = timeProvider.GetArgentinaToday();
|
||||
|
||||
RuleFor(x => x.Symbol)
|
||||
.NotEmpty()
|
||||
.WithMessage("Symbol no puede estar vacío.")
|
||||
.MaximumLength(4)
|
||||
.WithMessage("Symbol no puede exceder 4 caracteres.")
|
||||
.Must(s => !WordCounterService.ContainsEmoji(s))
|
||||
.WithMessage("Symbol no puede contener emojis. Usá símbolos ASCII o latinos (ej: $, %, !, ¡).");
|
||||
|
||||
RuleFor(x => x.Category)
|
||||
.NotEmpty()
|
||||
.WithMessage("Category no puede estar vacío.")
|
||||
.Must(ChargeableCharCategories.IsValid)
|
||||
.WithMessage($"Category inválida. Valores válidos: {string.Join(", ", new[] { ChargeableCharCategories.Currency, ChargeableCharCategories.Percentage, ChargeableCharCategories.Exclamation, ChargeableCharCategories.Question, ChargeableCharCategories.Other })}.");
|
||||
|
||||
RuleFor(x => x.PricePerUnit)
|
||||
.GreaterThanOrEqualTo(0m)
|
||||
.WithMessage("PricePerUnit debe ser >= 0. Usá 0 para desactivar el cobro de este símbolo (opt-in billing).");
|
||||
|
||||
RuleFor(x => x.ValidFrom)
|
||||
.GreaterThanOrEqualTo(today)
|
||||
.WithMessage($"ValidFrom debe ser >= hoy ({today:yyyy-MM-dd} ART). No se permiten configuraciones con fecha retroactiva.");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
namespace SIGCM2.Application.Pricing.ChargeableChars.Create;
|
||||
|
||||
/// <summary>
|
||||
/// PRC-001 — Response for CreateChargeableCharConfigCommand.
|
||||
/// </summary>
|
||||
public sealed record CreateChargeableCharConfigResponse(
|
||||
long Id,
|
||||
string Symbol,
|
||||
decimal PricePerUnit,
|
||||
DateOnly ValidFrom);
|
||||
@@ -0,0 +1,6 @@
|
||||
namespace SIGCM2.Application.Pricing.ChargeableChars.Deactivate;
|
||||
|
||||
/// <summary>
|
||||
/// PRC-001 — Command to deactivate an existing ChargeableCharConfig.
|
||||
/// </summary>
|
||||
public sealed record DeactivateChargeableCharConfigCommand(long Id);
|
||||
@@ -0,0 +1,70 @@
|
||||
using System.Transactions;
|
||||
using SIGCM2.Application.Abstractions;
|
||||
using SIGCM2.Application.Abstractions.Persistence;
|
||||
using SIGCM2.Application.Audit;
|
||||
using SIGCM2.Application.Common;
|
||||
|
||||
namespace SIGCM2.Application.Pricing.ChargeableChars.Deactivate;
|
||||
|
||||
/// <summary>
|
||||
/// PRC-001 — Handler for DeactivateChargeableCharConfigCommand.
|
||||
/// Flow: load existing → open TX → DeactivateAsync → audit → tx.Complete().
|
||||
/// </summary>
|
||||
public sealed class DeactivateChargeableCharConfigCommandHandler
|
||||
: ICommandHandler<DeactivateChargeableCharConfigCommand, DeactivateChargeableCharConfigResponse>
|
||||
{
|
||||
private readonly IChargeableCharConfigRepository _repo;
|
||||
private readonly IAuditLogger _audit;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
|
||||
public DeactivateChargeableCharConfigCommandHandler(
|
||||
IChargeableCharConfigRepository repo,
|
||||
IAuditLogger audit,
|
||||
TimeProvider timeProvider)
|
||||
{
|
||||
_repo = repo;
|
||||
_audit = audit;
|
||||
_timeProvider = timeProvider;
|
||||
}
|
||||
|
||||
public async Task<DeactivateChargeableCharConfigResponse> Handle(
|
||||
DeactivateChargeableCharConfigCommand command)
|
||||
{
|
||||
var today = _timeProvider.GetArgentinaToday();
|
||||
|
||||
// 1. Load existing — ensures the row exists.
|
||||
var existing = await _repo.GetByIdAsync(command.Id)
|
||||
?? throw new KeyNotFoundException($"ChargeableCharConfig con Id={command.Id} no existe.");
|
||||
|
||||
// 2. TX + deactivate + audit (fail-closed).
|
||||
using (var tx = new TransactionScope(
|
||||
TransactionScopeOption.Required,
|
||||
new TransactionOptions { IsolationLevel = System.Transactions.IsolationLevel.ReadCommitted },
|
||||
TransactionScopeAsyncFlowOption.Enabled))
|
||||
{
|
||||
await _repo.DeactivateAsync(command.Id, today);
|
||||
|
||||
await _audit.LogAsync(
|
||||
action: "tasacion.chargeable_char.deactivate",
|
||||
targetType: "ChargeableCharConfig",
|
||||
targetId: command.Id.ToString(),
|
||||
metadata: new
|
||||
{
|
||||
before = new
|
||||
{
|
||||
id = existing.Id,
|
||||
symbol = existing.Symbol,
|
||||
productTypeId = existing.ProductTypeId,
|
||||
validFrom = existing.ValidFrom.ToString("yyyy-MM-dd"),
|
||||
},
|
||||
deactivatedOn = today.ToString("yyyy-MM-dd"),
|
||||
});
|
||||
|
||||
tx.Complete();
|
||||
}
|
||||
|
||||
return new DeactivateChargeableCharConfigResponse(
|
||||
Id: command.Id,
|
||||
ValidTo: today);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
namespace SIGCM2.Application.Pricing.ChargeableChars.Deactivate;
|
||||
|
||||
/// <summary>
|
||||
/// PRC-001 — Response for DeactivateChargeableCharConfigCommand.
|
||||
/// ValidTo is the date the config was deactivated (= today_AR at time of operation).
|
||||
/// </summary>
|
||||
public sealed record DeactivateChargeableCharConfigResponse(
|
||||
long Id,
|
||||
DateOnly ValidTo);
|
||||
@@ -0,0 +1,10 @@
|
||||
namespace SIGCM2.Application.Pricing.ChargeableChars.Delete;
|
||||
|
||||
/// <summary>
|
||||
/// PRC-001 — Command to physically delete a ChargeableCharConfig row.
|
||||
/// NOTE: Since SYSTEM_VERSIONING is ON, the delete moves the row to the history table
|
||||
/// (SysEndTime = delete time). The row disappears from all current-state queries but
|
||||
/// the temporal audit trail is preserved. Guard for "used in invoicing" is deferred
|
||||
/// to the FAC-001 followup issue.
|
||||
/// </summary>
|
||||
public sealed record DeleteChargeableCharConfigCommand(long Id);
|
||||
@@ -0,0 +1,75 @@
|
||||
using System.Transactions;
|
||||
using SIGCM2.Application.Abstractions;
|
||||
using SIGCM2.Application.Abstractions.Persistence;
|
||||
using SIGCM2.Application.Audit;
|
||||
using SIGCM2.Application.Common;
|
||||
|
||||
namespace SIGCM2.Application.Pricing.ChargeableChars.Delete;
|
||||
|
||||
/// <summary>
|
||||
/// PRC-001 — Handler for DeleteChargeableCharConfigCommand.
|
||||
/// Flow: load existing → open TX → DeleteAsync → audit → tx.Complete().
|
||||
///
|
||||
/// NOTE on SYSTEM_VERSIONING: SQL Server moves the deleted row to the _History table with
|
||||
/// SysEndTime = deletion timestamp. This means:
|
||||
/// - Current-state queries (no FOR SYSTEM_TIME) return nothing — effectively "deleted".
|
||||
/// - Historical queries (FOR SYSTEM_TIME ALL / AS OF) still return the row — temporal audit intact.
|
||||
/// This is intentional. A "physical delete" (bypass SYSTEM_VERSIONING) is not supported here.
|
||||
///
|
||||
/// Future FAC-001 will add a guard to block delete if the row was used in invoicing.
|
||||
/// </summary>
|
||||
public sealed class DeleteChargeableCharConfigCommandHandler
|
||||
: ICommandHandler<DeleteChargeableCharConfigCommand, DeleteChargeableCharConfigResponse>
|
||||
{
|
||||
private readonly IChargeableCharConfigRepository _repo;
|
||||
private readonly IAuditLogger _audit;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
|
||||
public DeleteChargeableCharConfigCommandHandler(
|
||||
IChargeableCharConfigRepository repo,
|
||||
IAuditLogger audit,
|
||||
TimeProvider timeProvider)
|
||||
{
|
||||
_repo = repo;
|
||||
_audit = audit;
|
||||
_timeProvider = timeProvider;
|
||||
}
|
||||
|
||||
public async Task<DeleteChargeableCharConfigResponse> Handle(
|
||||
DeleteChargeableCharConfigCommand command)
|
||||
{
|
||||
// 1. Load existing — ensures the row exists before opening TX.
|
||||
var existing = await _repo.GetByIdAsync(command.Id)
|
||||
?? throw new KeyNotFoundException($"ChargeableCharConfig con Id={command.Id} no existe.");
|
||||
|
||||
// 2. TX + delete + audit (fail-closed).
|
||||
using (var tx = new TransactionScope(
|
||||
TransactionScopeOption.Required,
|
||||
new TransactionOptions { IsolationLevel = System.Transactions.IsolationLevel.ReadCommitted },
|
||||
TransactionScopeAsyncFlowOption.Enabled))
|
||||
{
|
||||
await _repo.DeleteAsync(command.Id);
|
||||
|
||||
await _audit.LogAsync(
|
||||
action: "tasacion.chargeable_char.delete",
|
||||
targetType: "ChargeableCharConfig",
|
||||
targetId: command.Id.ToString(),
|
||||
metadata: new
|
||||
{
|
||||
before = new
|
||||
{
|
||||
id = existing.Id,
|
||||
symbol = existing.Symbol,
|
||||
productTypeId = existing.ProductTypeId,
|
||||
isActive = existing.IsActive,
|
||||
validFrom = existing.ValidFrom.ToString("yyyy-MM-dd"),
|
||||
},
|
||||
deletedOn = _timeProvider.GetArgentinaToday().ToString("yyyy-MM-dd"),
|
||||
});
|
||||
|
||||
tx.Complete();
|
||||
}
|
||||
|
||||
return new DeleteChargeableCharConfigResponse(Id: command.Id);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
namespace SIGCM2.Application.Pricing.ChargeableChars.Delete;
|
||||
|
||||
/// <summary>
|
||||
/// PRC-001 — Response for a successful delete operation.
|
||||
/// </summary>
|
||||
public sealed record DeleteChargeableCharConfigResponse(long Id);
|
||||
@@ -0,0 +1,7 @@
|
||||
namespace SIGCM2.Application.Pricing.ChargeableChars.GetById;
|
||||
|
||||
/// <summary>
|
||||
/// PRC-001 — Query to fetch a single ChargeableCharConfig by Id.
|
||||
/// Returns null if not found (caller decides whether to 404).
|
||||
/// </summary>
|
||||
public sealed record GetChargeableCharConfigByIdQuery(long Id);
|
||||
@@ -0,0 +1,37 @@
|
||||
using SIGCM2.Application.Abstractions;
|
||||
using SIGCM2.Application.Abstractions.Persistence;
|
||||
using SIGCM2.Application.Pricing.ChargeableChars;
|
||||
using SIGCM2.Domain.Pricing.ChargeableChars;
|
||||
|
||||
namespace SIGCM2.Application.Pricing.ChargeableChars.GetById;
|
||||
|
||||
/// <summary>
|
||||
/// PRC-001 — Handler for GetChargeableCharConfigByIdQuery.
|
||||
/// Returns null DTO when not found (API layer maps to 404).
|
||||
/// </summary>
|
||||
public sealed class GetChargeableCharConfigByIdQueryHandler
|
||||
: ICommandHandler<GetChargeableCharConfigByIdQuery, ChargeableCharConfigDto?>
|
||||
{
|
||||
private readonly IChargeableCharConfigRepository _repo;
|
||||
|
||||
public GetChargeableCharConfigByIdQueryHandler(IChargeableCharConfigRepository repo)
|
||||
{
|
||||
_repo = repo;
|
||||
}
|
||||
|
||||
public async Task<ChargeableCharConfigDto?> Handle(GetChargeableCharConfigByIdQuery query)
|
||||
{
|
||||
var entity = await _repo.GetByIdAsync(query.Id);
|
||||
return entity is null ? null : ToDto(entity);
|
||||
}
|
||||
|
||||
private static ChargeableCharConfigDto ToDto(ChargeableCharConfig c) => new(
|
||||
c.Id,
|
||||
c.ProductTypeId,
|
||||
c.Symbol,
|
||||
c.Category,
|
||||
c.PricePerUnit,
|
||||
c.ValidFrom,
|
||||
c.ValidTo,
|
||||
c.IsActive);
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
namespace SIGCM2.Application.Pricing.ChargeableChars;
|
||||
|
||||
/// <summary>
|
||||
/// PRC-001 — Application service for resolving active chargeable-char config for a ProductType.
|
||||
///
|
||||
/// Priority rule: per-ProductType row overrides global (ProductTypeId IS NULL) for the same Symbol.
|
||||
/// Returns a dictionary keyed by Symbol for O(1) lookup during word-count pricing.
|
||||
/// </summary>
|
||||
public interface IChargeableCharConfigService
|
||||
{
|
||||
/// <summary>
|
||||
/// Returns the resolved active config for the given ProductType as of the given date.
|
||||
/// Per-ProductType rows take priority over global rows for the same Symbol.
|
||||
/// Global rows are used as fallback when no per-ProductType row exists for that Symbol.
|
||||
/// Returns an empty dictionary if no config exists at all.
|
||||
/// </summary>
|
||||
Task<IReadOnlyDictionary<string, ChargeableCharSnapshot>> GetActiveConfigForProductTypeAsync(
|
||||
long productTypeId,
|
||||
DateOnly asOf,
|
||||
CancellationToken ct = default);
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
namespace SIGCM2.Application.Pricing.ChargeableChars.List;
|
||||
|
||||
/// <summary>
|
||||
/// PRC-001 — Paginated list query for ChargeableCharConfig rows.
|
||||
/// Page/PageSize are clamped by the handler (page >= 1, pageSize in [1, 100]).
|
||||
/// </summary>
|
||||
public sealed record ListChargeableCharConfigQuery(
|
||||
long? ProductTypeId,
|
||||
bool ActiveOnly,
|
||||
int Page = 1,
|
||||
int PageSize = 20);
|
||||
@@ -0,0 +1,46 @@
|
||||
using SIGCM2.Application.Abstractions;
|
||||
using SIGCM2.Application.Abstractions.Persistence;
|
||||
using SIGCM2.Application.Common;
|
||||
using SIGCM2.Application.Pricing.ChargeableChars;
|
||||
using SIGCM2.Domain.Pricing.ChargeableChars;
|
||||
|
||||
namespace SIGCM2.Application.Pricing.ChargeableChars.List;
|
||||
|
||||
/// <summary>
|
||||
/// PRC-001 — Handler for ListChargeableCharConfigQuery.
|
||||
/// Projects ChargeableCharConfig entities to ChargeableCharConfigDto.
|
||||
/// </summary>
|
||||
public sealed class ListChargeableCharConfigQueryHandler
|
||||
: ICommandHandler<ListChargeableCharConfigQuery, PagedResult<ChargeableCharConfigDto>>
|
||||
{
|
||||
private readonly IChargeableCharConfigRepository _repo;
|
||||
|
||||
public ListChargeableCharConfigQueryHandler(IChargeableCharConfigRepository repo)
|
||||
{
|
||||
_repo = repo;
|
||||
}
|
||||
|
||||
public async Task<PagedResult<ChargeableCharConfigDto>> Handle(ListChargeableCharConfigQuery query)
|
||||
{
|
||||
var page = Math.Max(1, query.Page);
|
||||
var pageSize = Math.Clamp(query.PageSize, 1, 100);
|
||||
var skip = (page - 1) * pageSize;
|
||||
|
||||
var items = await _repo.ListAsync(query.ProductTypeId, query.ActiveOnly, skip, pageSize);
|
||||
var total = await _repo.CountAsync(query.ProductTypeId, query.ActiveOnly);
|
||||
|
||||
var dtos = items.Select(ToDto).ToList();
|
||||
|
||||
return new PagedResult<ChargeableCharConfigDto>(dtos, page, pageSize, total);
|
||||
}
|
||||
|
||||
private static ChargeableCharConfigDto ToDto(ChargeableCharConfig c) => new(
|
||||
c.Id,
|
||||
c.ProductTypeId,
|
||||
c.Symbol,
|
||||
c.Category,
|
||||
c.PricePerUnit,
|
||||
c.ValidFrom,
|
||||
c.ValidTo,
|
||||
c.IsActive);
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
namespace SIGCM2.Application.Pricing.ChargeableChars.Reactivate;
|
||||
|
||||
/// <summary>
|
||||
/// PRC-001 — Command to reactivate a previously closed ChargeableCharConfig row.
|
||||
/// Guard rules enforced by the SP (50410 ALREADY_ACTIVE / 50411 VIGENTE_EXISTS / 50412 POSTERIOR_ROWS_EXIST).
|
||||
/// </summary>
|
||||
public sealed record ReactivateChargeableCharConfigCommand(long Id);
|
||||
@@ -0,0 +1,71 @@
|
||||
using System.Transactions;
|
||||
using SIGCM2.Application.Abstractions;
|
||||
using SIGCM2.Application.Abstractions.Persistence;
|
||||
using SIGCM2.Application.Audit;
|
||||
using SIGCM2.Application.Common;
|
||||
|
||||
namespace SIGCM2.Application.Pricing.ChargeableChars.Reactivate;
|
||||
|
||||
/// <summary>
|
||||
/// PRC-001 — Handler for ReactivateChargeableCharConfigCommand.
|
||||
/// Flow: open TransactionScope → ReactivateAsync (SP with guard) → audit → tx.Complete().
|
||||
///
|
||||
/// Guard failures (ALREADY_ACTIVE / VIGENTE_EXISTS / POSTERIOR_ROWS_EXIST) are thrown by the
|
||||
/// repository as ChargeableCharConfigReactivationNotAllowedException and propagate to the
|
||||
/// ExceptionFilter which maps them to HTTP 409.
|
||||
/// </summary>
|
||||
public sealed class ReactivateChargeableCharConfigCommandHandler
|
||||
: ICommandHandler<ReactivateChargeableCharConfigCommand, ReactivateChargeableCharConfigResponse>
|
||||
{
|
||||
private readonly IChargeableCharConfigRepository _repo;
|
||||
private readonly IAuditLogger _audit;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
|
||||
public ReactivateChargeableCharConfigCommandHandler(
|
||||
IChargeableCharConfigRepository repo,
|
||||
IAuditLogger audit,
|
||||
TimeProvider timeProvider)
|
||||
{
|
||||
_repo = repo;
|
||||
_audit = audit;
|
||||
_timeProvider = timeProvider;
|
||||
}
|
||||
|
||||
public async Task<ReactivateChargeableCharConfigResponse> Handle(
|
||||
ReactivateChargeableCharConfigCommand command)
|
||||
{
|
||||
// Open TX before calling SP so that audit failure rolls back the SP work.
|
||||
using var tx = new TransactionScope(
|
||||
TransactionScopeOption.Required,
|
||||
new TransactionOptions { IsolationLevel = System.Transactions.IsolationLevel.ReadCommitted },
|
||||
TransactionScopeAsyncFlowOption.Enabled);
|
||||
|
||||
// SP enforces guard rules; throws ChargeableCharConfigReactivationNotAllowedException on failure.
|
||||
// Returns the reactivated entity so we can populate the response and the audit log.
|
||||
var reactivated = await _repo.ReactivateAsync(command.Id, CancellationToken.None);
|
||||
|
||||
await _audit.LogAsync(
|
||||
action: "tasacion.chargeable_char.reactivate",
|
||||
targetType: "ChargeableCharConfig",
|
||||
targetId: command.Id.ToString(),
|
||||
metadata: new
|
||||
{
|
||||
id = reactivated.Id,
|
||||
symbol = reactivated.Symbol,
|
||||
productTypeId = reactivated.ProductTypeId,
|
||||
validFrom = reactivated.ValidFrom.ToString("yyyy-MM-dd"),
|
||||
reactivatedOn = _timeProvider.GetArgentinaToday().ToString("yyyy-MM-dd"),
|
||||
});
|
||||
|
||||
tx.Complete();
|
||||
|
||||
return new ReactivateChargeableCharConfigResponse(
|
||||
Id: reactivated.Id,
|
||||
ProductTypeId: reactivated.ProductTypeId,
|
||||
Symbol: reactivated.Symbol,
|
||||
Category: reactivated.Category,
|
||||
PricePerUnit: reactivated.PricePerUnit,
|
||||
ValidFrom: reactivated.ValidFrom,
|
||||
IsActive: reactivated.IsActive);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
namespace SIGCM2.Application.Pricing.ChargeableChars.Reactivate;
|
||||
|
||||
/// <summary>
|
||||
/// PRC-001 — Response for a successful reactivation.
|
||||
/// Returns the current state of the row after it has been re-opened.
|
||||
/// </summary>
|
||||
public sealed record ReactivateChargeableCharConfigResponse(
|
||||
long Id,
|
||||
long? ProductTypeId,
|
||||
string Symbol,
|
||||
string Category,
|
||||
decimal PricePerUnit,
|
||||
DateOnly ValidFrom,
|
||||
bool IsActive);
|
||||
@@ -0,0 +1,11 @@
|
||||
namespace SIGCM2.Application.Pricing.ChargeableChars.SchedulePrice;
|
||||
|
||||
/// <summary>
|
||||
/// PRC-001 — Command to schedule a new price for an existing ChargeableCharConfig.
|
||||
/// Id: the existing row whose price should be superseded.
|
||||
/// ValidFrom must be > existing row's ValidFrom (forward-only, enforced in handler).
|
||||
/// </summary>
|
||||
public sealed record SchedulePriceChangeCommand(
|
||||
long Id,
|
||||
decimal PricePerUnit,
|
||||
DateOnly ValidFrom);
|
||||
@@ -0,0 +1,80 @@
|
||||
using System.Transactions;
|
||||
using SIGCM2.Application.Abstractions;
|
||||
using SIGCM2.Application.Abstractions.Persistence;
|
||||
using SIGCM2.Application.Audit;
|
||||
|
||||
namespace SIGCM2.Application.Pricing.ChargeableChars.SchedulePrice;
|
||||
|
||||
/// <summary>
|
||||
/// PRC-001 — Handler for SchedulePriceChangeCommand.
|
||||
/// Flow: load existing → validate forward-only via entity → open TX → InsertWithCloseAsync → audit → tx.Complete().
|
||||
/// </summary>
|
||||
public sealed class SchedulePriceChangeCommandHandler
|
||||
: ICommandHandler<SchedulePriceChangeCommand, SchedulePriceChangeResponse>
|
||||
{
|
||||
private readonly IChargeableCharConfigRepository _repo;
|
||||
private readonly IAuditLogger _audit;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
|
||||
public SchedulePriceChangeCommandHandler(
|
||||
IChargeableCharConfigRepository repo,
|
||||
IAuditLogger audit,
|
||||
TimeProvider timeProvider)
|
||||
{
|
||||
_repo = repo;
|
||||
_audit = audit;
|
||||
_timeProvider = timeProvider;
|
||||
}
|
||||
|
||||
public async Task<SchedulePriceChangeResponse> Handle(SchedulePriceChangeCommand command)
|
||||
{
|
||||
// 1. Load existing row — validates it exists and exposes ProductTypeId/Symbol/Category.
|
||||
var existing = await _repo.GetByIdAsync(command.Id)
|
||||
?? throw new KeyNotFoundException($"ChargeableCharConfig con Id={command.Id} no existe.");
|
||||
|
||||
// 2. Domain entity validates forward-only rule and builds the new entity value.
|
||||
// ScheduleNewPrice throws ChargeableCharConfigForwardOnlyException if not strictly forward.
|
||||
var newEntity = existing.ScheduleNewPrice(command.PricePerUnit, command.ValidFrom, _timeProvider);
|
||||
|
||||
// 3. TX + SP + audit (fail-closed).
|
||||
long newId;
|
||||
using (var tx = new TransactionScope(
|
||||
TransactionScopeOption.Required,
|
||||
new TransactionOptions { IsolationLevel = System.Transactions.IsolationLevel.ReadCommitted },
|
||||
TransactionScopeAsyncFlowOption.Enabled))
|
||||
{
|
||||
newId = await _repo.InsertWithCloseAsync(
|
||||
newEntity.ProductTypeId,
|
||||
newEntity.Symbol,
|
||||
newEntity.Category,
|
||||
newEntity.PricePerUnit,
|
||||
newEntity.ValidFrom);
|
||||
|
||||
await _audit.LogAsync(
|
||||
action: "tasacion.chargeable_char.price_change",
|
||||
targetType: "ChargeableCharConfig",
|
||||
targetId: newId.ToString(),
|
||||
metadata: new
|
||||
{
|
||||
before = new
|
||||
{
|
||||
id = existing.Id,
|
||||
pricePerUnit = existing.PricePerUnit,
|
||||
validFrom = existing.ValidFrom.ToString("yyyy-MM-dd"),
|
||||
},
|
||||
after = new
|
||||
{
|
||||
pricePerUnit = newEntity.PricePerUnit,
|
||||
validFrom = newEntity.ValidFrom.ToString("yyyy-MM-dd"),
|
||||
}
|
||||
});
|
||||
|
||||
tx.Complete();
|
||||
}
|
||||
|
||||
return new SchedulePriceChangeResponse(
|
||||
NewId: newId,
|
||||
PreviousValidFrom: existing.ValidFrom,
|
||||
NewValidFrom: newEntity.ValidFrom);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
using FluentValidation;
|
||||
using SIGCM2.Application.Common;
|
||||
|
||||
namespace SIGCM2.Application.Pricing.ChargeableChars.SchedulePrice;
|
||||
|
||||
/// <summary>
|
||||
/// PRC-001 — FluentValidation validator for SchedulePriceChangeCommand.
|
||||
/// Surface validation only (price > 0, validFrom >= today_AR, id > 0).
|
||||
/// Forward-only check (ValidFrom > existing row's ValidFrom) is performed in the handler
|
||||
/// where the existing entity is loaded.
|
||||
/// </summary>
|
||||
public sealed class SchedulePriceChangeCommandValidator : AbstractValidator<SchedulePriceChangeCommand>
|
||||
{
|
||||
public SchedulePriceChangeCommandValidator(TimeProvider timeProvider)
|
||||
{
|
||||
var today = timeProvider.GetArgentinaToday();
|
||||
|
||||
RuleFor(x => x.Id)
|
||||
.GreaterThan(0L)
|
||||
.WithMessage("Id debe ser un entero positivo.");
|
||||
|
||||
RuleFor(x => x.PricePerUnit)
|
||||
.GreaterThan(0m)
|
||||
.WithMessage("PricePerUnit debe ser > 0.");
|
||||
|
||||
RuleFor(x => x.ValidFrom)
|
||||
.GreaterThanOrEqualTo(today)
|
||||
.WithMessage($"ValidFrom debe ser >= hoy ({today:yyyy-MM-dd} ART).");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
namespace SIGCM2.Application.Pricing.ChargeableChars.SchedulePrice;
|
||||
|
||||
/// <summary>
|
||||
/// PRC-001 — Response for SchedulePriceChangeCommand.
|
||||
/// </summary>
|
||||
public sealed record SchedulePriceChangeResponse(
|
||||
long NewId,
|
||||
DateOnly PreviousValidFrom,
|
||||
DateOnly NewValidFrom);
|
||||
@@ -0,0 +1,13 @@
|
||||
namespace SIGCM2.Application.ProductTypes.Create;
|
||||
|
||||
public sealed record CreateProductTypeCommand(
|
||||
string Nombre,
|
||||
bool HasDuration,
|
||||
bool RequiresText,
|
||||
bool RequiresCategory,
|
||||
bool IsBundle,
|
||||
bool AllowImages,
|
||||
int? MaxImages,
|
||||
decimal? MaxImageSizeMB,
|
||||
int? MaxImageWidth,
|
||||
int? MaxImageHeight);
|
||||
@@ -0,0 +1,80 @@
|
||||
using System.Transactions;
|
||||
using SIGCM2.Application.Abstractions;
|
||||
using SIGCM2.Application.Abstractions.Persistence;
|
||||
using SIGCM2.Application.Audit;
|
||||
using SIGCM2.Domain.Entities;
|
||||
using SIGCM2.Domain.Exceptions;
|
||||
|
||||
namespace SIGCM2.Application.ProductTypes.Create;
|
||||
|
||||
public sealed class CreateProductTypeCommandHandler
|
||||
: ICommandHandler<CreateProductTypeCommand, ProductTypeCreatedDto>
|
||||
{
|
||||
private readonly IProductTypeRepository _repo;
|
||||
private readonly IAuditLogger _audit;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
|
||||
public CreateProductTypeCommandHandler(
|
||||
IProductTypeRepository repo,
|
||||
IAuditLogger audit,
|
||||
TimeProvider timeProvider)
|
||||
{
|
||||
_repo = repo;
|
||||
_audit = audit;
|
||||
_timeProvider = timeProvider;
|
||||
}
|
||||
|
||||
public async Task<ProductTypeCreatedDto> Handle(CreateProductTypeCommand command)
|
||||
{
|
||||
// 1. Duplicate name check (before factory — avoids wasting domain allocation on error)
|
||||
var exists = await _repo.ExistsByNombreAsync(command.Nombre, excludeId: null);
|
||||
if (exists)
|
||||
throw new ProductTypeNombreDuplicadoException(command.Nombre);
|
||||
|
||||
// 2. Build entity (factory normalizes multimedia if AllowImages=false)
|
||||
var entity = ProductType.ForCreation(
|
||||
command.Nombre,
|
||||
command.HasDuration, command.RequiresText, command.RequiresCategory, command.IsBundle,
|
||||
command.AllowImages,
|
||||
command.MaxImages, command.MaxImageSizeMB, command.MaxImageWidth, command.MaxImageHeight,
|
||||
_timeProvider);
|
||||
|
||||
// 3. Persist + audit (fail-closed: if audit throws, TX rolls back)
|
||||
using var tx = new TransactionScope(
|
||||
TransactionScopeOption.Required,
|
||||
new TransactionOptions { IsolationLevel = System.Transactions.IsolationLevel.ReadCommitted },
|
||||
TransactionScopeAsyncFlowOption.Enabled);
|
||||
|
||||
var newId = await _repo.AddAsync(entity);
|
||||
|
||||
await _audit.LogAsync(
|
||||
action: "producto_tipo.created",
|
||||
targetType: "ProductType",
|
||||
targetId: newId.ToString(),
|
||||
metadata: new
|
||||
{
|
||||
after = new
|
||||
{
|
||||
entity.Nombre,
|
||||
entity.HasDuration,
|
||||
entity.RequiresText,
|
||||
entity.RequiresCategory,
|
||||
entity.IsBundle,
|
||||
entity.AllowImages,
|
||||
entity.MaxImages,
|
||||
entity.MaxImageSizeMB,
|
||||
entity.MaxImageWidth,
|
||||
entity.MaxImageHeight,
|
||||
}
|
||||
});
|
||||
|
||||
tx.Complete();
|
||||
|
||||
return new ProductTypeCreatedDto(
|
||||
newId, entity.Nombre,
|
||||
entity.HasDuration, entity.RequiresText, entity.RequiresCategory, entity.IsBundle,
|
||||
entity.AllowImages,
|
||||
entity.MaxImages, entity.MaxImageSizeMB, entity.MaxImageWidth, entity.MaxImageHeight,
|
||||
entity.IsActive);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
using FluentValidation;
|
||||
|
||||
namespace SIGCM2.Application.ProductTypes.Create;
|
||||
|
||||
public sealed class CreateProductTypeCommandValidator : AbstractValidator<CreateProductTypeCommand>
|
||||
{
|
||||
public CreateProductTypeCommandValidator()
|
||||
{
|
||||
RuleFor(x => x.Nombre)
|
||||
.NotEmpty().WithMessage("El nombre del tipo de producto es requerido.")
|
||||
.MaximumLength(200).WithMessage("El nombre no puede superar los 200 caracteres.");
|
||||
|
||||
RuleFor(x => x.MaxImages)
|
||||
.GreaterThan(0).When(x => x.MaxImages.HasValue)
|
||||
.WithMessage("MaxImages debe ser mayor que 0 (o null para sin límite).");
|
||||
|
||||
RuleFor(x => x.MaxImageSizeMB)
|
||||
.GreaterThan(0).When(x => x.MaxImageSizeMB.HasValue)
|
||||
.WithMessage("MaxImageSizeMB debe ser mayor que 0 (o null para sin límite).");
|
||||
|
||||
RuleFor(x => x.MaxImageWidth)
|
||||
.GreaterThan(0).When(x => x.MaxImageWidth.HasValue)
|
||||
.WithMessage("MaxImageWidth debe ser mayor que 0 (o null para sin límite).");
|
||||
|
||||
RuleFor(x => x.MaxImageHeight)
|
||||
.GreaterThan(0).When(x => x.MaxImageHeight.HasValue)
|
||||
.WithMessage("MaxImageHeight debe ser mayor que 0 (o null para sin límite).");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
namespace SIGCM2.Application.ProductTypes.Create;
|
||||
|
||||
public sealed record ProductTypeCreatedDto(
|
||||
int Id,
|
||||
string Nombre,
|
||||
bool HasDuration,
|
||||
bool RequiresText,
|
||||
bool RequiresCategory,
|
||||
bool IsBundle,
|
||||
bool AllowImages,
|
||||
int? MaxImages,
|
||||
decimal? MaxImageSizeMB,
|
||||
int? MaxImageWidth,
|
||||
int? MaxImageHeight,
|
||||
bool IsActive);
|
||||
@@ -0,0 +1,3 @@
|
||||
namespace SIGCM2.Application.ProductTypes.Deactivate;
|
||||
|
||||
public sealed record DeactivateProductTypeCommand(int Id);
|
||||
@@ -0,0 +1,70 @@
|
||||
using System.Transactions;
|
||||
using SIGCM2.Application.Abstractions;
|
||||
using SIGCM2.Application.Abstractions.Persistence;
|
||||
using SIGCM2.Application.Audit;
|
||||
using SIGCM2.Domain.Entities;
|
||||
using SIGCM2.Domain.Exceptions;
|
||||
|
||||
namespace SIGCM2.Application.ProductTypes.Deactivate;
|
||||
|
||||
public sealed class DeactivateProductTypeCommandHandler
|
||||
: ICommandHandler<DeactivateProductTypeCommand, ProductTypeStatusDto>
|
||||
{
|
||||
private readonly IProductTypeRepository _repo;
|
||||
private readonly IProductQueryRepository _productQuery;
|
||||
private readonly IAuditLogger _audit;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
|
||||
public DeactivateProductTypeCommandHandler(
|
||||
IProductTypeRepository repo,
|
||||
IProductQueryRepository productQuery,
|
||||
IAuditLogger audit,
|
||||
TimeProvider timeProvider)
|
||||
{
|
||||
_repo = repo;
|
||||
_productQuery = productQuery;
|
||||
_audit = audit;
|
||||
_timeProvider = timeProvider;
|
||||
}
|
||||
|
||||
public async Task<ProductTypeStatusDto> Handle(DeactivateProductTypeCommand command)
|
||||
{
|
||||
// 1. Load entity
|
||||
var target = await _repo.GetByIdAsync(command.Id)
|
||||
?? throw new ProductTypeNotFoundException(command.Id);
|
||||
|
||||
// 2. Idempotent: already inactive → return without side effects (I7)
|
||||
if (!target.IsActive)
|
||||
return new ProductTypeStatusDto(command.Id, false);
|
||||
|
||||
// 3. Guard: check if any active product uses this type
|
||||
var inUse = await _productQuery.ExistsActiveByProductTypeAsync(command.Id);
|
||||
if (inUse)
|
||||
throw new ProductTypeEnUsoException(command.Id, productsActivos: 1);
|
||||
|
||||
// 4. Deactivate (immutable — returns new instance)
|
||||
var deactivated = target.WithDeactivated(_timeProvider);
|
||||
|
||||
// 5. Persist + audit (fail-closed)
|
||||
using var tx = new TransactionScope(
|
||||
TransactionScopeOption.Required,
|
||||
new TransactionOptions { IsolationLevel = System.Transactions.IsolationLevel.ReadCommitted },
|
||||
TransactionScopeAsyncFlowOption.Enabled);
|
||||
|
||||
await _repo.UpdateAsync(deactivated);
|
||||
|
||||
await _audit.LogAsync(
|
||||
action: "producto_tipo.deactivated",
|
||||
targetType: "ProductType",
|
||||
targetId: command.Id.ToString(),
|
||||
metadata: new
|
||||
{
|
||||
productTypeId = command.Id,
|
||||
nombre = target.Nombre,
|
||||
});
|
||||
|
||||
tx.Complete();
|
||||
|
||||
return new ProductTypeStatusDto(deactivated.Id, deactivated.IsActive);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
namespace SIGCM2.Application.ProductTypes.Deactivate;
|
||||
|
||||
public sealed record ProductTypeStatusDto(int Id, bool IsActive);
|
||||
@@ -0,0 +1,3 @@
|
||||
namespace SIGCM2.Application.ProductTypes.GetById;
|
||||
|
||||
public sealed record GetProductTypeByIdQuery(int Id);
|
||||
@@ -0,0 +1,30 @@
|
||||
using SIGCM2.Application.Abstractions;
|
||||
using SIGCM2.Application.Abstractions.Persistence;
|
||||
using SIGCM2.Domain.Exceptions;
|
||||
|
||||
namespace SIGCM2.Application.ProductTypes.GetById;
|
||||
|
||||
public sealed class GetProductTypeByIdQueryHandler
|
||||
: ICommandHandler<GetProductTypeByIdQuery, ProductTypeDetailDto>
|
||||
{
|
||||
private readonly IProductTypeRepository _repo;
|
||||
|
||||
public GetProductTypeByIdQueryHandler(IProductTypeRepository repo)
|
||||
{
|
||||
_repo = repo;
|
||||
}
|
||||
|
||||
public async Task<ProductTypeDetailDto> Handle(GetProductTypeByIdQuery query)
|
||||
{
|
||||
var pt = await _repo.GetByIdAsync(query.Id)
|
||||
?? throw new ProductTypeNotFoundException(query.Id);
|
||||
|
||||
return new ProductTypeDetailDto(
|
||||
pt.Id, pt.Nombre,
|
||||
pt.HasDuration, pt.RequiresText, pt.RequiresCategory, pt.IsBundle,
|
||||
pt.AllowImages,
|
||||
pt.MaxImages, pt.MaxImageSizeMB, pt.MaxImageWidth, pt.MaxImageHeight,
|
||||
pt.IsActive,
|
||||
pt.FechaCreacion, pt.FechaModificacion);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
namespace SIGCM2.Application.ProductTypes.GetById;
|
||||
|
||||
public sealed record ProductTypeDetailDto(
|
||||
int Id,
|
||||
string Nombre,
|
||||
bool HasDuration,
|
||||
bool RequiresText,
|
||||
bool RequiresCategory,
|
||||
bool IsBundle,
|
||||
bool AllowImages,
|
||||
int? MaxImages,
|
||||
decimal? MaxImageSizeMB,
|
||||
int? MaxImageWidth,
|
||||
int? MaxImageHeight,
|
||||
bool IsActive,
|
||||
DateTime FechaCreacion,
|
||||
DateTime? FechaModificacion);
|
||||
@@ -0,0 +1,7 @@
|
||||
namespace SIGCM2.Application.ProductTypes.List;
|
||||
|
||||
public sealed record ListProductTypesQuery(
|
||||
int Page = 1,
|
||||
int PageSize = 20,
|
||||
bool? Activo = true,
|
||||
string? Search = null);
|
||||
@@ -0,0 +1,32 @@
|
||||
using SIGCM2.Application.Abstractions;
|
||||
using SIGCM2.Application.Abstractions.Persistence;
|
||||
using SIGCM2.Application.Common;
|
||||
|
||||
namespace SIGCM2.Application.ProductTypes.List;
|
||||
|
||||
public sealed class ListProductTypesQueryHandler
|
||||
: ICommandHandler<ListProductTypesQuery, PagedResult<ProductTypeListItemDto>>
|
||||
{
|
||||
private readonly IProductTypeRepository _repo;
|
||||
|
||||
public ListProductTypesQueryHandler(IProductTypeRepository repo)
|
||||
{
|
||||
_repo = repo;
|
||||
}
|
||||
|
||||
public async Task<PagedResult<ProductTypeListItemDto>> Handle(ListProductTypesQuery query)
|
||||
{
|
||||
var page = Math.Max(1, query.Page);
|
||||
var pageSize = Math.Clamp(query.PageSize, 1, 100);
|
||||
|
||||
var repoQuery = new ProductTypesQuery(page, pageSize, query.Activo, query.Search);
|
||||
var paged = await _repo.GetPagedAsync(repoQuery);
|
||||
|
||||
var items = paged.Items.Select(p => new ProductTypeListItemDto(
|
||||
p.Id, p.Nombre,
|
||||
p.HasDuration, p.RequiresText, p.RequiresCategory, p.IsBundle,
|
||||
p.AllowImages, p.IsActive)).ToList();
|
||||
|
||||
return new PagedResult<ProductTypeListItemDto>(items, paged.Page, paged.PageSize, paged.Total);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
namespace SIGCM2.Application.ProductTypes.List;
|
||||
|
||||
public sealed record ProductTypeListItemDto(
|
||||
int Id,
|
||||
string Nombre,
|
||||
bool HasDuration,
|
||||
bool RequiresText,
|
||||
bool RequiresCategory,
|
||||
bool IsBundle,
|
||||
bool AllowImages,
|
||||
bool IsActive);
|
||||
@@ -0,0 +1,16 @@
|
||||
namespace SIGCM2.Application.ProductTypes.Update;
|
||||
|
||||
public sealed record ProductTypeUpdatedDto(
|
||||
int Id,
|
||||
string Nombre,
|
||||
bool HasDuration,
|
||||
bool RequiresText,
|
||||
bool RequiresCategory,
|
||||
bool IsBundle,
|
||||
bool AllowImages,
|
||||
int? MaxImages,
|
||||
decimal? MaxImageSizeMB,
|
||||
int? MaxImageWidth,
|
||||
int? MaxImageHeight,
|
||||
bool IsActive,
|
||||
DateTime? FechaModificacion);
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user