Compare commits
222 Commits
9eac044752
...
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 | |||
| d4b2183628 | |||
| 0863ed8682 | |||
| a804ef3c7b | |||
| 30b55e60ea | |||
| 8c08a706f0 | |||
| 600ff52dd2 | |||
| 882f947765 | |||
| 4739e5cd46 | |||
| a3a15a4118 | |||
| fcd34081d2 | |||
| 88274a9f10 | |||
| 038a2ade70 | |||
| 8ffee0dbe4 | |||
| 95432e843f | |||
| ea16d57646 | |||
| 9c05167788 | |||
| 3eda59f5aa | |||
| b1a461b6cb | |||
| 25407583eb | |||
| 4544a000ae | |||
| 83dd680fa3 | |||
| 8e2d6bfb14 | |||
| bd0c4deea7 | |||
| 2cd25e1036 | |||
| 8db2b333c0 | |||
| eead0a35cd | |||
| 1d051c93d6 | |||
| f267e4f427 | |||
| 4cb3eed21f | |||
| 088f2303c1 | |||
| 87364ff8e6 | |||
| f307306f91 | |||
| b16dd313ed | |||
| 98a4fea7c4 | |||
| 3ee0bf0724 | |||
| c6c4eda269 | |||
| f4bd84c3f1 | |||
| 58ff15a0c0 | |||
| 93664612d5 | |||
| a82d51ff7a | |||
| fc77576427 | |||
| 6458ee0106 | |||
| 6be637b4cf | |||
| 7d432a949a | |||
| 40482caf7b | |||
| 9263d9a178 | |||
| 4368c42599 | |||
| 65787db272 | |||
| 4720f6772f | |||
| 056045232c | |||
| 4b96cdefcc | |||
| d61292afa4 | |||
| 48779543f9 | |||
| 39160bbb83 | |||
| 489359f0b8 | |||
| 50f6f2b67a | |||
| 43877bd4a1 | |||
| bef8977c5c | |||
| b7ac9831f9 | |||
| 3829c93af6 | |||
| 4fb25356a3 | |||
| 455954fa98 | |||
| 870cbe91b3 | |||
| 1ad6633cdd | |||
| 91d353655d | |||
| 740298a9e1 | |||
| 6b946f6080 | |||
| 13480ad8c2 | |||
| a6f4011806 | |||
| 2f0da2d720 | |||
| a1a8e6e0cb | |||
| f672de78ce | |||
| bb98dbf217 | |||
| ff7d8986fd | |||
| 7c0646be0d |
1
.vite/vitest/results.json
Normal file
1
.vite/vitest/results.json
Normal file
@@ -0,0 +1 @@
|
|||||||
|
{"version":"2.1.9","results":[[":src/web/src/tests/stores/authStore.test.ts",{"duration":19.427999999999997,"failed":true}],[":src/web/src/tests/features/auth/ProtectedRoute.test.tsx",{"duration":0,"failed":true}],[":src/web/src/tests/api/axiosClient.test.ts",{"duration":259.31550000000016,"failed":true}],[":src/web/src/tests/features/users/UserForm.test.tsx",{"duration":0,"failed":true}],[":src/web/src/tests/features/users/UsersListPage.test.tsx",{"duration":0,"failed":true}],[":src/web/src/tests/features/permisos/RolPermisosEditor.test.tsx",{"duration":0,"failed":true}],[":src/web/src/tests/features/roles/RolForm.test.tsx",{"duration":0,"failed":true}],[":src/web/src/tests/features/auth/LoginPage.test.tsx",{"duration":0,"failed":true}],[":src/web/src/tests/features/profile/ChangeMyPasswordPage.test.tsx",{"duration":0,"failed":true}],[":src/web/src/tests/features/auth/useLogin.test.ts",{"duration":0,"failed":true}],[":src/web/src/tests/features/users/UserEditPage.test.tsx",{"duration":0,"failed":true}],[":src/web/src/tests/features/users/ResetPasswordModal.test.tsx",{"duration":0,"failed":true}],[":src/web/src/tests/features/auth/authApi.test.ts",{"duration":0,"failed":true}],[":src/web/src/tests/features/roles/RolesList.test.tsx",{"duration":0,"failed":true}],[":src/web/src/tests/features/users/useCreateUser.test.ts",{"duration":0,"failed":true}],[":src/web/src/tests/components/routing/MustChangePasswordGate.test.tsx",{"duration":0,"failed":true}],[":src/web/src/tests/features/auth/CanPerform.test.tsx",{"duration":0,"failed":true}],[":src/web/src/tests/features/auth/usePermission.test.ts",{"duration":0,"failed":true}],[":src/web/src/tests/features/users/useUsersList.test.ts",{"duration":0,"failed":true}],[":src/web/src/tests/features/users/listUsers.test.ts",{"duration":0,"failed":true}],[":src/web/src/tests/features/users/updateUser.test.ts",{"duration":0,"failed":true}],[":src/web/src/tests/features/users/getUser.test.ts",{"duration":0,"failed":true}]]}
|
||||||
@@ -19,6 +19,7 @@
|
|||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
<!-- Test dependencies -->
|
<!-- Test dependencies -->
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
|
<PackageVersion Include="Microsoft.Extensions.TimeProvider.Testing" Version="9.1.0" />
|
||||||
<PackageVersion Include="xunit" Version="2.9.3" />
|
<PackageVersion Include="xunit" Version="2.9.3" />
|
||||||
<PackageVersion Include="xunit.runner.visualstudio" Version="3.1.4" />
|
<PackageVersion Include="xunit.runner.visualstudio" Version="3.1.4" />
|
||||||
<PackageVersion Include="Microsoft.NET.Test.Sdk" Version="17.14.1" />
|
<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
|
cd src/web && npx vitest run
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### Coverage (backend)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Generar reporte de coverage en formato Cobertura
|
||||||
|
dotnet test --collect:"XPlat Code Coverage" --settings coverlet.runsettings --results-directory ./TestResults
|
||||||
|
```
|
||||||
|
|
||||||
|
El comando genera un `coverage.cobertura.xml` por cada proyecto de test en `./TestResults/`.
|
||||||
|
|
||||||
|
Para convertirlo a HTML:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Instalar ReportGenerator (solo la primera vez)
|
||||||
|
dotnet tool install -g dotnet-reportgenerator-globaltool
|
||||||
|
|
||||||
|
# Generar reporte HTML
|
||||||
|
reportgenerator -reports:"./TestResults/**/coverage.cobertura.xml" -targetdir:"./coverage-report" -reporttypes:Html
|
||||||
|
```
|
||||||
|
|
||||||
|
Abrí `./coverage-report/index.html` en el browser para ver el detalle por archivo.
|
||||||
|
|
||||||
## Convenciones
|
## Convenciones
|
||||||
|
|
||||||
- Ramas: `feature/UDT-XXX` desde `main`.
|
- Ramas: `feature/UDT-XXX` desde `main`.
|
||||||
|
|||||||
48
coverlet.runsettings
Normal file
48
coverlet.runsettings
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<RunSettings>
|
||||||
|
<!--
|
||||||
|
Configuracion de coverage con coverlet.collector.
|
||||||
|
Uso: dotnet test /collect:"XPlat Code Coverage" /settings:coverlet.runsettings /results-directory:./TestResults
|
||||||
|
-->
|
||||||
|
<RunConfiguration>
|
||||||
|
<!-- Mantener ejecución secuencial (hereda política de tests.runsettings) -->
|
||||||
|
<MaxCpuCount>1</MaxCpuCount>
|
||||||
|
</RunConfiguration>
|
||||||
|
|
||||||
|
<DataCollectionRunSettings>
|
||||||
|
<DataCollectors>
|
||||||
|
<DataCollector friendlyName="XPlat Code Coverage">
|
||||||
|
<Configuration>
|
||||||
|
<!-- Formato de salida: cobertura (compatible con ReportGenerator y CI/CD) -->
|
||||||
|
<Format>cobertura</Format>
|
||||||
|
|
||||||
|
<!-- Exclusiones por atributo generado -->
|
||||||
|
<ExcludeByAttribute>GeneratedCodeAttribute,ExcludeFromCodeCoverageAttribute</ExcludeByAttribute>
|
||||||
|
|
||||||
|
<!-- Exclusiones por tipo/namespace -->
|
||||||
|
<Exclude>
|
||||||
|
<!-- Migrations embebidas (SQL scripts, no lógica de negocio) -->
|
||||||
|
[*.Migrations]*,
|
||||||
|
<!-- Los proyectos de test no se miden a sí mismos -->
|
||||||
|
[*.Tests]*,
|
||||||
|
[SIGCM2.TestSupport]*,
|
||||||
|
<!-- Program.cs: host wiring, no testeable unitariamente -->
|
||||||
|
[SIGCM2.Api]Program,
|
||||||
|
<!-- Extension methods de DI: una línea por registro, ruido sin valor -->
|
||||||
|
[*]*.Extensions.*Extensions,
|
||||||
|
[*]*.DependencyInjection
|
||||||
|
</Exclude>
|
||||||
|
|
||||||
|
<!-- No medir las propiedades auto-implementadas -->
|
||||||
|
<SkipAutoProps>true</SkipAutoProps>
|
||||||
|
|
||||||
|
<!-- No incluir el assembly de tests en el reporte -->
|
||||||
|
<IncludeTestAssembly>false</IncludeTestAssembly>
|
||||||
|
|
||||||
|
<!-- Permitir timestamps reales en el reporte (no forzar determinismo) -->
|
||||||
|
<DeterministicReport>false</DeterministicReport>
|
||||||
|
</Configuration>
|
||||||
|
</DataCollector>
|
||||||
|
</DataCollectors>
|
||||||
|
</DataCollectionRunSettings>
|
||||||
|
</RunSettings>
|
||||||
@@ -27,6 +27,21 @@ database/
|
|||||||
| V008 | `V008__add_mustchangepassword_and_indexes.sql` | UDT-008 | Usuario.MustChangePassword + IX_Usuario_Activo_Rol |
|
| V008 | `V008__add_mustchangepassword_and_indexes.sql` | UDT-008 | Usuario.MustChangePassword + IX_Usuario_Activo_Rol |
|
||||||
| V009 | `V009__activate_permisos_overrides.sql` | UDT-009 | Migración shape `PermisosJson` `{grant, deny}` |
|
| V009 | `V009__activate_permisos_overrides.sql` | UDT-009 | Migración shape `PermisosJson` `{grant, deny}` |
|
||||||
| **V010** | **`V010__audit_infrastructure.sql`** | **UDT-010** | **Infra de auditoría + Temporal Tables. Ver nota abajo.** |
|
| **V010** | **`V010__audit_infrastructure.sql`** | **UDT-010** | **Infra de auditoría + Temporal Tables. Ver nota abajo.** |
|
||||||
|
| V011 | `V011__create_medio_seccion.sql` | ADM-001 | Medio + Seccion (temporal, retention 10y) + permiso `administracion:secciones:gestionar` |
|
||||||
|
| V012 | `V012__seed_medios.sql` | ADM-001 | Seed idempotente de Medios ELDIA y ELPLATA |
|
||||||
|
| V013 | `V013__create_puntos_de_venta.sql` | ADM-008 | PuntosDeVenta (temporal, retention 10y) + permiso `administracion:puntos_de_venta:gestionar` |
|
||||||
|
| V014 | `V014__create_tablas_fiscales.sql` | ADM-009 | TiposDeIva + IngresosBrutos (versioning por cadena) + permisos fiscales |
|
||||||
|
| V015 | `V015__create_local_timezone_views.sql` | UDT-011 | Vistas admin con OccurredAt convertido a hora Argentina |
|
||||||
|
| **V016** | **`V016__create_rubro.sql`** | **CAT-001** | **Rubro (adjacency list, temporal 10y) + permiso `catalogo:rubros:gestionar`** |
|
||||||
|
| **V017** | **`V017__create_product_type.sql`** | **PRD-001** | **ProductType (flags + multimedia limits, temporal 10y) + permiso `catalogo:tipos:gestionar`** |
|
||||||
|
| V018 | `V018__create_product.sql` | PRD-002 | Product (temporal 10y) + permiso `catalogo:productos:gestionar` + índices |
|
||||||
|
| V019 | `V019__create_product_prices.sql` | PRD-003 | ProductPrices (temporal 10y, forward-only) + SP `sp_ProductPrices_InsertWithClose` + permiso implícito |
|
||||||
|
| V020 | `V020__add_chargeable_chars_permission.sql` | PRC-001 | Permiso `tasacion:caracteres_especiales:gestionar` + asignación a admin |
|
||||||
|
| V021 | `V021__create_chargeable_char_config.sql` | PRC-001 | ChargeableCharConfig + ChargeableCharConfig_History (temporal 10y) + 2 SPs (`InsertWithClose`, `GetActiveForProductType`) + 2 índices |
|
||||||
|
| V022 | `V022__seed_chargeable_char_config.sql` | PRC-001 | Seed 4 filas globales (`$`, `%`, `!`, `¡`) con PricePerUnit=1.0000 |
|
||||||
|
| V023 | `V023__refactor_chargeable_char_config_to_product_type.sql` | PRC-001 (scope delta) | Refactor MedioId→ProductTypeId + nuevo SP `ReactivateWithGuard` + CK_Price_NonNegative (>= 0) |
|
||||||
|
| V024 | `V024__reseed_global_with_zero_price.sql` | PRC-001 (scope delta) | Reseed 4 globales a PricePerUnit=0.0000 (opt-in billing) |
|
||||||
|
| V025 | `V025__seed_chargeable_char_overrides_demo.sql` | PRC-001 (followup #54) | Seed demo de overrides ficticios per-ProductType (Clasificado/Notables/Fúnebres). Idempotente: no-op si los tipos no existen |
|
||||||
|
|
||||||
## Convenciones
|
## Convenciones
|
||||||
|
|
||||||
@@ -34,23 +49,24 @@ database/
|
|||||||
- **Idempotentes**: cada migración usa `IF NOT EXISTS` / `MERGE` / `IF NOT EXISTS (SELECT 1 FROM sys....)`. Re-ejecutarlas es seguro.
|
- **Idempotentes**: cada migración usa `IF NOT EXISTS` / `MERGE` / `IF NOT EXISTS (SELECT 1 FROM sys....)`. Re-ejecutarlas es seguro.
|
||||||
- **Boilerplate** inicial: `SET QUOTED_IDENTIFIER ON; SET ANSI_NULLS ON; SET NOCOUNT ON; GO`.
|
- **Boilerplate** inicial: `SET QUOTED_IDENTIFIER ON; SET ANSI_NULLS ON; SET NOCOUNT ON; GO`.
|
||||||
- **Naming**: `PK_*`, `UQ_*`, `FK_*`, `DF_*`, `CK_*`, `IX_*`.
|
- **Naming**: `PK_*`, `UQ_*`, `FK_*`, `DF_*`, `CK_*`, `IX_*`.
|
||||||
- **Se aplican a AMBAS bases**: `SIGCM2` (dev) y `SIGCM2_Test` (integration tests). El orden debe ser idéntico.
|
- **Se aplican a TRES bases**: `SIGCM2` (dev), `SIGCM2_Test_App` (Application.Tests) y `SIGCM2_Test_Api` (Api.Tests). El orden debe ser idéntico en las tres.
|
||||||
|
|
||||||
## Cómo aplicar migraciones
|
## Cómo aplicar migraciones
|
||||||
|
|
||||||
### En dev (manual)
|
### En dev (manual)
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Con sqlcmd:
|
# Con sqlcmd (aplicar a las tres bases en orden):
|
||||||
sqlcmd -S TECNICA3 -d SIGCM2 -U desarrollo -P desarrollo2026 -i database/migrations/V010__audit_infrastructure.sql
|
sqlcmd -S TECNICA3 -d SIGCM2 -U desarrollo -P desarrollo2026 -i database/migrations/V010__audit_infrastructure.sql
|
||||||
sqlcmd -S TECNICA3 -d SIGCM2_Test -U desarrollo -P desarrollo2026 -i database/migrations/V010__audit_infrastructure.sql
|
sqlcmd -S TECNICA3 -d SIGCM2_Test_App -U desarrollo -P desarrollo2026 -i database/migrations/V010__audit_infrastructure.sql
|
||||||
|
sqlcmd -S TECNICA3 -d SIGCM2_Test_Api -U desarrollo -P desarrollo2026 -i database/migrations/V010__audit_infrastructure.sql
|
||||||
```
|
```
|
||||||
|
|
||||||
O desde SSMS: abrir el archivo, conectar a cada base, F5.
|
O desde SSMS: abrir el archivo, conectar a cada base, F5.
|
||||||
|
|
||||||
### En integration tests
|
### En integration tests
|
||||||
|
|
||||||
`tests/SIGCM2.TestSupport/SqlTestFixture.cs` aplica automáticamente el schema necesario en `SIGCM2_Test` al inicializar el fixture (`EnsureV008SchemaAsync`, `EnsureV009SchemaAsync`, `EnsureV010SchemaAsync`…). **NO** hace falta correr el script manualmente.
|
`tests/SIGCM2.TestSupport/SqlTestFixture.cs` aplica automáticamente el schema necesario en `SIGCM2_Test_App` al inicializar el fixture (`EnsureV008SchemaAsync`, `EnsureV009SchemaAsync`, `EnsureV010SchemaAsync`…). `TestWebAppFactory` hace lo mismo contra `SIGCM2_Test_Api`. **NO** hace falta correr los scripts manualmente si el fixture ya lo cubre.
|
||||||
|
|
||||||
### En producción (roadmap futuro)
|
### En producción (roadmap futuro)
|
||||||
|
|
||||||
@@ -78,6 +94,32 @@ O desde SSMS: abrir el archivo, conectar a cada base, F5.
|
|||||||
|
|
||||||
**Catálogo de entidades auditables** (source of truth): `Obsidian/02-ARQUITECTURA-y-TECH-STACK/2.5 📋 Auditoría.md`. Cada UDT nueva que introduzca entidades de negocio debe agregar esas tablas al catálogo y activar `SYSTEM_VERSIONING` en su migración.
|
**Catálogo de entidades auditables** (source of truth): `Obsidian/02-ARQUITECTURA-y-TECH-STACK/2.5 📋 Auditoría.md`. Cada UDT nueva que introduzca entidades de negocio debe agregar esas tablas al catálogo y activar `SYSTEM_VERSIONING` en su migración.
|
||||||
|
|
||||||
|
### V011/V012 — ADM-001 Medios y Secciones
|
||||||
|
|
||||||
|
**Alcance**: crea `dbo.Medio` y `dbo.Seccion` con Temporal Tables (retention 10 años), el permiso `administracion:secciones:gestionar` (y lo asigna a rol `admin`), y siembra los dos Medios fundacionales `ELDIA` y `ELPLATA`.
|
||||||
|
|
||||||
|
**Notas**:
|
||||||
|
- `administracion:medios:gestionar` ya existía desde V005 — no se toca.
|
||||||
|
- `PlataformaEmpresaId` es `INT NULL` sin FK; la FK se agrega en INT-003 cuando se cree la tabla `PlataformaEmpresa`.
|
||||||
|
- `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
|
## Recursos
|
||||||
|
|
||||||
- Design autoritativo: `Obsidian/02-ARQUITECTURA-y-TECH-STACK/2.5 📋 Auditoría.md`
|
- Design autoritativo: `Obsidian/02-ARQUITECTURA-y-TECH-STACK/2.5 📋 Auditoría.md`
|
||||||
|
|||||||
30
database/init/create-test-api-db.sql
Normal file
30
database/init/create-test-api-db.sql
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
-- create-test-api-db.sql
|
||||||
|
-- Creates test databases for integration tests (idempotent).
|
||||||
|
-- Run once per environment on TECNICA3 before executing integration tests.
|
||||||
|
--
|
||||||
|
-- SIGCM2_Test_App -> used by SIGCM2.Application.Tests
|
||||||
|
-- SIGCM2_Test_Api -> used by SIGCM2.Api.Tests
|
||||||
|
-- SIGCM2_Test -> legacy (kept for old branches e.g. pre-merge CAT-001)
|
||||||
|
--
|
||||||
|
-- After creating the DBs, apply V010 to both new DBs:
|
||||||
|
-- See database/README.md > "Test DBs" section for the PowerShell runbook.
|
||||||
|
|
||||||
|
IF DB_ID(N'SIGCM2_Test_App') IS NULL
|
||||||
|
BEGIN
|
||||||
|
CREATE DATABASE [SIGCM2_Test_App]
|
||||||
|
COLLATE Modern_Spanish_CI_AS;
|
||||||
|
PRINT 'Database SIGCM2_Test_App created.';
|
||||||
|
END
|
||||||
|
ELSE
|
||||||
|
PRINT 'Database SIGCM2_Test_App already exists -- skip.';
|
||||||
|
GO
|
||||||
|
|
||||||
|
IF DB_ID(N'SIGCM2_Test_Api') IS NULL
|
||||||
|
BEGIN
|
||||||
|
CREATE DATABASE [SIGCM2_Test_Api]
|
||||||
|
COLLATE Modern_Spanish_CI_AS;
|
||||||
|
PRINT 'Database SIGCM2_Test_Api created.';
|
||||||
|
END
|
||||||
|
ELSE
|
||||||
|
PRINT 'Database SIGCM2_Test_Api already exists -- skip.';
|
||||||
|
GO
|
||||||
118
database/migrations/V011_ROLLBACK.sql
Normal file
118
database/migrations/V011_ROLLBACK.sql
Normal file
@@ -0,0 +1,118 @@
|
|||||||
|
-- V011_ROLLBACK.sql
|
||||||
|
-- Reversa de V011__create_medio_seccion.sql.
|
||||||
|
--
|
||||||
|
-- ⚠️ ADVERTENCIA: ejecutar ELIMINA Medio, Seccion, su historia temporal,
|
||||||
|
-- el permiso 'administracion:secciones:gestionar' y sus asignaciones.
|
||||||
|
-- ('administracion:medios:gestionar' NO se toca — es pre-existente de V005.)
|
||||||
|
--
|
||||||
|
-- Uso intended: ROLLBACK en entornos NO-productivos.
|
||||||
|
-- Prerequisito: no deben existir FKs vivas apuntando a Medio (p.ej., Punto de Venta, Tarifario).
|
||||||
|
-- Si ADM-008, ADM-009 o PRC-* ya están aplicados, este rollback falla — usar backup.
|
||||||
|
|
||||||
|
SET QUOTED_IDENTIFIER ON;
|
||||||
|
SET ANSI_NULLS ON;
|
||||||
|
SET NOCOUNT ON;
|
||||||
|
GO
|
||||||
|
|
||||||
|
-- ═══════════════════════════════════════════════════════════════════════
|
||||||
|
-- 1. Apagar SYSTEM_VERSIONING + remover PERIOD en Seccion y Medio
|
||||||
|
-- ═══════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
-- Seccion primero (FK al Medio)
|
||||||
|
IF EXISTS (SELECT 1 FROM sys.tables WHERE object_id = OBJECT_ID('dbo.Seccion') AND temporal_type = 2)
|
||||||
|
BEGIN
|
||||||
|
ALTER TABLE dbo.Seccion SET (SYSTEM_VERSIONING = OFF);
|
||||||
|
PRINT 'Seccion: SYSTEM_VERSIONING OFF.';
|
||||||
|
END
|
||||||
|
GO
|
||||||
|
|
||||||
|
IF EXISTS (SELECT 1 FROM sys.periods WHERE object_id = OBJECT_ID('dbo.Seccion'))
|
||||||
|
BEGIN
|
||||||
|
ALTER TABLE dbo.Seccion DROP PERIOD FOR SYSTEM_TIME;
|
||||||
|
PRINT 'Seccion: PERIOD FOR SYSTEM_TIME dropped.';
|
||||||
|
END
|
||||||
|
GO
|
||||||
|
|
||||||
|
IF COL_LENGTH('dbo.Seccion', 'ValidFrom') IS NOT NULL
|
||||||
|
BEGIN
|
||||||
|
ALTER TABLE dbo.Seccion DROP CONSTRAINT IF EXISTS DF_Seccion_ValidFrom;
|
||||||
|
ALTER TABLE dbo.Seccion DROP CONSTRAINT IF EXISTS DF_Seccion_ValidTo;
|
||||||
|
ALTER TABLE dbo.Seccion DROP COLUMN ValidFrom, ValidTo;
|
||||||
|
PRINT 'Seccion: ValidFrom/ValidTo dropped.';
|
||||||
|
END
|
||||||
|
GO
|
||||||
|
|
||||||
|
IF OBJECT_ID(N'dbo.Seccion_History', N'U') IS NOT NULL
|
||||||
|
BEGIN
|
||||||
|
DROP TABLE dbo.Seccion_History;
|
||||||
|
PRINT 'Seccion_History dropped.';
|
||||||
|
END
|
||||||
|
GO
|
||||||
|
|
||||||
|
-- Medio
|
||||||
|
IF EXISTS (SELECT 1 FROM sys.tables WHERE object_id = OBJECT_ID('dbo.Medio') AND temporal_type = 2)
|
||||||
|
BEGIN
|
||||||
|
ALTER TABLE dbo.Medio SET (SYSTEM_VERSIONING = OFF);
|
||||||
|
PRINT 'Medio: SYSTEM_VERSIONING OFF.';
|
||||||
|
END
|
||||||
|
GO
|
||||||
|
|
||||||
|
IF EXISTS (SELECT 1 FROM sys.periods WHERE object_id = OBJECT_ID('dbo.Medio'))
|
||||||
|
BEGIN
|
||||||
|
ALTER TABLE dbo.Medio DROP PERIOD FOR SYSTEM_TIME;
|
||||||
|
PRINT 'Medio: PERIOD FOR SYSTEM_TIME dropped.';
|
||||||
|
END
|
||||||
|
GO
|
||||||
|
|
||||||
|
IF COL_LENGTH('dbo.Medio', 'ValidFrom') IS NOT NULL
|
||||||
|
BEGIN
|
||||||
|
ALTER TABLE dbo.Medio DROP CONSTRAINT IF EXISTS DF_Medio_ValidFrom;
|
||||||
|
ALTER TABLE dbo.Medio DROP CONSTRAINT IF EXISTS DF_Medio_ValidTo;
|
||||||
|
ALTER TABLE dbo.Medio DROP COLUMN ValidFrom, ValidTo;
|
||||||
|
PRINT 'Medio: ValidFrom/ValidTo dropped.';
|
||||||
|
END
|
||||||
|
GO
|
||||||
|
|
||||||
|
IF OBJECT_ID(N'dbo.Medio_History', N'U') IS NOT NULL
|
||||||
|
BEGIN
|
||||||
|
DROP TABLE dbo.Medio_History;
|
||||||
|
PRINT 'Medio_History dropped.';
|
||||||
|
END
|
||||||
|
GO
|
||||||
|
|
||||||
|
-- ═══════════════════════════════════════════════════════════════════════
|
||||||
|
-- 2. Drop Seccion y Medio (Seccion primero por FK)
|
||||||
|
-- ═══════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
IF OBJECT_ID(N'dbo.Seccion', N'U') IS NOT NULL
|
||||||
|
BEGIN
|
||||||
|
DROP TABLE dbo.Seccion;
|
||||||
|
PRINT 'Table dbo.Seccion dropped.';
|
||||||
|
END
|
||||||
|
GO
|
||||||
|
|
||||||
|
IF OBJECT_ID(N'dbo.Medio', N'U') IS NOT NULL
|
||||||
|
BEGIN
|
||||||
|
DROP TABLE dbo.Medio;
|
||||||
|
PRINT 'Table dbo.Medio dropped.';
|
||||||
|
END
|
||||||
|
GO
|
||||||
|
|
||||||
|
-- ═══════════════════════════════════════════════════════════════════════
|
||||||
|
-- 3. Remover permiso 'administracion:secciones:gestionar' + RolPermiso
|
||||||
|
-- ═══════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
DELETE rp
|
||||||
|
FROM dbo.RolPermiso rp
|
||||||
|
JOIN dbo.Permiso p ON p.Id = rp.PermisoId
|
||||||
|
WHERE p.Codigo = 'administracion:secciones:gestionar';
|
||||||
|
GO
|
||||||
|
|
||||||
|
DELETE FROM dbo.Permiso
|
||||||
|
WHERE Codigo = 'administracion:secciones:gestionar';
|
||||||
|
GO
|
||||||
|
|
||||||
|
PRINT '';
|
||||||
|
PRINT 'V011 rolled back. dbo.Medio, dbo.Seccion and their history removed.';
|
||||||
|
PRINT 'administracion:medios:gestionar preserved (pre-existing from V005).';
|
||||||
|
GO
|
||||||
206
database/migrations/V011__create_medio_seccion.sql
Normal file
206
database/migrations/V011__create_medio_seccion.sql
Normal file
@@ -0,0 +1,206 @@
|
|||||||
|
-- V011__create_medio_seccion.sql
|
||||||
|
-- ADM-001 (Fase 1 CRITICAL PATH): Medios y Secciones — catálogo fundacional.
|
||||||
|
--
|
||||||
|
-- Cambios:
|
||||||
|
-- 1. dbo.Medio (Codigo UQ global, TipoMedio enum 1..4, PlataformaEmpresaId NULL, SYSTEM_VERSIONING ON).
|
||||||
|
-- 2. dbo.Seccion (FK MedioId, Codigo UQ por Medio, Tipo CHECK, SYSTEM_VERSIONING ON).
|
||||||
|
-- 3. Permiso 'administracion:secciones:gestionar' + asignación a rol 'admin'.
|
||||||
|
-- El permiso 'administracion:medios:gestionar' ya existía desde V005.
|
||||||
|
--
|
||||||
|
-- Patrón: V007 (permisos MERGE) + V010 (Temporal Tables con retention 10 años + PAGE compression en history).
|
||||||
|
-- Idempotente: seguro para re-ejecutar.
|
||||||
|
-- Reversa: V011_ROLLBACK.sql.
|
||||||
|
-- Run on: SIGCM2 (dev) y SIGCM2_Test (integration tests).
|
||||||
|
--
|
||||||
|
-- Source of truth: Obsidian/02-ARQUITECTURA-y-TECH-STACK/2.10 📋 UDTs Módulo Administración.md (ADM-001)
|
||||||
|
-- Entidades: Obsidian/03-MODELO-de-DATOS/3.2 Entidades Core/3.2.1 🏢 Medio.md
|
||||||
|
-- Auditoría: Obsidian/02-ARQUITECTURA-y-TECH-STACK/2.5 📋 Auditoría.md
|
||||||
|
|
||||||
|
SET QUOTED_IDENTIFIER ON;
|
||||||
|
SET ANSI_NULLS ON;
|
||||||
|
SET NOCOUNT ON;
|
||||||
|
GO
|
||||||
|
|
||||||
|
-- ═══════════════════════════════════════════════════════════════════════
|
||||||
|
-- 1. dbo.Medio
|
||||||
|
-- ═══════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
IF OBJECT_ID(N'dbo.Medio', N'U') IS NULL
|
||||||
|
BEGIN
|
||||||
|
CREATE TABLE dbo.Medio (
|
||||||
|
Id INT IDENTITY(1,1) NOT NULL CONSTRAINT PK_Medio PRIMARY KEY,
|
||||||
|
Codigo VARCHAR(30) NOT NULL,
|
||||||
|
Nombre NVARCHAR(100) NOT NULL,
|
||||||
|
Tipo TINYINT NOT NULL, -- TipoMedio: 1=Diario, 2=Radio, 3=Web, 4=Poster
|
||||||
|
PlataformaEmpresaId INT NULL, -- FK futura a INT-003 (IMAC mapping)
|
||||||
|
Activo BIT NOT NULL CONSTRAINT DF_Medio_Activo DEFAULT(1),
|
||||||
|
FechaCreacion DATETIME2(3) NOT NULL CONSTRAINT DF_Medio_FechaCreacion DEFAULT(SYSUTCDATETIME()),
|
||||||
|
FechaModificacion DATETIME2(3) NULL,
|
||||||
|
CONSTRAINT UQ_Medio_Codigo UNIQUE (Codigo),
|
||||||
|
CONSTRAINT CK_Medio_Tipo CHECK (Tipo BETWEEN 1 AND 4)
|
||||||
|
);
|
||||||
|
PRINT 'Table dbo.Medio created.';
|
||||||
|
END
|
||||||
|
ELSE
|
||||||
|
PRINT 'Table dbo.Medio already exists — skip.';
|
||||||
|
GO
|
||||||
|
|
||||||
|
IF NOT EXISTS (SELECT 1 FROM sys.indexes WHERE name = 'IX_Medio_Activo_Tipo' AND object_id = OBJECT_ID('dbo.Medio'))
|
||||||
|
BEGIN
|
||||||
|
CREATE INDEX IX_Medio_Activo_Tipo
|
||||||
|
ON dbo.Medio(Activo, Tipo)
|
||||||
|
INCLUDE (Codigo, Nombre, PlataformaEmpresaId);
|
||||||
|
PRINT 'Index IX_Medio_Activo_Tipo created.';
|
||||||
|
END
|
||||||
|
GO
|
||||||
|
|
||||||
|
-- ═══════════════════════════════════════════════════════════════════════
|
||||||
|
-- 2. dbo.Seccion
|
||||||
|
-- ═══════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
IF OBJECT_ID(N'dbo.Seccion', N'U') IS NULL
|
||||||
|
BEGIN
|
||||||
|
CREATE TABLE dbo.Seccion (
|
||||||
|
Id INT IDENTITY(1,1) NOT NULL CONSTRAINT PK_Seccion PRIMARY KEY,
|
||||||
|
MedioId INT NOT NULL,
|
||||||
|
Codigo VARCHAR(30) NOT NULL,
|
||||||
|
Nombre NVARCHAR(100) NOT NULL,
|
||||||
|
Tipo VARCHAR(20) NOT NULL, -- 'clasificados' | 'notables' | 'suplementos'
|
||||||
|
Activo BIT NOT NULL CONSTRAINT DF_Seccion_Activo DEFAULT(1),
|
||||||
|
FechaCreacion DATETIME2(3) NOT NULL CONSTRAINT DF_Seccion_FechaCreacion DEFAULT(SYSUTCDATETIME()),
|
||||||
|
FechaModificacion DATETIME2(3) NULL,
|
||||||
|
CONSTRAINT FK_Seccion_Medio FOREIGN KEY (MedioId) REFERENCES dbo.Medio(Id) ON DELETE NO ACTION,
|
||||||
|
CONSTRAINT UQ_Seccion_MedioId_Codigo UNIQUE (MedioId, Codigo),
|
||||||
|
CONSTRAINT CK_Seccion_Tipo CHECK (Tipo IN ('clasificados','notables','suplementos'))
|
||||||
|
);
|
||||||
|
PRINT 'Table dbo.Seccion created.';
|
||||||
|
END
|
||||||
|
ELSE
|
||||||
|
PRINT 'Table dbo.Seccion already exists — skip.';
|
||||||
|
GO
|
||||||
|
|
||||||
|
IF NOT EXISTS (SELECT 1 FROM sys.indexes WHERE name = 'IX_Seccion_MedioId_Activo' AND object_id = OBJECT_ID('dbo.Seccion'))
|
||||||
|
BEGIN
|
||||||
|
CREATE INDEX IX_Seccion_MedioId_Activo
|
||||||
|
ON dbo.Seccion(MedioId, Activo)
|
||||||
|
INCLUDE (Codigo, Nombre, Tipo);
|
||||||
|
PRINT 'Index IX_Seccion_MedioId_Activo created.';
|
||||||
|
END
|
||||||
|
GO
|
||||||
|
|
||||||
|
-- ═══════════════════════════════════════════════════════════════════════
|
||||||
|
-- 3. SYSTEM_VERSIONING — Medio
|
||||||
|
-- ═══════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
IF COL_LENGTH('dbo.Medio', 'ValidFrom') IS NULL
|
||||||
|
BEGIN
|
||||||
|
ALTER TABLE dbo.Medio
|
||||||
|
ADD
|
||||||
|
ValidFrom DATETIME2(3) GENERATED ALWAYS AS ROW START HIDDEN NOT NULL
|
||||||
|
CONSTRAINT DF_Medio_ValidFrom DEFAULT(SYSUTCDATETIME()),
|
||||||
|
ValidTo DATETIME2(3) GENERATED ALWAYS AS ROW END HIDDEN NOT NULL
|
||||||
|
CONSTRAINT DF_Medio_ValidTo DEFAULT(CONVERT(DATETIME2(3), '9999-12-31 23:59:59.999')),
|
||||||
|
PERIOD FOR SYSTEM_TIME (ValidFrom, ValidTo);
|
||||||
|
PRINT 'Medio: PERIOD FOR SYSTEM_TIME added.';
|
||||||
|
END
|
||||||
|
GO
|
||||||
|
|
||||||
|
IF NOT EXISTS (SELECT 1 FROM sys.tables WHERE object_id = OBJECT_ID('dbo.Medio') AND temporal_type = 2)
|
||||||
|
BEGIN
|
||||||
|
ALTER TABLE dbo.Medio
|
||||||
|
SET (SYSTEM_VERSIONING = ON (
|
||||||
|
HISTORY_TABLE = dbo.Medio_History,
|
||||||
|
HISTORY_RETENTION_PERIOD = 10 YEARS
|
||||||
|
));
|
||||||
|
PRINT 'Medio: SYSTEM_VERSIONING = ON (history: dbo.Medio_History, retention: 10 years).';
|
||||||
|
END
|
||||||
|
ELSE
|
||||||
|
PRINT 'Medio: SYSTEM_VERSIONING already ON — skip.';
|
||||||
|
GO
|
||||||
|
|
||||||
|
IF EXISTS (SELECT 1 FROM sys.tables WHERE name = 'Medio_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 = 'Medio_History' AND p.data_compression = 2
|
||||||
|
)
|
||||||
|
BEGIN
|
||||||
|
ALTER TABLE dbo.Medio_History REBUILD WITH (DATA_COMPRESSION = PAGE);
|
||||||
|
PRINT 'Medio_History: rebuilt with PAGE compression.';
|
||||||
|
END
|
||||||
|
GO
|
||||||
|
|
||||||
|
-- ═══════════════════════════════════════════════════════════════════════
|
||||||
|
-- 4. SYSTEM_VERSIONING — Seccion
|
||||||
|
-- ═══════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
IF COL_LENGTH('dbo.Seccion', 'ValidFrom') IS NULL
|
||||||
|
BEGIN
|
||||||
|
ALTER TABLE dbo.Seccion
|
||||||
|
ADD
|
||||||
|
ValidFrom DATETIME2(3) GENERATED ALWAYS AS ROW START HIDDEN NOT NULL
|
||||||
|
CONSTRAINT DF_Seccion_ValidFrom DEFAULT(SYSUTCDATETIME()),
|
||||||
|
ValidTo DATETIME2(3) GENERATED ALWAYS AS ROW END HIDDEN NOT NULL
|
||||||
|
CONSTRAINT DF_Seccion_ValidTo DEFAULT(CONVERT(DATETIME2(3), '9999-12-31 23:59:59.999')),
|
||||||
|
PERIOD FOR SYSTEM_TIME (ValidFrom, ValidTo);
|
||||||
|
PRINT 'Seccion: PERIOD FOR SYSTEM_TIME added.';
|
||||||
|
END
|
||||||
|
GO
|
||||||
|
|
||||||
|
IF NOT EXISTS (SELECT 1 FROM sys.tables WHERE object_id = OBJECT_ID('dbo.Seccion') AND temporal_type = 2)
|
||||||
|
BEGIN
|
||||||
|
ALTER TABLE dbo.Seccion
|
||||||
|
SET (SYSTEM_VERSIONING = ON (
|
||||||
|
HISTORY_TABLE = dbo.Seccion_History,
|
||||||
|
HISTORY_RETENTION_PERIOD = 10 YEARS
|
||||||
|
));
|
||||||
|
PRINT 'Seccion: SYSTEM_VERSIONING = ON.';
|
||||||
|
END
|
||||||
|
ELSE
|
||||||
|
PRINT 'Seccion: SYSTEM_VERSIONING already ON — skip.';
|
||||||
|
GO
|
||||||
|
|
||||||
|
IF EXISTS (SELECT 1 FROM sys.tables WHERE name = 'Seccion_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 = 'Seccion_History' AND p.data_compression = 2
|
||||||
|
)
|
||||||
|
BEGIN
|
||||||
|
ALTER TABLE dbo.Seccion_History REBUILD WITH (DATA_COMPRESSION = PAGE);
|
||||||
|
PRINT 'Seccion_History: rebuilt with PAGE compression.';
|
||||||
|
END
|
||||||
|
GO
|
||||||
|
|
||||||
|
-- ═══════════════════════════════════════════════════════════════════════
|
||||||
|
-- 5. Permiso nuevo: administracion:secciones:gestionar
|
||||||
|
-- ('administracion:medios:gestionar' ya fue sembrado en V005 — no se toca).
|
||||||
|
-- ═══════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
MERGE dbo.Permiso AS t
|
||||||
|
USING (VALUES
|
||||||
|
('administracion:secciones:gestionar', N'Gestionar secciones por medio', N'Crear, editar y desactivar secciones de un medio', 'administracion')
|
||||||
|
) 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', 'administracion:secciones: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 'V011 applied successfully — dbo.Medio + dbo.Seccion (temporal, retention 10y) + permiso secciones.';
|
||||||
|
PRINT 'Next: V012__seed_medios.sql (seed ELDIA, ELPLATA).';
|
||||||
|
GO
|
||||||
30
database/migrations/V012_ROLLBACK.sql
Normal file
30
database/migrations/V012_ROLLBACK.sql
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
-- V012_ROLLBACK.sql
|
||||||
|
-- Reversa de V012__seed_medios.sql.
|
||||||
|
--
|
||||||
|
-- Elimina los seed rows ELDIA y ELPLATA solo si NO tienen Secciones asociadas.
|
||||||
|
-- Si alguna sección depende de un seed Medio, el DELETE falla por FK ON DELETE NO ACTION.
|
||||||
|
|
||||||
|
SET QUOTED_IDENTIFIER ON;
|
||||||
|
SET ANSI_NULLS ON;
|
||||||
|
SET NOCOUNT ON;
|
||||||
|
GO
|
||||||
|
|
||||||
|
-- Falla temprano si hay secciones vivas apuntando a estos Medios.
|
||||||
|
IF EXISTS (
|
||||||
|
SELECT 1
|
||||||
|
FROM dbo.Seccion s
|
||||||
|
JOIN dbo.Medio m ON m.Id = s.MedioId
|
||||||
|
WHERE m.Codigo IN ('ELDIA', 'ELPLATA')
|
||||||
|
)
|
||||||
|
BEGIN
|
||||||
|
RAISERROR('Cannot rollback V012: existen Secciones vinculadas a ELDIA/ELPLATA. Rollback ADM-001 completo con V011_ROLLBACK.sql.', 16, 1);
|
||||||
|
RETURN;
|
||||||
|
END
|
||||||
|
GO
|
||||||
|
|
||||||
|
DELETE FROM dbo.Medio
|
||||||
|
WHERE Codigo IN ('ELDIA', 'ELPLATA');
|
||||||
|
GO
|
||||||
|
|
||||||
|
PRINT 'V012 rolled back — seed Medios ELDIA y ELPLATA removed.';
|
||||||
|
GO
|
||||||
27
database/migrations/V012__seed_medios.sql
Normal file
27
database/migrations/V012__seed_medios.sql
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
-- V012__seed_medios.sql
|
||||||
|
-- ADM-001: seed inicial de Medios ELDIA y ELPLATA.
|
||||||
|
--
|
||||||
|
-- Idempotente via MERGE por Codigo.
|
||||||
|
-- Tipo = 1 (Diario) per enum TipoMedio.
|
||||||
|
-- PlataformaEmpresaId = NULL (INT-003 lo poblará cuando exista el mapeo IMAC).
|
||||||
|
--
|
||||||
|
-- Run on: SIGCM2 (dev) y SIGCM2_Test (integration tests).
|
||||||
|
|
||||||
|
SET QUOTED_IDENTIFIER ON;
|
||||||
|
SET ANSI_NULLS ON;
|
||||||
|
SET NOCOUNT ON;
|
||||||
|
GO
|
||||||
|
|
||||||
|
MERGE dbo.Medio AS t
|
||||||
|
USING (VALUES
|
||||||
|
('ELDIA', N'El Día', 1),
|
||||||
|
('ELPLATA', N'El Plata', 1)
|
||||||
|
) AS s (Codigo, Nombre, Tipo)
|
||||||
|
ON t.Codigo = s.Codigo
|
||||||
|
WHEN NOT MATCHED BY TARGET THEN
|
||||||
|
INSERT (Codigo, Nombre, Tipo, PlataformaEmpresaId, Activo)
|
||||||
|
VALUES (s.Codigo, s.Nombre, s.Tipo, NULL, 1);
|
||||||
|
GO
|
||||||
|
|
||||||
|
PRINT 'V012 applied — Medios ELDIA y ELPLATA seeded (idempotent).';
|
||||||
|
GO
|
||||||
81
database/migrations/V013_ROLLBACK.sql
Normal file
81
database/migrations/V013_ROLLBACK.sql
Normal file
@@ -0,0 +1,81 @@
|
|||||||
|
-- V013_ROLLBACK.sql
|
||||||
|
-- Reversa de V013__create_puntos_de_venta.sql.
|
||||||
|
--
|
||||||
|
-- ADVERTENCIA: ejecutar ELIMINA PuntoDeVenta, su historia temporal,
|
||||||
|
-- el permiso 'administracion:puntos_de_venta:gestionar' y sus asignaciones.
|
||||||
|
--
|
||||||
|
-- Uso intended: ROLLBACK en entornos NO-productivos.
|
||||||
|
-- Prerequisito: no deben existir FKs vivas apuntando a PuntoDeVenta (p.ej., comprobantes FAC-001).
|
||||||
|
-- Si FAC-001 ya está aplicado, este rollback fallará — usar backup.
|
||||||
|
--
|
||||||
|
-- NOTA: SecuenciaComprobante y SP usp_ReservarNumeroComprobante ya no forman parte
|
||||||
|
-- de V013 (eliminados en cirugía post-smoke Batch 9). Este rollback solo maneja PuntoDeVenta.
|
||||||
|
|
||||||
|
SET QUOTED_IDENTIFIER ON;
|
||||||
|
SET ANSI_NULLS ON;
|
||||||
|
SET NOCOUNT ON;
|
||||||
|
GO
|
||||||
|
|
||||||
|
-- ═══════════════════════════════════════════════════════════════════════
|
||||||
|
-- 1. Apagar SYSTEM_VERSIONING + remover PERIOD — PuntoDeVenta
|
||||||
|
-- ═══════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
IF EXISTS (SELECT 1 FROM sys.tables WHERE object_id = OBJECT_ID('dbo.PuntoDeVenta') AND temporal_type = 2)
|
||||||
|
BEGIN
|
||||||
|
ALTER TABLE dbo.PuntoDeVenta SET (SYSTEM_VERSIONING = OFF);
|
||||||
|
PRINT 'PuntoDeVenta: SYSTEM_VERSIONING OFF.';
|
||||||
|
END
|
||||||
|
GO
|
||||||
|
|
||||||
|
IF EXISTS (SELECT 1 FROM sys.periods WHERE object_id = OBJECT_ID('dbo.PuntoDeVenta'))
|
||||||
|
BEGIN
|
||||||
|
ALTER TABLE dbo.PuntoDeVenta DROP PERIOD FOR SYSTEM_TIME;
|
||||||
|
PRINT 'PuntoDeVenta: PERIOD FOR SYSTEM_TIME dropped.';
|
||||||
|
END
|
||||||
|
GO
|
||||||
|
|
||||||
|
IF COL_LENGTH('dbo.PuntoDeVenta', 'ValidFrom') IS NOT NULL
|
||||||
|
BEGIN
|
||||||
|
ALTER TABLE dbo.PuntoDeVenta DROP CONSTRAINT IF EXISTS DF_PuntoDeVenta_ValidFrom;
|
||||||
|
ALTER TABLE dbo.PuntoDeVenta DROP CONSTRAINT IF EXISTS DF_PuntoDeVenta_ValidTo;
|
||||||
|
ALTER TABLE dbo.PuntoDeVenta DROP COLUMN ValidFrom, ValidTo;
|
||||||
|
PRINT 'PuntoDeVenta: ValidFrom/ValidTo dropped.';
|
||||||
|
END
|
||||||
|
GO
|
||||||
|
|
||||||
|
IF OBJECT_ID(N'dbo.PuntoDeVenta_History', N'U') IS NOT NULL
|
||||||
|
BEGIN
|
||||||
|
DROP TABLE dbo.PuntoDeVenta_History;
|
||||||
|
PRINT 'PuntoDeVenta_History dropped.';
|
||||||
|
END
|
||||||
|
GO
|
||||||
|
|
||||||
|
-- ═══════════════════════════════════════════════════════════════════════
|
||||||
|
-- 2. Drop tabla PuntoDeVenta
|
||||||
|
-- ═══════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
IF OBJECT_ID(N'dbo.PuntoDeVenta', N'U') IS NOT NULL
|
||||||
|
BEGIN
|
||||||
|
DROP TABLE dbo.PuntoDeVenta;
|
||||||
|
PRINT 'Table dbo.PuntoDeVenta dropped.';
|
||||||
|
END
|
||||||
|
GO
|
||||||
|
|
||||||
|
-- ═══════════════════════════════════════════════════════════════════════
|
||||||
|
-- 3. Remover permiso 'administracion:puntos_de_venta:gestionar' + RolPermiso
|
||||||
|
-- ═══════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
DELETE rp
|
||||||
|
FROM dbo.RolPermiso rp
|
||||||
|
JOIN dbo.Permiso p ON p.Id = rp.PermisoId
|
||||||
|
WHERE p.Codigo = 'administracion:puntos_de_venta:gestionar';
|
||||||
|
GO
|
||||||
|
|
||||||
|
DELETE FROM dbo.Permiso
|
||||||
|
WHERE Codigo = 'administracion:puntos_de_venta:gestionar';
|
||||||
|
GO
|
||||||
|
|
||||||
|
PRINT '';
|
||||||
|
PRINT 'V013 rolled back. dbo.PuntoDeVenta and its history removed.';
|
||||||
|
PRINT 'Permiso administracion:puntos_de_venta:gestionar removed.';
|
||||||
|
GO
|
||||||
179
database/migrations/V013__create_puntos_de_venta.sql
Normal file
179
database/migrations/V013__create_puntos_de_venta.sql
Normal file
@@ -0,0 +1,179 @@
|
|||||||
|
-- V013__create_puntos_de_venta.sql
|
||||||
|
-- ADM-008 Puntos de Venta: DDL para dbo.PuntoDeVenta + permiso AFIP.
|
||||||
|
--
|
||||||
|
-- NOTA POST-SMOKE (Batch 9): SecuenciaComprobante, SP usp_ReservarNumeroComprobante
|
||||||
|
-- y TipoComprobante fueron eliminados. SIG-CM2.0 NO genera números AFIP — IMAC
|
||||||
|
-- (Plataforma Infogestión) los asigna externamente. Un worker futuro (INT-001)
|
||||||
|
-- polleará la vista de Infogestión para asociar NumeroOrdenInterno ↔ NumeroFacturaAFIP + CAI.
|
||||||
|
-- PuntoDeVenta.NumeroAFIP es config fija que se manda en el payload a IMAC.
|
||||||
|
--
|
||||||
|
-- Cambios:
|
||||||
|
-- 1. dbo.PuntoDeVenta (FK→Medio, UNIQUE(MedioId,NumeroAFIP), SYSTEM_VERSIONING ON, retention 10Y).
|
||||||
|
-- 2. Drops idempotentes de artefactos de versión previa (SecuenciaComprobante + SP).
|
||||||
|
-- 3. Permiso 'administracion:puntos_de_venta:gestionar' + asignación a rol 'admin'.
|
||||||
|
--
|
||||||
|
-- Patrón: V011 (Temporal Tables + Permiso MERGE + PAGE compression en history).
|
||||||
|
-- Idempotente: seguro para re-ejecutar.
|
||||||
|
-- Reversa: V013_ROLLBACK.sql.
|
||||||
|
-- Run on: SIGCM2 (dev) y SIGCM2_Test (integration tests).
|
||||||
|
--
|
||||||
|
-- NOTA: el código de permiso usa guion_bajo (_) según CK_Permiso_Codigo_Format del proyecto.
|
||||||
|
-- Código efectivo: 'administracion:puntos_de_venta:gestionar'
|
||||||
|
-- El spec menciona 'administracion:puntos-de-venta:gestionar' (guion) pero el CHECK constraint
|
||||||
|
-- solo permite [a-z0-9_:] — se usa guion_bajo para cumplir la constraint existente.
|
||||||
|
-- El backend y frontend deben usar el código con guion_bajo.
|
||||||
|
--
|
||||||
|
-- Covers: REQ-PDV-001, -003, -009
|
||||||
|
-- Source of truth: Obsidian/02-ARQUITECTURA-y-TECH-STACK/2.10 ADM-008
|
||||||
|
--
|
||||||
|
-- NOTA T1.3 — Seeds: NO se seedean PuntoDeVenta.
|
||||||
|
-- Cada instalación configura sus propios PdVs con el NumeroAFIP real asignado por AFIP/ARCA.
|
||||||
|
-- Seedear con valores ficticios generaría confusión operativa. El admin los crea manualmente.
|
||||||
|
|
||||||
|
SET QUOTED_IDENTIFIER ON;
|
||||||
|
SET ANSI_NULLS ON;
|
||||||
|
SET NOCOUNT ON;
|
||||||
|
GO
|
||||||
|
|
||||||
|
-- ═══════════════════════════════════════════════════════════════════════
|
||||||
|
-- 0. Drops idempotentes de artefactos de versión previa
|
||||||
|
-- (SecuenciaComprobante + SP — eliminados en cirugía post-smoke Batch 9)
|
||||||
|
-- ═══════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
IF OBJECT_ID(N'dbo.usp_ReservarNumeroComprobante', N'P') IS NOT NULL
|
||||||
|
BEGIN
|
||||||
|
DROP PROCEDURE dbo.usp_ReservarNumeroComprobante;
|
||||||
|
PRINT 'SP dbo.usp_ReservarNumeroComprobante dropped (cleanup).';
|
||||||
|
END
|
||||||
|
GO
|
||||||
|
|
||||||
|
IF EXISTS (SELECT 1 FROM sys.tables WHERE object_id = OBJECT_ID('dbo.SecuenciaComprobante') AND temporal_type = 2)
|
||||||
|
BEGIN
|
||||||
|
ALTER TABLE dbo.SecuenciaComprobante SET (SYSTEM_VERSIONING = OFF);
|
||||||
|
PRINT 'SecuenciaComprobante: SYSTEM_VERSIONING = OFF (cleanup).';
|
||||||
|
END
|
||||||
|
GO
|
||||||
|
|
||||||
|
IF OBJECT_ID(N'dbo.SecuenciaComprobante_History', N'U') IS NOT NULL
|
||||||
|
BEGIN
|
||||||
|
DROP TABLE dbo.SecuenciaComprobante_History;
|
||||||
|
PRINT 'SecuenciaComprobante_History dropped (cleanup).';
|
||||||
|
END
|
||||||
|
GO
|
||||||
|
|
||||||
|
IF OBJECT_ID(N'dbo.SecuenciaComprobante', N'U') IS NOT NULL
|
||||||
|
BEGIN
|
||||||
|
DROP TABLE dbo.SecuenciaComprobante;
|
||||||
|
PRINT 'Table dbo.SecuenciaComprobante dropped (cleanup).';
|
||||||
|
END
|
||||||
|
GO
|
||||||
|
|
||||||
|
-- ═══════════════════════════════════════════════════════════════════════
|
||||||
|
-- 1. dbo.PuntoDeVenta
|
||||||
|
-- ═══════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
IF OBJECT_ID(N'dbo.PuntoDeVenta', N'U') IS NULL
|
||||||
|
BEGIN
|
||||||
|
CREATE TABLE dbo.PuntoDeVenta (
|
||||||
|
Id INT IDENTITY(1,1) NOT NULL CONSTRAINT PK_PuntoDeVenta PRIMARY KEY,
|
||||||
|
MedioId INT NOT NULL,
|
||||||
|
NumeroAFIP SMALLINT NOT NULL,
|
||||||
|
Nombre NVARCHAR(100) NOT NULL,
|
||||||
|
Descripcion NVARCHAR(255) NULL,
|
||||||
|
Activo BIT NOT NULL CONSTRAINT DF_PuntoDeVenta_Activo DEFAULT(1),
|
||||||
|
FechaCreacion DATETIME2(3) NOT NULL CONSTRAINT DF_PuntoDeVenta_FechaCreacion DEFAULT(SYSUTCDATETIME()),
|
||||||
|
FechaModificacion DATETIME2(3) NULL,
|
||||||
|
CONSTRAINT FK_PuntoDeVenta_Medio FOREIGN KEY (MedioId) REFERENCES dbo.Medio(Id) ON DELETE NO ACTION,
|
||||||
|
CONSTRAINT UQ_PuntoDeVenta_Medio_AFIP UNIQUE (MedioId, NumeroAFIP),
|
||||||
|
CONSTRAINT CK_PuntoDeVenta_NumeroAFIP CHECK (NumeroAFIP >= 1)
|
||||||
|
);
|
||||||
|
PRINT 'Table dbo.PuntoDeVenta created.';
|
||||||
|
END
|
||||||
|
ELSE
|
||||||
|
PRINT 'Table dbo.PuntoDeVenta already exists — skip.';
|
||||||
|
GO
|
||||||
|
|
||||||
|
IF NOT EXISTS (SELECT 1 FROM sys.indexes WHERE name = 'IX_PuntoDeVenta_MedioId_Activo' AND object_id = OBJECT_ID('dbo.PuntoDeVenta'))
|
||||||
|
BEGIN
|
||||||
|
CREATE INDEX IX_PuntoDeVenta_MedioId_Activo
|
||||||
|
ON dbo.PuntoDeVenta(MedioId, Activo)
|
||||||
|
INCLUDE (NumeroAFIP, Nombre);
|
||||||
|
PRINT 'Index IX_PuntoDeVenta_MedioId_Activo created.';
|
||||||
|
END
|
||||||
|
GO
|
||||||
|
|
||||||
|
-- ═══════════════════════════════════════════════════════════════════════
|
||||||
|
-- 2. SYSTEM_VERSIONING — PuntoDeVenta
|
||||||
|
-- ═══════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
IF COL_LENGTH('dbo.PuntoDeVenta', 'ValidFrom') IS NULL
|
||||||
|
BEGIN
|
||||||
|
ALTER TABLE dbo.PuntoDeVenta
|
||||||
|
ADD
|
||||||
|
ValidFrom DATETIME2(3) GENERATED ALWAYS AS ROW START HIDDEN NOT NULL
|
||||||
|
CONSTRAINT DF_PuntoDeVenta_ValidFrom DEFAULT(SYSUTCDATETIME()),
|
||||||
|
ValidTo DATETIME2(3) GENERATED ALWAYS AS ROW END HIDDEN NOT NULL
|
||||||
|
CONSTRAINT DF_PuntoDeVenta_ValidTo DEFAULT(CONVERT(DATETIME2(3), '9999-12-31 23:59:59.999')),
|
||||||
|
PERIOD FOR SYSTEM_TIME (ValidFrom, ValidTo);
|
||||||
|
PRINT 'PuntoDeVenta: PERIOD FOR SYSTEM_TIME added.';
|
||||||
|
END
|
||||||
|
GO
|
||||||
|
|
||||||
|
IF NOT EXISTS (SELECT 1 FROM sys.tables WHERE object_id = OBJECT_ID('dbo.PuntoDeVenta') AND temporal_type = 2)
|
||||||
|
BEGIN
|
||||||
|
ALTER TABLE dbo.PuntoDeVenta
|
||||||
|
SET (SYSTEM_VERSIONING = ON (
|
||||||
|
HISTORY_TABLE = dbo.PuntoDeVenta_History,
|
||||||
|
HISTORY_RETENTION_PERIOD = 10 YEARS
|
||||||
|
));
|
||||||
|
PRINT 'PuntoDeVenta: SYSTEM_VERSIONING = ON (history: dbo.PuntoDeVenta_History, retention: 10 years).';
|
||||||
|
END
|
||||||
|
ELSE
|
||||||
|
PRINT 'PuntoDeVenta: SYSTEM_VERSIONING already ON — skip.';
|
||||||
|
GO
|
||||||
|
|
||||||
|
IF EXISTS (SELECT 1 FROM sys.tables WHERE name = 'PuntoDeVenta_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 = 'PuntoDeVenta_History' AND p.data_compression = 2
|
||||||
|
)
|
||||||
|
BEGIN
|
||||||
|
ALTER TABLE dbo.PuntoDeVenta_History REBUILD WITH (DATA_COMPRESSION = PAGE);
|
||||||
|
PRINT 'PuntoDeVenta_History: rebuilt with PAGE compression.';
|
||||||
|
END
|
||||||
|
GO
|
||||||
|
|
||||||
|
-- ═══════════════════════════════════════════════════════════════════════
|
||||||
|
-- 3. Permiso: administracion:puntos_de_venta:gestionar
|
||||||
|
-- ═══════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
MERGE dbo.Permiso AS t
|
||||||
|
USING (VALUES
|
||||||
|
('administracion:puntos_de_venta:gestionar', N'Gestionar puntos de venta', N'Crear, editar y desactivar puntos de venta AFIP', 'administracion')
|
||||||
|
) 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', 'administracion:puntos_de_venta: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 'V013 applied successfully.';
|
||||||
|
PRINT ' - dbo.PuntoDeVenta (temporal, retention 10y, PAGE compression)';
|
||||||
|
PRINT ' - Permiso administracion:puntos_de_venta:gestionar (asignado a admin)';
|
||||||
|
PRINT ' - Artefactos de version previa (SecuenciaComprobante + SP) eliminados si existian';
|
||||||
|
GO
|
||||||
141
database/migrations/V014_ROLLBACK.sql
Normal file
141
database/migrations/V014_ROLLBACK.sql
Normal file
@@ -0,0 +1,141 @@
|
|||||||
|
-- V014_ROLLBACK.sql
|
||||||
|
-- Reversa de V014__create_tablas_fiscales.sql.
|
||||||
|
--
|
||||||
|
-- ADVERTENCIA: ejecutar ELIMINA TipoDeIva, IngresosBrutos, sus historiales temporales,
|
||||||
|
-- el permiso 'administracion:fiscal:gestionar' y sus asignaciones.
|
||||||
|
--
|
||||||
|
-- Uso intended: ROLLBACK en entornos NO-productivos.
|
||||||
|
-- Prerequisito: no deben existir FKs vivas apuntando a estas tablas (FAC-001, etc.).
|
||||||
|
-- Si FAC-001 ya esta aplicado, este rollback fallara — usar backup.
|
||||||
|
--
|
||||||
|
-- Idempotente: seguro para re-ejecutar (guards en cada paso).
|
||||||
|
|
||||||
|
SET QUOTED_IDENTIFIER ON;
|
||||||
|
SET ANSI_NULLS ON;
|
||||||
|
SET NOCOUNT ON;
|
||||||
|
GO
|
||||||
|
|
||||||
|
-- ═══════════════════════════════════════════════════════════════════════
|
||||||
|
-- 1. Apagar SYSTEM_VERSIONING — TipoDeIva
|
||||||
|
-- ═══════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
IF EXISTS (SELECT 1 FROM sys.tables WHERE object_id = OBJECT_ID('dbo.TipoDeIva') AND temporal_type = 2)
|
||||||
|
BEGIN
|
||||||
|
ALTER TABLE dbo.TipoDeIva SET (SYSTEM_VERSIONING = OFF);
|
||||||
|
PRINT 'TipoDeIva: SYSTEM_VERSIONING OFF.';
|
||||||
|
END
|
||||||
|
GO
|
||||||
|
|
||||||
|
IF EXISTS (SELECT 1 FROM sys.periods WHERE object_id = OBJECT_ID('dbo.TipoDeIva'))
|
||||||
|
BEGIN
|
||||||
|
ALTER TABLE dbo.TipoDeIva DROP PERIOD FOR SYSTEM_TIME;
|
||||||
|
PRINT 'TipoDeIva: PERIOD FOR SYSTEM_TIME dropped.';
|
||||||
|
END
|
||||||
|
GO
|
||||||
|
|
||||||
|
IF COL_LENGTH('dbo.TipoDeIva', 'ValidFrom') IS NOT NULL
|
||||||
|
BEGIN
|
||||||
|
ALTER TABLE dbo.TipoDeIva DROP CONSTRAINT IF EXISTS DF_TipoDeIva_ValidFrom;
|
||||||
|
ALTER TABLE dbo.TipoDeIva DROP CONSTRAINT IF EXISTS DF_TipoDeIva_ValidTo;
|
||||||
|
ALTER TABLE dbo.TipoDeIva DROP COLUMN ValidFrom, ValidTo;
|
||||||
|
PRINT 'TipoDeIva: ValidFrom/ValidTo dropped.';
|
||||||
|
END
|
||||||
|
GO
|
||||||
|
|
||||||
|
-- ═══════════════════════════════════════════════════════════════════════
|
||||||
|
-- 2. Apagar SYSTEM_VERSIONING — IngresosBrutos
|
||||||
|
-- ═══════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
IF EXISTS (SELECT 1 FROM sys.tables WHERE object_id = OBJECT_ID('dbo.IngresosBrutos') AND temporal_type = 2)
|
||||||
|
BEGIN
|
||||||
|
ALTER TABLE dbo.IngresosBrutos SET (SYSTEM_VERSIONING = OFF);
|
||||||
|
PRINT 'IngresosBrutos: SYSTEM_VERSIONING OFF.';
|
||||||
|
END
|
||||||
|
GO
|
||||||
|
|
||||||
|
IF EXISTS (SELECT 1 FROM sys.periods WHERE object_id = OBJECT_ID('dbo.IngresosBrutos'))
|
||||||
|
BEGIN
|
||||||
|
ALTER TABLE dbo.IngresosBrutos DROP PERIOD FOR SYSTEM_TIME;
|
||||||
|
PRINT 'IngresosBrutos: PERIOD FOR SYSTEM_TIME dropped.';
|
||||||
|
END
|
||||||
|
GO
|
||||||
|
|
||||||
|
IF COL_LENGTH('dbo.IngresosBrutos', 'ValidFrom') IS NOT NULL
|
||||||
|
BEGIN
|
||||||
|
ALTER TABLE dbo.IngresosBrutos DROP CONSTRAINT IF EXISTS DF_IIBB_ValidFrom;
|
||||||
|
ALTER TABLE dbo.IngresosBrutos DROP CONSTRAINT IF EXISTS DF_IIBB_ValidTo;
|
||||||
|
ALTER TABLE dbo.IngresosBrutos DROP COLUMN ValidFrom, ValidTo;
|
||||||
|
PRINT 'IngresosBrutos: ValidFrom/ValidTo dropped.';
|
||||||
|
END
|
||||||
|
GO
|
||||||
|
|
||||||
|
-- ═══════════════════════════════════════════════════════════════════════
|
||||||
|
-- 3. Drop FK self antes de DROP TABLE (para evitar constraint violation)
|
||||||
|
-- ═══════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
IF OBJECT_ID('FK_TipoDeIva_Predecesor', 'F') IS NOT NULL
|
||||||
|
BEGIN
|
||||||
|
ALTER TABLE dbo.TipoDeIva DROP CONSTRAINT FK_TipoDeIva_Predecesor;
|
||||||
|
PRINT 'FK_TipoDeIva_Predecesor dropped.';
|
||||||
|
END
|
||||||
|
GO
|
||||||
|
|
||||||
|
IF OBJECT_ID('FK_IIBB_Predecesor', 'F') IS NOT NULL
|
||||||
|
BEGIN
|
||||||
|
ALTER TABLE dbo.IngresosBrutos DROP CONSTRAINT FK_IIBB_Predecesor;
|
||||||
|
PRINT 'FK_IIBB_Predecesor dropped.';
|
||||||
|
END
|
||||||
|
GO
|
||||||
|
|
||||||
|
-- ═══════════════════════════════════════════════════════════════════════
|
||||||
|
-- 4. Drop history tables → main tables
|
||||||
|
-- ═══════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
IF OBJECT_ID(N'dbo.TipoDeIva_History', N'U') IS NOT NULL
|
||||||
|
BEGIN
|
||||||
|
DROP TABLE dbo.TipoDeIva_History;
|
||||||
|
PRINT 'TipoDeIva_History dropped.';
|
||||||
|
END
|
||||||
|
GO
|
||||||
|
|
||||||
|
IF OBJECT_ID(N'dbo.IngresosBrutos_History', N'U') IS NOT NULL
|
||||||
|
BEGIN
|
||||||
|
DROP TABLE dbo.IngresosBrutos_History;
|
||||||
|
PRINT 'IngresosBrutos_History dropped.';
|
||||||
|
END
|
||||||
|
GO
|
||||||
|
|
||||||
|
IF OBJECT_ID(N'dbo.TipoDeIva', N'U') IS NOT NULL
|
||||||
|
BEGIN
|
||||||
|
DROP TABLE dbo.TipoDeIva;
|
||||||
|
PRINT 'Table dbo.TipoDeIva dropped.';
|
||||||
|
END
|
||||||
|
GO
|
||||||
|
|
||||||
|
IF OBJECT_ID(N'dbo.IngresosBrutos', N'U') IS NOT NULL
|
||||||
|
BEGIN
|
||||||
|
DROP TABLE dbo.IngresosBrutos;
|
||||||
|
PRINT 'Table dbo.IngresosBrutos dropped.';
|
||||||
|
END
|
||||||
|
GO
|
||||||
|
|
||||||
|
-- ═══════════════════════════════════════════════════════════════════════
|
||||||
|
-- 5. Remover permiso 'administracion:fiscal:gestionar' + RolPermiso
|
||||||
|
-- ═══════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
DELETE rp
|
||||||
|
FROM dbo.RolPermiso rp
|
||||||
|
JOIN dbo.Permiso p ON p.Id = rp.PermisoId
|
||||||
|
WHERE p.Codigo = 'administracion:fiscal:gestionar';
|
||||||
|
GO
|
||||||
|
|
||||||
|
DELETE FROM dbo.Permiso
|
||||||
|
WHERE Codigo = 'administracion:fiscal:gestionar';
|
||||||
|
GO
|
||||||
|
|
||||||
|
PRINT '';
|
||||||
|
PRINT 'V014 rolled back.';
|
||||||
|
PRINT ' - dbo.TipoDeIva and dbo.TipoDeIva_History removed.';
|
||||||
|
PRINT ' - dbo.IngresosBrutos and dbo.IngresosBrutos_History removed.';
|
||||||
|
PRINT ' - Permiso administracion:fiscal:gestionar removed.';
|
||||||
|
GO
|
||||||
293
database/migrations/V014__create_tablas_fiscales.sql
Normal file
293
database/migrations/V014__create_tablas_fiscales.sql
Normal file
@@ -0,0 +1,293 @@
|
|||||||
|
-- V014__create_tablas_fiscales.sql
|
||||||
|
-- ADM-009 Tablas Fiscales: DDL para dbo.TipoDeIva + dbo.IngresosBrutos + permisos.
|
||||||
|
--
|
||||||
|
-- Patron: append-only versioned ref data.
|
||||||
|
-- Porcentaje/Alicuota son INMUTABLES post-creacion; cambiar el valor = nueva fila + cierre de predecesora.
|
||||||
|
-- PredecesorId (FK self) establece la cadena de versiones (historial de negocio).
|
||||||
|
-- SYSTEM_VERSIONING ON para historial tecnico (auditoria temporal de SQL Server).
|
||||||
|
--
|
||||||
|
-- Idempotente: seguro para re-ejecutar.
|
||||||
|
-- Reversa: V014_ROLLBACK.sql.
|
||||||
|
-- Run on: SIGCM2 (dev) y SIGCM2_Test (integration tests).
|
||||||
|
--
|
||||||
|
-- Covers: REQ-SEED-001, REQ-SEED-002, REQ-SEED-003, REQ-TEMPORAL-001, REQ-FISCAL-AUTH-002
|
||||||
|
|
||||||
|
SET QUOTED_IDENTIFIER ON;
|
||||||
|
SET ANSI_NULLS ON;
|
||||||
|
SET NOCOUNT ON;
|
||||||
|
GO
|
||||||
|
|
||||||
|
-- ═══════════════════════════════════════════════════════════════════════
|
||||||
|
-- 1. dbo.TipoDeIva
|
||||||
|
-- ═══════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
IF OBJECT_ID(N'dbo.TipoDeIva', N'U') IS NULL
|
||||||
|
BEGIN
|
||||||
|
CREATE TABLE dbo.TipoDeIva (
|
||||||
|
Id INT IDENTITY(1,1) NOT NULL CONSTRAINT PK_TipoDeIva PRIMARY KEY,
|
||||||
|
Codigo VARCHAR(32) NOT NULL,
|
||||||
|
Descripcion NVARCHAR(100) NOT NULL,
|
||||||
|
Porcentaje DECIMAL(5,2) NOT NULL,
|
||||||
|
AplicaIVA BIT NOT NULL,
|
||||||
|
Activo BIT NOT NULL CONSTRAINT DF_TipoDeIva_Activo DEFAULT(1),
|
||||||
|
VigenciaDesde DATE NOT NULL,
|
||||||
|
VigenciaHasta DATE NULL,
|
||||||
|
PredecesorId INT NULL,
|
||||||
|
FechaCreacion DATETIME2(3) NOT NULL CONSTRAINT DF_TipoDeIva_FechaCreacion DEFAULT(SYSUTCDATETIME()),
|
||||||
|
FechaModificacion DATETIME2(3) NULL,
|
||||||
|
CONSTRAINT CK_TipoDeIva_Porcentaje CHECK (Porcentaje >= 0 AND Porcentaje <= 100),
|
||||||
|
CONSTRAINT CK_TipoDeIva_Vigencia CHECK (VigenciaHasta IS NULL OR VigenciaHasta >= VigenciaDesde),
|
||||||
|
CONSTRAINT UQ_TipoDeIva_Codigo_Vigencia UNIQUE (Codigo, VigenciaDesde),
|
||||||
|
CONSTRAINT FK_TipoDeIva_Predecesor FOREIGN KEY (PredecesorId) REFERENCES dbo.TipoDeIva(Id)
|
||||||
|
);
|
||||||
|
PRINT 'Table dbo.TipoDeIva created.';
|
||||||
|
END
|
||||||
|
ELSE
|
||||||
|
PRINT 'Table dbo.TipoDeIva already exists — skip.';
|
||||||
|
GO
|
||||||
|
|
||||||
|
-- Indices TipoDeIva
|
||||||
|
IF NOT EXISTS (SELECT 1 FROM sys.indexes WHERE name = 'IX_TipoDeIva_Codigo_VigenciaDesde' AND object_id = OBJECT_ID('dbo.TipoDeIva'))
|
||||||
|
BEGIN
|
||||||
|
CREATE INDEX IX_TipoDeIva_Codigo_VigenciaDesde
|
||||||
|
ON dbo.TipoDeIva(Codigo, VigenciaDesde DESC);
|
||||||
|
PRINT 'Index IX_TipoDeIva_Codigo_VigenciaDesde created.';
|
||||||
|
END
|
||||||
|
GO
|
||||||
|
|
||||||
|
IF NOT EXISTS (SELECT 1 FROM sys.indexes WHERE name = 'IX_TipoDeIva_PredecesorId' AND object_id = OBJECT_ID('dbo.TipoDeIva'))
|
||||||
|
BEGIN
|
||||||
|
CREATE INDEX IX_TipoDeIva_PredecesorId
|
||||||
|
ON dbo.TipoDeIva(PredecesorId)
|
||||||
|
WHERE PredecesorId IS NOT NULL;
|
||||||
|
PRINT 'Index IX_TipoDeIva_PredecesorId created.';
|
||||||
|
END
|
||||||
|
GO
|
||||||
|
|
||||||
|
-- SYSTEM_VERSIONING — TipoDeIva
|
||||||
|
IF COL_LENGTH('dbo.TipoDeIva', 'ValidFrom') IS NULL
|
||||||
|
BEGIN
|
||||||
|
ALTER TABLE dbo.TipoDeIva
|
||||||
|
ADD
|
||||||
|
ValidFrom DATETIME2(3) GENERATED ALWAYS AS ROW START HIDDEN NOT NULL
|
||||||
|
CONSTRAINT DF_TipoDeIva_ValidFrom DEFAULT(SYSUTCDATETIME()),
|
||||||
|
ValidTo DATETIME2(3) GENERATED ALWAYS AS ROW END HIDDEN NOT NULL
|
||||||
|
CONSTRAINT DF_TipoDeIva_ValidTo DEFAULT(CONVERT(DATETIME2(3), '9999-12-31 23:59:59.999')),
|
||||||
|
PERIOD FOR SYSTEM_TIME (ValidFrom, ValidTo);
|
||||||
|
PRINT 'TipoDeIva: PERIOD FOR SYSTEM_TIME added.';
|
||||||
|
END
|
||||||
|
GO
|
||||||
|
|
||||||
|
IF NOT EXISTS (SELECT 1 FROM sys.tables WHERE object_id = OBJECT_ID('dbo.TipoDeIva') AND temporal_type = 2)
|
||||||
|
BEGIN
|
||||||
|
ALTER TABLE dbo.TipoDeIva
|
||||||
|
SET (SYSTEM_VERSIONING = ON (
|
||||||
|
HISTORY_TABLE = dbo.TipoDeIva_History,
|
||||||
|
HISTORY_RETENTION_PERIOD = 10 YEARS
|
||||||
|
));
|
||||||
|
PRINT 'TipoDeIva: SYSTEM_VERSIONING = ON (history: dbo.TipoDeIva_History, retention: 10 years).';
|
||||||
|
END
|
||||||
|
ELSE
|
||||||
|
PRINT 'TipoDeIva: SYSTEM_VERSIONING already ON — skip.';
|
||||||
|
GO
|
||||||
|
|
||||||
|
IF EXISTS (SELECT 1 FROM sys.tables WHERE name = 'TipoDeIva_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 = 'TipoDeIva_History' AND p.data_compression = 2
|
||||||
|
)
|
||||||
|
BEGIN
|
||||||
|
ALTER TABLE dbo.TipoDeIva_History REBUILD WITH (DATA_COMPRESSION = PAGE);
|
||||||
|
PRINT 'TipoDeIva_History: rebuilt with PAGE compression.';
|
||||||
|
END
|
||||||
|
GO
|
||||||
|
|
||||||
|
-- ═══════════════════════════════════════════════════════════════════════
|
||||||
|
-- 2. dbo.IngresosBrutos
|
||||||
|
-- ═══════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
IF OBJECT_ID(N'dbo.IngresosBrutos', N'U') IS NULL
|
||||||
|
BEGIN
|
||||||
|
CREATE TABLE dbo.IngresosBrutos (
|
||||||
|
Id INT IDENTITY(1,1) NOT NULL CONSTRAINT PK_IngresosBrutos PRIMARY KEY,
|
||||||
|
Provincia VARCHAR(50) NOT NULL,
|
||||||
|
Descripcion NVARCHAR(100) NOT NULL,
|
||||||
|
Alicuota DECIMAL(5,2) NOT NULL,
|
||||||
|
Activo BIT NOT NULL CONSTRAINT DF_IIBB_Activo DEFAULT(1),
|
||||||
|
VigenciaDesde DATE NOT NULL,
|
||||||
|
VigenciaHasta DATE NULL,
|
||||||
|
PredecesorId INT NULL,
|
||||||
|
FechaCreacion DATETIME2(3) NOT NULL CONSTRAINT DF_IIBB_FechaCreacion DEFAULT(SYSUTCDATETIME()),
|
||||||
|
FechaModificacion DATETIME2(3) NULL,
|
||||||
|
CONSTRAINT CK_IIBB_Alicuota CHECK (Alicuota >= 0 AND Alicuota <= 100),
|
||||||
|
CONSTRAINT CK_IIBB_Vigencia CHECK (VigenciaHasta IS NULL OR VigenciaHasta >= VigenciaDesde),
|
||||||
|
CONSTRAINT UQ_IIBB_Provincia_Vigencia UNIQUE (Provincia, VigenciaDesde),
|
||||||
|
CONSTRAINT FK_IIBB_Predecesor FOREIGN KEY (PredecesorId) REFERENCES dbo.IngresosBrutos(Id)
|
||||||
|
);
|
||||||
|
PRINT 'Table dbo.IngresosBrutos created.';
|
||||||
|
END
|
||||||
|
ELSE
|
||||||
|
PRINT 'Table dbo.IngresosBrutos already exists — skip.';
|
||||||
|
GO
|
||||||
|
|
||||||
|
-- Indices IngresosBrutos
|
||||||
|
IF NOT EXISTS (SELECT 1 FROM sys.indexes WHERE name = 'IX_IIBB_Provincia_VigenciaDesde' AND object_id = OBJECT_ID('dbo.IngresosBrutos'))
|
||||||
|
BEGIN
|
||||||
|
CREATE INDEX IX_IIBB_Provincia_VigenciaDesde
|
||||||
|
ON dbo.IngresosBrutos(Provincia, VigenciaDesde DESC);
|
||||||
|
PRINT 'Index IX_IIBB_Provincia_VigenciaDesde created.';
|
||||||
|
END
|
||||||
|
GO
|
||||||
|
|
||||||
|
IF NOT EXISTS (SELECT 1 FROM sys.indexes WHERE name = 'IX_IIBB_PredecesorId' AND object_id = OBJECT_ID('dbo.IngresosBrutos'))
|
||||||
|
BEGIN
|
||||||
|
CREATE INDEX IX_IIBB_PredecesorId
|
||||||
|
ON dbo.IngresosBrutos(PredecesorId)
|
||||||
|
WHERE PredecesorId IS NOT NULL;
|
||||||
|
PRINT 'Index IX_IIBB_PredecesorId created.';
|
||||||
|
END
|
||||||
|
GO
|
||||||
|
|
||||||
|
-- SYSTEM_VERSIONING — IngresosBrutos
|
||||||
|
IF COL_LENGTH('dbo.IngresosBrutos', 'ValidFrom') IS NULL
|
||||||
|
BEGIN
|
||||||
|
ALTER TABLE dbo.IngresosBrutos
|
||||||
|
ADD
|
||||||
|
ValidFrom DATETIME2(3) GENERATED ALWAYS AS ROW START HIDDEN NOT NULL
|
||||||
|
CONSTRAINT DF_IIBB_ValidFrom DEFAULT(SYSUTCDATETIME()),
|
||||||
|
ValidTo DATETIME2(3) GENERATED ALWAYS AS ROW END HIDDEN NOT NULL
|
||||||
|
CONSTRAINT DF_IIBB_ValidTo DEFAULT(CONVERT(DATETIME2(3), '9999-12-31 23:59:59.999')),
|
||||||
|
PERIOD FOR SYSTEM_TIME (ValidFrom, ValidTo);
|
||||||
|
PRINT 'IngresosBrutos: PERIOD FOR SYSTEM_TIME added.';
|
||||||
|
END
|
||||||
|
GO
|
||||||
|
|
||||||
|
IF NOT EXISTS (SELECT 1 FROM sys.tables WHERE object_id = OBJECT_ID('dbo.IngresosBrutos') AND temporal_type = 2)
|
||||||
|
BEGIN
|
||||||
|
ALTER TABLE dbo.IngresosBrutos
|
||||||
|
SET (SYSTEM_VERSIONING = ON (
|
||||||
|
HISTORY_TABLE = dbo.IngresosBrutos_History,
|
||||||
|
HISTORY_RETENTION_PERIOD = 10 YEARS
|
||||||
|
));
|
||||||
|
PRINT 'IngresosBrutos: SYSTEM_VERSIONING = ON (history: dbo.IngresosBrutos_History, retention: 10 years).';
|
||||||
|
END
|
||||||
|
ELSE
|
||||||
|
PRINT 'IngresosBrutos: SYSTEM_VERSIONING already ON — skip.';
|
||||||
|
GO
|
||||||
|
|
||||||
|
IF EXISTS (SELECT 1 FROM sys.tables WHERE name = 'IngresosBrutos_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 = 'IngresosBrutos_History' AND p.data_compression = 2
|
||||||
|
)
|
||||||
|
BEGIN
|
||||||
|
ALTER TABLE dbo.IngresosBrutos_History REBUILD WITH (DATA_COMPRESSION = PAGE);
|
||||||
|
PRINT 'IngresosBrutos_History: rebuilt with PAGE compression.';
|
||||||
|
END
|
||||||
|
GO
|
||||||
|
|
||||||
|
-- ═══════════════════════════════════════════════════════════════════════
|
||||||
|
-- 3. Seed TipoDeIva — 4 filas canonicas (REQ-SEED-001)
|
||||||
|
-- MERGE garantiza idempotencia (REQ-SEED-003)
|
||||||
|
-- EXENTO y NO_GRAVADO no aplican IVA; IVA_105 e IVA_21 si aplican.
|
||||||
|
-- ═══════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
MERGE dbo.TipoDeIva AS t
|
||||||
|
USING (VALUES
|
||||||
|
('EXENTO', N'Exento de IVA', CAST(0 AS DECIMAL(5,2)), CAST(0 AS BIT), CAST('2020-01-01' AS DATE)),
|
||||||
|
('NO_GRAVADO', N'No gravado', CAST(0 AS DECIMAL(5,2)), CAST(0 AS BIT), CAST('2020-01-01' AS DATE)),
|
||||||
|
('IVA_105', N'IVA alicuota diferencial 10.5%', CAST(10.5 AS DECIMAL(5,2)), CAST(1 AS BIT), CAST('2020-01-01' AS DATE)),
|
||||||
|
('IVA_21', N'IVA alicuota general 21%', CAST(21 AS DECIMAL(5,2)), CAST(1 AS BIT), CAST('2020-01-01' AS DATE))
|
||||||
|
) AS s (Codigo, Descripcion, Porcentaje, AplicaIVA, VigenciaDesde)
|
||||||
|
ON t.Codigo = s.Codigo AND t.PredecesorId IS NULL
|
||||||
|
WHEN NOT MATCHED BY TARGET THEN
|
||||||
|
INSERT (Codigo, Descripcion, Porcentaje, AplicaIVA, Activo, VigenciaDesde, VigenciaHasta, PredecesorId)
|
||||||
|
VALUES (s.Codigo, s.Descripcion, s.Porcentaje, s.AplicaIVA, 1, s.VigenciaDesde, NULL, NULL);
|
||||||
|
GO
|
||||||
|
|
||||||
|
PRINT 'TipoDeIva: 4 canonical rows seeded (EXENTO, NO_GRAVADO, IVA_105, IVA_21).';
|
||||||
|
GO
|
||||||
|
|
||||||
|
-- ═══════════════════════════════════════════════════════════════════════
|
||||||
|
-- 4. Seed IngresosBrutos — 24 filas (23 provincias INDEC + CABA) (REQ-SEED-002)
|
||||||
|
-- Alicuota=0 placeholder — el operador cargara las alicuotas reales via UI.
|
||||||
|
-- MERGE garantiza idempotencia (REQ-SEED-003).
|
||||||
|
-- Provincias almacenadas como nombre de enum ProvinciaArgentina PascalCase (VARCHAR(50)).
|
||||||
|
-- DISCOVERY: spec dice 25 filas pero lista canonica del design tiene 24 entradas
|
||||||
|
-- (23 provincias INDEC + CABA). Implementado con 24. Ver apply-progress.
|
||||||
|
-- T700 cleanup: valores cambiados de UPPER_SNAKE_CASE a PascalCase (matching enum.ToString()).
|
||||||
|
-- ═══════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
MERGE dbo.IngresosBrutos AS t
|
||||||
|
USING (VALUES
|
||||||
|
('BuenosAires', N'Ingresos Brutos - Buenos Aires'),
|
||||||
|
('CiudadAutonomaDeBuenosAires', N'Ingresos Brutos - Ciudad Autonoma de Buenos Aires'),
|
||||||
|
('Catamarca', N'Ingresos Brutos - Catamarca'),
|
||||||
|
('Chaco', N'Ingresos Brutos - Chaco'),
|
||||||
|
('Chubut', N'Ingresos Brutos - Chubut'),
|
||||||
|
('Cordoba', N'Ingresos Brutos - Cordoba'),
|
||||||
|
('Corrientes', N'Ingresos Brutos - Corrientes'),
|
||||||
|
('EntreRios', N'Ingresos Brutos - Entre Rios'),
|
||||||
|
('Formosa', N'Ingresos Brutos - Formosa'),
|
||||||
|
('Jujuy', N'Ingresos Brutos - Jujuy'),
|
||||||
|
('LaPampa', N'Ingresos Brutos - La Pampa'),
|
||||||
|
('LaRioja', N'Ingresos Brutos - La Rioja'),
|
||||||
|
('Mendoza', N'Ingresos Brutos - Mendoza'),
|
||||||
|
('Misiones', N'Ingresos Brutos - Misiones'),
|
||||||
|
('Neuquen', N'Ingresos Brutos - Neuquen'),
|
||||||
|
('RioNegro', N'Ingresos Brutos - Rio Negro'),
|
||||||
|
('Salta', N'Ingresos Brutos - Salta'),
|
||||||
|
('SanJuan', N'Ingresos Brutos - San Juan'),
|
||||||
|
('SanLuis', N'Ingresos Brutos - San Luis'),
|
||||||
|
('SantaCruz', N'Ingresos Brutos - Santa Cruz'),
|
||||||
|
('SantaFe', N'Ingresos Brutos - Santa Fe'),
|
||||||
|
('SantiagoDelEstero', N'Ingresos Brutos - Santiago del Estero'),
|
||||||
|
('TierraDelFuego', N'Ingresos Brutos - Tierra del Fuego'),
|
||||||
|
('Tucuman', N'Ingresos Brutos - Tucuman')
|
||||||
|
) AS s (Provincia, Descripcion)
|
||||||
|
ON t.Provincia = s.Provincia AND t.PredecesorId IS NULL
|
||||||
|
WHEN NOT MATCHED BY TARGET THEN
|
||||||
|
INSERT (Provincia, Descripcion, Alicuota, Activo, VigenciaDesde, VigenciaHasta, PredecesorId)
|
||||||
|
VALUES (s.Provincia, s.Descripcion, CAST(0 AS DECIMAL(5,2)), 1, CAST('2020-01-01' AS DATE), NULL, NULL);
|
||||||
|
GO
|
||||||
|
|
||||||
|
PRINT 'IngresosBrutos: 24 canonical rows seeded (23 provincias INDEC + CABA, Alicuota=0 placeholder, PascalCase).';
|
||||||
|
GO
|
||||||
|
|
||||||
|
-- ═══════════════════════════════════════════════════════════════════════
|
||||||
|
-- 5. Permiso: administracion:fiscal:gestionar (REQ-FISCAL-AUTH-002)
|
||||||
|
-- ═══════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
MERGE dbo.Permiso AS t
|
||||||
|
USING (VALUES
|
||||||
|
('administracion:fiscal:gestionar', N'Gestionar tablas fiscales', N'Gestionar tablas fiscales (IVA, IIBB)', 'administracion')
|
||||||
|
) 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', 'administracion:fiscal: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 'V014 applied successfully.';
|
||||||
|
PRINT ' - dbo.TipoDeIva (temporal, retention 10y, PAGE compression)';
|
||||||
|
PRINT ' - dbo.IngresosBrutos (temporal, retention 10y, PAGE compression)';
|
||||||
|
PRINT ' - TipoDeIva: 4 canonical rows (EXENTO, NO_GRAVADO, IVA_105, IVA_21)';
|
||||||
|
PRINT ' - IngresosBrutos: 24 rows (23 provincias INDEC + CABA, Alicuota=0 placeholder)';
|
||||||
|
PRINT ' - Permiso administracion:fiscal:gestionar (asignado a admin)';
|
||||||
|
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
|
||||||
123
src/api/SIGCM2.Api/Contracts/Fiscal/FiscalContracts.cs
Normal file
123
src/api/SIGCM2.Api/Contracts/Fiscal/FiscalContracts.cs
Normal file
@@ -0,0 +1,123 @@
|
|||||||
|
using SIGCM2.Application.IngresosBrutos.Dtos;
|
||||||
|
using SIGCM2.Application.TiposDeIva.Dtos;
|
||||||
|
using SIGCM2.Domain.Fiscal;
|
||||||
|
|
||||||
|
namespace SIGCM2.Api.Contracts.Fiscal;
|
||||||
|
|
||||||
|
// ── IVA Request records ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/// <summary>ADM-009: Create TipoDeIva request body.</summary>
|
||||||
|
public sealed record CreateTipoDeIvaRequest(
|
||||||
|
string? Codigo,
|
||||||
|
string? Descripcion,
|
||||||
|
decimal? Porcentaje,
|
||||||
|
bool? AplicaIVA,
|
||||||
|
string? VigenciaDesde,
|
||||||
|
string? VigenciaHasta = null);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// ADM-009: Update TipoDeIva request body — only cosmetic fields.
|
||||||
|
/// Porcentaje is intentionally absent; any attempt to pass it in the body
|
||||||
|
/// is detected via raw JSON inspection and returns 409.
|
||||||
|
/// </summary>
|
||||||
|
public sealed record UpdateTipoDeIvaRequest(
|
||||||
|
string? Codigo,
|
||||||
|
string? Descripcion,
|
||||||
|
bool? AplicaIVA,
|
||||||
|
bool? Activo);
|
||||||
|
|
||||||
|
/// <summary>ADM-009: Create new TipoDeIva version request body.</summary>
|
||||||
|
public sealed record NuevaVersionTipoDeIvaRequest(
|
||||||
|
decimal? Porcentaje,
|
||||||
|
string? VigenciaDesde);
|
||||||
|
|
||||||
|
// ── IIBB Request records ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/// <summary>ADM-009: Create IngresosBrutos request body.</summary>
|
||||||
|
public sealed record CreateIngresosBrutosRequest(
|
||||||
|
string? Provincia,
|
||||||
|
string? Descripcion,
|
||||||
|
decimal? Alicuota,
|
||||||
|
string? VigenciaDesde,
|
||||||
|
string? VigenciaHasta = null);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// ADM-009: Update IngresosBrutos request body — only cosmetic fields.
|
||||||
|
/// Alicuota and Provincia are intentionally absent.
|
||||||
|
/// </summary>
|
||||||
|
public sealed record UpdateIngresosBrutosRequest(
|
||||||
|
string? Descripcion,
|
||||||
|
bool? Activo);
|
||||||
|
|
||||||
|
/// <summary>ADM-009: Create new IngresosBrutos version request body.</summary>
|
||||||
|
public sealed record NuevaVersionIngresosBrutosRequest(
|
||||||
|
decimal? Alicuota,
|
||||||
|
string? VigenciaDesde);
|
||||||
|
|
||||||
|
// ── Shared Response records ───────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/// <summary>ADM-009: Response for nueva-version operations.</summary>
|
||||||
|
public sealed record NuevaVersionResponse(
|
||||||
|
int PredecesoraId,
|
||||||
|
int NuevaVersionId);
|
||||||
|
|
||||||
|
// ── Mapper ────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Maps Application-layer DTOs to API response shapes.
|
||||||
|
/// Application DTOs are already well-formed for most cases;
|
||||||
|
/// IIBB Provincia is mapped to its display string for the API.
|
||||||
|
/// </summary>
|
||||||
|
public static class FiscalContractMapper
|
||||||
|
{
|
||||||
|
public static object ToIvaResponse(TipoDeIvaDto dto) => new
|
||||||
|
{
|
||||||
|
dto.Id,
|
||||||
|
dto.Codigo,
|
||||||
|
dto.Descripcion,
|
||||||
|
dto.Porcentaje,
|
||||||
|
dto.AplicaIVA,
|
||||||
|
dto.Activo,
|
||||||
|
dto.VigenciaDesde,
|
||||||
|
dto.VigenciaHasta,
|
||||||
|
dto.PredecesorId,
|
||||||
|
dto.FechaCreacion,
|
||||||
|
dto.FechaModificacion
|
||||||
|
};
|
||||||
|
|
||||||
|
public static object ToIibbResponse(IngresosBrutosDto dto) => new
|
||||||
|
{
|
||||||
|
dto.Id,
|
||||||
|
Provincia = dto.Provincia.ToDisplayString(),
|
||||||
|
dto.Descripcion,
|
||||||
|
dto.Alicuota,
|
||||||
|
dto.Activo,
|
||||||
|
dto.VigenciaDesde,
|
||||||
|
dto.VigenciaHasta,
|
||||||
|
dto.PredecesorId,
|
||||||
|
dto.FechaCreacion,
|
||||||
|
dto.FechaModificacion
|
||||||
|
};
|
||||||
|
|
||||||
|
public static object ToHistorialIvaResponse(HistorialCadenaDto dto) => new
|
||||||
|
{
|
||||||
|
dto.Id,
|
||||||
|
dto.Codigo,
|
||||||
|
dto.Porcentaje,
|
||||||
|
dto.VigenciaDesde,
|
||||||
|
dto.VigenciaHasta,
|
||||||
|
dto.PredecesorId,
|
||||||
|
dto.Version
|
||||||
|
};
|
||||||
|
|
||||||
|
public static object ToHistorialIibbResponse(HistorialCadenaIibbDto dto) => new
|
||||||
|
{
|
||||||
|
dto.Id,
|
||||||
|
Provincia = dto.Provincia.ToDisplayString(),
|
||||||
|
dto.Alicuota,
|
||||||
|
dto.VigenciaDesde,
|
||||||
|
dto.VigenciaHasta,
|
||||||
|
dto.PredecesorId,
|
||||||
|
dto.Version
|
||||||
|
};
|
||||||
|
}
|
||||||
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);
|
||||||
576
src/api/SIGCM2.Api/Controllers/FiscalController.cs
Normal file
576
src/api/SIGCM2.Api/Controllers/FiscalController.cs
Normal file
@@ -0,0 +1,576 @@
|
|||||||
|
using System.Text.Json;
|
||||||
|
using FluentValidation;
|
||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
using SIGCM2.Api.Authorization;
|
||||||
|
using SIGCM2.Api.Contracts.Fiscal;
|
||||||
|
using SIGCM2.Application.Abstractions;
|
||||||
|
using SIGCM2.Application.Common;
|
||||||
|
using SIGCM2.Application.IngresosBrutos.Create;
|
||||||
|
using SIGCM2.Application.IngresosBrutos.Deactivate;
|
||||||
|
using SIGCM2.Application.IngresosBrutos.Dtos;
|
||||||
|
using SIGCM2.Application.IngresosBrutos.GetById;
|
||||||
|
using SIGCM2.Application.IngresosBrutos.GetHistorial;
|
||||||
|
using SIGCM2.Application.IngresosBrutos.List;
|
||||||
|
using SIGCM2.Application.IngresosBrutos.NuevaVersion;
|
||||||
|
using SIGCM2.Application.IngresosBrutos.Reactivate;
|
||||||
|
using SIGCM2.Application.IngresosBrutos.Update;
|
||||||
|
using SIGCM2.Application.TiposDeIva.Create;
|
||||||
|
using SIGCM2.Application.TiposDeIva.Deactivate;
|
||||||
|
using SIGCM2.Application.TiposDeIva.Dtos;
|
||||||
|
using SIGCM2.Application.TiposDeIva.GetById;
|
||||||
|
using SIGCM2.Application.TiposDeIva.GetHistorial;
|
||||||
|
using SIGCM2.Application.TiposDeIva.List;
|
||||||
|
using SIGCM2.Application.TiposDeIva.NuevaVersion;
|
||||||
|
using SIGCM2.Application.TiposDeIva.Reactivate;
|
||||||
|
using SIGCM2.Application.TiposDeIva.Update;
|
||||||
|
using SIGCM2.Domain.Exceptions;
|
||||||
|
using SIGCM2.Domain.Fiscal;
|
||||||
|
|
||||||
|
namespace SIGCM2.Api.Controllers;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// ADM-009: Tablas Fiscales — IVA + IngresosBrutos endpoints at /api/v1/admin/fiscal.
|
||||||
|
/// All endpoints require permission 'administracion:fiscal:gestionar'.
|
||||||
|
/// </summary>
|
||||||
|
[ApiController]
|
||||||
|
[Route("api/v1/admin/fiscal")]
|
||||||
|
public sealed class FiscalController : ControllerBase
|
||||||
|
{
|
||||||
|
private readonly IDispatcher _dispatcher;
|
||||||
|
private readonly IValidator<CreateTipoDeIvaCommand> _createIvaValidator;
|
||||||
|
private readonly IValidator<UpdateTipoDeIvaCommand> _updateIvaValidator;
|
||||||
|
private readonly IValidator<NuevaVersionTipoDeIvaCommand> _nuevaVersionIvaValidator;
|
||||||
|
private readonly IValidator<CreateIngresosBrutosCommand> _createIibbValidator;
|
||||||
|
private readonly IValidator<UpdateIngresosBrutosCommand> _updateIibbValidator;
|
||||||
|
private readonly IValidator<NuevaVersionIngresosBrutosCommand> _nuevaVersionIibbValidator;
|
||||||
|
|
||||||
|
public FiscalController(
|
||||||
|
IDispatcher dispatcher,
|
||||||
|
IValidator<CreateTipoDeIvaCommand> createIvaValidator,
|
||||||
|
IValidator<UpdateTipoDeIvaCommand> updateIvaValidator,
|
||||||
|
IValidator<NuevaVersionTipoDeIvaCommand> nuevaVersionIvaValidator,
|
||||||
|
IValidator<CreateIngresosBrutosCommand> createIibbValidator,
|
||||||
|
IValidator<UpdateIngresosBrutosCommand> updateIibbValidator,
|
||||||
|
IValidator<NuevaVersionIngresosBrutosCommand> nuevaVersionIibbValidator)
|
||||||
|
{
|
||||||
|
_dispatcher = dispatcher;
|
||||||
|
_createIvaValidator = createIvaValidator;
|
||||||
|
_updateIvaValidator = updateIvaValidator;
|
||||||
|
_nuevaVersionIvaValidator = nuevaVersionIvaValidator;
|
||||||
|
_createIibbValidator = createIibbValidator;
|
||||||
|
_updateIibbValidator = updateIibbValidator;
|
||||||
|
_nuevaVersionIibbValidator = nuevaVersionIibbValidator;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ══════════════════════════════════════════════════════════════════════════
|
||||||
|
// IVA endpoints
|
||||||
|
// ══════════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
/// <summary>Lists TiposDeIva with optional filters. Requires administracion:fiscal:gestionar.</summary>
|
||||||
|
[HttpGet("iva")]
|
||||||
|
[RequirePermission("administracion:fiscal:gestionar")]
|
||||||
|
[ProducesResponseType(StatusCodes.Status200OK)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status403Forbidden)]
|
||||||
|
public async Task<IActionResult> ListIva(
|
||||||
|
[FromQuery] int page = 1,
|
||||||
|
[FromQuery] int pageSize = 20,
|
||||||
|
[FromQuery] bool? activo = null,
|
||||||
|
[FromQuery] string? codigo = null)
|
||||||
|
{
|
||||||
|
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 result = await _dispatcher.Send<ListTiposDeIvaQuery, PagedResult<TipoDeIvaDto>>(query);
|
||||||
|
|
||||||
|
return Ok(new
|
||||||
|
{
|
||||||
|
Items = result.Items.Select(FiscalContractMapper.ToIvaResponse).ToList(),
|
||||||
|
result.Page,
|
||||||
|
result.PageSize,
|
||||||
|
result.Total
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Gets a single TipoDeIva by id.</summary>
|
||||||
|
[HttpGet("iva/{id:int}")]
|
||||||
|
[RequirePermission("administracion:fiscal:gestionar")]
|
||||||
|
[ProducesResponseType(StatusCodes.Status200OK)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status403Forbidden)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||||
|
public async Task<IActionResult> GetIvaById([FromRoute] int id)
|
||||||
|
{
|
||||||
|
var query = new GetTipoDeIvaByIdQuery(id);
|
||||||
|
var result = await _dispatcher.Send<GetTipoDeIvaByIdQuery, TipoDeIvaDto>(query);
|
||||||
|
return Ok(FiscalContractMapper.ToIvaResponse(result));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Gets the full version chain for a TipoDeIva.</summary>
|
||||||
|
[HttpGet("iva/{id:int}/historial")]
|
||||||
|
[RequirePermission("administracion:fiscal:gestionar")]
|
||||||
|
[ProducesResponseType(StatusCodes.Status200OK)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status403Forbidden)]
|
||||||
|
public async Task<IActionResult> GetHistorialIva([FromRoute] int id)
|
||||||
|
{
|
||||||
|
var query = new GetHistorialTipoDeIvaQuery(id);
|
||||||
|
var result = await _dispatcher.Send<GetHistorialTipoDeIvaQuery, IReadOnlyList<HistorialCadenaDto>>(query);
|
||||||
|
return Ok(result.Select(FiscalContractMapper.ToHistorialIvaResponse).ToList());
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Creates a new TipoDeIva. Returns 201 on success.</summary>
|
||||||
|
[HttpPost("iva")]
|
||||||
|
[RequirePermission("administracion:fiscal:gestionar")]
|
||||||
|
[ProducesResponseType(StatusCodes.Status201Created)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status400BadRequest)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status403Forbidden)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status409Conflict)]
|
||||||
|
public async Task<IActionResult> CreateIva([FromBody] CreateTipoDeIvaRequest request)
|
||||||
|
{
|
||||||
|
var vigenciaDesde = ParseDateOnly(request.VigenciaDesde, "vigenciaDesde");
|
||||||
|
if (vigenciaDesde is null)
|
||||||
|
return BadRequest(new { error = "vigenciaDesde must be a valid date (yyyy-MM-dd)" });
|
||||||
|
|
||||||
|
DateOnly? vigenciaHasta = null;
|
||||||
|
if (request.VigenciaHasta is not null)
|
||||||
|
{
|
||||||
|
vigenciaHasta = ParseDateOnly(request.VigenciaHasta, "vigenciaHasta");
|
||||||
|
if (vigenciaHasta is null)
|
||||||
|
return BadRequest(new { error = "vigenciaHasta must be a valid date (yyyy-MM-dd)" });
|
||||||
|
}
|
||||||
|
|
||||||
|
var command = new CreateTipoDeIvaCommand(
|
||||||
|
Codigo: request.Codigo ?? string.Empty,
|
||||||
|
Descripcion: request.Descripcion ?? string.Empty,
|
||||||
|
Porcentaje: request.Porcentaje ?? 0m,
|
||||||
|
AplicaIVA: request.AplicaIVA ?? false,
|
||||||
|
VigenciaDesde: vigenciaDesde.Value,
|
||||||
|
VigenciaHasta: vigenciaHasta);
|
||||||
|
|
||||||
|
var validation = await _createIvaValidator.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<CreateTipoDeIvaCommand, TipoDeIvaDto>(command);
|
||||||
|
return CreatedAtAction(nameof(GetIvaById), new { id = result.Id }, FiscalContractMapper.ToIvaResponse(result));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Updates cosmetic fields of a TipoDeIva (Codigo, Descripcion, AplicaIVA, Activo).
|
||||||
|
/// IMPORTANT: if the raw body contains "porcentaje" (case-insensitive) → 409 inmutable_usar_nueva_version.
|
||||||
|
/// </summary>
|
||||||
|
[HttpPatch("iva/{id:int}")]
|
||||||
|
[RequirePermission("administracion:fiscal:gestionar")]
|
||||||
|
[ProducesResponseType(StatusCodes.Status200OK)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status400BadRequest)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status403Forbidden)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status409Conflict)]
|
||||||
|
public async Task<IActionResult> UpdateIva([FromRoute] int id)
|
||||||
|
{
|
||||||
|
// Read raw body to detect immutable-field tampering before deserialization
|
||||||
|
Request.EnableBuffering();
|
||||||
|
using var reader = new StreamReader(Request.Body, leaveOpen: true);
|
||||||
|
var rawBody = await reader.ReadToEndAsync();
|
||||||
|
Request.Body.Position = 0;
|
||||||
|
|
||||||
|
// Defend against porcentaje in body — must return 409 before dispatch
|
||||||
|
if (ContainsImmutableField(rawBody, "porcentaje"))
|
||||||
|
throw new PorcentajeInmutableException();
|
||||||
|
|
||||||
|
UpdateTipoDeIvaRequest? request;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
request = JsonSerializer.Deserialize<UpdateTipoDeIvaRequest>(rawBody,
|
||||||
|
new JsonSerializerOptions { PropertyNameCaseInsensitive = true });
|
||||||
|
}
|
||||||
|
catch (JsonException)
|
||||||
|
{
|
||||||
|
return BadRequest(new { error = "Invalid JSON body" });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (request is null)
|
||||||
|
return BadRequest(new { error = "Request body is required" });
|
||||||
|
|
||||||
|
var command = new UpdateTipoDeIvaCommand(
|
||||||
|
Id: id,
|
||||||
|
Codigo: request.Codigo ?? string.Empty,
|
||||||
|
Descripcion: request.Descripcion ?? string.Empty,
|
||||||
|
AplicaIVA: request.AplicaIVA ?? false,
|
||||||
|
Activo: request.Activo ?? true);
|
||||||
|
|
||||||
|
var validation = await _updateIvaValidator.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<UpdateTipoDeIvaCommand, TipoDeIvaDto>(command);
|
||||||
|
return Ok(FiscalContractMapper.ToIvaResponse(result));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Creates a new version of a TipoDeIva (closes the predecessor). Returns 201.</summary>
|
||||||
|
[HttpPost("iva/{id:int}/nueva-version")]
|
||||||
|
[RequirePermission("administracion:fiscal:gestionar")]
|
||||||
|
[ProducesResponseType(StatusCodes.Status201Created)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status400BadRequest)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status403Forbidden)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status409Conflict)]
|
||||||
|
public async Task<IActionResult> NuevaVersionIva(
|
||||||
|
[FromRoute] int id,
|
||||||
|
[FromBody] NuevaVersionTipoDeIvaRequest request)
|
||||||
|
{
|
||||||
|
var vigenciaDesde = ParseDateOnly(request.VigenciaDesde, "vigenciaDesde");
|
||||||
|
if (vigenciaDesde is null)
|
||||||
|
return BadRequest(new { error = "vigenciaDesde must be a valid date (yyyy-MM-dd)" });
|
||||||
|
|
||||||
|
var command = new NuevaVersionTipoDeIvaCommand(
|
||||||
|
PredecesoraId: id,
|
||||||
|
NuevoPorcentaje: request.Porcentaje ?? 0m,
|
||||||
|
VigenciaDesde: vigenciaDesde.Value);
|
||||||
|
|
||||||
|
var validation = await _nuevaVersionIvaValidator.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<NuevaVersionTipoDeIvaCommand, SIGCM2.Application.TiposDeIva.Dtos.NuevaVersionResultDto>(command);
|
||||||
|
return CreatedAtAction(
|
||||||
|
nameof(GetIvaById),
|
||||||
|
new { id = result.NuevaVersionId },
|
||||||
|
new NuevaVersionResponse(result.PredecesoraId, result.NuevaVersionId));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Deactivates a TipoDeIva. Idempotent.</summary>
|
||||||
|
[HttpPost("iva/{id:int}/deactivate")]
|
||||||
|
[RequirePermission("administracion:fiscal:gestionar")]
|
||||||
|
[ProducesResponseType(StatusCodes.Status200OK)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status403Forbidden)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||||
|
public async Task<IActionResult> DeactivateIva([FromRoute] int id)
|
||||||
|
{
|
||||||
|
var command = new DeactivateTipoDeIvaCommand(id);
|
||||||
|
var result = await _dispatcher.Send<DeactivateTipoDeIvaCommand, TipoDeIvaDto>(command);
|
||||||
|
return Ok(FiscalContractMapper.ToIvaResponse(result));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Reactivates a TipoDeIva. Idempotent.</summary>
|
||||||
|
[HttpPost("iva/{id:int}/reactivate")]
|
||||||
|
[RequirePermission("administracion:fiscal:gestionar")]
|
||||||
|
[ProducesResponseType(StatusCodes.Status200OK)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status403Forbidden)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||||
|
public async Task<IActionResult> ReactivateIva([FromRoute] int id)
|
||||||
|
{
|
||||||
|
var command = new ReactivateTipoDeIvaCommand(id);
|
||||||
|
var result = await _dispatcher.Send<ReactivateTipoDeIvaCommand, TipoDeIvaDto>(command);
|
||||||
|
return Ok(FiscalContractMapper.ToIvaResponse(result));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ══════════════════════════════════════════════════════════════════════════
|
||||||
|
// IngresosBrutos endpoints
|
||||||
|
// ══════════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
/// <summary>Lists IngresosBrutos with optional filters.</summary>
|
||||||
|
[HttpGet("iibb")]
|
||||||
|
[RequirePermission("administracion:fiscal:gestionar")]
|
||||||
|
[ProducesResponseType(StatusCodes.Status200OK)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status403Forbidden)]
|
||||||
|
public async Task<IActionResult> ListIibb(
|
||||||
|
[FromQuery] int page = 1,
|
||||||
|
[FromQuery] int pageSize = 20,
|
||||||
|
[FromQuery] bool? activo = null,
|
||||||
|
[FromQuery] string? provincia = null)
|
||||||
|
{
|
||||||
|
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;
|
||||||
|
if (provincia is not null)
|
||||||
|
{
|
||||||
|
if (!Enum.TryParse<ProvinciaArgentina>(provincia, ignoreCase: true, out var parsed))
|
||||||
|
return BadRequest(new { error = $"'{provincia}' is not a valid ProvinciaArgentina value." });
|
||||||
|
provinciaEnum = parsed;
|
||||||
|
}
|
||||||
|
|
||||||
|
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(),
|
||||||
|
result.Page,
|
||||||
|
result.PageSize,
|
||||||
|
result.Total
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Gets a single IngresosBrutos by id.</summary>
|
||||||
|
[HttpGet("iibb/{id:int}")]
|
||||||
|
[RequirePermission("administracion:fiscal:gestionar")]
|
||||||
|
[ProducesResponseType(StatusCodes.Status200OK)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status403Forbidden)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||||
|
public async Task<IActionResult> GetIibbById([FromRoute] int id)
|
||||||
|
{
|
||||||
|
var query = new GetIngresosBrutosByIdQuery(id);
|
||||||
|
var result = await _dispatcher.Send<GetIngresosBrutosByIdQuery, IngresosBrutosDto>(query);
|
||||||
|
return Ok(FiscalContractMapper.ToIibbResponse(result));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Gets the full version chain for an IngresosBrutos entry.</summary>
|
||||||
|
[HttpGet("iibb/{id:int}/historial")]
|
||||||
|
[RequirePermission("administracion:fiscal:gestionar")]
|
||||||
|
[ProducesResponseType(StatusCodes.Status200OK)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status403Forbidden)]
|
||||||
|
public async Task<IActionResult> GetHistorialIibb([FromRoute] int id)
|
||||||
|
{
|
||||||
|
var query = new GetHistorialIngresosBrutosQuery(id);
|
||||||
|
var result = await _dispatcher.Send<GetHistorialIngresosBrutosQuery, IReadOnlyList<HistorialCadenaIibbDto>>(query);
|
||||||
|
return Ok(result.Select(FiscalContractMapper.ToHistorialIibbResponse).ToList());
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Creates a new IngresosBrutos entry. Returns 201 on success.</summary>
|
||||||
|
[HttpPost("iibb")]
|
||||||
|
[RequirePermission("administracion:fiscal:gestionar")]
|
||||||
|
[ProducesResponseType(StatusCodes.Status201Created)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status400BadRequest)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status403Forbidden)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status409Conflict)]
|
||||||
|
public async Task<IActionResult> CreateIibb([FromBody] CreateIngresosBrutosRequest request)
|
||||||
|
{
|
||||||
|
if (request.Provincia is null)
|
||||||
|
return BadRequest(new { error = "provincia is required" });
|
||||||
|
|
||||||
|
// Accept enum name (PascalCase) or display string
|
||||||
|
ProvinciaArgentina provinciaEnum;
|
||||||
|
if (Enum.TryParse<ProvinciaArgentina>(request.Provincia, ignoreCase: true, out var parsedEnum))
|
||||||
|
{
|
||||||
|
provinciaEnum = parsedEnum;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
provinciaEnum = ProvinciaArgentinaExtensions.FromDisplayString(request.Provincia);
|
||||||
|
}
|
||||||
|
catch (ArgumentException)
|
||||||
|
{
|
||||||
|
return BadRequest(new { error = $"'{request.Provincia}' is not a valid provincia. Use enum name or display string." });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var vigenciaDesde = ParseDateOnly(request.VigenciaDesde, "vigenciaDesde");
|
||||||
|
if (vigenciaDesde is null)
|
||||||
|
return BadRequest(new { error = "vigenciaDesde must be a valid date (yyyy-MM-dd)" });
|
||||||
|
|
||||||
|
DateOnly? vigenciaHasta = null;
|
||||||
|
if (request.VigenciaHasta is not null)
|
||||||
|
{
|
||||||
|
vigenciaHasta = ParseDateOnly(request.VigenciaHasta, "vigenciaHasta");
|
||||||
|
if (vigenciaHasta is null)
|
||||||
|
return BadRequest(new { error = "vigenciaHasta must be a valid date (yyyy-MM-dd)" });
|
||||||
|
}
|
||||||
|
|
||||||
|
var command = new CreateIngresosBrutosCommand(
|
||||||
|
Provincia: provinciaEnum,
|
||||||
|
Descripcion: request.Descripcion ?? string.Empty,
|
||||||
|
Alicuota: request.Alicuota ?? 0m,
|
||||||
|
VigenciaDesde: vigenciaDesde.Value,
|
||||||
|
VigenciaHasta: vigenciaHasta);
|
||||||
|
|
||||||
|
var validation = await _createIibbValidator.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<CreateIngresosBrutosCommand, IngresosBrutosDto>(command);
|
||||||
|
return CreatedAtAction(nameof(GetIibbById), new { id = result.Id }, FiscalContractMapper.ToIibbResponse(result));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Updates cosmetic fields of IngresosBrutos (Descripcion, Activo).
|
||||||
|
/// IMPORTANT: if the raw body contains "alicuota" (case-insensitive) → 409 inmutable_usar_nueva_version.
|
||||||
|
/// </summary>
|
||||||
|
[HttpPatch("iibb/{id:int}")]
|
||||||
|
[RequirePermission("administracion:fiscal:gestionar")]
|
||||||
|
[ProducesResponseType(StatusCodes.Status200OK)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status400BadRequest)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status403Forbidden)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status409Conflict)]
|
||||||
|
public async Task<IActionResult> UpdateIibb([FromRoute] int id)
|
||||||
|
{
|
||||||
|
Request.EnableBuffering();
|
||||||
|
using var reader = new StreamReader(Request.Body, leaveOpen: true);
|
||||||
|
var rawBody = await reader.ReadToEndAsync();
|
||||||
|
Request.Body.Position = 0;
|
||||||
|
|
||||||
|
if (ContainsImmutableField(rawBody, "alicuota"))
|
||||||
|
throw new AlicuotaInmutableException();
|
||||||
|
|
||||||
|
UpdateIngresosBrutosRequest? request;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
request = JsonSerializer.Deserialize<UpdateIngresosBrutosRequest>(rawBody,
|
||||||
|
new JsonSerializerOptions { PropertyNameCaseInsensitive = true });
|
||||||
|
}
|
||||||
|
catch (JsonException)
|
||||||
|
{
|
||||||
|
return BadRequest(new { error = "Invalid JSON body" });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (request is null)
|
||||||
|
return BadRequest(new { error = "Request body is required" });
|
||||||
|
|
||||||
|
var command = new UpdateIngresosBrutosCommand(
|
||||||
|
Id: id,
|
||||||
|
Descripcion: request.Descripcion ?? string.Empty,
|
||||||
|
Activo: request.Activo ?? true);
|
||||||
|
|
||||||
|
var validation = await _updateIibbValidator.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<UpdateIngresosBrutosCommand, IngresosBrutosDto>(command);
|
||||||
|
return Ok(FiscalContractMapper.ToIibbResponse(result));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Creates a new version of IngresosBrutos (closes the predecessor). Returns 201.</summary>
|
||||||
|
[HttpPost("iibb/{id:int}/nueva-version")]
|
||||||
|
[RequirePermission("administracion:fiscal:gestionar")]
|
||||||
|
[ProducesResponseType(StatusCodes.Status201Created)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status400BadRequest)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status403Forbidden)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status409Conflict)]
|
||||||
|
public async Task<IActionResult> NuevaVersionIibb(
|
||||||
|
[FromRoute] int id,
|
||||||
|
[FromBody] NuevaVersionIngresosBrutosRequest request)
|
||||||
|
{
|
||||||
|
var vigenciaDesde = ParseDateOnly(request.VigenciaDesde, "vigenciaDesde");
|
||||||
|
if (vigenciaDesde is null)
|
||||||
|
return BadRequest(new { error = "vigenciaDesde must be a valid date (yyyy-MM-dd)" });
|
||||||
|
|
||||||
|
var command = new NuevaVersionIngresosBrutosCommand(
|
||||||
|
PredecesoraId: id,
|
||||||
|
NuevaAlicuota: request.Alicuota ?? 0m,
|
||||||
|
VigenciaDesde: vigenciaDesde.Value);
|
||||||
|
|
||||||
|
var validation = await _nuevaVersionIibbValidator.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<NuevaVersionIngresosBrutosCommand, SIGCM2.Application.IngresosBrutos.Dtos.NuevaVersionIibbResultDto>(command);
|
||||||
|
return CreatedAtAction(
|
||||||
|
nameof(GetIibbById),
|
||||||
|
new { id = result.NuevaVersionId },
|
||||||
|
new NuevaVersionResponse(result.PredecesoraId, result.NuevaVersionId));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Deactivates an IngresosBrutos entry. Idempotent.</summary>
|
||||||
|
[HttpPost("iibb/{id:int}/deactivate")]
|
||||||
|
[RequirePermission("administracion:fiscal:gestionar")]
|
||||||
|
[ProducesResponseType(StatusCodes.Status200OK)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status403Forbidden)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||||
|
public async Task<IActionResult> DeactivateIibb([FromRoute] int id)
|
||||||
|
{
|
||||||
|
var command = new DeactivateIngresosBrutosCommand(id);
|
||||||
|
var result = await _dispatcher.Send<DeactivateIngresosBrutosCommand, IngresosBrutosDto>(command);
|
||||||
|
return Ok(FiscalContractMapper.ToIibbResponse(result));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Reactivates an IngresosBrutos entry. Idempotent.</summary>
|
||||||
|
[HttpPost("iibb/{id:int}/reactivate")]
|
||||||
|
[RequirePermission("administracion:fiscal:gestionar")]
|
||||||
|
[ProducesResponseType(StatusCodes.Status200OK)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status403Forbidden)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||||
|
public async Task<IActionResult> ReactivateIibb([FromRoute] int id)
|
||||||
|
{
|
||||||
|
var command = new ReactivateIngresosBrutosCommand(id);
|
||||||
|
var result = await _dispatcher.Send<ReactivateIngresosBrutosCommand, IngresosBrutosDto>(command);
|
||||||
|
return Ok(FiscalContractMapper.ToIibbResponse(result));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ══════════════════════════════════════════════════════════════════════════
|
||||||
|
// Private helpers
|
||||||
|
// ══════════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Parses a date string "yyyy-MM-dd" to DateOnly. Returns null if invalid.
|
||||||
|
/// </summary>
|
||||||
|
private static DateOnly? ParseDateOnly(string? value, string fieldName)
|
||||||
|
{
|
||||||
|
if (value is null) return null;
|
||||||
|
return DateOnly.TryParseExact(value, "yyyy-MM-dd",
|
||||||
|
System.Globalization.CultureInfo.InvariantCulture,
|
||||||
|
System.Globalization.DateTimeStyles.None,
|
||||||
|
out var result)
|
||||||
|
? result
|
||||||
|
: null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Checks if a raw JSON string contains a given field name (case-insensitive).
|
||||||
|
/// Used to detect immutable-field tampering before deserialization silently drops the field.
|
||||||
|
/// </summary>
|
||||||
|
private static bool ContainsImmutableField(string rawJson, string fieldName)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(rawJson)) return false;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
using var doc = JsonDocument.Parse(rawJson);
|
||||||
|
return doc.RootElement.ValueKind == JsonValueKind.Object &&
|
||||||
|
doc.RootElement.EnumerateObject()
|
||||||
|
.Any(p => string.Equals(p.Name, fieldName, StringComparison.OrdinalIgnoreCase));
|
||||||
|
}
|
||||||
|
catch (JsonException)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
173
src/api/SIGCM2.Api/Controllers/MediosController.cs
Normal file
173
src/api/SIGCM2.Api/Controllers/MediosController.cs
Normal file
@@ -0,0 +1,173 @@
|
|||||||
|
using FluentValidation;
|
||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
using SIGCM2.Api.Authorization;
|
||||||
|
using SIGCM2.Application.Abstractions;
|
||||||
|
using SIGCM2.Application.Common;
|
||||||
|
using SIGCM2.Application.Medios.Create;
|
||||||
|
using SIGCM2.Application.Medios.Deactivate;
|
||||||
|
using SIGCM2.Application.Medios.GetById;
|
||||||
|
using SIGCM2.Application.Medios.List;
|
||||||
|
using SIGCM2.Application.Medios.Reactivate;
|
||||||
|
using SIGCM2.Application.Medios.Update;
|
||||||
|
using SIGCM2.Domain.Entities;
|
||||||
|
|
||||||
|
namespace SIGCM2.Api.Controllers;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// ADM-001: Medio management endpoints at /api/v1/admin/medios.
|
||||||
|
/// All endpoints require permission 'administracion:medios:gestionar'.
|
||||||
|
/// </summary>
|
||||||
|
[ApiController]
|
||||||
|
[Route("api/v1/admin/medios")]
|
||||||
|
public sealed class MediosController : ControllerBase
|
||||||
|
{
|
||||||
|
private readonly IDispatcher _dispatcher;
|
||||||
|
private readonly IValidator<CreateMedioCommand> _createValidator;
|
||||||
|
private readonly IValidator<UpdateMedioCommand> _updateValidator;
|
||||||
|
|
||||||
|
public MediosController(
|
||||||
|
IDispatcher dispatcher,
|
||||||
|
IValidator<CreateMedioCommand> createValidator,
|
||||||
|
IValidator<UpdateMedioCommand> updateValidator)
|
||||||
|
{
|
||||||
|
_dispatcher = dispatcher;
|
||||||
|
_createValidator = createValidator;
|
||||||
|
_updateValidator = updateValidator;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Creates a new medio. Requires administracion:medios:gestionar.</summary>
|
||||||
|
[HttpPost]
|
||||||
|
[RequirePermission("administracion:medios:gestionar")]
|
||||||
|
[ProducesResponseType(typeof(MedioCreatedDto), StatusCodes.Status201Created)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status400BadRequest)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status403Forbidden)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status409Conflict)]
|
||||||
|
public async Task<IActionResult> CreateMedio([FromBody] CreateMedioRequest request)
|
||||||
|
{
|
||||||
|
var command = new CreateMedioCommand(
|
||||||
|
Codigo: request.Codigo ?? string.Empty,
|
||||||
|
Nombre: request.Nombre ?? string.Empty,
|
||||||
|
Tipo: request.Tipo ?? TipoMedio.Diario,
|
||||||
|
PlataformaEmpresaId: request.PlataformaEmpresaId);
|
||||||
|
|
||||||
|
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<CreateMedioCommand, MedioCreatedDto>(command);
|
||||||
|
return CreatedAtAction(nameof(GetMedioById), new { id = result.Id }, result);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Lists medios with optional filters and pagination.</summary>
|
||||||
|
[HttpGet]
|
||||||
|
[RequirePermission("administracion:medios:gestionar")]
|
||||||
|
[ProducesResponseType(typeof(PagedResult<MedioListItemDto>), StatusCodes.Status200OK)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status403Forbidden)]
|
||||||
|
public async Task<IActionResult> ListMedios(
|
||||||
|
[FromQuery] int page = 1,
|
||||||
|
[FromQuery] int pageSize = 20,
|
||||||
|
[FromQuery] bool? activo = null,
|
||||||
|
[FromQuery] TipoMedio? tipo = null,
|
||||||
|
[FromQuery] string? q = null)
|
||||||
|
{
|
||||||
|
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 ListMediosQuery(page, pageSize, activo, tipo, q);
|
||||||
|
var result = await _dispatcher.Send<ListMediosQuery, PagedResult<MedioListItemDto>>(query);
|
||||||
|
return Ok(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Gets a single medio by id.</summary>
|
||||||
|
[HttpGet("{id:int}")]
|
||||||
|
[RequirePermission("administracion:medios:gestionar")]
|
||||||
|
[ProducesResponseType(typeof(MedioDetailDto), StatusCodes.Status200OK)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status403Forbidden)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||||
|
public async Task<IActionResult> GetMedioById([FromRoute] int id)
|
||||||
|
{
|
||||||
|
var query = new GetMedioByIdQuery(id);
|
||||||
|
var result = await _dispatcher.Send<GetMedioByIdQuery, MedioDetailDto>(query);
|
||||||
|
return Ok(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Updates a medio's editable fields.</summary>
|
||||||
|
[HttpPut("{id:int}")]
|
||||||
|
[RequirePermission("administracion:medios:gestionar")]
|
||||||
|
[ProducesResponseType(typeof(MedioUpdatedDto), StatusCodes.Status200OK)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status400BadRequest)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status403Forbidden)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||||
|
public async Task<IActionResult> UpdateMedio([FromRoute] int id, [FromBody] UpdateMedioRequest request)
|
||||||
|
{
|
||||||
|
var command = new UpdateMedioCommand(
|
||||||
|
Id: id,
|
||||||
|
Nombre: request.Nombre ?? string.Empty,
|
||||||
|
Tipo: request.Tipo ?? TipoMedio.Diario,
|
||||||
|
PlataformaEmpresaId: request.PlataformaEmpresaId);
|
||||||
|
|
||||||
|
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<UpdateMedioCommand, MedioUpdatedDto>(command);
|
||||||
|
return Ok(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Deactivates a medio (idempotent).</summary>
|
||||||
|
[HttpPost("{id:int}/deactivate")]
|
||||||
|
[RequirePermission("administracion:medios:gestionar")]
|
||||||
|
[ProducesResponseType(StatusCodes.Status204NoContent)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status403Forbidden)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||||
|
public async Task<IActionResult> DeactivateMedio([FromRoute] int id)
|
||||||
|
{
|
||||||
|
var command = new DeactivateMedioCommand(id);
|
||||||
|
await _dispatcher.Send<DeactivateMedioCommand, MedioStatusDto>(command);
|
||||||
|
return NoContent();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Reactivates a medio (idempotent).</summary>
|
||||||
|
[HttpPost("{id:int}/reactivate")]
|
||||||
|
[RequirePermission("administracion:medios:gestionar")]
|
||||||
|
[ProducesResponseType(StatusCodes.Status204NoContent)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status403Forbidden)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||||
|
public async Task<IActionResult> ReactivateMedio([FromRoute] int id)
|
||||||
|
{
|
||||||
|
var command = new ReactivateMedioCommand(id);
|
||||||
|
await _dispatcher.Send<ReactivateMedioCommand, MedioStatusDto>(command);
|
||||||
|
return NoContent();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Request body records ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/// <summary>ADM-001: Create medio request body.</summary>
|
||||||
|
public sealed record CreateMedioRequest(
|
||||||
|
string? Codigo,
|
||||||
|
string? Nombre,
|
||||||
|
TipoMedio? Tipo,
|
||||||
|
int? PlataformaEmpresaId);
|
||||||
|
|
||||||
|
/// <summary>ADM-001: Update medio request body.</summary>
|
||||||
|
public sealed record UpdateMedioRequest(
|
||||||
|
string? Nombre,
|
||||||
|
TipoMedio? Tipo,
|
||||||
|
int? PlataformaEmpresaId);
|
||||||
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);
|
||||||
175
src/api/SIGCM2.Api/Controllers/PuntosDeVentaController.cs
Normal file
175
src/api/SIGCM2.Api/Controllers/PuntosDeVentaController.cs
Normal file
@@ -0,0 +1,175 @@
|
|||||||
|
using FluentValidation;
|
||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
using SIGCM2.Api.Authorization;
|
||||||
|
using SIGCM2.Application.Abstractions;
|
||||||
|
using SIGCM2.Application.Common;
|
||||||
|
using SIGCM2.Application.PuntosDeVenta.Create;
|
||||||
|
using SIGCM2.Application.PuntosDeVenta.Deactivate;
|
||||||
|
using SIGCM2.Application.PuntosDeVenta.GetById;
|
||||||
|
using SIGCM2.Application.PuntosDeVenta.List;
|
||||||
|
using SIGCM2.Application.PuntosDeVenta.Reactivate;
|
||||||
|
using SIGCM2.Application.PuntosDeVenta.Update;
|
||||||
|
|
||||||
|
namespace SIGCM2.Api.Controllers;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// ADM-008: PuntoDeVenta management endpoints at /api/v1/admin/puntos-de-venta.
|
||||||
|
/// All endpoints require permission 'administracion:puntos_de_venta:gestionar'.
|
||||||
|
/// </summary>
|
||||||
|
[ApiController]
|
||||||
|
[Route("api/v1/admin/puntos-de-venta")]
|
||||||
|
public sealed class PuntosDeVentaController : ControllerBase
|
||||||
|
{
|
||||||
|
private readonly IDispatcher _dispatcher;
|
||||||
|
private readonly IValidator<CreatePuntoDeVentaCommand> _createValidator;
|
||||||
|
private readonly IValidator<UpdatePuntoDeVentaCommand> _updateValidator;
|
||||||
|
|
||||||
|
public PuntosDeVentaController(
|
||||||
|
IDispatcher dispatcher,
|
||||||
|
IValidator<CreatePuntoDeVentaCommand> createValidator,
|
||||||
|
IValidator<UpdatePuntoDeVentaCommand> updateValidator)
|
||||||
|
{
|
||||||
|
_dispatcher = dispatcher;
|
||||||
|
_createValidator = createValidator;
|
||||||
|
_updateValidator = updateValidator;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Creates a new punto de venta. Requires administracion:puntos_de_venta:gestionar.</summary>
|
||||||
|
[HttpPost]
|
||||||
|
[RequirePermission("administracion:puntos_de_venta:gestionar")]
|
||||||
|
[ProducesResponseType(typeof(PuntoDeVentaCreatedDto), StatusCodes.Status201Created)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status400BadRequest)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status403Forbidden)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status409Conflict)]
|
||||||
|
public async Task<IActionResult> CreatePuntoDeVenta([FromBody] CreatePuntoDeVentaRequest request)
|
||||||
|
{
|
||||||
|
var command = new CreatePuntoDeVentaCommand(
|
||||||
|
MedioId: request.MedioId ?? 0,
|
||||||
|
NumeroAFIP: request.NumeroAFIP ?? 0,
|
||||||
|
Nombre: request.Nombre ?? string.Empty,
|
||||||
|
Descripcion: request.Descripcion);
|
||||||
|
|
||||||
|
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<CreatePuntoDeVentaCommand, PuntoDeVentaCreatedDto>(command);
|
||||||
|
return CreatedAtAction(nameof(GetPuntoDeVentaById), new { id = result.Id }, result);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Lists puntos de venta with optional filters.</summary>
|
||||||
|
[HttpGet]
|
||||||
|
[RequirePermission("administracion:puntos_de_venta:gestionar")]
|
||||||
|
[ProducesResponseType(typeof(PagedResult<PuntoDeVentaListItemDto>), StatusCodes.Status200OK)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status403Forbidden)]
|
||||||
|
public async Task<IActionResult> ListPuntosDeVenta(
|
||||||
|
[FromQuery] int page = 1,
|
||||||
|
[FromQuery] int pageSize = 20,
|
||||||
|
[FromQuery] int? medioId = null,
|
||||||
|
[FromQuery] bool? activo = null)
|
||||||
|
{
|
||||||
|
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 ListPuntosDeVentaQuery(page, pageSize, medioId, activo);
|
||||||
|
var result = await _dispatcher.Send<ListPuntosDeVentaQuery, PagedResult<PuntoDeVentaListItemDto>>(query);
|
||||||
|
return Ok(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Gets a single punto de venta by id.</summary>
|
||||||
|
[HttpGet("{id:int}")]
|
||||||
|
[RequirePermission("administracion:puntos_de_venta:gestionar")]
|
||||||
|
[ProducesResponseType(typeof(PuntoDeVentaDetailDto), StatusCodes.Status200OK)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status403Forbidden)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||||
|
public async Task<IActionResult> GetPuntoDeVentaById([FromRoute] int id)
|
||||||
|
{
|
||||||
|
var query = new GetPuntoDeVentaByIdQuery(id);
|
||||||
|
var result = await _dispatcher.Send<GetPuntoDeVentaByIdQuery, PuntoDeVentaDetailDto>(query);
|
||||||
|
return Ok(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Updates a punto de venta's editable fields.</summary>
|
||||||
|
[HttpPut("{id:int}")]
|
||||||
|
[RequirePermission("administracion:puntos_de_venta:gestionar")]
|
||||||
|
[ProducesResponseType(typeof(PuntoDeVentaUpdatedDto), StatusCodes.Status200OK)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status400BadRequest)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status403Forbidden)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status409Conflict)]
|
||||||
|
public async Task<IActionResult> UpdatePuntoDeVenta([FromRoute] int id, [FromBody] UpdatePuntoDeVentaRequest request)
|
||||||
|
{
|
||||||
|
var command = new UpdatePuntoDeVentaCommand(
|
||||||
|
Id: id,
|
||||||
|
Nombre: request.Nombre ?? string.Empty,
|
||||||
|
NumeroAFIP: request.NumeroAFIP ?? 0,
|
||||||
|
Descripcion: request.Descripcion);
|
||||||
|
|
||||||
|
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<UpdatePuntoDeVentaCommand, PuntoDeVentaUpdatedDto>(command);
|
||||||
|
return Ok(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Deactivates a punto de venta.</summary>
|
||||||
|
[HttpPost("{id:int}/deactivate")]
|
||||||
|
[RequirePermission("administracion:puntos_de_venta:gestionar")]
|
||||||
|
[ProducesResponseType(StatusCodes.Status204NoContent)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status403Forbidden)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||||
|
public async Task<IActionResult> DeactivatePuntoDeVenta([FromRoute] int id)
|
||||||
|
{
|
||||||
|
var command = new DeactivatePuntoDeVentaCommand(id);
|
||||||
|
await _dispatcher.Send<DeactivatePuntoDeVentaCommand, PuntoDeVentaStatusDto>(command);
|
||||||
|
return NoContent();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Reactivates a punto de venta (only if parent Medio is active).</summary>
|
||||||
|
[HttpPost("{id:int}/reactivate")]
|
||||||
|
[RequirePermission("administracion:puntos_de_venta:gestionar")]
|
||||||
|
[ProducesResponseType(StatusCodes.Status204NoContent)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status403Forbidden)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status409Conflict)]
|
||||||
|
public async Task<IActionResult> ReactivatePuntoDeVenta([FromRoute] int id)
|
||||||
|
{
|
||||||
|
var command = new ReactivatePuntoDeVentaCommand(id);
|
||||||
|
await _dispatcher.Send<ReactivatePuntoDeVentaCommand, PuntoDeVentaStatusDto>(command);
|
||||||
|
return NoContent();
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Request body records ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/// <summary>ADM-008: Create punto de venta request body.</summary>
|
||||||
|
public sealed record CreatePuntoDeVentaRequest(
|
||||||
|
int? MedioId,
|
||||||
|
short? NumeroAFIP,
|
||||||
|
string? Nombre,
|
||||||
|
string? Descripcion);
|
||||||
|
|
||||||
|
/// <summary>ADM-008: Update punto de venta request body.</summary>
|
||||||
|
public sealed record UpdatePuntoDeVentaRequest(
|
||||||
|
string? Nombre,
|
||||||
|
short? NumeroAFIP,
|
||||||
|
string? Descripcion);
|
||||||
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);
|
||||||
172
src/api/SIGCM2.Api/Controllers/SeccionesController.cs
Normal file
172
src/api/SIGCM2.Api/Controllers/SeccionesController.cs
Normal file
@@ -0,0 +1,172 @@
|
|||||||
|
using FluentValidation;
|
||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
using SIGCM2.Api.Authorization;
|
||||||
|
using SIGCM2.Application.Abstractions;
|
||||||
|
using SIGCM2.Application.Common;
|
||||||
|
using SIGCM2.Application.Secciones.Create;
|
||||||
|
using SIGCM2.Application.Secciones.Deactivate;
|
||||||
|
using SIGCM2.Application.Secciones.GetById;
|
||||||
|
using SIGCM2.Application.Secciones.List;
|
||||||
|
using SIGCM2.Application.Secciones.Reactivate;
|
||||||
|
using SIGCM2.Application.Secciones.Update;
|
||||||
|
|
||||||
|
namespace SIGCM2.Api.Controllers;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// ADM-001: Seccion management endpoints at /api/v1/admin/secciones.
|
||||||
|
/// All endpoints require permission 'administracion:secciones:gestionar'.
|
||||||
|
/// </summary>
|
||||||
|
[ApiController]
|
||||||
|
[Route("api/v1/admin/secciones")]
|
||||||
|
public sealed class SeccionesController : ControllerBase
|
||||||
|
{
|
||||||
|
private readonly IDispatcher _dispatcher;
|
||||||
|
private readonly IValidator<CreateSeccionCommand> _createValidator;
|
||||||
|
private readonly IValidator<UpdateSeccionCommand> _updateValidator;
|
||||||
|
|
||||||
|
public SeccionesController(
|
||||||
|
IDispatcher dispatcher,
|
||||||
|
IValidator<CreateSeccionCommand> createValidator,
|
||||||
|
IValidator<UpdateSeccionCommand> updateValidator)
|
||||||
|
{
|
||||||
|
_dispatcher = dispatcher;
|
||||||
|
_createValidator = createValidator;
|
||||||
|
_updateValidator = updateValidator;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Creates a new seccion. Requires administracion:secciones:gestionar.</summary>
|
||||||
|
[HttpPost]
|
||||||
|
[RequirePermission("administracion:secciones:gestionar")]
|
||||||
|
[ProducesResponseType(typeof(SeccionCreatedDto), StatusCodes.Status201Created)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status400BadRequest)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status403Forbidden)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status409Conflict)]
|
||||||
|
public async Task<IActionResult> CreateSeccion([FromBody] CreateSeccionRequest request)
|
||||||
|
{
|
||||||
|
var command = new CreateSeccionCommand(
|
||||||
|
MedioId: request.MedioId ?? 0,
|
||||||
|
Codigo: request.Codigo ?? string.Empty,
|
||||||
|
Nombre: request.Nombre ?? string.Empty,
|
||||||
|
Tipo: request.Tipo ?? string.Empty);
|
||||||
|
|
||||||
|
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<CreateSeccionCommand, SeccionCreatedDto>(command);
|
||||||
|
return CreatedAtAction(nameof(GetSeccionById), new { id = result.Id }, result);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Lists secciones with optional filters and pagination.</summary>
|
||||||
|
[HttpGet]
|
||||||
|
[RequirePermission("administracion:secciones:gestionar")]
|
||||||
|
[ProducesResponseType(typeof(PagedResult<SeccionListItemDto>), StatusCodes.Status200OK)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status403Forbidden)]
|
||||||
|
public async Task<IActionResult> ListSecciones(
|
||||||
|
[FromQuery] int page = 1,
|
||||||
|
[FromQuery] int pageSize = 20,
|
||||||
|
[FromQuery] int? medioId = null,
|
||||||
|
[FromQuery] string? tipo = null,
|
||||||
|
[FromQuery] bool? activo = null,
|
||||||
|
[FromQuery] string? q = null)
|
||||||
|
{
|
||||||
|
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 ListSeccionesQuery(page, pageSize, medioId, tipo, activo, q);
|
||||||
|
var result = await _dispatcher.Send<ListSeccionesQuery, PagedResult<SeccionListItemDto>>(query);
|
||||||
|
return Ok(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Gets a single seccion by id.</summary>
|
||||||
|
[HttpGet("{id:int}")]
|
||||||
|
[RequirePermission("administracion:secciones:gestionar")]
|
||||||
|
[ProducesResponseType(typeof(SeccionDetailDto), StatusCodes.Status200OK)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status403Forbidden)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||||
|
public async Task<IActionResult> GetSeccionById([FromRoute] int id)
|
||||||
|
{
|
||||||
|
var query = new GetSeccionByIdQuery(id);
|
||||||
|
var result = await _dispatcher.Send<GetSeccionByIdQuery, SeccionDetailDto>(query);
|
||||||
|
return Ok(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Updates a seccion's editable fields.</summary>
|
||||||
|
[HttpPut("{id:int}")]
|
||||||
|
[RequirePermission("administracion:secciones:gestionar")]
|
||||||
|
[ProducesResponseType(typeof(SeccionUpdatedDto), StatusCodes.Status200OK)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status400BadRequest)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status403Forbidden)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||||
|
public async Task<IActionResult> UpdateSeccion([FromRoute] int id, [FromBody] UpdateSeccionRequest request)
|
||||||
|
{
|
||||||
|
var command = new UpdateSeccionCommand(
|
||||||
|
Id: id,
|
||||||
|
Nombre: request.Nombre ?? string.Empty,
|
||||||
|
Tipo: request.Tipo ?? string.Empty);
|
||||||
|
|
||||||
|
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<UpdateSeccionCommand, SeccionUpdatedDto>(command);
|
||||||
|
return Ok(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Deactivates a seccion (idempotent).</summary>
|
||||||
|
[HttpPost("{id:int}/deactivate")]
|
||||||
|
[RequirePermission("administracion:secciones:gestionar")]
|
||||||
|
[ProducesResponseType(StatusCodes.Status204NoContent)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status403Forbidden)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||||
|
public async Task<IActionResult> DeactivateSeccion([FromRoute] int id)
|
||||||
|
{
|
||||||
|
var command = new DeactivateSeccionCommand(id);
|
||||||
|
await _dispatcher.Send<DeactivateSeccionCommand, SeccionStatusDto>(command);
|
||||||
|
return NoContent();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Reactivates a seccion (idempotent).</summary>
|
||||||
|
[HttpPost("{id:int}/reactivate")]
|
||||||
|
[RequirePermission("administracion:secciones:gestionar")]
|
||||||
|
[ProducesResponseType(StatusCodes.Status204NoContent)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status403Forbidden)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||||
|
public async Task<IActionResult> ReactivateSeccion([FromRoute] int id)
|
||||||
|
{
|
||||||
|
var command = new ReactivateSeccionCommand(id);
|
||||||
|
await _dispatcher.Send<ReactivateSeccionCommand, SeccionStatusDto>(command);
|
||||||
|
return NoContent();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Request body records ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/// <summary>ADM-001: Create seccion request body.</summary>
|
||||||
|
public sealed record CreateSeccionRequest(
|
||||||
|
int? MedioId,
|
||||||
|
string? Codigo,
|
||||||
|
string? Nombre,
|
||||||
|
string? Tipo);
|
||||||
|
|
||||||
|
/// <summary>ADM-001: Update seccion request body.</summary>
|
||||||
|
public sealed record UpdateSeccionRequest(
|
||||||
|
string? Nombre,
|
||||||
|
string? Tipo);
|
||||||
@@ -3,6 +3,7 @@ using Microsoft.AspNetCore.Mvc;
|
|||||||
using Microsoft.AspNetCore.Mvc.Filters;
|
using Microsoft.AspNetCore.Mvc.Filters;
|
||||||
using Microsoft.Data.SqlClient;
|
using Microsoft.Data.SqlClient;
|
||||||
using SIGCM2.Domain.Exceptions;
|
using SIGCM2.Domain.Exceptions;
|
||||||
|
using SIGCM2.Domain.Pricing.Exceptions;
|
||||||
|
|
||||||
namespace SIGCM2.Api.Filters;
|
namespace SIGCM2.Api.Filters;
|
||||||
|
|
||||||
@@ -169,6 +170,438 @@ public sealed class ExceptionFilter : IExceptionFilter
|
|||||||
context.ExceptionHandled = true;
|
context.ExceptionHandled = true;
|
||||||
break;
|
break;
|
||||||
|
|
||||||
|
// CAT-001: Rubro exceptions
|
||||||
|
case RubroNotFoundException rubroNotFoundEx:
|
||||||
|
context.Result = new ObjectResult(new
|
||||||
|
{
|
||||||
|
error = "rubro_not_found",
|
||||||
|
message = rubroNotFoundEx.Message
|
||||||
|
})
|
||||||
|
{
|
||||||
|
StatusCode = StatusCodes.Status404NotFound
|
||||||
|
};
|
||||||
|
context.ExceptionHandled = true;
|
||||||
|
break;
|
||||||
|
|
||||||
|
case RubroNombreDuplicadoEnPadreException rubroDupEx:
|
||||||
|
context.Result = new ObjectResult(new
|
||||||
|
{
|
||||||
|
error = "rubro_nombre_duplicado",
|
||||||
|
message = rubroDupEx.Message
|
||||||
|
})
|
||||||
|
{
|
||||||
|
StatusCode = StatusCodes.Status409Conflict
|
||||||
|
};
|
||||||
|
context.ExceptionHandled = true;
|
||||||
|
break;
|
||||||
|
|
||||||
|
case RubroTieneHijosActivosException rubroHijosEx:
|
||||||
|
context.Result = new ObjectResult(new
|
||||||
|
{
|
||||||
|
error = "rubro_tiene_hijos_activos",
|
||||||
|
message = rubroHijosEx.Message
|
||||||
|
})
|
||||||
|
{
|
||||||
|
StatusCode = StatusCodes.Status409Conflict
|
||||||
|
};
|
||||||
|
context.ExceptionHandled = true;
|
||||||
|
break;
|
||||||
|
|
||||||
|
case RubroPadreInactivoException rubroPadreEx:
|
||||||
|
context.Result = new ObjectResult(new
|
||||||
|
{
|
||||||
|
error = "rubro_padre_inactivo",
|
||||||
|
message = rubroPadreEx.Message
|
||||||
|
})
|
||||||
|
{
|
||||||
|
StatusCode = StatusCodes.Status400BadRequest
|
||||||
|
};
|
||||||
|
context.ExceptionHandled = true;
|
||||||
|
break;
|
||||||
|
|
||||||
|
case RubroMaxDepthExceededException rubroDepthEx:
|
||||||
|
context.Result = new ObjectResult(new
|
||||||
|
{
|
||||||
|
error = "rubro_max_depth_exceeded",
|
||||||
|
message = rubroDepthEx.Message
|
||||||
|
})
|
||||||
|
{
|
||||||
|
StatusCode = StatusCodes.Status422UnprocessableEntity
|
||||||
|
};
|
||||||
|
context.ExceptionHandled = true;
|
||||||
|
break;
|
||||||
|
|
||||||
|
case RubroCycleDetectedException rubroCycleEx:
|
||||||
|
context.Result = new ObjectResult(new
|
||||||
|
{
|
||||||
|
error = "rubro_cycle_detected",
|
||||||
|
message = rubroCycleEx.Message
|
||||||
|
})
|
||||||
|
{
|
||||||
|
StatusCode = StatusCodes.Status400BadRequest
|
||||||
|
};
|
||||||
|
context.ExceptionHandled = true;
|
||||||
|
break;
|
||||||
|
|
||||||
|
// CAT-002: Rubro Regla de Oro (rama vs hoja)
|
||||||
|
case RubroPadreEsHojaConAvisosException rubroPadreHojaEx:
|
||||||
|
context.Result = new ObjectResult(new
|
||||||
|
{
|
||||||
|
error = "rubro_padre_es_hoja_con_avisos",
|
||||||
|
message = rubroPadreHojaEx.Message
|
||||||
|
})
|
||||||
|
{
|
||||||
|
StatusCode = StatusCodes.Status409Conflict
|
||||||
|
};
|
||||||
|
context.ExceptionHandled = true;
|
||||||
|
break;
|
||||||
|
|
||||||
|
case RubroEsRamaConHijosActivosException rubroRamaHijosEx:
|
||||||
|
context.Result = new ObjectResult(new
|
||||||
|
{
|
||||||
|
error = "rubro_es_rama_con_hijos_activos",
|
||||||
|
message = rubroRamaHijosEx.Message
|
||||||
|
})
|
||||||
|
{
|
||||||
|
StatusCode = StatusCodes.Status409Conflict
|
||||||
|
};
|
||||||
|
context.ExceptionHandled = true;
|
||||||
|
break;
|
||||||
|
|
||||||
|
case RubroConProductosActivosException rubroProductosEx:
|
||||||
|
context.Result = new ObjectResult(new
|
||||||
|
{
|
||||||
|
error = "rubro_con_productos_activos",
|
||||||
|
message = rubroProductosEx.Message
|
||||||
|
})
|
||||||
|
{
|
||||||
|
StatusCode = StatusCodes.Status409Conflict
|
||||||
|
};
|
||||||
|
context.ExceptionHandled = true;
|
||||||
|
break;
|
||||||
|
|
||||||
|
// ADM-001: Medio exceptions
|
||||||
|
case MedioCodigoDuplicadoException medioCodDupEx:
|
||||||
|
context.Result = new ObjectResult(new
|
||||||
|
{
|
||||||
|
error = "medio_codigo_duplicado",
|
||||||
|
message = medioCodDupEx.Message
|
||||||
|
})
|
||||||
|
{
|
||||||
|
StatusCode = StatusCodes.Status409Conflict
|
||||||
|
};
|
||||||
|
context.ExceptionHandled = true;
|
||||||
|
break;
|
||||||
|
|
||||||
|
case MedioNotFoundException medioNotFoundEx:
|
||||||
|
context.Result = new ObjectResult(new
|
||||||
|
{
|
||||||
|
error = "medio_not_found",
|
||||||
|
message = medioNotFoundEx.Message
|
||||||
|
})
|
||||||
|
{
|
||||||
|
StatusCode = StatusCodes.Status404NotFound
|
||||||
|
};
|
||||||
|
context.ExceptionHandled = true;
|
||||||
|
break;
|
||||||
|
|
||||||
|
case MedioInactivoException medioInactivoEx:
|
||||||
|
context.Result = new ObjectResult(new
|
||||||
|
{
|
||||||
|
error = "medio_inactivo",
|
||||||
|
message = medioInactivoEx.Message
|
||||||
|
})
|
||||||
|
{
|
||||||
|
StatusCode = StatusCodes.Status409Conflict
|
||||||
|
};
|
||||||
|
context.ExceptionHandled = true;
|
||||||
|
break;
|
||||||
|
|
||||||
|
// ADM-001: Seccion exceptions
|
||||||
|
case SeccionCodigoDuplicadoEnMedioException seccionCodDupEx:
|
||||||
|
context.Result = new ObjectResult(new
|
||||||
|
{
|
||||||
|
error = "seccion_codigo_duplicado_en_medio",
|
||||||
|
message = seccionCodDupEx.Message
|
||||||
|
})
|
||||||
|
{
|
||||||
|
StatusCode = StatusCodes.Status409Conflict
|
||||||
|
};
|
||||||
|
context.ExceptionHandled = true;
|
||||||
|
break;
|
||||||
|
|
||||||
|
case SeccionNotFoundException seccionNotFoundEx:
|
||||||
|
context.Result = new ObjectResult(new
|
||||||
|
{
|
||||||
|
error = "seccion_not_found",
|
||||||
|
message = seccionNotFoundEx.Message
|
||||||
|
})
|
||||||
|
{
|
||||||
|
StatusCode = StatusCodes.Status404NotFound
|
||||||
|
};
|
||||||
|
context.ExceptionHandled = true;
|
||||||
|
break;
|
||||||
|
|
||||||
|
// ADM-009: TipoDeIva fiscal exceptions
|
||||||
|
case PorcentajeInmutableException:
|
||||||
|
context.Result = new ObjectResult(new
|
||||||
|
{
|
||||||
|
error = "inmutable_usar_nueva_version",
|
||||||
|
message = "El porcentaje de un TipoDeIva es inmutable. Creá una nueva versión vía POST /iva/{id}/nueva-version."
|
||||||
|
})
|
||||||
|
{
|
||||||
|
StatusCode = StatusCodes.Status409Conflict
|
||||||
|
};
|
||||||
|
context.ExceptionHandled = true;
|
||||||
|
break;
|
||||||
|
|
||||||
|
case AlicuotaInmutableException:
|
||||||
|
context.Result = new ObjectResult(new
|
||||||
|
{
|
||||||
|
error = "inmutable_usar_nueva_version",
|
||||||
|
message = "La alícuota de IngresosBrutos es inmutable. Creá una nueva versión vía POST /iibb/{id}/nueva-version."
|
||||||
|
})
|
||||||
|
{
|
||||||
|
StatusCode = StatusCodes.Status409Conflict
|
||||||
|
};
|
||||||
|
context.ExceptionHandled = true;
|
||||||
|
break;
|
||||||
|
|
||||||
|
case PredecesorYaCerradoException predecesorYaCerradoEx:
|
||||||
|
context.Result = new ObjectResult(new
|
||||||
|
{
|
||||||
|
error = "predecesora_ya_cerrada",
|
||||||
|
message = predecesorYaCerradoEx.Message
|
||||||
|
})
|
||||||
|
{
|
||||||
|
StatusCode = StatusCodes.Status409Conflict
|
||||||
|
};
|
||||||
|
context.ExceptionHandled = true;
|
||||||
|
break;
|
||||||
|
|
||||||
|
case DuplicateCodigoException duplicateCodigoEx:
|
||||||
|
context.Result = new ObjectResult(new
|
||||||
|
{
|
||||||
|
error = "duplicate_codigo",
|
||||||
|
message = duplicateCodigoEx.Message
|
||||||
|
})
|
||||||
|
{
|
||||||
|
StatusCode = StatusCodes.Status409Conflict
|
||||||
|
};
|
||||||
|
context.ExceptionHandled = true;
|
||||||
|
break;
|
||||||
|
|
||||||
|
case DuplicateProvinciaException duplicateProvinciaEx:
|
||||||
|
context.Result = new ObjectResult(new
|
||||||
|
{
|
||||||
|
error = "duplicate_provincia",
|
||||||
|
message = duplicateProvinciaEx.Message
|
||||||
|
})
|
||||||
|
{
|
||||||
|
StatusCode = StatusCodes.Status409Conflict
|
||||||
|
};
|
||||||
|
context.ExceptionHandled = true;
|
||||||
|
break;
|
||||||
|
|
||||||
|
case TipoDeIvaNotFoundException tipoDeIvaNotFoundEx:
|
||||||
|
context.Result = new ObjectResult(new
|
||||||
|
{
|
||||||
|
error = "tipo_iva_not_found",
|
||||||
|
message = tipoDeIvaNotFoundEx.Message
|
||||||
|
})
|
||||||
|
{
|
||||||
|
StatusCode = StatusCodes.Status404NotFound
|
||||||
|
};
|
||||||
|
context.ExceptionHandled = true;
|
||||||
|
break;
|
||||||
|
|
||||||
|
case IngresosBrutosNotFoundException ingresosBrutosNotFoundEx:
|
||||||
|
context.Result = new ObjectResult(new
|
||||||
|
{
|
||||||
|
error = "ingresos_brutos_not_found",
|
||||||
|
message = ingresosBrutosNotFoundEx.Message
|
||||||
|
})
|
||||||
|
{
|
||||||
|
StatusCode = StatusCodes.Status404NotFound
|
||||||
|
};
|
||||||
|
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
|
||||||
|
{
|
||||||
|
error = "punto_de_venta_not_found",
|
||||||
|
message = puntoDeVentaNotFoundEx.Message
|
||||||
|
})
|
||||||
|
{
|
||||||
|
StatusCode = StatusCodes.Status404NotFound
|
||||||
|
};
|
||||||
|
context.ExceptionHandled = true;
|
||||||
|
break;
|
||||||
|
|
||||||
|
case NumeroAFIPDuplicadoException numeroAFIPDupEx:
|
||||||
|
context.Result = new ObjectResult(new
|
||||||
|
{
|
||||||
|
error = "numero_afip_duplicado",
|
||||||
|
message = numeroAFIPDupEx.Message
|
||||||
|
})
|
||||||
|
{
|
||||||
|
StatusCode = StatusCodes.Status409Conflict
|
||||||
|
};
|
||||||
|
context.ExceptionHandled = true;
|
||||||
|
break;
|
||||||
|
|
||||||
// UDT-009: permiso override validation errors
|
// UDT-009: permiso override validation errors
|
||||||
case InvalidPermisoCodesException ipce:
|
case InvalidPermisoCodesException ipce:
|
||||||
context.Result = new ObjectResult(new Microsoft.AspNetCore.Mvc.ProblemDetails
|
context.Result = new ObjectResult(new Microsoft.AspNetCore.Mvc.ProblemDetails
|
||||||
@@ -198,6 +631,109 @@ public sealed class ExceptionFilter : IExceptionFilter
|
|||||||
context.ExceptionHandled = true;
|
context.ExceptionHandled = true;
|
||||||
break;
|
break;
|
||||||
|
|
||||||
|
// ADM-009: vigencia_desde_invalida — domain throws ArgumentException for invalid vigencia range
|
||||||
|
case ArgumentException argEx when argEx.Message.Contains("vigencia_desde_invalida") ||
|
||||||
|
argEx.ParamName == "vigenciaDesde" ||
|
||||||
|
argEx.Message.Contains("debe ser posterior"):
|
||||||
|
context.Result = new ObjectResult(new
|
||||||
|
{
|
||||||
|
error = "vigencia_desde_invalida",
|
||||||
|
message = argEx.Message
|
||||||
|
})
|
||||||
|
{
|
||||||
|
StatusCode = StatusCodes.Status400BadRequest
|
||||||
|
};
|
||||||
|
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:
|
case ValidationException validationEx:
|
||||||
var errors = validationEx.Errors
|
var errors = validationEx.Errors
|
||||||
.GroupBy(e => e.PropertyName)
|
.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 Serilog;
|
||||||
using Scalar.AspNetCore;
|
using Scalar.AspNetCore;
|
||||||
using SIGCM2.Api.Authorization;
|
using SIGCM2.Api.Authorization;
|
||||||
|
using SIGCM2.Api.Filters;
|
||||||
using SIGCM2.Api.HealthChecks;
|
using SIGCM2.Api.HealthChecks;
|
||||||
|
using SIGCM2.Api.Json;
|
||||||
using SIGCM2.Api.Middleware;
|
using SIGCM2.Api.Middleware;
|
||||||
using SIGCM2.Application;
|
using SIGCM2.Application;
|
||||||
using SIGCM2.Infrastructure;
|
using SIGCM2.Infrastructure;
|
||||||
using SIGCM2.Infrastructure.Audit.Jobs;
|
using SIGCM2.Infrastructure.Audit.Jobs;
|
||||||
using SIGCM2.Api.Filters;
|
|
||||||
|
|
||||||
// Bootstrap logger — before DI is built
|
// Bootstrap logger — before DI is built
|
||||||
Log.Logger = new LoggerConfiguration()
|
Log.Logger = new LoggerConfiguration()
|
||||||
@@ -36,10 +37,15 @@ builder.Services.AddAuthorization();
|
|||||||
builder.Services.AddScoped<IAuthorizationHandler, PermissionAuthorizationHandler>();
|
builder.Services.AddScoped<IAuthorizationHandler, PermissionAuthorizationHandler>();
|
||||||
builder.Services.AddSingleton<IAuthorizationMiddlewareResultHandler, ForbiddenProblemDetailsHandler>();
|
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 =>
|
builder.Services.AddControllers(opts =>
|
||||||
{
|
{
|
||||||
opts.Filters.Add<ExceptionFilter>();
|
opts.Filters.Add<ExceptionFilter>();
|
||||||
|
}).AddJsonOptions(jsonOpts =>
|
||||||
|
{
|
||||||
|
jsonOpts.JsonSerializerOptions.Converters.Add(new DateOnlyJsonConverter());
|
||||||
});
|
});
|
||||||
|
|
||||||
// OpenAPI / Scalar
|
// OpenAPI / Scalar
|
||||||
|
|||||||
@@ -32,5 +32,8 @@
|
|||||||
],
|
],
|
||||||
"Enrich": [ "FromLogContext", "WithMachineName", "WithThreadId" ]
|
"Enrich": [ "FromLogContext", "WithMachineName", "WithThreadId" ]
|
||||||
},
|
},
|
||||||
|
"Rubros": {
|
||||||
|
"MaxDepth": 10
|
||||||
|
},
|
||||||
"AllowedHosts": "*"
|
"AllowedHosts": "*"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,24 @@
|
|||||||
|
namespace SIGCM2.Application.Abstractions.Persistence;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Query-only access to Aviso counts by Rubro.
|
||||||
|
/// CAT-002 introduces the contract. The real Dapper-based impl lands in PRD-002
|
||||||
|
/// (when dbo.Aviso exists). Until then, NullAvisoQueryRepository is the binding.
|
||||||
|
/// </summary>
|
||||||
|
public interface IAvisoQueryRepository
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Returns the count of avisos (active, non-archived) assigned to the given rubro.
|
||||||
|
/// </summary>
|
||||||
|
Task<int> CountAvisosEnRubroAsync(int rubroId, CancellationToken ct = default);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Returns a dictionary of { rubroId → count } for the provided ids.
|
||||||
|
/// Used by GetRubroTreeQueryHandler to avoid N+1 when populating TieneAvisos per node.
|
||||||
|
/// The implementation MUST do a single query; the stub returns an empty dictionary
|
||||||
|
/// (every rubro gets 0 via dictionary.GetValueOrDefault).
|
||||||
|
/// </summary>
|
||||||
|
Task<IReadOnlyDictionary<int, int>> CountAvisosBatchAsync(
|
||||||
|
IReadOnlyCollection<int> rubroIds,
|
||||||
|
CancellationToken ct = default);
|
||||||
|
}
|
||||||
@@ -0,0 +1,104 @@
|
|||||||
|
using SIGCM2.Domain.Pricing.ChargeableChars;
|
||||||
|
|
||||||
|
namespace SIGCM2.Application.Abstractions.Persistence;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// PRC-001 — Write + query access to dbo.ChargeableCharConfig.
|
||||||
|
/// Implemented by ChargeableCharConfigRepository (Dapper) in Infrastructure.
|
||||||
|
///
|
||||||
|
/// InsertWithCloseAsync: invokes usp_ChargeableCharConfig_InsertWithClose which atomically
|
||||||
|
/// closes any active row for (ProductTypeId, Symbol) and inserts the new row.
|
||||||
|
///
|
||||||
|
/// GetActiveForProductTypeAsync: invokes usp_ChargeableCharConfig_GetActiveForProductType which
|
||||||
|
/// returns both per-ProductType rows AND global (ProductTypeId IS NULL) rows for the given asOfDate.
|
||||||
|
/// The Application service applies the per-ProductType > global priority rule.
|
||||||
|
/// </summary>
|
||||||
|
public interface IChargeableCharConfigRepository
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Invokes usp_ChargeableCharConfig_InsertWithClose inside the ambient TransactionScope.
|
||||||
|
/// Closes any active row matching (ProductTypeId, Symbol) and inserts a new one.
|
||||||
|
/// Returns the Id of the newly inserted row.
|
||||||
|
/// Throws:
|
||||||
|
/// - ChargeableCharConfigForwardOnlyException on SQL THROW 50409
|
||||||
|
/// - ChargeableCharConfigInvalidException on SQL THROW 50404 (not-found guard)
|
||||||
|
/// </summary>
|
||||||
|
Task<long> InsertWithCloseAsync(
|
||||||
|
long? productTypeId,
|
||||||
|
string symbol,
|
||||||
|
string category,
|
||||||
|
decimal price,
|
||||||
|
DateOnly validFrom,
|
||||||
|
CancellationToken ct = default);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Returns all active rows whose [ValidFrom, ValidTo] window covers the given asOfDate
|
||||||
|
/// for the specified ProductType, including global rows (ProductTypeId IS NULL).
|
||||||
|
/// The SP returns both per-ProductType AND global rows — callers apply priority.
|
||||||
|
/// </summary>
|
||||||
|
Task<IReadOnlyList<ChargeableCharConfig>> GetActiveForProductTypeAsync(
|
||||||
|
long productTypeId,
|
||||||
|
DateOnly asOfDate,
|
||||||
|
CancellationToken ct = default);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Returns paginated rows filtered by ProductTypeId and IsActive.
|
||||||
|
/// Skip = (page - 1) * pageSize computed by the caller.
|
||||||
|
/// </summary>
|
||||||
|
Task<IReadOnlyList<ChargeableCharConfig>> ListAsync(
|
||||||
|
long? productTypeId,
|
||||||
|
bool activeOnly,
|
||||||
|
int skip,
|
||||||
|
int take,
|
||||||
|
CancellationToken ct = default);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Returns total row count for the given filters (used for pagination metadata).
|
||||||
|
/// </summary>
|
||||||
|
Task<int> CountAsync(
|
||||||
|
long? productTypeId,
|
||||||
|
bool activeOnly,
|
||||||
|
CancellationToken ct = default);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Returns the row with the given Id, or null if not found.
|
||||||
|
/// </summary>
|
||||||
|
Task<ChargeableCharConfig?> GetByIdAsync(
|
||||||
|
long id,
|
||||||
|
CancellationToken ct = default);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Deactivates the row with the given Id by setting IsActive = false and ValidTo = today.
|
||||||
|
/// Idempotent: no-op if already inactive.
|
||||||
|
/// Called inside the ambient TransactionScope of the handler.
|
||||||
|
/// </summary>
|
||||||
|
Task DeactivateAsync(
|
||||||
|
long id,
|
||||||
|
DateOnly today,
|
||||||
|
CancellationToken ct = default);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Invokes usp_ChargeableCharConfig_ReactivateWithGuard.
|
||||||
|
/// Guard rules (enforced by SP):
|
||||||
|
/// 50410 → target row is already active → ChargeableCharConfigReactivationNotAllowedException(ALREADY_ACTIVE)
|
||||||
|
/// 50411 → a vigente active row exists for (ProductTypeId, Symbol) → ChargeableCharConfigReactivationNotAllowedException(VIGENTE_EXISTS)
|
||||||
|
/// 50412 → posterior rows exist after target row → ChargeableCharConfigReactivationNotAllowedException(POSTERIOR_ROWS_EXIST)
|
||||||
|
/// 50404 → row not found → ChargeableCharConfigInvalidException
|
||||||
|
/// On success: re-opens the row (IsActive=true, ValidTo=NULL) and returns the reactivated entity.
|
||||||
|
/// </summary>
|
||||||
|
Task<ChargeableCharConfig> ReactivateAsync(
|
||||||
|
long id,
|
||||||
|
CancellationToken ct = default);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Physically deletes the row with the given Id from dbo.ChargeableCharConfig (current state).
|
||||||
|
/// NOTE: Since SYSTEM_VERSIONING is ON, SQL Server moves the row to the history table with
|
||||||
|
/// SysEndTime set to the delete time. The row disappears from all current-state queries but
|
||||||
|
/// remains queryable via FOR SYSTEM_TIME. Temporal audit trail is preserved.
|
||||||
|
/// Future guard for "used in invoicing" is deferred to FAC-001 followup issue.
|
||||||
|
/// Throws KeyNotFoundException if the row does not exist.
|
||||||
|
/// </summary>
|
||||||
|
Task DeleteAsync(
|
||||||
|
long id,
|
||||||
|
CancellationToken ct = default);
|
||||||
|
}
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
using SIGCM2.Application.Common;
|
||||||
|
using SIGCM2.Domain.Fiscal;
|
||||||
|
using IibbEntity = SIGCM2.Domain.Entities.IngresosBrutos;
|
||||||
|
|
||||||
|
namespace SIGCM2.Application.Abstractions.Persistence;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Persistence contract for IngresosBrutos. Implemented by Dapper repo in Infrastructure.
|
||||||
|
/// </summary>
|
||||||
|
public interface IIngresosBrutosRepository
|
||||||
|
{
|
||||||
|
/// <summary>Inserts a new IngresosBrutos record and returns the generated identity Id.</summary>
|
||||||
|
Task<int> InsertAsync(IibbEntity entity, CancellationToken ct = default);
|
||||||
|
|
||||||
|
/// <summary>Returns the IngresosBrutos with the given Id, or null if not found.</summary>
|
||||||
|
Task<IibbEntity?> GetByIdAsync(int id, CancellationToken ct = default);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Updates cosmetic fields only (Descripcion, Activo).
|
||||||
|
/// Never touches Alicuota, Provincia, or vigencia dates.
|
||||||
|
/// </summary>
|
||||||
|
Task<bool> UpdateCosmeticoAsync(int id, string descripcion, bool activo, CancellationToken ct = default);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Closes the vigencia of the predecessor: UPDATE SET VigenciaHasta = @vigenciaHasta
|
||||||
|
/// WHERE Id = @id AND VigenciaHasta IS NULL (optimistic guard for race conditions).
|
||||||
|
/// Returns true if one row was affected, false if the row was already closed (race detected).
|
||||||
|
/// </summary>
|
||||||
|
Task<bool> UpdateCierreVigenciaAsync(int id, DateOnly vigenciaHasta, CancellationToken ct = default);
|
||||||
|
|
||||||
|
/// <summary>Sets Activo to the given value. Returns true if one row was affected.</summary>
|
||||||
|
Task<bool> SetActivoAsync(int id, bool activo, CancellationToken ct = default);
|
||||||
|
|
||||||
|
/// <summary>Returns a paged list applying optional Activo and Provincia filters.</summary>
|
||||||
|
Task<PagedResult<IibbEntity>> ListAsync(IngresosBrutosQuery query, CancellationToken ct = default);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Returns the full version chain for the record identified by <paramref name="id"/>,
|
||||||
|
/// ordered from root (no PredecesorId) to the requested Id (inclusive).
|
||||||
|
/// Implemented via a recursive CTE in the concrete repository.
|
||||||
|
/// </summary>
|
||||||
|
Task<IReadOnlyList<IibbEntity>> GetHistorialAsync(int id, CancellationToken ct = default);
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
using SIGCM2.Application.Common;
|
||||||
|
using SIGCM2.Domain.Entities;
|
||||||
|
|
||||||
|
namespace SIGCM2.Application.Abstractions.Persistence;
|
||||||
|
|
||||||
|
public interface IMedioRepository
|
||||||
|
{
|
||||||
|
Task<int> AddAsync(Medio m, CancellationToken ct = default);
|
||||||
|
Task<Medio?> GetByIdAsync(int id, CancellationToken ct = default);
|
||||||
|
Task<bool> ExistsByCodigoAsync(string codigo, CancellationToken ct = default);
|
||||||
|
Task UpdateAsync(Medio m, CancellationToken ct = default);
|
||||||
|
Task<PagedResult<Medio>> GetPagedAsync(MediosQuery q, 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,13 @@
|
|||||||
|
using SIGCM2.Application.Common;
|
||||||
|
using SIGCM2.Domain.Entities;
|
||||||
|
|
||||||
|
namespace SIGCM2.Application.Abstractions.Persistence;
|
||||||
|
|
||||||
|
public interface IPuntoDeVentaRepository
|
||||||
|
{
|
||||||
|
Task<int> AddAsync(PuntoDeVenta pdv, CancellationToken ct = default);
|
||||||
|
Task<PuntoDeVenta?> GetByIdAsync(int id, CancellationToken ct = default);
|
||||||
|
Task<bool> ExistsByNumeroAFIPInMedioAsync(int medioId, short numeroAFIP, int? excludeId = null, CancellationToken ct = default);
|
||||||
|
Task UpdateAsync(PuntoDeVenta pdv, CancellationToken ct = default);
|
||||||
|
Task<PagedResult<PuntoDeVenta>> GetPagedAsync(PuntosDeVentaQuery q, CancellationToken ct = default);
|
||||||
|
}
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
using SIGCM2.Domain.Entities;
|
||||||
|
|
||||||
|
namespace SIGCM2.Application.Abstractions.Persistence;
|
||||||
|
|
||||||
|
public interface IRubroRepository
|
||||||
|
{
|
||||||
|
Task<int> AddAsync(Rubro rubro, CancellationToken ct = default);
|
||||||
|
Task<Rubro?> GetByIdAsync(int id, CancellationToken ct = default);
|
||||||
|
Task<IReadOnlyList<Rubro>> GetAllAsync(bool incluirInactivos, CancellationToken ct = default);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Returns all descendants of rootId via recursive CTE (used only by MoveRubro for cycle detection).
|
||||||
|
/// </summary>
|
||||||
|
Task<IReadOnlyList<Rubro>> GetDescendantsAsync(int rootId, CancellationToken ct = default);
|
||||||
|
|
||||||
|
Task UpdateAsync(Rubro rubro, CancellationToken ct = default);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Returns the count of active children for the given parentId.
|
||||||
|
/// Used by soft-delete to guard against deleting non-leaf rubros.
|
||||||
|
/// </summary>
|
||||||
|
Task<int> CountActiveChildrenAsync(int id, CancellationToken ct = default);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Returns MAX(Orden)+1 among siblings of the given parentId (0 if no siblings).
|
||||||
|
/// Used for append-on-create ordering.
|
||||||
|
/// </summary>
|
||||||
|
Task<int> GetMaxOrdenAsync(int? parentId, CancellationToken ct = default);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Returns true if an active Rubro with the same Nombre (CI) exists under the same parentId,
|
||||||
|
/// optionally excluding the Rubro with the given id (for rename operations).
|
||||||
|
/// </summary>
|
||||||
|
Task<bool> ExistsByNombreUnderParentAsync(int? parentId, string nombre, int? excludeId, CancellationToken ct = default);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Returns the depth of the given parentId (0 if parentId is null = root level).
|
||||||
|
/// Uses a recursive CTE going upward through ancestors.
|
||||||
|
/// </summary>
|
||||||
|
Task<int> GetDepthAsync(int? parentId, CancellationToken ct = default);
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
using SIGCM2.Application.Common;
|
||||||
|
using SIGCM2.Domain.Entities;
|
||||||
|
|
||||||
|
namespace SIGCM2.Application.Abstractions.Persistence;
|
||||||
|
|
||||||
|
public interface ISeccionRepository
|
||||||
|
{
|
||||||
|
Task<int> AddAsync(Seccion s, CancellationToken ct = default);
|
||||||
|
Task<Seccion?> GetByIdAsync(int id, CancellationToken ct = default);
|
||||||
|
Task<bool> ExistsByCodigoInMedioAsync(int medioId, string codigo, CancellationToken ct = default);
|
||||||
|
Task UpdateAsync(Seccion s, CancellationToken ct = default);
|
||||||
|
Task<PagedResult<Seccion>> GetPagedAsync(SeccionesQuery q, CancellationToken ct = default);
|
||||||
|
}
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
using SIGCM2.Application.Common;
|
||||||
|
using SIGCM2.Domain.Entities;
|
||||||
|
|
||||||
|
namespace SIGCM2.Application.Abstractions.Persistence;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Persistence contract for TipoDeIva. Implemented by Dapper repo in Infrastructure.
|
||||||
|
/// </summary>
|
||||||
|
public interface ITipoDeIvaRepository
|
||||||
|
{
|
||||||
|
/// <summary>Inserts a new TipoDeIva and returns the generated identity Id.</summary>
|
||||||
|
Task<int> InsertAsync(TipoDeIva entity, CancellationToken ct = default);
|
||||||
|
|
||||||
|
/// <summary>Returns the TipoDeIva with the given Id, or null if not found.</summary>
|
||||||
|
Task<TipoDeIva?> GetByIdAsync(int id, CancellationToken ct = default);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Updates cosmetic fields only (Codigo, Descripcion, AplicaIVA, Activo).
|
||||||
|
/// Never touches Porcentaje or vigencia dates.
|
||||||
|
/// </summary>
|
||||||
|
Task<bool> UpdateCosmeticoAsync(int id, string codigo, string descripcion, bool aplicaIVA, bool activo, CancellationToken ct = default);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Closes the vigencia of the predecessor: UPDATE SET VigenciaHasta = @vigenciaHasta
|
||||||
|
/// WHERE Id = @id AND VigenciaHasta IS NULL (optimistic guard for race conditions).
|
||||||
|
/// Returns true if one row was affected, false if the row was already closed (race detected).
|
||||||
|
/// </summary>
|
||||||
|
Task<bool> UpdateCierreVigenciaAsync(int id, DateOnly vigenciaHasta, CancellationToken ct = default);
|
||||||
|
|
||||||
|
/// <summary>Sets Activo to the given value. Returns true if one row was affected.</summary>
|
||||||
|
Task<bool> SetActivoAsync(int id, bool activo, CancellationToken ct = default);
|
||||||
|
|
||||||
|
/// <summary>Returns a paged list applying optional Activo and Codigo filters.</summary>
|
||||||
|
Task<PagedResult<TipoDeIva>> ListAsync(TiposDeIvaQuery query, CancellationToken ct = default);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Returns the full version chain for the record identified by <paramref name="id"/>,
|
||||||
|
/// ordered from root (no PredecesorId) to the requested Id (inclusive).
|
||||||
|
/// Implemented via a recursive CTE in the concrete repository.
|
||||||
|
/// </summary>
|
||||||
|
Task<IReadOnlyList<TipoDeIva>> GetHistorialAsync(int id, CancellationToken ct = default);
|
||||||
|
}
|
||||||
@@ -22,6 +22,7 @@ public sealed class LoginCommandHandler : ICommandHandler<LoginCommand, LoginRes
|
|||||||
private readonly IRolPermisoRepository _rolPermisoRepository;
|
private readonly IRolPermisoRepository _rolPermisoRepository;
|
||||||
private readonly ISecurityEventLogger _security;
|
private readonly ISecurityEventLogger _security;
|
||||||
private readonly ILogger<LoginCommandHandler> _logger;
|
private readonly ILogger<LoginCommandHandler> _logger;
|
||||||
|
private readonly TimeProvider _timeProvider;
|
||||||
|
|
||||||
public LoginCommandHandler(
|
public LoginCommandHandler(
|
||||||
IUsuarioRepository repository,
|
IUsuarioRepository repository,
|
||||||
@@ -33,7 +34,8 @@ public sealed class LoginCommandHandler : ICommandHandler<LoginCommand, LoginRes
|
|||||||
AuthOptions authOptions,
|
AuthOptions authOptions,
|
||||||
IRolPermisoRepository rolPermisoRepository,
|
IRolPermisoRepository rolPermisoRepository,
|
||||||
ISecurityEventLogger security,
|
ISecurityEventLogger security,
|
||||||
ILogger<LoginCommandHandler> logger)
|
ILogger<LoginCommandHandler> logger,
|
||||||
|
TimeProvider timeProvider)
|
||||||
{
|
{
|
||||||
_repository = repository;
|
_repository = repository;
|
||||||
_hasher = hasher;
|
_hasher = hasher;
|
||||||
@@ -45,6 +47,7 @@ public sealed class LoginCommandHandler : ICommandHandler<LoginCommand, LoginRes
|
|||||||
_rolPermisoRepository = rolPermisoRepository;
|
_rolPermisoRepository = rolPermisoRepository;
|
||||||
_security = security;
|
_security = security;
|
||||||
_logger = logger;
|
_logger = logger;
|
||||||
|
_timeProvider = timeProvider;
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<LoginResponseDto> Handle(LoginCommand command)
|
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
|
// Generate and persist refresh token — only the hash hits the DB
|
||||||
var rawRefresh = _refreshGenerator.Generate();
|
var rawRefresh = _refreshGenerator.Generate();
|
||||||
var hash = TokenHasher.Sha256Base64Url(rawRefresh);
|
var hash = TokenHasher.Sha256Base64Url(rawRefresh);
|
||||||
var now = DateTime.UtcNow;
|
var now = _timeProvider.GetUtcNow().UtcDateTime;
|
||||||
var ttl = TimeSpan.FromDays(_authOptions.RefreshTokenDays);
|
var ttl = TimeSpan.FromDays(_authOptions.RefreshTokenDays);
|
||||||
var entity = RefreshToken.IssueForNewFamily(
|
var entity = RefreshToken.IssueForNewFamily(
|
||||||
usuario.Id, hash, now, ttl,
|
usuario.Id, hash, now, ttl,
|
||||||
|
|||||||
@@ -8,18 +8,24 @@ public sealed class LogoutCommandHandler : ICommandHandler<LogoutCommand, Logout
|
|||||||
{
|
{
|
||||||
private readonly IRefreshTokenRepository _refreshRepo;
|
private readonly IRefreshTokenRepository _refreshRepo;
|
||||||
private readonly ISecurityEventLogger _security;
|
private readonly ISecurityEventLogger _security;
|
||||||
|
private readonly TimeProvider _timeProvider;
|
||||||
|
|
||||||
public LogoutCommandHandler(IRefreshTokenRepository refreshRepo, ISecurityEventLogger security)
|
public LogoutCommandHandler(
|
||||||
|
IRefreshTokenRepository refreshRepo,
|
||||||
|
ISecurityEventLogger security,
|
||||||
|
TimeProvider timeProvider)
|
||||||
{
|
{
|
||||||
_refreshRepo = refreshRepo;
|
_refreshRepo = refreshRepo;
|
||||||
_security = security;
|
_security = security;
|
||||||
|
_timeProvider = timeProvider;
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<LogoutResponseDto> Handle(LogoutCommand command)
|
public async Task<LogoutResponseDto> Handle(LogoutCommand command)
|
||||||
{
|
{
|
||||||
// Revoke all active tokens for the user across all families.
|
// Revoke all active tokens for the user across all families.
|
||||||
// Idempotent: 0 rows affected is not an error.
|
// 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);
|
await _security.LogAsync("logout", "success", actorUserId: command.UsuarioId);
|
||||||
return new LogoutResponseDto(true, "Sesión cerrada correctamente");
|
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 IClientContext _clientCtx;
|
||||||
private readonly AuthOptions _authOptions;
|
private readonly AuthOptions _authOptions;
|
||||||
private readonly ISecurityEventLogger _security;
|
private readonly ISecurityEventLogger _security;
|
||||||
|
private readonly TimeProvider _timeProvider;
|
||||||
|
|
||||||
public RefreshCommandHandler(
|
public RefreshCommandHandler(
|
||||||
IRefreshTokenRepository refreshRepo,
|
IRefreshTokenRepository refreshRepo,
|
||||||
@@ -25,7 +26,8 @@ public sealed class RefreshCommandHandler : ICommandHandler<RefreshCommand, Refr
|
|||||||
IRefreshTokenGenerator refreshGenerator,
|
IRefreshTokenGenerator refreshGenerator,
|
||||||
IClientContext clientCtx,
|
IClientContext clientCtx,
|
||||||
AuthOptions authOptions,
|
AuthOptions authOptions,
|
||||||
ISecurityEventLogger security)
|
ISecurityEventLogger security,
|
||||||
|
TimeProvider timeProvider)
|
||||||
{
|
{
|
||||||
_refreshRepo = refreshRepo;
|
_refreshRepo = refreshRepo;
|
||||||
_usuarioRepo = usuarioRepo;
|
_usuarioRepo = usuarioRepo;
|
||||||
@@ -34,6 +36,7 @@ public sealed class RefreshCommandHandler : ICommandHandler<RefreshCommand, Refr
|
|||||||
_clientCtx = clientCtx;
|
_clientCtx = clientCtx;
|
||||||
_authOptions = authOptions;
|
_authOptions = authOptions;
|
||||||
_security = security;
|
_security = security;
|
||||||
|
_timeProvider = timeProvider;
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<RefreshResponseDto> Handle(RefreshCommand command)
|
public async Task<RefreshResponseDto> Handle(RefreshCommand command)
|
||||||
@@ -60,7 +63,7 @@ public sealed class RefreshCommandHandler : ICommandHandler<RefreshCommand, Refr
|
|||||||
if (stored is null)
|
if (stored is null)
|
||||||
throw new InvalidRefreshTokenException();
|
throw new InvalidRefreshTokenException();
|
||||||
|
|
||||||
var now = DateTime.UtcNow;
|
var now = _timeProvider.GetUtcNow().UtcDateTime;
|
||||||
|
|
||||||
// 4. Reuse detection: already revoked → chain revocation and throw
|
// 4. Reuse detection: already revoked → chain revocation and throw
|
||||||
if (stored.IsRevoked)
|
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);
|
||||||
|
}
|
||||||
11
src/api/SIGCM2.Application/Common/IngresosBrutosQuery.cs
Normal file
11
src/api/SIGCM2.Application/Common/IngresosBrutosQuery.cs
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
using SIGCM2.Domain.Fiscal;
|
||||||
|
|
||||||
|
namespace SIGCM2.Application.Common;
|
||||||
|
|
||||||
|
/// <summary>Query parameters for listing ingresos brutos with optional filters and paging.</summary>
|
||||||
|
public sealed record IngresosBrutosQuery(
|
||||||
|
int Page,
|
||||||
|
int PageSize,
|
||||||
|
bool? Activo,
|
||||||
|
ProvinciaArgentina? Provincia
|
||||||
|
);
|
||||||
12
src/api/SIGCM2.Application/Common/MediosQuery.cs
Normal file
12
src/api/SIGCM2.Application/Common/MediosQuery.cs
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
using SIGCM2.Domain.Entities;
|
||||||
|
|
||||||
|
namespace SIGCM2.Application.Common;
|
||||||
|
|
||||||
|
/// <summary>Query parameters for listing medios with optional filters and paging.</summary>
|
||||||
|
public sealed record MediosQuery(
|
||||||
|
int Page,
|
||||||
|
int PageSize,
|
||||||
|
bool? Activo,
|
||||||
|
TipoMedio? Tipo,
|
||||||
|
string? Search
|
||||||
|
);
|
||||||
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);
|
||||||
9
src/api/SIGCM2.Application/Common/PuntosDeVentaQuery.cs
Normal file
9
src/api/SIGCM2.Application/Common/PuntosDeVentaQuery.cs
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
namespace SIGCM2.Application.Common;
|
||||||
|
|
||||||
|
/// <summary>Query parameters for listing puntos de venta with optional filters and paging.</summary>
|
||||||
|
public sealed record PuntosDeVentaQuery(
|
||||||
|
int Page,
|
||||||
|
int PageSize,
|
||||||
|
int? MedioId,
|
||||||
|
bool? Activo
|
||||||
|
);
|
||||||
11
src/api/SIGCM2.Application/Common/SeccionesQuery.cs
Normal file
11
src/api/SIGCM2.Application/Common/SeccionesQuery.cs
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
namespace SIGCM2.Application.Common;
|
||||||
|
|
||||||
|
/// <summary>Query parameters for listing secciones with optional filters and paging.</summary>
|
||||||
|
public sealed record SeccionesQuery(
|
||||||
|
int Page,
|
||||||
|
int PageSize,
|
||||||
|
int? MedioId,
|
||||||
|
string? Tipo,
|
||||||
|
bool? Activo,
|
||||||
|
string? Search
|
||||||
|
);
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
9
src/api/SIGCM2.Application/Common/TiposDeIvaQuery.cs
Normal file
9
src/api/SIGCM2.Application/Common/TiposDeIvaQuery.cs
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
namespace SIGCM2.Application.Common;
|
||||||
|
|
||||||
|
/// <summary>Query parameters for listing tipos de IVA with optional filters and paging.</summary>
|
||||||
|
public sealed record TiposDeIvaQuery(
|
||||||
|
int Page,
|
||||||
|
int PageSize,
|
||||||
|
bool? Activo,
|
||||||
|
string? Codigo
|
||||||
|
);
|
||||||
@@ -5,6 +5,12 @@ using SIGCM2.Application.Auth.Login;
|
|||||||
using SIGCM2.Application.Auth.Logout;
|
using SIGCM2.Application.Auth.Logout;
|
||||||
using SIGCM2.Application.Auth.Refresh;
|
using SIGCM2.Application.Auth.Refresh;
|
||||||
using SIGCM2.Application.Common;
|
using SIGCM2.Application.Common;
|
||||||
|
using SIGCM2.Application.Medios.Create;
|
||||||
|
using SIGCM2.Application.Medios.Deactivate;
|
||||||
|
using SIGCM2.Application.Medios.GetById;
|
||||||
|
using SIGCM2.Application.Medios.List;
|
||||||
|
using SIGCM2.Application.Medios.Reactivate;
|
||||||
|
using SIGCM2.Application.Medios.Update;
|
||||||
using SIGCM2.Application.Permisos.Assign;
|
using SIGCM2.Application.Permisos.Assign;
|
||||||
using SIGCM2.Application.Permisos.Dtos;
|
using SIGCM2.Application.Permisos.Dtos;
|
||||||
using SIGCM2.Application.Permisos.GetByRol;
|
using SIGCM2.Application.Permisos.GetByRol;
|
||||||
@@ -15,6 +21,36 @@ using SIGCM2.Application.Roles.Dtos;
|
|||||||
using SIGCM2.Application.Roles.Get;
|
using SIGCM2.Application.Roles.Get;
|
||||||
using SIGCM2.Application.Roles.List;
|
using SIGCM2.Application.Roles.List;
|
||||||
using SIGCM2.Application.Roles.Update;
|
using SIGCM2.Application.Roles.Update;
|
||||||
|
using SIGCM2.Application.PuntosDeVenta.Create;
|
||||||
|
using SIGCM2.Application.PuntosDeVenta.Deactivate;
|
||||||
|
using SIGCM2.Application.PuntosDeVenta.GetById;
|
||||||
|
using SIGCM2.Application.PuntosDeVenta.List;
|
||||||
|
using SIGCM2.Application.PuntosDeVenta.Reactivate;
|
||||||
|
using SIGCM2.Application.PuntosDeVenta.Update;
|
||||||
|
using SIGCM2.Application.TiposDeIva.Create;
|
||||||
|
using SIGCM2.Application.TiposDeIva.Deactivate;
|
||||||
|
using SIGCM2.Application.TiposDeIva.Dtos;
|
||||||
|
using SIGCM2.Application.TiposDeIva.GetById;
|
||||||
|
using SIGCM2.Application.TiposDeIva.GetHistorial;
|
||||||
|
using SIGCM2.Application.TiposDeIva.List;
|
||||||
|
using SIGCM2.Application.TiposDeIva.NuevaVersion;
|
||||||
|
using SIGCM2.Application.TiposDeIva.Reactivate;
|
||||||
|
using SIGCM2.Application.TiposDeIva.Update;
|
||||||
|
using SIGCM2.Application.IngresosBrutos.Create;
|
||||||
|
using SIGCM2.Application.IngresosBrutos.Deactivate;
|
||||||
|
using SIGCM2.Application.IngresosBrutos.Dtos;
|
||||||
|
using SIGCM2.Application.IngresosBrutos.GetById;
|
||||||
|
using SIGCM2.Application.IngresosBrutos.GetHistorial;
|
||||||
|
using SIGCM2.Application.IngresosBrutos.List;
|
||||||
|
using SIGCM2.Application.IngresosBrutos.NuevaVersion;
|
||||||
|
using SIGCM2.Application.IngresosBrutos.Reactivate;
|
||||||
|
using SIGCM2.Application.IngresosBrutos.Update;
|
||||||
|
using SIGCM2.Application.Secciones.Create;
|
||||||
|
using SIGCM2.Application.Secciones.Deactivate;
|
||||||
|
using SIGCM2.Application.Secciones.GetById;
|
||||||
|
using SIGCM2.Application.Secciones.List;
|
||||||
|
using SIGCM2.Application.Secciones.Reactivate;
|
||||||
|
using SIGCM2.Application.Secciones.Update;
|
||||||
using SIGCM2.Application.Usuarios.ChangeMyPassword;
|
using SIGCM2.Application.Usuarios.ChangeMyPassword;
|
||||||
using SIGCM2.Application.Usuarios.Create;
|
using SIGCM2.Application.Usuarios.Create;
|
||||||
using SIGCM2.Application.Usuarios.Deactivate;
|
using SIGCM2.Application.Usuarios.Deactivate;
|
||||||
@@ -24,6 +60,37 @@ using SIGCM2.Application.Usuarios.Reactivate;
|
|||||||
using SIGCM2.Application.Usuarios.ResetPassword;
|
using SIGCM2.Application.Usuarios.ResetPassword;
|
||||||
using SIGCM2.Application.Usuarios.Permisos;
|
using SIGCM2.Application.Usuarios.Permisos;
|
||||||
using SIGCM2.Application.Usuarios.Update;
|
using SIGCM2.Application.Usuarios.Update;
|
||||||
|
using SIGCM2.Application.Rubros.Create;
|
||||||
|
using SIGCM2.Application.Rubros.Update;
|
||||||
|
using SIGCM2.Application.Rubros.Deactivate;
|
||||||
|
using SIGCM2.Application.Rubros.Move;
|
||||||
|
using SIGCM2.Application.Rubros.GetTree;
|
||||||
|
using SIGCM2.Application.Rubros.GetById;
|
||||||
|
using SIGCM2.Application.Rubros.Dtos;
|
||||||
|
using SIGCM2.Application.Abstractions.Persistence;
|
||||||
|
using SIGCM2.Application.Avisos;
|
||||||
|
using SIGCM2.Application.Products.Create;
|
||||||
|
using SIGCM2.Application.Products.Update;
|
||||||
|
using SIGCM2.Application.Products.Deactivate;
|
||||||
|
using SIGCM2.Application.Products.GetById;
|
||||||
|
using SIGCM2.Application.Products.List;
|
||||||
|
using SIGCM2.Application.Products.Prices;
|
||||||
|
using SIGCM2.Application.Products.Prices.AddPrice;
|
||||||
|
using SIGCM2.Application.Products.Prices.GetHistory;
|
||||||
|
using SIGCM2.Application.Products.Pricing;
|
||||||
|
using SIGCM2.Application.ProductTypes.Create;
|
||||||
|
using SIGCM2.Application.ProductTypes.Update;
|
||||||
|
using SIGCM2.Application.ProductTypes.Deactivate;
|
||||||
|
using SIGCM2.Application.ProductTypes.List;
|
||||||
|
using SIGCM2.Application.ProductTypes.GetById;
|
||||||
|
using SIGCM2.Application.Pricing.ChargeableChars;
|
||||||
|
using SIGCM2.Application.Pricing.ChargeableChars.Create;
|
||||||
|
using SIGCM2.Application.Pricing.ChargeableChars.SchedulePrice;
|
||||||
|
using SIGCM2.Application.Pricing.ChargeableChars.Deactivate;
|
||||||
|
using SIGCM2.Application.Pricing.ChargeableChars.Reactivate;
|
||||||
|
using SIGCM2.Application.Pricing.ChargeableChars.Delete;
|
||||||
|
using SIGCM2.Application.Pricing.ChargeableChars.List;
|
||||||
|
using SIGCM2.Application.Pricing.ChargeableChars.GetById;
|
||||||
|
|
||||||
namespace SIGCM2.Application;
|
namespace SIGCM2.Application;
|
||||||
|
|
||||||
@@ -31,6 +98,9 @@ public static class DependencyInjection
|
|||||||
{
|
{
|
||||||
public static IServiceCollection AddApplication(this IServiceCollection services)
|
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
|
// Command handlers
|
||||||
services.AddScoped<ICommandHandler<LoginCommand, LoginResponseDto>, LoginCommandHandler>();
|
services.AddScoped<ICommandHandler<LoginCommand, LoginResponseDto>, LoginCommandHandler>();
|
||||||
services.AddScoped<ICommandHandler<RefreshCommand, RefreshResponseDto>, RefreshCommandHandler>();
|
services.AddScoped<ICommandHandler<RefreshCommand, RefreshResponseDto>, RefreshCommandHandler>();
|
||||||
@@ -62,6 +132,92 @@ public static class DependencyInjection
|
|||||||
services.AddScoped<ICommandHandler<GetUsuarioPermisosQuery, UsuarioPermisosDto>, GetUsuarioPermisosQueryHandler>();
|
services.AddScoped<ICommandHandler<GetUsuarioPermisosQuery, UsuarioPermisosDto>, GetUsuarioPermisosQueryHandler>();
|
||||||
services.AddScoped<ICommandHandler<UpdateUsuarioPermisosOverridesCommand, UsuarioPermisosDto>, UpdateUsuarioPermisosOverridesCommandHandler>();
|
services.AddScoped<ICommandHandler<UpdateUsuarioPermisosOverridesCommand, UsuarioPermisosDto>, UpdateUsuarioPermisosOverridesCommandHandler>();
|
||||||
|
|
||||||
|
// Medios (ADM-001)
|
||||||
|
services.AddScoped<ICommandHandler<CreateMedioCommand, MedioCreatedDto>, CreateMedioCommandHandler>();
|
||||||
|
services.AddScoped<ICommandHandler<UpdateMedioCommand, MedioUpdatedDto>, UpdateMedioCommandHandler>();
|
||||||
|
services.AddScoped<ICommandHandler<DeactivateMedioCommand, MedioStatusDto>, DeactivateMedioCommandHandler>();
|
||||||
|
services.AddScoped<ICommandHandler<ReactivateMedioCommand, MedioStatusDto>, ReactivateMedioCommandHandler>();
|
||||||
|
services.AddScoped<ICommandHandler<ListMediosQuery, PagedResult<MedioListItemDto>>, ListMediosQueryHandler>();
|
||||||
|
services.AddScoped<ICommandHandler<GetMedioByIdQuery, MedioDetailDto>, GetMedioByIdQueryHandler>();
|
||||||
|
|
||||||
|
// Secciones (ADM-001)
|
||||||
|
services.AddScoped<ICommandHandler<CreateSeccionCommand, SeccionCreatedDto>, CreateSeccionCommandHandler>();
|
||||||
|
services.AddScoped<ICommandHandler<UpdateSeccionCommand, SeccionUpdatedDto>, UpdateSeccionCommandHandler>();
|
||||||
|
services.AddScoped<ICommandHandler<DeactivateSeccionCommand, SeccionStatusDto>, DeactivateSeccionCommandHandler>();
|
||||||
|
services.AddScoped<ICommandHandler<ReactivateSeccionCommand, SeccionStatusDto>, ReactivateSeccionCommandHandler>();
|
||||||
|
services.AddScoped<ICommandHandler<ListSeccionesQuery, PagedResult<SeccionListItemDto>>, ListSeccionesQueryHandler>();
|
||||||
|
services.AddScoped<ICommandHandler<GetSeccionByIdQuery, SeccionDetailDto>, GetSeccionByIdQueryHandler>();
|
||||||
|
|
||||||
|
// Puntos de Venta (ADM-008)
|
||||||
|
services.AddScoped<ICommandHandler<CreatePuntoDeVentaCommand, PuntoDeVentaCreatedDto>, CreatePuntoDeVentaCommandHandler>();
|
||||||
|
services.AddScoped<ICommandHandler<UpdatePuntoDeVentaCommand, PuntoDeVentaUpdatedDto>, UpdatePuntoDeVentaCommandHandler>();
|
||||||
|
services.AddScoped<ICommandHandler<DeactivatePuntoDeVentaCommand, PuntoDeVentaStatusDto>, DeactivatePuntoDeVentaCommandHandler>();
|
||||||
|
services.AddScoped<ICommandHandler<ReactivatePuntoDeVentaCommand, PuntoDeVentaStatusDto>, ReactivatePuntoDeVentaCommandHandler>();
|
||||||
|
services.AddScoped<ICommandHandler<ListPuntosDeVentaQuery, PagedResult<PuntoDeVentaListItemDto>>, ListPuntosDeVentaQueryHandler>();
|
||||||
|
services.AddScoped<ICommandHandler<GetPuntoDeVentaByIdQuery, PuntoDeVentaDetailDto>, GetPuntoDeVentaByIdQueryHandler>();
|
||||||
|
|
||||||
|
// Tipos de IVA (ADM-009)
|
||||||
|
services.AddScoped<ICommandHandler<CreateTipoDeIvaCommand, TipoDeIvaDto>, CreateTipoDeIvaCommandHandler>();
|
||||||
|
services.AddScoped<ICommandHandler<UpdateTipoDeIvaCommand, TipoDeIvaDto>, UpdateTipoDeIvaCommandHandler>();
|
||||||
|
services.AddScoped<ICommandHandler<NuevaVersionTipoDeIvaCommand, NuevaVersionResultDto>, NuevaVersionTipoDeIvaCommandHandler>();
|
||||||
|
services.AddScoped<ICommandHandler<DeactivateTipoDeIvaCommand, TipoDeIvaDto>, DeactivateTipoDeIvaCommandHandler>();
|
||||||
|
services.AddScoped<ICommandHandler<ReactivateTipoDeIvaCommand, TipoDeIvaDto>, ReactivateTipoDeIvaCommandHandler>();
|
||||||
|
services.AddScoped<ICommandHandler<GetTipoDeIvaByIdQuery, TipoDeIvaDto>, GetTipoDeIvaByIdQueryHandler>();
|
||||||
|
services.AddScoped<ICommandHandler<ListTiposDeIvaQuery, PagedResult<TipoDeIvaDto>>, ListTiposDeIvaQueryHandler>();
|
||||||
|
services.AddScoped<ICommandHandler<GetHistorialTipoDeIvaQuery, IReadOnlyList<HistorialCadenaDto>>, GetHistorialTipoDeIvaQueryHandler>();
|
||||||
|
|
||||||
|
// Ingresos Brutos (ADM-009)
|
||||||
|
services.AddScoped<ICommandHandler<CreateIngresosBrutosCommand, IngresosBrutosDto>, CreateIngresosBrutosCommandHandler>();
|
||||||
|
services.AddScoped<ICommandHandler<UpdateIngresosBrutosCommand, IngresosBrutosDto>, UpdateIngresosBrutosCommandHandler>();
|
||||||
|
services.AddScoped<ICommandHandler<NuevaVersionIngresosBrutosCommand, NuevaVersionIibbResultDto>, NuevaVersionIngresosBrutosCommandHandler>();
|
||||||
|
services.AddScoped<ICommandHandler<DeactivateIngresosBrutosCommand, IngresosBrutosDto>, DeactivateIngresosBrutosCommandHandler>();
|
||||||
|
services.AddScoped<ICommandHandler<ReactivateIngresosBrutosCommand, IngresosBrutosDto>, ReactivateIngresosBrutosCommandHandler>();
|
||||||
|
services.AddScoped<ICommandHandler<GetIngresosBrutosByIdQuery, IngresosBrutosDto>, GetIngresosBrutosByIdQueryHandler>();
|
||||||
|
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)
|
// FluentValidation validators (scans entire Application assembly)
|
||||||
services.AddValidatorsFromAssemblyContaining<LoginCommandValidator>();
|
services.AddValidatorsFromAssemblyContaining<LoginCommandValidator>();
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,10 @@
|
|||||||
|
using SIGCM2.Domain.Fiscal;
|
||||||
|
|
||||||
|
namespace SIGCM2.Application.IngresosBrutos.Create;
|
||||||
|
|
||||||
|
public sealed record CreateIngresosBrutosCommand(
|
||||||
|
ProvinciaArgentina Provincia,
|
||||||
|
string Descripcion,
|
||||||
|
decimal Alicuota,
|
||||||
|
DateOnly VigenciaDesde,
|
||||||
|
DateOnly? VigenciaHasta = null);
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
using System.Transactions;
|
||||||
|
using SIGCM2.Application.Abstractions;
|
||||||
|
using SIGCM2.Application.Abstractions.Persistence;
|
||||||
|
using SIGCM2.Application.Audit;
|
||||||
|
using SIGCM2.Application.IngresosBrutos.Dtos;
|
||||||
|
|
||||||
|
namespace SIGCM2.Application.IngresosBrutos.Create;
|
||||||
|
|
||||||
|
public sealed class CreateIngresosBrutosCommandHandler
|
||||||
|
: ICommandHandler<CreateIngresosBrutosCommand, IngresosBrutosDto>
|
||||||
|
{
|
||||||
|
private readonly IIngresosBrutosRepository _repo;
|
||||||
|
private readonly IAuditLogger _audit;
|
||||||
|
private readonly TimeProvider _timeProvider;
|
||||||
|
|
||||||
|
public CreateIngresosBrutosCommandHandler(IIngresosBrutosRepository repo, IAuditLogger audit, TimeProvider timeProvider)
|
||||||
|
{
|
||||||
|
_repo = repo;
|
||||||
|
_audit = audit;
|
||||||
|
_timeProvider = timeProvider;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<IngresosBrutosDto> Handle(CreateIngresosBrutosCommand command)
|
||||||
|
{
|
||||||
|
var entity = Domain.Entities.IngresosBrutos.ForCreation(
|
||||||
|
command.Provincia,
|
||||||
|
command.Descripcion,
|
||||||
|
command.Alicuota,
|
||||||
|
command.VigenciaDesde,
|
||||||
|
command.VigenciaHasta);
|
||||||
|
|
||||||
|
using var tx = new TransactionScope(
|
||||||
|
TransactionScopeOption.Required,
|
||||||
|
new TransactionOptions { IsolationLevel = System.Transactions.IsolationLevel.ReadCommitted },
|
||||||
|
TransactionScopeAsyncFlowOption.Enabled);
|
||||||
|
|
||||||
|
var newId = await _repo.InsertAsync(entity);
|
||||||
|
|
||||||
|
await _audit.LogAsync(
|
||||||
|
action: "ingresos_brutos.create",
|
||||||
|
targetType: "IngresosBrutos",
|
||||||
|
targetId: newId.ToString(),
|
||||||
|
metadata: new { entity.Provincia, entity.Alicuota, entity.VigenciaDesde });
|
||||||
|
|
||||||
|
tx.Complete();
|
||||||
|
|
||||||
|
return IngresosBrutosMapper.ToDto(Domain.Entities.IngresosBrutos.FromDb(
|
||||||
|
id: newId,
|
||||||
|
provincia: entity.Provincia,
|
||||||
|
descripcion: entity.Descripcion,
|
||||||
|
alicuota: entity.Alicuota,
|
||||||
|
activo: entity.Activo,
|
||||||
|
vigenciaDesde: entity.VigenciaDesde,
|
||||||
|
vigenciaHasta: entity.VigenciaHasta,
|
||||||
|
predecesorId: entity.PredecesorId,
|
||||||
|
fechaCreacion: _timeProvider.GetUtcNow().UtcDateTime,
|
||||||
|
fechaModificacion: null));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
using FluentValidation;
|
||||||
|
|
||||||
|
namespace SIGCM2.Application.IngresosBrutos.Create;
|
||||||
|
|
||||||
|
public sealed class CreateIngresosBrutosCommandValidator : AbstractValidator<CreateIngresosBrutosCommand>
|
||||||
|
{
|
||||||
|
private const int DescripcionMaxLength = 255;
|
||||||
|
|
||||||
|
public CreateIngresosBrutosCommandValidator()
|
||||||
|
{
|
||||||
|
RuleFor(x => x.Descripcion)
|
||||||
|
.NotEmpty().WithMessage("La descripción es requerida.")
|
||||||
|
.MaximumLength(DescripcionMaxLength)
|
||||||
|
.WithMessage($"La descripción no puede superar los {DescripcionMaxLength} caracteres.");
|
||||||
|
|
||||||
|
RuleFor(x => x.Alicuota)
|
||||||
|
.InclusiveBetween(0m, 100m)
|
||||||
|
.WithMessage("La alícuota debe estar entre 0 y 100.");
|
||||||
|
|
||||||
|
RuleFor(x => x.VigenciaDesde)
|
||||||
|
.NotEqual(default(DateOnly))
|
||||||
|
.WithMessage("La fecha de vigencia desde es requerida.");
|
||||||
|
|
||||||
|
RuleFor(x => x.VigenciaHasta)
|
||||||
|
.GreaterThanOrEqualTo(x => x.VigenciaDesde)
|
||||||
|
.WithMessage("VigenciaHasta no puede ser anterior a VigenciaDesde.")
|
||||||
|
.When(x => x.VigenciaHasta.HasValue);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
namespace SIGCM2.Application.IngresosBrutos.Deactivate;
|
||||||
|
|
||||||
|
public sealed record DeactivateIngresosBrutosCommand(int Id);
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
using System.Transactions;
|
||||||
|
using SIGCM2.Application.Abstractions;
|
||||||
|
using SIGCM2.Application.Abstractions.Persistence;
|
||||||
|
using SIGCM2.Application.Audit;
|
||||||
|
using SIGCM2.Application.IngresosBrutos.Dtos;
|
||||||
|
using SIGCM2.Domain.Exceptions;
|
||||||
|
|
||||||
|
namespace SIGCM2.Application.IngresosBrutos.Deactivate;
|
||||||
|
|
||||||
|
public sealed class DeactivateIngresosBrutosCommandHandler
|
||||||
|
: ICommandHandler<DeactivateIngresosBrutosCommand, IngresosBrutosDto>
|
||||||
|
{
|
||||||
|
private readonly IIngresosBrutosRepository _repo;
|
||||||
|
private readonly IAuditLogger _audit;
|
||||||
|
private readonly TimeProvider _timeProvider;
|
||||||
|
|
||||||
|
public DeactivateIngresosBrutosCommandHandler(IIngresosBrutosRepository repo, IAuditLogger audit, TimeProvider timeProvider)
|
||||||
|
{
|
||||||
|
_repo = repo;
|
||||||
|
_audit = audit;
|
||||||
|
_timeProvider = timeProvider;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<IngresosBrutosDto> Handle(DeactivateIngresosBrutosCommand command)
|
||||||
|
{
|
||||||
|
var entity = await _repo.GetByIdAsync(command.Id)
|
||||||
|
?? throw new IngresosBrutosNotFoundException(command.Id);
|
||||||
|
|
||||||
|
if (!entity.Activo)
|
||||||
|
return IngresosBrutosMapper.ToDto(entity);
|
||||||
|
|
||||||
|
using var tx = new TransactionScope(
|
||||||
|
TransactionScopeOption.Required,
|
||||||
|
new TransactionOptions { IsolationLevel = System.Transactions.IsolationLevel.ReadCommitted },
|
||||||
|
TransactionScopeAsyncFlowOption.Enabled);
|
||||||
|
|
||||||
|
await _repo.SetActivoAsync(command.Id, false);
|
||||||
|
|
||||||
|
await _audit.LogAsync(
|
||||||
|
action: "ingresos_brutos.deactivate",
|
||||||
|
targetType: "IngresosBrutos",
|
||||||
|
targetId: command.Id.ToString());
|
||||||
|
|
||||||
|
tx.Complete();
|
||||||
|
|
||||||
|
var now = _timeProvider.GetUtcNow().UtcDateTime;
|
||||||
|
return IngresosBrutosMapper.ToDto(entity.Deactivate(now));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
using SIGCM2.Domain.Fiscal;
|
||||||
|
|
||||||
|
namespace SIGCM2.Application.IngresosBrutos.Dtos;
|
||||||
|
|
||||||
|
public sealed record HistorialCadenaIibbDto(
|
||||||
|
int Id,
|
||||||
|
ProvinciaArgentina Provincia,
|
||||||
|
decimal Alicuota,
|
||||||
|
DateOnly VigenciaDesde,
|
||||||
|
DateOnly? VigenciaHasta,
|
||||||
|
int? PredecesorId,
|
||||||
|
/// <summary>1-based index in the version chain (1 = root, N = current).</summary>
|
||||||
|
int Version
|
||||||
|
);
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
using SIGCM2.Domain.Fiscal;
|
||||||
|
|
||||||
|
namespace SIGCM2.Application.IngresosBrutos.Dtos;
|
||||||
|
|
||||||
|
public sealed record IngresosBrutosDto(
|
||||||
|
int Id,
|
||||||
|
ProvinciaArgentina Provincia,
|
||||||
|
string Descripcion,
|
||||||
|
decimal Alicuota,
|
||||||
|
bool Activo,
|
||||||
|
DateOnly VigenciaDesde,
|
||||||
|
DateOnly? VigenciaHasta,
|
||||||
|
int? PredecesorId,
|
||||||
|
DateTime FechaCreacion,
|
||||||
|
DateTime? FechaModificacion
|
||||||
|
);
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
using SIGCM2.Domain.Entities;
|
||||||
|
|
||||||
|
namespace SIGCM2.Application.IngresosBrutos.Dtos;
|
||||||
|
|
||||||
|
public static class IngresosBrutosMapper
|
||||||
|
{
|
||||||
|
public static IngresosBrutosDto ToDto(Domain.Entities.IngresosBrutos entity) => new(
|
||||||
|
Id: entity.Id,
|
||||||
|
Provincia: entity.Provincia,
|
||||||
|
Descripcion: entity.Descripcion,
|
||||||
|
Alicuota: entity.Alicuota,
|
||||||
|
Activo: entity.Activo,
|
||||||
|
VigenciaDesde: entity.VigenciaDesde,
|
||||||
|
VigenciaHasta: entity.VigenciaHasta,
|
||||||
|
PredecesorId: entity.PredecesorId,
|
||||||
|
FechaCreacion: entity.FechaCreacion,
|
||||||
|
FechaModificacion: entity.FechaModificacion
|
||||||
|
);
|
||||||
|
|
||||||
|
public static IReadOnlyList<HistorialCadenaIibbDto> ToHistorialChain(IReadOnlyList<Domain.Entities.IngresosBrutos> chain)
|
||||||
|
{
|
||||||
|
var result = new List<HistorialCadenaIibbDto>(chain.Count);
|
||||||
|
for (var i = 0; i < chain.Count; i++)
|
||||||
|
{
|
||||||
|
var item = chain[i];
|
||||||
|
result.Add(new HistorialCadenaIibbDto(
|
||||||
|
Id: item.Id,
|
||||||
|
Provincia: item.Provincia,
|
||||||
|
Alicuota: item.Alicuota,
|
||||||
|
VigenciaDesde: item.VigenciaDesde,
|
||||||
|
VigenciaHasta: item.VigenciaHasta,
|
||||||
|
PredecesorId: item.PredecesorId,
|
||||||
|
Version: i + 1
|
||||||
|
));
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
namespace SIGCM2.Application.IngresosBrutos.Dtos;
|
||||||
|
|
||||||
|
public sealed record NuevaVersionIibbResultDto(
|
||||||
|
int PredecesoraId,
|
||||||
|
int NuevaVersionId
|
||||||
|
);
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
namespace SIGCM2.Application.IngresosBrutos.GetById;
|
||||||
|
|
||||||
|
public sealed record GetIngresosBrutosByIdQuery(int Id);
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
using SIGCM2.Application.Abstractions;
|
||||||
|
using SIGCM2.Application.Abstractions.Persistence;
|
||||||
|
using SIGCM2.Application.IngresosBrutos.Dtos;
|
||||||
|
using SIGCM2.Domain.Exceptions;
|
||||||
|
|
||||||
|
namespace SIGCM2.Application.IngresosBrutos.GetById;
|
||||||
|
|
||||||
|
public sealed class GetIngresosBrutosByIdQueryHandler
|
||||||
|
: ICommandHandler<GetIngresosBrutosByIdQuery, IngresosBrutosDto>
|
||||||
|
{
|
||||||
|
private readonly IIngresosBrutosRepository _repo;
|
||||||
|
|
||||||
|
public GetIngresosBrutosByIdQueryHandler(IIngresosBrutosRepository repo)
|
||||||
|
{
|
||||||
|
_repo = repo;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<IngresosBrutosDto> Handle(GetIngresosBrutosByIdQuery query)
|
||||||
|
{
|
||||||
|
var entity = await _repo.GetByIdAsync(query.Id)
|
||||||
|
?? throw new IngresosBrutosNotFoundException(query.Id);
|
||||||
|
|
||||||
|
return IngresosBrutosMapper.ToDto(entity);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
namespace SIGCM2.Application.IngresosBrutos.GetHistorial;
|
||||||
|
|
||||||
|
public sealed record GetHistorialIngresosBrutosQuery(int Id);
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
using SIGCM2.Application.Abstractions;
|
||||||
|
using SIGCM2.Application.Abstractions.Persistence;
|
||||||
|
using SIGCM2.Application.IngresosBrutos.Dtos;
|
||||||
|
|
||||||
|
namespace SIGCM2.Application.IngresosBrutos.GetHistorial;
|
||||||
|
|
||||||
|
public sealed class GetHistorialIngresosBrutosQueryHandler
|
||||||
|
: ICommandHandler<GetHistorialIngresosBrutosQuery, IReadOnlyList<HistorialCadenaIibbDto>>
|
||||||
|
{
|
||||||
|
private readonly IIngresosBrutosRepository _repo;
|
||||||
|
|
||||||
|
public GetHistorialIngresosBrutosQueryHandler(IIngresosBrutosRepository repo)
|
||||||
|
{
|
||||||
|
_repo = repo;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<IReadOnlyList<HistorialCadenaIibbDto>> Handle(GetHistorialIngresosBrutosQuery query)
|
||||||
|
{
|
||||||
|
var chain = await _repo.GetHistorialAsync(query.Id);
|
||||||
|
return IngresosBrutosMapper.ToHistorialChain(chain);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
using SIGCM2.Domain.Fiscal;
|
||||||
|
|
||||||
|
namespace SIGCM2.Application.IngresosBrutos.List;
|
||||||
|
|
||||||
|
public sealed record ListIngresosBrutosQuery(
|
||||||
|
int Page,
|
||||||
|
int PageSize,
|
||||||
|
bool? Activo,
|
||||||
|
ProvinciaArgentina? Provincia);
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
using SIGCM2.Application.Abstractions;
|
||||||
|
using SIGCM2.Application.Abstractions.Persistence;
|
||||||
|
using SIGCM2.Application.Common;
|
||||||
|
using SIGCM2.Application.IngresosBrutos.Dtos;
|
||||||
|
|
||||||
|
namespace SIGCM2.Application.IngresosBrutos.List;
|
||||||
|
|
||||||
|
public sealed class ListIngresosBrutosQueryHandler
|
||||||
|
: ICommandHandler<ListIngresosBrutosQuery, PagedResult<IngresosBrutosDto>>
|
||||||
|
{
|
||||||
|
private readonly IIngresosBrutosRepository _repo;
|
||||||
|
|
||||||
|
public ListIngresosBrutosQueryHandler(IIngresosBrutosRepository repo)
|
||||||
|
{
|
||||||
|
_repo = repo;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<PagedResult<IngresosBrutosDto>> Handle(ListIngresosBrutosQuery query)
|
||||||
|
{
|
||||||
|
var page = Math.Max(1, query.Page);
|
||||||
|
var pageSize = Math.Clamp(query.PageSize, 1, 100);
|
||||||
|
|
||||||
|
var repoQuery = new IngresosBrutosQuery(page, pageSize, query.Activo, query.Provincia);
|
||||||
|
var paged = await _repo.ListAsync(repoQuery);
|
||||||
|
|
||||||
|
var items = paged.Items.Select(IngresosBrutosMapper.ToDto).ToList();
|
||||||
|
|
||||||
|
return new PagedResult<IngresosBrutosDto>(items, paged.Page, paged.PageSize, paged.Total);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
namespace SIGCM2.Application.IngresosBrutos.NuevaVersion;
|
||||||
|
|
||||||
|
public sealed record NuevaVersionIngresosBrutosCommand(
|
||||||
|
int PredecesoraId,
|
||||||
|
decimal NuevaAlicuota,
|
||||||
|
DateOnly VigenciaDesde);
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
using System.Transactions;
|
||||||
|
using SIGCM2.Application.Abstractions;
|
||||||
|
using SIGCM2.Application.Abstractions.Persistence;
|
||||||
|
using SIGCM2.Application.Audit;
|
||||||
|
using SIGCM2.Application.IngresosBrutos.Dtos;
|
||||||
|
using SIGCM2.Domain.Exceptions;
|
||||||
|
|
||||||
|
namespace SIGCM2.Application.IngresosBrutos.NuevaVersion;
|
||||||
|
|
||||||
|
public sealed class NuevaVersionIngresosBrutosCommandHandler
|
||||||
|
: ICommandHandler<NuevaVersionIngresosBrutosCommand, NuevaVersionIibbResultDto>
|
||||||
|
{
|
||||||
|
private readonly IIngresosBrutosRepository _repo;
|
||||||
|
private readonly IAuditLogger _audit;
|
||||||
|
private readonly TimeProvider _timeProvider;
|
||||||
|
|
||||||
|
public NuevaVersionIngresosBrutosCommandHandler(IIngresosBrutosRepository repo, IAuditLogger audit, TimeProvider timeProvider)
|
||||||
|
{
|
||||||
|
_repo = repo;
|
||||||
|
_audit = audit;
|
||||||
|
_timeProvider = timeProvider;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<NuevaVersionIibbResultDto> Handle(NuevaVersionIngresosBrutosCommand command)
|
||||||
|
{
|
||||||
|
// Step 1: load predecesora
|
||||||
|
var predecesora = await _repo.GetByIdAsync(command.PredecesoraId)
|
||||||
|
?? throw new IngresosBrutosNotFoundException(command.PredecesoraId);
|
||||||
|
|
||||||
|
// Step 2: guard — predecesora must be open and active
|
||||||
|
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,
|
||||||
|
now);
|
||||||
|
|
||||||
|
using var tx = new TransactionScope(
|
||||||
|
TransactionScopeOption.Required,
|
||||||
|
new TransactionOptions { IsolationLevel = System.Transactions.IsolationLevel.ReadCommitted },
|
||||||
|
TransactionScopeAsyncFlowOption.Enabled);
|
||||||
|
|
||||||
|
// Step 5: optimistic close — race guard
|
||||||
|
var closed = await _repo.UpdateCierreVigenciaAsync(
|
||||||
|
command.PredecesoraId,
|
||||||
|
predecesoraCerrada.VigenciaHasta!.Value);
|
||||||
|
|
||||||
|
if (!closed)
|
||||||
|
throw new PredecesorYaCerradoException(command.PredecesoraId);
|
||||||
|
|
||||||
|
// Step 6: insert new version
|
||||||
|
var nuevoId = await _repo.InsertAsync(nuevaVersion);
|
||||||
|
|
||||||
|
// Step 7: audit (fail-closed)
|
||||||
|
await _audit.LogAsync(
|
||||||
|
action: "ingresos_brutos.nueva_version",
|
||||||
|
targetType: "IngresosBrutos",
|
||||||
|
targetId: nuevoId.ToString(),
|
||||||
|
metadata: new
|
||||||
|
{
|
||||||
|
predecesoraId = command.PredecesoraId,
|
||||||
|
nuevoId,
|
||||||
|
alicuotaNueva = command.NuevaAlicuota,
|
||||||
|
vigenciaDesde = command.VigenciaDesde,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Step 8: commit
|
||||||
|
tx.Complete();
|
||||||
|
|
||||||
|
return new NuevaVersionIibbResultDto(command.PredecesoraId, nuevoId);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
using FluentValidation;
|
||||||
|
|
||||||
|
namespace SIGCM2.Application.IngresosBrutos.NuevaVersion;
|
||||||
|
|
||||||
|
public sealed class NuevaVersionIngresosBrutosCommandValidator : AbstractValidator<NuevaVersionIngresosBrutosCommand>
|
||||||
|
{
|
||||||
|
public NuevaVersionIngresosBrutosCommandValidator()
|
||||||
|
{
|
||||||
|
RuleFor(x => x.PredecesoraId)
|
||||||
|
.GreaterThan(0).WithMessage("El id de la predecesora debe ser mayor a 0.");
|
||||||
|
|
||||||
|
RuleFor(x => x.NuevaAlicuota)
|
||||||
|
.InclusiveBetween(0m, 100m)
|
||||||
|
.WithMessage("La nueva alícuota debe estar entre 0 y 100.");
|
||||||
|
|
||||||
|
RuleFor(x => x.VigenciaDesde)
|
||||||
|
.NotEqual(default(DateOnly))
|
||||||
|
.WithMessage("La fecha de vigencia desde es requerida.");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
namespace SIGCM2.Application.IngresosBrutos.Reactivate;
|
||||||
|
|
||||||
|
public sealed record ReactivateIngresosBrutosCommand(int Id);
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
using System.Transactions;
|
||||||
|
using SIGCM2.Application.Abstractions;
|
||||||
|
using SIGCM2.Application.Abstractions.Persistence;
|
||||||
|
using SIGCM2.Application.Audit;
|
||||||
|
using SIGCM2.Application.IngresosBrutos.Dtos;
|
||||||
|
using SIGCM2.Domain.Exceptions;
|
||||||
|
|
||||||
|
namespace SIGCM2.Application.IngresosBrutos.Reactivate;
|
||||||
|
|
||||||
|
public sealed class ReactivateIngresosBrutosCommandHandler
|
||||||
|
: ICommandHandler<ReactivateIngresosBrutosCommand, IngresosBrutosDto>
|
||||||
|
{
|
||||||
|
private readonly IIngresosBrutosRepository _repo;
|
||||||
|
private readonly IAuditLogger _audit;
|
||||||
|
private readonly TimeProvider _timeProvider;
|
||||||
|
|
||||||
|
public ReactivateIngresosBrutosCommandHandler(IIngresosBrutosRepository repo, IAuditLogger audit, TimeProvider timeProvider)
|
||||||
|
{
|
||||||
|
_repo = repo;
|
||||||
|
_audit = audit;
|
||||||
|
_timeProvider = timeProvider;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<IngresosBrutosDto> Handle(ReactivateIngresosBrutosCommand command)
|
||||||
|
{
|
||||||
|
var entity = await _repo.GetByIdAsync(command.Id)
|
||||||
|
?? throw new IngresosBrutosNotFoundException(command.Id);
|
||||||
|
|
||||||
|
if (entity.Activo)
|
||||||
|
return IngresosBrutosMapper.ToDto(entity);
|
||||||
|
|
||||||
|
using var tx = new TransactionScope(
|
||||||
|
TransactionScopeOption.Required,
|
||||||
|
new TransactionOptions { IsolationLevel = System.Transactions.IsolationLevel.ReadCommitted },
|
||||||
|
TransactionScopeAsyncFlowOption.Enabled);
|
||||||
|
|
||||||
|
await _repo.SetActivoAsync(command.Id, true);
|
||||||
|
|
||||||
|
await _audit.LogAsync(
|
||||||
|
action: "ingresos_brutos.reactivate",
|
||||||
|
targetType: "IngresosBrutos",
|
||||||
|
targetId: command.Id.ToString());
|
||||||
|
|
||||||
|
tx.Complete();
|
||||||
|
|
||||||
|
var now = _timeProvider.GetUtcNow().UtcDateTime;
|
||||||
|
return IngresosBrutosMapper.ToDto(entity.Reactivate(now));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
namespace SIGCM2.Application.IngresosBrutos.Update;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Updates only cosmetic fields: Descripcion, Activo.
|
||||||
|
/// Alicuota and Provincia are NOT part of this command — they are immutable.
|
||||||
|
/// </summary>
|
||||||
|
public sealed record UpdateIngresosBrutosCommand(
|
||||||
|
int Id,
|
||||||
|
string Descripcion,
|
||||||
|
bool Activo);
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
using System.Transactions;
|
||||||
|
using SIGCM2.Application.Abstractions;
|
||||||
|
using SIGCM2.Application.Abstractions.Persistence;
|
||||||
|
using SIGCM2.Application.Audit;
|
||||||
|
using SIGCM2.Application.IngresosBrutos.Dtos;
|
||||||
|
using SIGCM2.Domain.Exceptions;
|
||||||
|
|
||||||
|
namespace SIGCM2.Application.IngresosBrutos.Update;
|
||||||
|
|
||||||
|
public sealed class UpdateIngresosBrutosCommandHandler
|
||||||
|
: ICommandHandler<UpdateIngresosBrutosCommand, IngresosBrutosDto>
|
||||||
|
{
|
||||||
|
private readonly IIngresosBrutosRepository _repo;
|
||||||
|
private readonly IAuditLogger _audit;
|
||||||
|
private readonly TimeProvider _timeProvider;
|
||||||
|
|
||||||
|
public UpdateIngresosBrutosCommandHandler(IIngresosBrutosRepository repo, IAuditLogger audit, TimeProvider timeProvider)
|
||||||
|
{
|
||||||
|
_repo = repo;
|
||||||
|
_audit = audit;
|
||||||
|
_timeProvider = timeProvider;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<IngresosBrutosDto> Handle(UpdateIngresosBrutosCommand command)
|
||||||
|
{
|
||||||
|
var entity = await _repo.GetByIdAsync(command.Id)
|
||||||
|
?? throw new IngresosBrutosNotFoundException(command.Id);
|
||||||
|
|
||||||
|
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,
|
||||||
|
new TransactionOptions { IsolationLevel = System.Transactions.IsolationLevel.ReadCommitted },
|
||||||
|
TransactionScopeAsyncFlowOption.Enabled);
|
||||||
|
|
||||||
|
await _repo.UpdateCosmeticoAsync(command.Id, command.Descripcion, command.Activo);
|
||||||
|
|
||||||
|
await _audit.LogAsync(
|
||||||
|
action: "ingresos_brutos.update",
|
||||||
|
targetType: "IngresosBrutos",
|
||||||
|
targetId: command.Id.ToString(),
|
||||||
|
metadata: new
|
||||||
|
{
|
||||||
|
before = new { entity.Descripcion, entity.Activo },
|
||||||
|
after = new { command.Descripcion, command.Activo },
|
||||||
|
});
|
||||||
|
|
||||||
|
tx.Complete();
|
||||||
|
|
||||||
|
return IngresosBrutosMapper.ToDto(updated);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
using FluentValidation;
|
||||||
|
|
||||||
|
namespace SIGCM2.Application.IngresosBrutos.Update;
|
||||||
|
|
||||||
|
public sealed class UpdateIngresosBrutosCommandValidator : AbstractValidator<UpdateIngresosBrutosCommand>
|
||||||
|
{
|
||||||
|
private const int DescripcionMaxLength = 255;
|
||||||
|
|
||||||
|
public UpdateIngresosBrutosCommandValidator()
|
||||||
|
{
|
||||||
|
RuleFor(x => x.Id)
|
||||||
|
.GreaterThan(0).WithMessage("El id debe ser mayor a 0.");
|
||||||
|
|
||||||
|
RuleFor(x => x.Descripcion)
|
||||||
|
.NotEmpty().WithMessage("La descripción es requerida.")
|
||||||
|
.MaximumLength(DescripcionMaxLength)
|
||||||
|
.WithMessage($"La descripción no puede superar los {DescripcionMaxLength} caracteres.");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
using SIGCM2.Domain.Entities;
|
||||||
|
|
||||||
|
namespace SIGCM2.Application.Medios.Create;
|
||||||
|
|
||||||
|
public sealed record CreateMedioCommand(
|
||||||
|
string Codigo,
|
||||||
|
string Nombre,
|
||||||
|
TipoMedio Tipo,
|
||||||
|
int? PlataformaEmpresaId);
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
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.Medios.Create;
|
||||||
|
|
||||||
|
public sealed class CreateMedioCommandHandler : ICommandHandler<CreateMedioCommand, MedioCreatedDto>
|
||||||
|
{
|
||||||
|
private readonly IMedioRepository _repo;
|
||||||
|
private readonly IAuditLogger _audit;
|
||||||
|
|
||||||
|
public CreateMedioCommandHandler(IMedioRepository repo, IAuditLogger audit)
|
||||||
|
{
|
||||||
|
_repo = repo;
|
||||||
|
_audit = audit;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<MedioCreatedDto> Handle(CreateMedioCommand command)
|
||||||
|
{
|
||||||
|
var codigoNorm = command.Codigo.ToUpperInvariant();
|
||||||
|
|
||||||
|
var exists = await _repo.ExistsByCodigoAsync(codigoNorm);
|
||||||
|
if (exists)
|
||||||
|
throw new MedioCodigoDuplicadoException(codigoNorm);
|
||||||
|
|
||||||
|
var medio = Medio.ForCreation(codigoNorm, command.Nombre, command.Tipo, command.PlataformaEmpresaId);
|
||||||
|
|
||||||
|
using var tx = new TransactionScope(
|
||||||
|
TransactionScopeOption.Required,
|
||||||
|
new TransactionOptions { IsolationLevel = System.Transactions.IsolationLevel.ReadCommitted },
|
||||||
|
TransactionScopeAsyncFlowOption.Enabled);
|
||||||
|
|
||||||
|
var newId = await _repo.AddAsync(medio);
|
||||||
|
|
||||||
|
await _audit.LogAsync(
|
||||||
|
action: "medio.create",
|
||||||
|
targetType: "Medio",
|
||||||
|
targetId: newId.ToString(),
|
||||||
|
metadata: new
|
||||||
|
{
|
||||||
|
after = new
|
||||||
|
{
|
||||||
|
medio.Codigo,
|
||||||
|
medio.Nombre,
|
||||||
|
medio.Tipo,
|
||||||
|
medio.PlataformaEmpresaId,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
tx.Complete();
|
||||||
|
|
||||||
|
return new MedioCreatedDto(
|
||||||
|
Id: newId,
|
||||||
|
Codigo: medio.Codigo,
|
||||||
|
Nombre: medio.Nombre,
|
||||||
|
Tipo: medio.Tipo,
|
||||||
|
PlataformaEmpresaId: medio.PlataformaEmpresaId,
|
||||||
|
Activo: medio.Activo);
|
||||||
|
}
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user