Compare commits
363 Commits
2111070c77
...
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 | |||
| 9eac044752 | |||
| b526df2125 | |||
| 2bb90118ab | |||
| b619c05762 | |||
| a3f01bc6c9 | |||
| 26efb74c22 | |||
| a3d6214d09 | |||
| 300badda73 | |||
| 0b4af4c332 | |||
| 08d6622e43 | |||
| 68f96b90c7 | |||
| c95bc7fe01 | |||
| 1c79dfa0a4 | |||
| 2d1d187f6e | |||
| d201d9e08e | |||
| fa76d0055a | |||
| 5f7d9e6b89 | |||
| 83d76b95d4 | |||
| 7b7ef1c137 | |||
| 41b6882b5c | |||
| 278e1cf378 | |||
| 3bc2625e21 | |||
| 6e6c729bac | |||
| c488e2430d | |||
| 492705c076 | |||
| 6822d56e11 | |||
| a30b10ebff | |||
| b7882613a4 | |||
| 9dbf3e895d | |||
| c1426b2257 | |||
| 7d4dc4d2bb | |||
| 47323302cc | |||
| 5fd88b5a9d | |||
| bf64ffb35e | |||
| fb07a1139a | |||
| 86310de286 | |||
| 54955231bf | |||
| da1eb83ac1 | |||
| be86c2fac9 | |||
| 68897f446b | |||
| 06908263f6 | |||
| 9e93c70d8b | |||
| 851fed8692 | |||
| 2e2d4543ad | |||
| 25ed0f6452 | |||
| 64e0a8b5fb | |||
| 9512f4125d | |||
| d998d215e0 | |||
| 7d96d5ff18 | |||
| a3bd066f7b | |||
| 473566f255 | |||
| 14c385fdb1 | |||
| 2925336783 | |||
| 9dcd63543e | |||
| d1f7b3805b | |||
| 5ddc5ddf02 | |||
| c0d1ea4ac2 | |||
| 8513e99554 | |||
| 96e7290fb7 | |||
| f6cdd7650b | |||
| 8935115da9 | |||
| 2efd5e2fdb | |||
| 0218d8d371 | |||
| 4866c4f21f | |||
| 58d0df601f | |||
| cdb8dcd03c | |||
| 2afac53fca | |||
| 1a864e9f8b | |||
| 885a8cef17 | |||
| 4913a35d06 | |||
| be2257a9bf | |||
| 704794a2e2 | |||
| 7ddb71c24c | |||
| 7d2190c37e | |||
| f6ad371de4 | |||
| 4d3e55c422 | |||
| e5ee8e673b | |||
| 57e4cdac01 | |||
| fae06fb8b8 | |||
| 6f999b8fcd | |||
| 34b714750a | |||
| e0e9ec3b88 | |||
| c6352e1e39 | |||
| ddc7c8d53d | |||
| 890da06f71 | |||
| bce591e63c | |||
| dd99e5cc69 | |||
| 3d598faffc | |||
| 023d30fce4 | |||
| 5b3797a81c | |||
| 96dbeecc0f | |||
| 7fadb88da0 | |||
| dd4f4dbd5e | |||
| bdaaaffaf6 | |||
| d40b7247fc | |||
| f806e0a483 | |||
| f1d4ea0047 | |||
| fd2ff8a802 | |||
| 8768067fdd | |||
| 4e7b2690bd | |||
| aed26e3de9 | |||
| cb4250f7b3 | |||
| 19ac807500 | |||
| 0c809da633 | |||
| e405c0453b | |||
| d326dd87e0 | |||
| 2806e8dfa6 | |||
| c910ff2fc5 | |||
| a363e3658d | |||
| 8bbd2b6f2a | |||
| b79efc778a | |||
| 6c02197369 | |||
| 15a7687e4c | |||
| f5e67b78a5 | |||
| 25639398c2 | |||
| 971f6f572f | |||
| 84006776b6 | |||
| 802c89ffe5 | |||
| ba6dffb137 | |||
| 83c6a95ee2 | |||
| aacfd29673 | |||
| 22aff10330 | |||
| 99bb3364c3 | |||
| 2efe4115c4 | |||
| ffb68db57e | |||
| 3b66415e17 | |||
| cc532ff319 | |||
| b3d78ff56d | |||
| 5e1e979377 | |||
| 7eea0fd17c | |||
| 8acd2975ba | |||
| a15d8c166e | |||
| 4fa891f340 | |||
| 6c4d572111 | |||
| f4f063f5f0 | |||
| a692576bc3 | |||
| 5f6ebccb54 | |||
| b657dc0d2a | |||
| 9891f96618 | |||
| ca57ce33b5 | |||
| 8c26cd3ac5 |
83
.atl/skill-registry.md
Normal file
83
.atl/skill-registry.md
Normal file
@@ -0,0 +1,83 @@
|
||||
# Skill Registry — sig-cm2
|
||||
Generated: 2026-04-13
|
||||
|
||||
## User Skills
|
||||
|
||||
| Skill | Trigger |
|
||||
|-------|---------|
|
||||
| `sdd-init` | User says "sdd init", "iniciar sdd", "openspec init" |
|
||||
| `sdd-explore` | Orchestrator launches exploration of a feature or codebase area |
|
||||
| `sdd-propose` | Orchestrator launches proposal for a change |
|
||||
| `sdd-spec` | Orchestrator launches spec writing for a change |
|
||||
| `sdd-design` | Orchestrator launches technical design for a change |
|
||||
| `sdd-tasks` | Orchestrator launches task breakdown for a change |
|
||||
| `sdd-apply` | Orchestrator launches implementation of tasks |
|
||||
| `sdd-verify` | Orchestrator launches verification of a completed change |
|
||||
| `sdd-archive` | Orchestrator launches archival of a completed change |
|
||||
| `sdd-onboard` | User wants a guided SDD walkthrough |
|
||||
| `judgment-day` | User says "judgment day", "review adversarial", "doble review", "juzgar" |
|
||||
| `go-testing` | Writing Go tests, using teatest, Bubbletea TUI testing |
|
||||
| `skill-creator` | Creating a new AI agent skill |
|
||||
| `branch-pr` | Creating a pull request, preparing changes for review |
|
||||
| `issue-creation` | Creating a GitHub issue, bug report, or feature request |
|
||||
| `skill-registry` | Update skill registry, "actualizar skills" |
|
||||
| `obsidian-cli` | Interact with Obsidian vault via CLI |
|
||||
| `obsidian-markdown` | Creating/editing Obsidian Flavored Markdown (.md files in vault) |
|
||||
| `gitea-workflow` | Agile workflow for Gitea repos, "run the workflow", "what's next" |
|
||||
| `find-skills` | "Find a skill for X", "how do I do X", discover capabilities |
|
||||
|
||||
## Project Conventions
|
||||
|
||||
| File | Role |
|
||||
|------|------|
|
||||
| `Obsidian/SPEC.md` | Source of truth — visión, módulos, tech stack |
|
||||
| `Obsidian/STATUS.md` | Estado de UDTs — ÚNICO lugar para marcar tareas `[x]` |
|
||||
| `Obsidian/INSTRUCCIONES_IA.md` | SOP del agente: bucle de ejecución, reglas de lectura |
|
||||
| `Obsidian/02-ARQUITECTURA-y-TECH-STACK/` | UDTs por módulo con CMV (Contexto Mínimo Viable) |
|
||||
| `Obsidian/04-DOMINIO-y-REGLAS-de-NEGOCIO/` | Reglas de negocio — consultar ante dudas |
|
||||
|
||||
## Compact Rules
|
||||
|
||||
### SIG-CM2 Development Rules
|
||||
- Orden de implementación SIEMPRE: BD → Backend → Frontend
|
||||
- Rama por UDT: `feature/UDT-XXX` (o VTA-XXX, TAS-XXX, INT-XXX, ADM-XXX)
|
||||
- Commits: `tipo(módulo): descripción` — feat/fix/docs/refactor/test/chore/security
|
||||
- NUNCA leer `Obsidian/07-RELEVAMIENTOS/` sin instrucción humana explícita
|
||||
- Para dudas de negocio: consultar `04-DOMINIO-y-REGLAS-de-NEGOCIO/` o `SPEC.md`
|
||||
- Antes de cada UDT: leer STATUS.md → leer UDT en carpeta 02 → cargar solo el CMV indicado
|
||||
|
||||
### Architecture
|
||||
- Clean Architecture: SIGCM2.Api / SIGCM2.Application / SIGCM2.Domain / SIGCM2.Infrastructure
|
||||
- Backend ORM: Dapper 2.x (NO Entity Framework — decisión arquitectural)
|
||||
- Lógica crítica de negocio: Stored Procedures en SQL Server
|
||||
- Frontend state: Zustand (global) + TanStack Query (server state)
|
||||
- Frontend estructura: src/api, src/components/{ui,features}, src/features/*, src/hooks, src/layouts, src/pages, src/stores, src/utils
|
||||
|
||||
### Strict TDD Mode (ACTIVE)
|
||||
- Tests ANTES del código de producción (Red → Green → Refactor)
|
||||
- Backend: xUnit + NSubstitute — comando: `dotnet test`
|
||||
- Frontend: Vitest + React Testing Library — comando: `vitest`
|
||||
- Coverage backend: `dotnet test --collect:"XPlat Code Coverage"`
|
||||
- Coverage frontend: `vitest --coverage`
|
||||
|
||||
### Design System (frontend) — v2.3
|
||||
- Source of truth: `Obsidian/02-ARQUITECTURA-y-TECH-STACK/2.14 🎨 Design System.md`. Engram topic_key: `sig-cm2/design-system`
|
||||
- Personality: tech sophisticated (Vercel/Linear/Railway). Glass + gradient mesh + multi-layer shadows + glow blobs corners
|
||||
- Brand `#008fbe` (logo) → escalado OKLCH `--brand-50..950`. **Violet accent** `oklch(0.62 0.20 280)` (`--accent-violet-*`) para combos tech. Neutral cool slate con shift hue 250-252 (`--neutral-50..950`)
|
||||
- NO usar `gray-*`/`slate-*`/`blue-*` genéricos de Tailwind. Solo brand/neutral/violet/semantic
|
||||
- Tokens semánticos: `bg-background`, `text-foreground`, `bg-primary`, `bg-card`, `text-muted-foreground`, `border-border`, `ring-ring`, `bg-input` (con `border-input-border`). NUNCA hardcodear `bg-white`/`text-black`/hex inline
|
||||
- Density compact: button 32-40px, input 40px (`h-10`), table row 40px. `--radius` base 10px (sm/md/lg/xl = 6/8/10/14)
|
||||
- Light + Dark con default = system preference (`useTheme()` hook). Dark NO es pure black (slate-violet). Smoke test ambos antes de mergear
|
||||
- Forms: ≤4 campos single col, ≥5 campos `grid grid-cols-1 md:grid-cols-2 gap-4`
|
||||
- Tablas mobile: priority columns + tap-to-expand (NO cards-on-mobile, NO pure horizontal scroll)
|
||||
- **Utilities CSS** (`@layer components` en index.css): `.glass`, `.gradient-mesh`, `.grid-bg` (usar en root layouts), `.surface` (tablas), `.focus-glow`
|
||||
- **Card variants**: `default` (shadow-md) / `elevated` / `glass` / `flat`
|
||||
- **Tooltips**: usar SIEMPRE `<Tooltip>` de `@/components/ui/tooltip` (Radix Portal). NO CSS absolute en sidebars/modals — clipping issue
|
||||
- **Sidebar**: colapsable con `useSidebar()` hook (persiste en localStorage). Toggle en top header al lado del brand
|
||||
- **DataTable**: usar SIEMPRE `<DataTable>` de `@/components/ui/data-table` para tablas. NUNCA HTML `<table>` crudo. Soporta `meta: { priority: 'high'|'medium'|'low' }` para responsive + tap-to-expand row mobile automático
|
||||
- **shadcn MCP**: registrado globalmente (user scope). Pedirle a Claude que instale componentes shadcn — lo hace via MCP sin que el dev toque CLI. 22 componentes ya instalados
|
||||
- Toasts via `sonner` (`<Toaster richColors closeButton position="top-right" />` ya montado en `App.tsx`). `toast.success()` / `toast.error()`
|
||||
- TooltipProvider ya envuelve App con `delayDuration={150}`
|
||||
- Componentes shadcn: instalar via shadcn MCP server o `npx shadcn@latest add`. NUNCA copy-paste manual del website
|
||||
- WCAG AA obligatorio: focus rings visibles (ya forzado en CSS base), contrast ≥ 4.5:1 texto normal, aria-label en botones icon-only
|
||||
- Browser autofill fix ya aplicado en `@layer base` — respeta tokens del DS
|
||||
12
.gitignore
vendored
12
.gitignore
vendored
@@ -67,4 +67,14 @@ src/web/.vite/
|
||||
# ----------------------------------------------------------------------------
|
||||
# ## Documentación ##
|
||||
# ----------------------------------------------------------------------------
|
||||
/Obsidian
|
||||
/Obsidian
|
||||
|
||||
# ----------------------------------------------------------------------------
|
||||
# ## Claude Code local state ##
|
||||
# ----------------------------------------------------------------------------
|
||||
.claude/
|
||||
|
||||
# ----------------------------------------------------------------------------
|
||||
# ## Visual Studio auto-generated solution (usamos SIGCM2.slnx en la raíz) ##
|
||||
# ----------------------------------------------------------------------------
|
||||
src/src.sln
|
||||
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}]]}
|
||||
@@ -15,15 +15,18 @@
|
||||
<PackageVersion Include="Scalar.AspNetCore" Version="2.5.6" />
|
||||
<PackageVersion Include="System.IdentityModel.Tokens.Jwt" Version="8.9.0" />
|
||||
<PackageVersion Include="Microsoft.IdentityModel.Tokens" Version="8.9.0" />
|
||||
<PackageVersion Include="Quartz.Extensions.Hosting" Version="3.13.1" />
|
||||
</ItemGroup>
|
||||
<!-- Test dependencies -->
|
||||
<ItemGroup>
|
||||
<PackageVersion Include="Microsoft.Extensions.TimeProvider.Testing" Version="9.1.0" />
|
||||
<PackageVersion Include="xunit" Version="2.9.3" />
|
||||
<PackageVersion Include="xunit.runner.visualstudio" Version="3.1.4" />
|
||||
<PackageVersion Include="Microsoft.NET.Test.Sdk" Version="17.14.1" />
|
||||
<PackageVersion Include="NSubstitute" Version="5.3.0" />
|
||||
<PackageVersion Include="FluentAssertions" Version="6.12.2" />
|
||||
<PackageVersion Include="Microsoft.AspNetCore.Mvc.Testing" Version="10.0.0-preview.3.25172.1" />
|
||||
<PackageVersion Include="Microsoft.AspNetCore.TestHost" Version="10.0.0-preview.3.25172.1" />
|
||||
<PackageVersion Include="Respawn" Version="6.2.1" />
|
||||
<PackageVersion Include="coverlet.collector" Version="6.0.4" />
|
||||
</ItemGroup>
|
||||
|
||||
103
README.md
Normal file
103
README.md
Normal file
@@ -0,0 +1,103 @@
|
||||
# SIG-CM 2.0
|
||||
|
||||
Sistema de gestión comercial — migración del sistema legacy (VB6) a una plataforma web moderna.
|
||||
|
||||
## Stack
|
||||
|
||||
- **Backend**: .NET 10 · C# 13 · ASP.NET Core · Clean Architecture · Dapper 2.x · SQL Server 2022 · JWT RS256 · Serilog · FluentValidation · xUnit + NSubstitute
|
||||
- **Frontend**: React 19 · TypeScript 5 strict · Vite 6 · Tailwind 4 · Zustand · React Router 7 · TanStack Query · Axios · Vitest + RTL
|
||||
- **Infra**: Docker · Gitea Actions · Obsidian (documentación interna) · SQL Server
|
||||
|
||||
## Estructura
|
||||
|
||||
```
|
||||
src/api/ # Backend .NET (Clean Architecture)
|
||||
SIGCM2.Api/ controllers, filters, Program.cs
|
||||
SIGCM2.Application/ commands, handlers, validators, abstractions
|
||||
SIGCM2.Domain/ entities, exceptions, domain security
|
||||
SIGCM2.Infrastructure/ persistence (Dapper), security, DI
|
||||
|
||||
src/web/ # Frontend React 19 (Vite + TS strict)
|
||||
src/features/ feature modules (auth, users, …)
|
||||
src/components/ shared UI + layout
|
||||
src/tests/ Vitest suites
|
||||
|
||||
database/
|
||||
migrations/ .sql con orden Vxxx
|
||||
seeds/ datos iniciales
|
||||
schemas/ definiciones auxiliares
|
||||
|
||||
tests/
|
||||
SIGCM2.Api.Tests/ integration (TestWebAppFactory + SQL Server)
|
||||
SIGCM2.Application.Tests/ unit (handlers, validators)
|
||||
SIGCM2.TestSupport/ fixtures compartidas
|
||||
|
||||
Obsidian/ # Source of truth funcional (IGNORADO por git)
|
||||
STATUS.md roadmap y estado de UDTs
|
||||
INSTRUCCIONES_IA.md SOP del agente de IA
|
||||
02-ARQUITECTURA.../ specs por módulo
|
||||
```
|
||||
|
||||
## Cómo correr
|
||||
|
||||
### Requisitos
|
||||
- .NET 10 SDK
|
||||
- Node 20+
|
||||
- SQL Server 2019+ (local o remoto)
|
||||
|
||||
### Backend
|
||||
|
||||
```bash
|
||||
cd src/api/SIGCM2.Api
|
||||
dotnet run
|
||||
```
|
||||
|
||||
Config en `appsettings.json` (DB: `SIGCM2`, usuario `desarrollo`, server `TECNICA3`). Para tests de integración se usa `SIGCM2_Test`.
|
||||
|
||||
### Frontend
|
||||
|
||||
```bash
|
||||
cd src/web
|
||||
npm install
|
||||
npm run dev
|
||||
```
|
||||
|
||||
### Tests
|
||||
|
||||
```bash
|
||||
# Backend
|
||||
dotnet test tests/SIGCM2.Application.Tests # unit
|
||||
dotnet test tests/SIGCM2.Api.Tests # integration (requiere SIGCM2_Test)
|
||||
|
||||
# Frontend
|
||||
cd src/web && npx vitest run
|
||||
```
|
||||
|
||||
### Coverage (backend)
|
||||
|
||||
```bash
|
||||
# Generar reporte de coverage en formato Cobertura
|
||||
dotnet test --collect:"XPlat Code Coverage" --settings coverlet.runsettings --results-directory ./TestResults
|
||||
```
|
||||
|
||||
El comando genera un `coverage.cobertura.xml` por cada proyecto de test en `./TestResults/`.
|
||||
|
||||
Para convertirlo a HTML:
|
||||
|
||||
```bash
|
||||
# Instalar ReportGenerator (solo la primera vez)
|
||||
dotnet tool install -g dotnet-reportgenerator-globaltool
|
||||
|
||||
# Generar reporte HTML
|
||||
reportgenerator -reports:"./TestResults/**/coverage.cobertura.xml" -targetdir:"./coverage-report" -reporttypes:Html
|
||||
```
|
||||
|
||||
Abrí `./coverage-report/index.html` en el browser para ver el detalle por archivo.
|
||||
|
||||
## Convenciones
|
||||
|
||||
- Ramas: `feature/UDT-XXX` desde `main`.
|
||||
- Commits: `tipo(módulo): descripción` — `feat`, `fix`, `docs`, `refactor`, `test`, `chore`, `security`.
|
||||
- Orden de trabajo por UDT: **BD → Backend → Frontend**.
|
||||
- Desarrollo guiado por Spec-Driven Development (SDD) + Strict TDD.
|
||||
- Follow-ups / deuda técnica se registran como issues de Gitea con label `followup`.
|
||||
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>
|
||||
127
database/README.md
Normal file
127
database/README.md
Normal file
@@ -0,0 +1,127 @@
|
||||
# `database/` — SIG-CM 2.0
|
||||
|
||||
Todo el DDL del sistema vive acá: migraciones versionadas, stored procedures, functions, seeds.
|
||||
|
||||
## Estructura
|
||||
|
||||
```
|
||||
database/
|
||||
├── migrations/ # Migraciones versionadas V0XX__<descripcion>.sql (SQL puro, idempotentes)
|
||||
├── procedures/ # Stored procedures — creados/alterados por UDTs específicas
|
||||
├── functions/ # User-defined functions
|
||||
├── seeds/ # Data de referencia no-versionada
|
||||
└── schemas/ # Extracciones de schema (referencia)
|
||||
```
|
||||
|
||||
## Migraciones aplicadas (orden obligatorio)
|
||||
|
||||
| Versión | Archivo | UDT | Descripción |
|
||||
|---|---|---|---|
|
||||
| V001 | `V001__create_usuario.sql` | UDT-001 | Tabla Usuario + IX_Usuario_Username_Activo |
|
||||
| V002 | `V002__create_refresh_token.sql` | UDT-002 | Tabla RefreshToken |
|
||||
| V003 | `V003__create_rol.sql` | UDT-004 | Tabla Rol + 8 roles canónicos |
|
||||
| V004 | `V004__alter_usuario_rol_fk.sql` | UDT-004 | FK Usuario.Rol → Rol.Codigo |
|
||||
| V005 | `V005__create_permiso.sql` | UDT-005 | Tabla Permiso + 18 permisos canónicos |
|
||||
| V006 | `V006__create_rol_permiso.sql` | UDT-005 | Tabla RolPermiso + seed 36 rows |
|
||||
| V007 | `V007__add_admin_permissions_udt006.sql` | UDT-006 | 3 permisos administrativos RBAC |
|
||||
| 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}` |
|
||||
| **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
|
||||
|
||||
- **SQL puro** ejecutado manualmente (no hay runner automático; decisión arquitectónica).
|
||||
- **Idempotentes**: cada migración usa `IF NOT EXISTS` / `MERGE` / `IF NOT EXISTS (SELECT 1 FROM sys....)`. Re-ejecutarlas es seguro.
|
||||
- **Boilerplate** inicial: `SET QUOTED_IDENTIFIER ON; SET ANSI_NULLS ON; SET NOCOUNT ON; GO`.
|
||||
- **Naming**: `PK_*`, `UQ_*`, `FK_*`, `DF_*`, `CK_*`, `IX_*`.
|
||||
- **Se aplican a TRES bases**: `SIGCM2` (dev), `SIGCM2_Test_App` (Application.Tests) y `SIGCM2_Test_Api` (Api.Tests). El orden debe ser idéntico en las tres.
|
||||
|
||||
## Cómo aplicar migraciones
|
||||
|
||||
### En dev (manual)
|
||||
|
||||
```bash
|
||||
# Con sqlcmd (aplicar a las tres bases en orden):
|
||||
sqlcmd -S TECNICA3 -d SIGCM2 -U desarrollo -P desarrollo2026 -i database/migrations/V010__audit_infrastructure.sql
|
||||
sqlcmd -S TECNICA3 -d SIGCM2_Test_App -U desarrollo -P desarrollo2026 -i database/migrations/V010__audit_infrastructure.sql
|
||||
sqlcmd -S TECNICA3 -d SIGCM2_Test_Api -U desarrollo -P desarrollo2026 -i database/migrations/V010__audit_infrastructure.sql
|
||||
```
|
||||
|
||||
O desde SSMS: abrir el archivo, conectar a cada base, F5.
|
||||
|
||||
### En integration tests
|
||||
|
||||
`tests/SIGCM2.TestSupport/SqlTestFixture.cs` aplica automáticamente el schema necesario en `SIGCM2_Test_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)
|
||||
|
||||
1. Backup completo de la base.
|
||||
2. Revisar notas específicas de la migración (ver abajo).
|
||||
3. Ventana de mantenimiento si la migración lo requiere.
|
||||
4. `sqlcmd` + script + verify que los `PRINT` salieron esperados.
|
||||
5. Smoke test post-migración.
|
||||
|
||||
## ⚠️ Notas especiales por migración
|
||||
|
||||
### V010 — Infraestructura de Auditoría
|
||||
|
||||
**Riesgos específicos**:
|
||||
- Activa `SYSTEM_VERSIONING` en `Usuario`, `Rol`, `Permiso`, `RolPermiso`. Tablas con datos. `ALTER TABLE ADD PERIOD FOR SYSTEM_TIME` toma **Sch-M lock** (schema modification). En dev con pocos usuarios el lock es milisegundos; en prod con conexiones activas puede generar espera.
|
||||
- Crea filegroups `AUDIT_HOT` y `AUDIT_COLD` con archivos físicos `<DB>_AUDIT_HOT.ndf` y `<DB>_AUDIT_COLD.ndf` en el default data path del server.
|
||||
- Crea 2 partition functions + schemes mensuales (boundaries 2026-01..2027-02). El job `AuditPartitionManagerJob` (B11) extiende la ventana mes a mes.
|
||||
|
||||
**Para aplicar en prod**:
|
||||
1. **Backup completo previo** (no negociable).
|
||||
2. **Ventana de mantenimiento ≥ 10 min** (los ALTER de SYSTEM_VERSIONING son rápidos pero pueden caer en timeouts si hay transacciones largas).
|
||||
3. Ejecutar el script + verificar todos los `PRINT` "created/applied".
|
||||
4. Smoke test post: `SELECT TOP 1 * FROM dbo.AuditEvent` (vacío OK); `SELECT temporal_type FROM sys.tables WHERE name = 'Usuario'` (debe devolver `2` = system-versioned).
|
||||
5. Si algo falla → `V010_ROLLBACK.sql` (pierde toda la historia) o restore de backup.
|
||||
|
||||
**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
|
||||
|
||||
- Design autoritativo: `Obsidian/02-ARQUITECTURA-y-TECH-STACK/2.5 📋 Auditoría.md`
|
||||
- Decisión persistida (engram): `sig-cm2/audit-architecture`
|
||||
- SDD artifacts UDT-010 (engram): `sdd/udt-010-auditoria-trazabilidad/{explore,proposal,spec,design,tasks,apply-progress}`
|
||||
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
|
||||
63
database/migrations/V002__create_refresh_token.sql
Normal file
63
database/migrations/V002__create_refresh_token.sql
Normal file
@@ -0,0 +1,63 @@
|
||||
-- V002__create_refresh_token.sql
|
||||
-- Creates dbo.RefreshToken table for opaque token rotation with chain revocation
|
||||
-- Run on: SIGCM2 (prod) and SIGCM2_Test (integration tests)
|
||||
|
||||
SET QUOTED_IDENTIFIER ON;
|
||||
SET ANSI_NULLS ON;
|
||||
GO
|
||||
|
||||
IF OBJECT_ID(N'dbo.RefreshToken', N'U') IS NOT NULL
|
||||
BEGIN
|
||||
PRINT 'Table dbo.RefreshToken already exists — skipping.';
|
||||
RETURN;
|
||||
END
|
||||
GO
|
||||
|
||||
CREATE TABLE dbo.RefreshToken
|
||||
(
|
||||
Id INT IDENTITY(1,1) NOT NULL,
|
||||
UsuarioId INT NOT NULL,
|
||||
TokenHash NVARCHAR(88) NOT NULL, -- SHA-256 base64url = 43 chars sin padding; margen a 88
|
||||
FamilyId UNIQUEIDENTIFIER NOT NULL, -- una familia = una sesion de login
|
||||
IssuedAt DATETIME2(3) NOT NULL,
|
||||
ExpiresAt DATETIME2(3) NOT NULL, -- absolute: heredado en cada rotacion
|
||||
RevokedAt DATETIME2(3) NULL,
|
||||
ReplacedById INT NULL,
|
||||
CreatedByIp VARCHAR(45) NOT NULL, -- IPv4/IPv6 textual
|
||||
UserAgent NVARCHAR(512) NULL,
|
||||
|
||||
CONSTRAINT PK_RefreshToken PRIMARY KEY CLUSTERED (Id),
|
||||
CONSTRAINT FK_RefreshToken_Usuario
|
||||
FOREIGN KEY (UsuarioId) REFERENCES dbo.Usuario(Id),
|
||||
CONSTRAINT FK_RefreshToken_ReplacedBy
|
||||
FOREIGN KEY (ReplacedById) REFERENCES dbo.RefreshToken(Id),
|
||||
CONSTRAINT UQ_RefreshToken_TokenHash UNIQUE (TokenHash)
|
||||
);
|
||||
GO
|
||||
|
||||
-- Lookup por familia para chain revocation
|
||||
CREATE INDEX IX_RefreshToken_UsuarioId_FamilyId
|
||||
ON dbo.RefreshToken (UsuarioId, FamilyId);
|
||||
GO
|
||||
|
||||
-- Indice filtrado para revocaciones masivas de activos
|
||||
CREATE INDEX IX_RefreshToken_Active
|
||||
ON dbo.RefreshToken (UsuarioId, FamilyId)
|
||||
WHERE RevokedAt IS NULL;
|
||||
GO
|
||||
|
||||
-- Housekeeping futuro
|
||||
CREATE INDEX IX_RefreshToken_ExpiresAt
|
||||
ON dbo.RefreshToken (ExpiresAt)
|
||||
WHERE RevokedAt IS NULL;
|
||||
GO
|
||||
|
||||
EXEC sys.sp_addextendedproperty
|
||||
@name = N'MS_Description',
|
||||
@value = N'Refresh tokens opacos (SHA-256 hash) con rotacion y chain revocation por familia',
|
||||
@level0type = N'SCHEMA', @level0name = N'dbo',
|
||||
@level1type = N'TABLE', @level1name = N'RefreshToken';
|
||||
GO
|
||||
|
||||
PRINT 'Table dbo.RefreshToken created successfully.';
|
||||
GO
|
||||
58
database/migrations/V003__create_rol.sql
Normal file
58
database/migrations/V003__create_rol.sql
Normal file
@@ -0,0 +1,58 @@
|
||||
-- V003__create_rol.sql
|
||||
-- Creates dbo.Rol master table (referenced by Usuario.Rol via FK in V004) and seeds
|
||||
-- the 8 canonical business roles (RBAC doc §2.4.2).
|
||||
-- Run on: SIGCM2 (prod) and SIGCM2_Test (integration tests)
|
||||
|
||||
SET QUOTED_IDENTIFIER ON;
|
||||
SET ANSI_NULLS ON;
|
||||
SET NOCOUNT ON;
|
||||
GO
|
||||
|
||||
IF OBJECT_ID(N'dbo.Rol', N'U') IS NULL
|
||||
BEGIN
|
||||
CREATE TABLE dbo.Rol
|
||||
(
|
||||
Id INT IDENTITY(1,1) NOT NULL CONSTRAINT PK_Rol PRIMARY KEY,
|
||||
Codigo VARCHAR(30) NOT NULL,
|
||||
Nombre NVARCHAR(60) NOT NULL,
|
||||
Descripcion NVARCHAR(250) NULL,
|
||||
Activo BIT NOT NULL CONSTRAINT DF_Rol_Activo DEFAULT(1),
|
||||
FechaCreacion DATETIME2(3) NOT NULL CONSTRAINT DF_Rol_FC DEFAULT(SYSUTCDATETIME()),
|
||||
FechaModificacion DATETIME2(3) NULL,
|
||||
CONSTRAINT UQ_Rol_Codigo UNIQUE (Codigo),
|
||||
-- Codigo format: lowercase letter followed by lowercase letters, digits or underscore.
|
||||
-- Using binary collation to enforce case-sensitivity (default DB collation is case-insensitive).
|
||||
CONSTRAINT CK_Rol_Codigo_Format CHECK (
|
||||
PATINDEX('[a-z]%', Codigo COLLATE Latin1_General_BIN2) = 1
|
||||
AND PATINDEX('%[^a-z0-9_]%', Codigo COLLATE Latin1_General_BIN2) = 0
|
||||
)
|
||||
);
|
||||
|
||||
PRINT 'Table dbo.Rol created successfully.';
|
||||
END
|
||||
ELSE
|
||||
BEGIN
|
||||
PRINT 'Table dbo.Rol already exists — skipping create.';
|
||||
END
|
||||
GO
|
||||
|
||||
-- Seed 8 canonical roles (idempotent).
|
||||
MERGE dbo.Rol AS target
|
||||
USING (VALUES
|
||||
('admin', N'Administrador', N'Supervisor total del sistema'),
|
||||
('cajero', N'Cajero', N'Atención de mostrador, contado'),
|
||||
('operador_ctacte', N'Operador Cta Cte', N'Gestión de cuenta corriente'),
|
||||
('picadora', N'Picadora/Correctora', N'Edición de textos y corrección'),
|
||||
('jefe_publicidad', N'Jefe de Publicidad', N'Supervisión de pauta y recursos'),
|
||||
('productor', N'Productor', N'Consulta y carga restringida'),
|
||||
('diagramacion', N'Diagramación/Taller', N'Solo lectura de pauta'),
|
||||
('reportes', N'Reportes', N'Solo lectura de reportes y estadísticas')
|
||||
) AS source (Codigo, Nombre, Descripcion)
|
||||
ON target.Codigo = source.Codigo
|
||||
WHEN NOT MATCHED BY TARGET THEN
|
||||
INSERT (Codigo, Nombre, Descripcion, Activo)
|
||||
VALUES (source.Codigo, source.Nombre, source.Descripcion, 1);
|
||||
GO
|
||||
|
||||
PRINT 'Rol seeds applied (8 canonical roles).';
|
||||
GO
|
||||
44
database/migrations/V004__alter_usuario_rol_fk.sql
Normal file
44
database/migrations/V004__alter_usuario_rol_fk.sql
Normal file
@@ -0,0 +1,44 @@
|
||||
-- V004__alter_usuario_rol_fk.sql
|
||||
-- Replaces the hardcoded CHECK constraint on Usuario.Rol with a FOREIGN KEY
|
||||
-- against dbo.Rol(Codigo). Must run AFTER V003 (which creates dbo.Rol and seeds the
|
||||
-- codes already in use, including 'admin').
|
||||
-- Run on: SIGCM2 (prod) and SIGCM2_Test (integration tests)
|
||||
|
||||
SET QUOTED_IDENTIFIER ON;
|
||||
SET ANSI_NULLS ON;
|
||||
SET NOCOUNT ON;
|
||||
GO
|
||||
|
||||
-- 1) Drop the old hardcoded whitelist CHECK constraint (if still present).
|
||||
IF EXISTS (
|
||||
SELECT 1 FROM sys.check_constraints
|
||||
WHERE name = 'CK_Usuario_Rol'
|
||||
AND parent_object_id = OBJECT_ID(N'dbo.Usuario')
|
||||
)
|
||||
BEGIN
|
||||
ALTER TABLE dbo.Usuario DROP CONSTRAINT CK_Usuario_Rol;
|
||||
PRINT 'Dropped CK_Usuario_Rol (hardcoded whitelist).';
|
||||
END
|
||||
ELSE
|
||||
BEGIN
|
||||
PRINT 'CK_Usuario_Rol not present — skipping drop.';
|
||||
END
|
||||
GO
|
||||
|
||||
-- 2) Add the FK Usuario.Rol -> Rol.Codigo (only if not already present).
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM sys.foreign_keys
|
||||
WHERE name = 'FK_Usuario_Rol'
|
||||
AND parent_object_id = OBJECT_ID(N'dbo.Usuario')
|
||||
)
|
||||
BEGIN
|
||||
ALTER TABLE dbo.Usuario
|
||||
ADD CONSTRAINT FK_Usuario_Rol
|
||||
FOREIGN KEY (Rol) REFERENCES dbo.Rol(Codigo);
|
||||
PRINT 'Added FK_Usuario_Rol -> dbo.Rol(Codigo).';
|
||||
END
|
||||
ELSE
|
||||
BEGIN
|
||||
PRINT 'FK_Usuario_Rol already present — skipping.';
|
||||
END
|
||||
GO
|
||||
65
database/migrations/V005__create_permiso.sql
Normal file
65
database/migrations/V005__create_permiso.sql
Normal file
@@ -0,0 +1,65 @@
|
||||
-- V005__create_permiso.sql
|
||||
-- Tabla catálogo de permisos atómicos RBAC (18 permisos iniciales §2.4.2).
|
||||
-- Requerimiento: ejecutar ANTES de V006 (FK PermisoId).
|
||||
-- Run on: SIGCM2 (prod) and SIGCM2_Test (integration tests)
|
||||
|
||||
SET QUOTED_IDENTIFIER ON;
|
||||
SET ANSI_NULLS ON;
|
||||
SET NOCOUNT ON;
|
||||
GO
|
||||
|
||||
IF OBJECT_ID(N'dbo.Permiso', N'U') IS NULL
|
||||
BEGIN
|
||||
CREATE TABLE dbo.Permiso (
|
||||
Id INT IDENTITY(1,1) NOT NULL CONSTRAINT PK_Permiso PRIMARY KEY,
|
||||
Codigo VARCHAR(60) NOT NULL,
|
||||
Nombre NVARCHAR(100) NOT NULL,
|
||||
Descripcion NVARCHAR(500) NULL,
|
||||
Modulo VARCHAR(30) NOT NULL,
|
||||
Activo BIT NOT NULL CONSTRAINT DF_Permiso_Activo DEFAULT(1),
|
||||
FechaCreacion DATETIME2(3) NOT NULL CONSTRAINT DF_Permiso_FC DEFAULT(SYSUTCDATETIME()),
|
||||
CONSTRAINT UQ_Permiso_Codigo UNIQUE (Codigo),
|
||||
-- Formato: segmentos en minúsculas separados por ':', p.ej. ventas:contado:crear
|
||||
-- Usa collation binaria para forzar case-sensitivity (igual que CK_Rol_Codigo_Format).
|
||||
CONSTRAINT CK_Permiso_Codigo_Format CHECK (
|
||||
PATINDEX('[a-z]%', Codigo COLLATE Latin1_General_BIN2) = 1
|
||||
AND PATINDEX('%[^a-z0-9_:]%', Codigo COLLATE Latin1_General_BIN2) = 0
|
||||
)
|
||||
);
|
||||
PRINT 'Table dbo.Permiso created.';
|
||||
END
|
||||
ELSE
|
||||
PRINT 'Table dbo.Permiso already exists — skip.';
|
||||
GO
|
||||
|
||||
-- Seed 18 permisos canónicos (idempotente via MERGE).
|
||||
-- Convención RBAC: cada permiso nuevo → asignar a admin en la misma migración (V006+).
|
||||
MERGE dbo.Permiso AS t
|
||||
USING (VALUES
|
||||
('ventas:contado:crear', N'Cargar orden contado', NULL, 'ventas'),
|
||||
('ventas:contado:modificar', N'Modificar orden contado', NULL, 'ventas'),
|
||||
('ventas:contado:cobrar', N'Cobrar orden contado', NULL, 'ventas'),
|
||||
('ventas:contado:facturar', N'Facturar orden contado', NULL, 'ventas'),
|
||||
('ventas:ctacte:crear', N'Cargar orden cuenta corriente', NULL, 'ventas'),
|
||||
('ventas:ctacte:facturar', N'Facturar lote cuenta corriente', NULL, 'ventas'),
|
||||
('textos:editar', N'Editar textos', NULL, 'textos'),
|
||||
('textos:reclamos:ver', N'Ver reclamos de textos', NULL, 'textos'),
|
||||
('pauta:azanu:ver', N'Ver AZANU en pauta', NULL, 'pauta'),
|
||||
('pauta:limpiar', N'Limpieza de pauta', NULL, 'pauta'),
|
||||
('pauta:recursos:fueradehora', N'Recursos fuera de hora', NULL, 'pauta'),
|
||||
('productores:deuda:ver', N'Ver deuda propia de productores', NULL, 'productores'),
|
||||
('productores:pendientes:crear', N'Cargar pendientes de productores', NULL, 'productores'),
|
||||
('productores:deuda:bypass', N'Bypass de deuda de productores', NULL, 'productores'),
|
||||
('administracion:usuarios:gestionar', N'Gestionar usuarios del sistema', N'Crear, editar y desactivar usuarios', 'administracion'),
|
||||
('administracion:tarifarios:gestionar', N'Gestionar tarifarios', N'Crear y modificar tarifarios de publicidad', 'administracion'),
|
||||
('administracion:medios:gestionar', N'Gestionar medios publicitarios', N'Alta y configuración de medios', 'administracion'),
|
||||
('administracion:auditoria:ver', N'Ver logs de auditoría', N'Acceso al dashboard de auditoría', '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
|
||||
|
||||
PRINT 'Permiso seeds applied (18 permisos).';
|
||||
GO
|
||||
96
database/migrations/V006__create_rol_permiso.sql
Normal file
96
database/migrations/V006__create_rol_permiso.sql
Normal file
@@ -0,0 +1,96 @@
|
||||
-- V006__create_rol_permiso.sql
|
||||
-- Tabla M:N Rol ↔ Permiso + seed inicial según matriz §2.4.2.
|
||||
-- Requiere: V003 (dbo.Rol), V005 (dbo.Permiso).
|
||||
-- Convención RBAC: cada permiso nuevo → asignar explícitamente a admin en la misma migración.
|
||||
-- Run on: SIGCM2 (prod) and SIGCM2_Test (integration tests)
|
||||
|
||||
SET QUOTED_IDENTIFIER ON;
|
||||
SET ANSI_NULLS ON;
|
||||
SET NOCOUNT ON;
|
||||
GO
|
||||
|
||||
IF OBJECT_ID(N'dbo.RolPermiso', N'U') IS NULL
|
||||
BEGIN
|
||||
CREATE TABLE dbo.RolPermiso (
|
||||
Id INT IDENTITY(1,1) NOT NULL CONSTRAINT PK_RolPermiso PRIMARY KEY,
|
||||
RolId INT NOT NULL CONSTRAINT FK_RolPermiso_Rol REFERENCES dbo.Rol(Id) ON DELETE CASCADE,
|
||||
PermisoId INT NOT NULL CONSTRAINT FK_RolPermiso_Permiso REFERENCES dbo.Permiso(Id) ON DELETE CASCADE,
|
||||
FechaAsignacion DATETIME2(3) NOT NULL CONSTRAINT DF_RolPermiso_FA DEFAULT(SYSUTCDATETIME()),
|
||||
CONSTRAINT UQ_RolPermiso UNIQUE (RolId, PermisoId)
|
||||
);
|
||||
CREATE INDEX IX_RolPermiso_RolId ON dbo.RolPermiso (RolId);
|
||||
CREATE INDEX IX_RolPermiso_PermisoId ON dbo.RolPermiso (PermisoId);
|
||||
PRINT 'Table dbo.RolPermiso created.';
|
||||
END
|
||||
ELSE
|
||||
PRINT 'Table dbo.RolPermiso already exists — skip.';
|
||||
GO
|
||||
|
||||
-- Seed: mapeo rol → permisos según matriz §2.4.2
|
||||
-- admin: 18 permisos (explícito — sin wildcard, convención RBAC)
|
||||
-- cajero: 4 permisos (ventas contado)
|
||||
-- operador_ctacte: 2 permisos (ventas ctacte)
|
||||
-- picadora: 2 permisos (textos)
|
||||
-- jefe_publicidad: 7 permisos (textos + pauta + productores)
|
||||
-- productor: 2 permisos (productores)
|
||||
-- diagramacion: 1 permiso (pauta:azanu:ver)
|
||||
-- reportes: 0 permisos (solo lectura reportes — sin permisos en este catálogo)
|
||||
-- Total rows: 36
|
||||
MERGE dbo.RolPermiso AS t
|
||||
USING (
|
||||
SELECT r.Id AS RolId, p.Id AS PermisoId
|
||||
FROM (VALUES
|
||||
-- admin (18 permisos)
|
||||
('admin', 'ventas:contado:crear'),
|
||||
('admin', 'ventas:contado:modificar'),
|
||||
('admin', 'ventas:contado:cobrar'),
|
||||
('admin', 'ventas:contado:facturar'),
|
||||
('admin', 'ventas:ctacte:crear'),
|
||||
('admin', 'ventas:ctacte:facturar'),
|
||||
('admin', 'textos:editar'),
|
||||
('admin', 'textos:reclamos:ver'),
|
||||
('admin', 'pauta:azanu:ver'),
|
||||
('admin', 'pauta:limpiar'),
|
||||
('admin', 'pauta:recursos:fueradehora'),
|
||||
('admin', 'productores:deuda:ver'),
|
||||
('admin', 'productores:pendientes:crear'),
|
||||
('admin', 'productores:deuda:bypass'),
|
||||
('admin', 'administracion:usuarios:gestionar'),
|
||||
('admin', 'administracion:tarifarios:gestionar'),
|
||||
('admin', 'administracion:medios:gestionar'),
|
||||
('admin', 'administracion:auditoria:ver'),
|
||||
-- cajero (4 permisos)
|
||||
('cajero', 'ventas:contado:crear'),
|
||||
('cajero', 'ventas:contado:modificar'),
|
||||
('cajero', 'ventas:contado:cobrar'),
|
||||
('cajero', 'ventas:contado:facturar'),
|
||||
-- operador_ctacte (2 permisos)
|
||||
('operador_ctacte', 'ventas:ctacte:crear'),
|
||||
('operador_ctacte', 'ventas:ctacte:facturar'),
|
||||
-- picadora (2 permisos)
|
||||
('picadora', 'textos:editar'),
|
||||
('picadora', 'textos:reclamos:ver'),
|
||||
-- jefe_publicidad (7 permisos)
|
||||
('jefe_publicidad', 'textos:editar'),
|
||||
('jefe_publicidad', 'textos:reclamos:ver'),
|
||||
('jefe_publicidad', 'pauta:azanu:ver'),
|
||||
('jefe_publicidad', 'pauta:limpiar'),
|
||||
('jefe_publicidad', 'pauta:recursos:fueradehora'),
|
||||
('jefe_publicidad', 'productores:deuda:ver'),
|
||||
('jefe_publicidad', 'productores:deuda:bypass'),
|
||||
-- productor (2 permisos)
|
||||
('productor', 'productores:deuda:ver'),
|
||||
('productor', 'productores:pendientes:crear'),
|
||||
-- diagramacion (1 permiso)
|
||||
('diagramacion', 'pauta:azanu:ver')
|
||||
-- reportes: 0 permisos — no filas
|
||||
) 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 'RolPermiso seeds applied (36 rows: admin×18 + cajero×4 + operador_ctacte×2 + picadora×2 + jefe_publicidad×7 + productor×2 + diagramacion×1).';
|
||||
GO
|
||||
42
database/migrations/V007__add_admin_permissions_udt006.sql
Normal file
42
database/migrations/V007__add_admin_permissions_udt006.sql
Normal file
@@ -0,0 +1,42 @@
|
||||
-- V007__add_admin_permissions_udt006.sql
|
||||
-- Agrega 3 permisos administrativos requeridos por UDT-006 (middleware de autorización RBAC).
|
||||
-- Los 3 nuevos permisos se asignan al rol 'admin' inmediatamente.
|
||||
-- Convención RBAC: cada permiso nuevo → asignar explícitamente a admin en la misma migración.
|
||||
-- Run on: SIGCM2 (prod) and SIGCM2_Test (integration tests)
|
||||
|
||||
SET QUOTED_IDENTIFIER ON;
|
||||
SET ANSI_NULLS ON;
|
||||
SET NOCOUNT ON;
|
||||
GO
|
||||
|
||||
-- Agregar los 3 permisos nuevos al catálogo (idempotente via MERGE)
|
||||
MERGE dbo.Permiso AS t
|
||||
USING (VALUES
|
||||
('administracion:roles:gestionar', N'Gestionar roles del sistema', N'Crear, editar y desactivar roles RBAC', 'administracion'),
|
||||
('administracion:roles_permisos:gestionar', N'Gestionar asignación de permisos', N'Asignar y revocar permisos por rol', 'administracion'),
|
||||
('administracion:permisos:ver', N'Ver catálogo de permisos', N'Consultar el listado de permisos del sistema', '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
|
||||
|
||||
-- Asignar los 3 nuevos permisos al rol 'admin' (idempotente via MERGE)
|
||||
MERGE dbo.RolPermiso AS t
|
||||
USING (
|
||||
SELECT r.Id AS RolId, p.Id AS PermisoId
|
||||
FROM (VALUES
|
||||
('admin', 'administracion:roles:gestionar'),
|
||||
('admin', 'administracion:roles_permisos:gestionar'),
|
||||
('admin', 'administracion:permisos:ver')
|
||||
) 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 'V007: 3 permisos administracion:roles|roles-permisos|permisos agregados al catalogo y asignados a admin.';
|
||||
GO
|
||||
@@ -0,0 +1,34 @@
|
||||
-- V008: Add MustChangePassword column + IX_Usuario_Activo_Rol index
|
||||
-- Idempotent: re-runnable without errors.
|
||||
SET QUOTED_IDENTIFIER ON;
|
||||
SET ANSI_NULLS ON;
|
||||
SET NOCOUNT ON;
|
||||
GO
|
||||
|
||||
-- Add MustChangePassword column (idempotent via COL_LENGTH check)
|
||||
IF COL_LENGTH('dbo.Usuario', 'MustChangePassword') IS NULL
|
||||
BEGIN
|
||||
ALTER TABLE dbo.Usuario
|
||||
ADD MustChangePassword BIT NOT NULL
|
||||
CONSTRAINT DF_Usuario_MustChangePassword DEFAULT(0);
|
||||
PRINT 'Column MustChangePassword added to dbo.Usuario.';
|
||||
END
|
||||
ELSE
|
||||
PRINT 'Column MustChangePassword already exists — skipping.';
|
||||
GO
|
||||
|
||||
-- Compound index for listado filtrado (Activo + Rol) and anti-lockout guard
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM sys.indexes
|
||||
WHERE name = 'IX_Usuario_Activo_Rol'
|
||||
AND object_id = OBJECT_ID('dbo.Usuario')
|
||||
)
|
||||
BEGIN
|
||||
CREATE INDEX IX_Usuario_Activo_Rol
|
||||
ON dbo.Usuario(Activo, Rol)
|
||||
INCLUDE (Id, Username, Email, UltimoLogin, FechaModificacion);
|
||||
PRINT 'Index IX_Usuario_Activo_Rol created.';
|
||||
END
|
||||
ELSE
|
||||
PRINT 'Index IX_Usuario_Activo_Rol already exists — skipping.';
|
||||
GO
|
||||
43
database/migrations/V009__activate_permisos_overrides.sql
Normal file
43
database/migrations/V009__activate_permisos_overrides.sql
Normal file
@@ -0,0 +1,43 @@
|
||||
-- V009__activate_permisos_overrides.sql
|
||||
-- Activates Usuario.PermisosJson as explicit overrides {grant, deny} on top of role permissions.
|
||||
-- Idempotent: safe to run multiple times.
|
||||
|
||||
SET QUOTED_IDENTIFIER ON;
|
||||
SET ANSI_NULLS ON;
|
||||
GO
|
||||
|
||||
-- 1. Drop old default constraint if it exists (handles any previous shape)
|
||||
IF EXISTS (
|
||||
SELECT 1 FROM sys.default_constraints
|
||||
WHERE name = 'DF_Usuario_Permisos'
|
||||
AND parent_object_id = OBJECT_ID('dbo.Usuario')
|
||||
)
|
||||
BEGIN
|
||||
ALTER TABLE dbo.Usuario DROP CONSTRAINT DF_Usuario_Permisos;
|
||||
PRINT 'Dropped DF_Usuario_Permisos.';
|
||||
END
|
||||
GO
|
||||
|
||||
-- 2. Re-add default constraint with canonical shape
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM sys.default_constraints
|
||||
WHERE name = 'DF_Usuario_Permisos'
|
||||
AND parent_object_id = OBJECT_ID('dbo.Usuario')
|
||||
)
|
||||
BEGIN
|
||||
ALTER TABLE dbo.Usuario
|
||||
ADD CONSTRAINT DF_Usuario_Permisos
|
||||
DEFAULT('{"grant":[],"deny":[]}') FOR PermisosJson;
|
||||
PRINT 'Added DF_Usuario_Permisos with new shape {"grant":[],"deny":[]}.';
|
||||
END
|
||||
GO
|
||||
|
||||
-- 3. Migrate legacy values to new canonical shape
|
||||
UPDATE dbo.Usuario
|
||||
SET PermisosJson = '{"grant":[],"deny":[]}'
|
||||
WHERE PermisosJson IN ('[]', '["*"]', '')
|
||||
OR PermisosJson IS NULL
|
||||
OR LTRIM(RTRIM(PermisosJson)) = '';
|
||||
|
||||
PRINT 'Migrated legacy PermisosJson rows to canonical shape.';
|
||||
GO
|
||||
183
database/migrations/V010_ROLLBACK.sql
Normal file
183
database/migrations/V010_ROLLBACK.sql
Normal file
@@ -0,0 +1,183 @@
|
||||
-- V010_ROLLBACK.sql
|
||||
-- Reversa de V010__audit_infrastructure.sql.
|
||||
--
|
||||
-- ⚠️ ADVERTENCIA: ejecutar este script ELIMINA toda la historia auditada.
|
||||
-- - dbo.AuditEvent y dbo.SecurityEvent se dropean (junto con datos).
|
||||
-- - History tables (Usuario_History, Rol_History, Permiso_History, RolPermiso_History) se dropean.
|
||||
-- - Particionamiento, filegroups y archivos físicos se desmontan.
|
||||
--
|
||||
-- Uso intended: ROLLBACK de emergencia en entornos NO-productivos.
|
||||
-- En prod futuro, este script NO se ejecuta: si hace falta revertir, se hace
|
||||
-- restore de backup previo a V010.
|
||||
|
||||
SET QUOTED_IDENTIFIER ON;
|
||||
SET ANSI_NULLS ON;
|
||||
SET NOCOUNT ON;
|
||||
GO
|
||||
|
||||
-- ═══════════════════════════════════════════════════════════════════════
|
||||
-- 1. Apagar SYSTEM_VERSIONING + remover columnas PERIOD en las 4 tablas
|
||||
-- ═══════════════════════════════════════════════════════════════════════
|
||||
|
||||
-- Usuario
|
||||
IF EXISTS (SELECT 1 FROM sys.tables WHERE object_id = OBJECT_ID('dbo.Usuario') AND temporal_type = 2)
|
||||
BEGIN
|
||||
ALTER TABLE dbo.Usuario SET (SYSTEM_VERSIONING = OFF);
|
||||
PRINT 'Usuario: SYSTEM_VERSIONING OFF.';
|
||||
END
|
||||
GO
|
||||
|
||||
IF EXISTS (SELECT 1 FROM sys.periods WHERE object_id = OBJECT_ID('dbo.Usuario'))
|
||||
BEGIN
|
||||
ALTER TABLE dbo.Usuario DROP PERIOD FOR SYSTEM_TIME;
|
||||
PRINT 'Usuario: PERIOD FOR SYSTEM_TIME dropped.';
|
||||
END
|
||||
GO
|
||||
|
||||
IF COL_LENGTH('dbo.Usuario', 'ValidFrom') IS NOT NULL
|
||||
BEGIN
|
||||
ALTER TABLE dbo.Usuario DROP CONSTRAINT IF EXISTS DF_Usuario_ValidFrom;
|
||||
ALTER TABLE dbo.Usuario DROP CONSTRAINT IF EXISTS DF_Usuario_ValidTo;
|
||||
ALTER TABLE dbo.Usuario DROP COLUMN ValidFrom, ValidTo;
|
||||
PRINT 'Usuario: ValidFrom/ValidTo dropped.';
|
||||
END
|
||||
GO
|
||||
|
||||
IF OBJECT_ID(N'dbo.Usuario_History', N'U') IS NOT NULL
|
||||
BEGIN
|
||||
DROP TABLE dbo.Usuario_History;
|
||||
PRINT 'Usuario_History dropped.';
|
||||
END
|
||||
GO
|
||||
|
||||
-- Rol
|
||||
IF EXISTS (SELECT 1 FROM sys.tables WHERE object_id = OBJECT_ID('dbo.Rol') AND temporal_type = 2)
|
||||
ALTER TABLE dbo.Rol SET (SYSTEM_VERSIONING = OFF);
|
||||
GO
|
||||
|
||||
IF EXISTS (SELECT 1 FROM sys.periods WHERE object_id = OBJECT_ID('dbo.Rol'))
|
||||
ALTER TABLE dbo.Rol DROP PERIOD FOR SYSTEM_TIME;
|
||||
GO
|
||||
|
||||
IF COL_LENGTH('dbo.Rol', 'ValidFrom') IS NOT NULL
|
||||
BEGIN
|
||||
ALTER TABLE dbo.Rol DROP CONSTRAINT IF EXISTS DF_Rol_ValidFrom;
|
||||
ALTER TABLE dbo.Rol DROP CONSTRAINT IF EXISTS DF_Rol_ValidTo;
|
||||
ALTER TABLE dbo.Rol DROP COLUMN ValidFrom, ValidTo;
|
||||
END
|
||||
GO
|
||||
|
||||
IF OBJECT_ID(N'dbo.Rol_History', N'U') IS NOT NULL DROP TABLE dbo.Rol_History;
|
||||
GO
|
||||
|
||||
-- Permiso
|
||||
IF EXISTS (SELECT 1 FROM sys.tables WHERE object_id = OBJECT_ID('dbo.Permiso') AND temporal_type = 2)
|
||||
ALTER TABLE dbo.Permiso SET (SYSTEM_VERSIONING = OFF);
|
||||
GO
|
||||
|
||||
IF EXISTS (SELECT 1 FROM sys.periods WHERE object_id = OBJECT_ID('dbo.Permiso'))
|
||||
ALTER TABLE dbo.Permiso DROP PERIOD FOR SYSTEM_TIME;
|
||||
GO
|
||||
|
||||
IF COL_LENGTH('dbo.Permiso', 'ValidFrom') IS NOT NULL
|
||||
BEGIN
|
||||
ALTER TABLE dbo.Permiso DROP CONSTRAINT IF EXISTS DF_Permiso_ValidFrom;
|
||||
ALTER TABLE dbo.Permiso DROP CONSTRAINT IF EXISTS DF_Permiso_ValidTo;
|
||||
ALTER TABLE dbo.Permiso DROP COLUMN ValidFrom, ValidTo;
|
||||
END
|
||||
GO
|
||||
|
||||
IF OBJECT_ID(N'dbo.Permiso_History', N'U') IS NOT NULL DROP TABLE dbo.Permiso_History;
|
||||
GO
|
||||
|
||||
-- RolPermiso
|
||||
IF EXISTS (SELECT 1 FROM sys.tables WHERE object_id = OBJECT_ID('dbo.RolPermiso') AND temporal_type = 2)
|
||||
ALTER TABLE dbo.RolPermiso SET (SYSTEM_VERSIONING = OFF);
|
||||
GO
|
||||
|
||||
IF EXISTS (SELECT 1 FROM sys.periods WHERE object_id = OBJECT_ID('dbo.RolPermiso'))
|
||||
ALTER TABLE dbo.RolPermiso DROP PERIOD FOR SYSTEM_TIME;
|
||||
GO
|
||||
|
||||
IF COL_LENGTH('dbo.RolPermiso', 'ValidFrom') IS NOT NULL
|
||||
BEGIN
|
||||
ALTER TABLE dbo.RolPermiso DROP CONSTRAINT IF EXISTS DF_RolPermiso_ValidFrom;
|
||||
ALTER TABLE dbo.RolPermiso DROP CONSTRAINT IF EXISTS DF_RolPermiso_ValidTo;
|
||||
ALTER TABLE dbo.RolPermiso DROP COLUMN ValidFrom, ValidTo;
|
||||
END
|
||||
GO
|
||||
|
||||
IF OBJECT_ID(N'dbo.RolPermiso_History', N'U') IS NOT NULL DROP TABLE dbo.RolPermiso_History;
|
||||
GO
|
||||
|
||||
-- ═══════════════════════════════════════════════════════════════════════
|
||||
-- 2. Drop AuditEvent + SecurityEvent
|
||||
-- ═══════════════════════════════════════════════════════════════════════
|
||||
|
||||
IF OBJECT_ID(N'dbo.AuditEvent', N'U') IS NOT NULL
|
||||
BEGIN
|
||||
DROP TABLE dbo.AuditEvent;
|
||||
PRINT 'Table dbo.AuditEvent dropped.';
|
||||
END
|
||||
GO
|
||||
|
||||
IF OBJECT_ID(N'dbo.SecurityEvent', N'U') IS NOT NULL
|
||||
BEGIN
|
||||
DROP TABLE dbo.SecurityEvent;
|
||||
PRINT 'Table dbo.SecurityEvent dropped.';
|
||||
END
|
||||
GO
|
||||
|
||||
-- ═══════════════════════════════════════════════════════════════════════
|
||||
-- 3. Drop partition schemes + functions
|
||||
-- ═══════════════════════════════════════════════════════════════════════
|
||||
|
||||
IF EXISTS (SELECT 1 FROM sys.partition_schemes WHERE name = 'ps_AuditEvent_Monthly')
|
||||
DROP PARTITION SCHEME ps_AuditEvent_Monthly;
|
||||
GO
|
||||
|
||||
IF EXISTS (SELECT 1 FROM sys.partition_functions WHERE name = 'pf_AuditEvent_Monthly')
|
||||
DROP PARTITION FUNCTION pf_AuditEvent_Monthly;
|
||||
GO
|
||||
|
||||
IF EXISTS (SELECT 1 FROM sys.partition_schemes WHERE name = 'ps_SecurityEvent_Monthly')
|
||||
DROP PARTITION SCHEME ps_SecurityEvent_Monthly;
|
||||
GO
|
||||
|
||||
IF EXISTS (SELECT 1 FROM sys.partition_functions WHERE name = 'pf_SecurityEvent_Monthly')
|
||||
DROP PARTITION FUNCTION pf_SecurityEvent_Monthly;
|
||||
GO
|
||||
|
||||
-- ═══════════════════════════════════════════════════════════════════════
|
||||
-- 4. Remover archivos físicos y filegroups
|
||||
-- ═══════════════════════════════════════════════════════════════════════
|
||||
|
||||
DECLARE @dbName NVARCHAR(128) = DB_NAME();
|
||||
DECLARE @hotLogical NVARCHAR(128) = @dbName + N'_AUDIT_HOT';
|
||||
DECLARE @coldLogical NVARCHAR(128) = @dbName + N'_AUDIT_COLD';
|
||||
DECLARE @sql NVARCHAR(MAX);
|
||||
|
||||
IF EXISTS (SELECT 1 FROM sys.database_files WHERE name = @hotLogical)
|
||||
BEGIN
|
||||
SET @sql = N'ALTER DATABASE CURRENT REMOVE FILE [' + @hotLogical + N'];';
|
||||
EXEC sp_executesql @sql;
|
||||
PRINT 'File ' + @hotLogical + ' removed.';
|
||||
END
|
||||
|
||||
IF EXISTS (SELECT 1 FROM sys.database_files WHERE name = @coldLogical)
|
||||
BEGIN
|
||||
SET @sql = N'ALTER DATABASE CURRENT REMOVE FILE [' + @coldLogical + N'];';
|
||||
EXEC sp_executesql @sql;
|
||||
PRINT 'File ' + @coldLogical + ' removed.';
|
||||
END
|
||||
|
||||
IF EXISTS (SELECT 1 FROM sys.filegroups WHERE name = 'AUDIT_HOT')
|
||||
EXEC sp_executesql N'ALTER DATABASE CURRENT REMOVE FILEGROUP AUDIT_HOT;';
|
||||
|
||||
IF EXISTS (SELECT 1 FROM sys.filegroups WHERE name = 'AUDIT_COLD')
|
||||
EXEC sp_executesql N'ALTER DATABASE CURRENT REMOVE FILEGROUP AUDIT_COLD;';
|
||||
GO
|
||||
|
||||
PRINT '';
|
||||
PRINT 'V010 rolled back. Audit infrastructure removed. All audit history is permanently LOST.';
|
||||
GO
|
||||
434
database/migrations/V010__audit_infrastructure.sql
Normal file
434
database/migrations/V010__audit_infrastructure.sql
Normal file
@@ -0,0 +1,434 @@
|
||||
-- V010__audit_infrastructure.sql
|
||||
-- UDT-010: Infraestructura de Auditoría y Trazabilidad (Fase 0.5 — transversal).
|
||||
--
|
||||
-- Cambios:
|
||||
-- 1. Filegroups AUDIT_HOT y AUDIT_COLD (archivos físicos en default data path).
|
||||
-- 2. Partition functions + schemes mensuales (RANGE RIGHT) para AuditEvent y SecurityEvent.
|
||||
-- 3. dbo.AuditEvent particionada + 4 índices + CHECK constraints.
|
||||
-- 4. dbo.SecurityEvent particionada + 3 índices + CHECK constraints.
|
||||
-- 5. SYSTEM_VERSIONING en dbo.Usuario, dbo.Rol, dbo.Permiso, dbo.RolPermiso
|
||||
-- con HISTORY_RETENTION_PERIOD = 10 YEARS y PAGE compression en history tables.
|
||||
--
|
||||
-- Source of truth del diseño: Obsidian/02-ARQUITECTURA-y-TECH-STACK/2.5 📋 Auditoría.md
|
||||
-- Idempotente: seguro para re-ejecutar.
|
||||
-- Reversa: V010_ROLLBACK.sql (pierde TODA la historia auditada).
|
||||
-- Run on: SIGCM2 (dev) y SIGCM2_Test (integration tests). Para producción futuro,
|
||||
-- revisar database/README.md (ventana de mantenimiento + backup previo).
|
||||
|
||||
SET QUOTED_IDENTIFIER ON;
|
||||
SET ANSI_NULLS ON;
|
||||
SET NOCOUNT ON;
|
||||
GO
|
||||
|
||||
-- ═══════════════════════════════════════════════════════════════════════
|
||||
-- 1. FILEGROUPS + ARCHIVOS FÍSICOS
|
||||
-- ═══════════════════════════════════════════════════════════════════════
|
||||
-- Usamos el default data path del server + DB_NAME como prefijo lógico,
|
||||
-- así SIGCM2 y SIGCM2_Test coexisten sin colisión de logical file names.
|
||||
|
||||
DECLARE @dbName NVARCHAR(128) = DB_NAME();
|
||||
DECLARE @dataPath NVARCHAR(260) = CAST(SERVERPROPERTY('InstanceDefaultDataPath') AS NVARCHAR(260));
|
||||
DECLARE @hotLogical NVARCHAR(128) = @dbName + N'_AUDIT_HOT';
|
||||
DECLARE @coldLogical NVARCHAR(128) = @dbName + N'_AUDIT_COLD';
|
||||
DECLARE @hotPhysical NVARCHAR(260) = @dataPath + @hotLogical + N'.ndf';
|
||||
DECLARE @coldPhysical NVARCHAR(260) = @dataPath + @coldLogical + N'.ndf';
|
||||
DECLARE @sql NVARCHAR(MAX);
|
||||
|
||||
-- Filegroup AUDIT_HOT
|
||||
IF NOT EXISTS (SELECT 1 FROM sys.filegroups WHERE name = 'AUDIT_HOT')
|
||||
BEGIN
|
||||
EXEC sp_executesql N'ALTER DATABASE CURRENT ADD FILEGROUP AUDIT_HOT;';
|
||||
PRINT 'Filegroup AUDIT_HOT created.';
|
||||
END
|
||||
ELSE
|
||||
PRINT 'Filegroup AUDIT_HOT already exists — skip.';
|
||||
|
||||
IF NOT EXISTS (SELECT 1 FROM sys.database_files WHERE name = @hotLogical)
|
||||
BEGIN
|
||||
SET @sql = N'ALTER DATABASE CURRENT ADD FILE (
|
||||
NAME = N''' + @hotLogical + N''',
|
||||
FILENAME = N''' + @hotPhysical + N''',
|
||||
SIZE = 64MB,
|
||||
FILEGROWTH = 64MB
|
||||
) TO FILEGROUP AUDIT_HOT;';
|
||||
EXEC sp_executesql @sql;
|
||||
PRINT 'File ' + @hotLogical + ' added to filegroup AUDIT_HOT.';
|
||||
END
|
||||
ELSE
|
||||
PRINT 'File ' + @hotLogical + ' already exists — skip.';
|
||||
|
||||
-- Filegroup AUDIT_COLD
|
||||
IF NOT EXISTS (SELECT 1 FROM sys.filegroups WHERE name = 'AUDIT_COLD')
|
||||
BEGIN
|
||||
EXEC sp_executesql N'ALTER DATABASE CURRENT ADD FILEGROUP AUDIT_COLD;';
|
||||
PRINT 'Filegroup AUDIT_COLD created.';
|
||||
END
|
||||
ELSE
|
||||
PRINT 'Filegroup AUDIT_COLD already exists — skip.';
|
||||
|
||||
IF NOT EXISTS (SELECT 1 FROM sys.database_files WHERE name = @coldLogical)
|
||||
BEGIN
|
||||
SET @sql = N'ALTER DATABASE CURRENT ADD FILE (
|
||||
NAME = N''' + @coldLogical + N''',
|
||||
FILENAME = N''' + @coldPhysical + N''',
|
||||
SIZE = 64MB,
|
||||
FILEGROWTH = 64MB
|
||||
) TO FILEGROUP AUDIT_COLD;';
|
||||
EXEC sp_executesql @sql;
|
||||
PRINT 'File ' + @coldLogical + ' added to filegroup AUDIT_COLD.';
|
||||
END
|
||||
ELSE
|
||||
PRINT 'File ' + @coldLogical + ' already exists — skip.';
|
||||
GO
|
||||
|
||||
-- ═══════════════════════════════════════════════════════════════════════
|
||||
-- 2. PARTITION FUNCTIONS + SCHEMES (mensuales, RANGE RIGHT)
|
||||
-- ═══════════════════════════════════════════════════════════════════════
|
||||
-- Boundaries iniciales: 14 valores de 2026-01-01 a 2027-02-01 → 15 particiones.
|
||||
-- AuditPartitionManagerJob (B11) extiende mes a mes automáticamente.
|
||||
|
||||
IF NOT EXISTS (SELECT 1 FROM sys.partition_functions WHERE name = 'pf_AuditEvent_Monthly')
|
||||
BEGIN
|
||||
CREATE PARTITION FUNCTION pf_AuditEvent_Monthly (DATETIME2(3))
|
||||
AS RANGE RIGHT FOR VALUES (
|
||||
'2026-01-01T00:00:00.000', '2026-02-01T00:00:00.000', '2026-03-01T00:00:00.000',
|
||||
'2026-04-01T00:00:00.000', '2026-05-01T00:00:00.000', '2026-06-01T00:00:00.000',
|
||||
'2026-07-01T00:00:00.000', '2026-08-01T00:00:00.000', '2026-09-01T00:00:00.000',
|
||||
'2026-10-01T00:00:00.000', '2026-11-01T00:00:00.000', '2026-12-01T00:00:00.000',
|
||||
'2027-01-01T00:00:00.000', '2027-02-01T00:00:00.000'
|
||||
);
|
||||
PRINT 'Partition function pf_AuditEvent_Monthly created.';
|
||||
END
|
||||
ELSE
|
||||
PRINT 'Partition function pf_AuditEvent_Monthly already exists — skip.';
|
||||
GO
|
||||
|
||||
IF NOT EXISTS (SELECT 1 FROM sys.partition_schemes WHERE name = 'ps_AuditEvent_Monthly')
|
||||
BEGIN
|
||||
CREATE PARTITION SCHEME ps_AuditEvent_Monthly
|
||||
AS PARTITION pf_AuditEvent_Monthly ALL TO ([AUDIT_HOT]);
|
||||
PRINT 'Partition scheme ps_AuditEvent_Monthly created.';
|
||||
END
|
||||
ELSE
|
||||
PRINT 'Partition scheme ps_AuditEvent_Monthly already exists — skip.';
|
||||
GO
|
||||
|
||||
IF NOT EXISTS (SELECT 1 FROM sys.partition_functions WHERE name = 'pf_SecurityEvent_Monthly')
|
||||
BEGIN
|
||||
CREATE PARTITION FUNCTION pf_SecurityEvent_Monthly (DATETIME2(3))
|
||||
AS RANGE RIGHT FOR VALUES (
|
||||
'2026-01-01T00:00:00.000', '2026-02-01T00:00:00.000', '2026-03-01T00:00:00.000',
|
||||
'2026-04-01T00:00:00.000', '2026-05-01T00:00:00.000', '2026-06-01T00:00:00.000',
|
||||
'2026-07-01T00:00:00.000', '2026-08-01T00:00:00.000', '2026-09-01T00:00:00.000',
|
||||
'2026-10-01T00:00:00.000', '2026-11-01T00:00:00.000', '2026-12-01T00:00:00.000',
|
||||
'2027-01-01T00:00:00.000', '2027-02-01T00:00:00.000'
|
||||
);
|
||||
PRINT 'Partition function pf_SecurityEvent_Monthly created.';
|
||||
END
|
||||
ELSE
|
||||
PRINT 'Partition function pf_SecurityEvent_Monthly already exists — skip.';
|
||||
GO
|
||||
|
||||
IF NOT EXISTS (SELECT 1 FROM sys.partition_schemes WHERE name = 'ps_SecurityEvent_Monthly')
|
||||
BEGIN
|
||||
CREATE PARTITION SCHEME ps_SecurityEvent_Monthly
|
||||
AS PARTITION pf_SecurityEvent_Monthly ALL TO ([AUDIT_HOT]);
|
||||
PRINT 'Partition scheme ps_SecurityEvent_Monthly created.';
|
||||
END
|
||||
ELSE
|
||||
PRINT 'Partition scheme ps_SecurityEvent_Monthly already exists — skip.';
|
||||
GO
|
||||
|
||||
-- ═══════════════════════════════════════════════════════════════════════
|
||||
-- 3. dbo.AuditEvent (eventos de dominio, retention 10 años)
|
||||
-- ═══════════════════════════════════════════════════════════════════════
|
||||
|
||||
IF OBJECT_ID(N'dbo.AuditEvent', N'U') IS NULL
|
||||
BEGIN
|
||||
CREATE TABLE dbo.AuditEvent (
|
||||
Id BIGINT IDENTITY(1,1) NOT NULL,
|
||||
OccurredAt DATETIME2(3) NOT NULL CONSTRAINT DF_AuditEvent_OccurredAt DEFAULT(SYSUTCDATETIME()),
|
||||
ActorUserId INT NULL, -- NULL solo para eventos del sistema (jobs)
|
||||
ActorRoleId INT NULL, -- rol efectivo al momento del evento (denormalizado)
|
||||
Action VARCHAR(100) NOT NULL, -- "usuario.create", "cliente.deactivate"
|
||||
TargetType VARCHAR(50) NOT NULL, -- "Usuario", "Cliente", "Factura"
|
||||
TargetId VARCHAR(100) NOT NULL, -- PK del target como string (soporta INT/GUID)
|
||||
CorrelationId UNIQUEIDENTIFIER NULL, -- linkea eventos de una misma operación
|
||||
IpAddress VARCHAR(45) NULL, -- IPv4/IPv6
|
||||
UserAgent VARCHAR(500) NULL,
|
||||
Metadata NVARCHAR(MAX) NULL, -- JSON libre (ya sanitizado por la app)
|
||||
CONSTRAINT PK_AuditEvent PRIMARY KEY CLUSTERED (OccurredAt, Id)
|
||||
ON ps_AuditEvent_Monthly(OccurredAt),
|
||||
CONSTRAINT CK_AuditEvent_Action CHECK (Action LIKE '%.%'),
|
||||
CONSTRAINT CK_AuditEvent_Metadata CHECK (Metadata IS NULL OR ISJSON(Metadata) = 1)
|
||||
) ON ps_AuditEvent_Monthly(OccurredAt);
|
||||
PRINT 'Table dbo.AuditEvent created (partitioned monthly on OccurredAt).';
|
||||
END
|
||||
ELSE
|
||||
PRINT 'Table dbo.AuditEvent already exists — skip.';
|
||||
GO
|
||||
|
||||
-- Índices (cubren 95% de queries: actor / target / action / correlation)
|
||||
IF NOT EXISTS (SELECT 1 FROM sys.indexes WHERE name = 'IX_AuditEvent_Actor' AND object_id = OBJECT_ID('dbo.AuditEvent'))
|
||||
BEGIN
|
||||
CREATE NONCLUSTERED INDEX IX_AuditEvent_Actor
|
||||
ON dbo.AuditEvent(ActorUserId, OccurredAt DESC)
|
||||
INCLUDE (Action, TargetType, TargetId, CorrelationId)
|
||||
WITH (DATA_COMPRESSION = PAGE)
|
||||
ON ps_AuditEvent_Monthly(OccurredAt);
|
||||
PRINT 'Index IX_AuditEvent_Actor created.';
|
||||
END
|
||||
GO
|
||||
|
||||
IF NOT EXISTS (SELECT 1 FROM sys.indexes WHERE name = 'IX_AuditEvent_Target' AND object_id = OBJECT_ID('dbo.AuditEvent'))
|
||||
BEGIN
|
||||
CREATE NONCLUSTERED INDEX IX_AuditEvent_Target
|
||||
ON dbo.AuditEvent(TargetType, TargetId, OccurredAt DESC)
|
||||
INCLUDE (ActorUserId, Action, CorrelationId)
|
||||
WITH (DATA_COMPRESSION = PAGE)
|
||||
ON ps_AuditEvent_Monthly(OccurredAt);
|
||||
PRINT 'Index IX_AuditEvent_Target created.';
|
||||
END
|
||||
GO
|
||||
|
||||
IF NOT EXISTS (SELECT 1 FROM sys.indexes WHERE name = 'IX_AuditEvent_Action' AND object_id = OBJECT_ID('dbo.AuditEvent'))
|
||||
BEGIN
|
||||
CREATE NONCLUSTERED INDEX IX_AuditEvent_Action
|
||||
ON dbo.AuditEvent(Action, OccurredAt DESC)
|
||||
INCLUDE (ActorUserId, TargetType, TargetId)
|
||||
WITH (DATA_COMPRESSION = PAGE)
|
||||
ON ps_AuditEvent_Monthly(OccurredAt);
|
||||
PRINT 'Index IX_AuditEvent_Action created.';
|
||||
END
|
||||
GO
|
||||
|
||||
IF NOT EXISTS (SELECT 1 FROM sys.indexes WHERE name = 'IX_AuditEvent_Correlation' AND object_id = OBJECT_ID('dbo.AuditEvent'))
|
||||
BEGIN
|
||||
CREATE NONCLUSTERED INDEX IX_AuditEvent_Correlation
|
||||
ON dbo.AuditEvent(CorrelationId)
|
||||
WHERE CorrelationId IS NOT NULL
|
||||
ON ps_AuditEvent_Monthly(OccurredAt);
|
||||
PRINT 'Index IX_AuditEvent_Correlation (filtered) created.';
|
||||
END
|
||||
GO
|
||||
|
||||
-- ═══════════════════════════════════════════════════════════════════════
|
||||
-- 4. dbo.SecurityEvent (eventos de seguridad, retention 5 años)
|
||||
-- ═══════════════════════════════════════════════════════════════════════
|
||||
|
||||
IF OBJECT_ID(N'dbo.SecurityEvent', N'U') IS NULL
|
||||
BEGIN
|
||||
CREATE TABLE dbo.SecurityEvent (
|
||||
Id BIGINT IDENTITY(1,1) NOT NULL,
|
||||
OccurredAt DATETIME2(3) NOT NULL CONSTRAINT DF_SecurityEvent_OccurredAt DEFAULT(SYSUTCDATETIME()),
|
||||
ActorUserId INT NULL, -- NULL si login falló y user no existe
|
||||
AttemptedUsername VARCHAR(256) NULL, -- para login failures
|
||||
SessionId UNIQUEIDENTIFIER NULL,
|
||||
Action VARCHAR(100) NOT NULL, -- "login", "logout", "refresh.reuse_detected", "permission.denied"
|
||||
Result VARCHAR(20) NOT NULL, -- "success" | "failure"
|
||||
FailureReason VARCHAR(200) NULL, -- "invalid_password", "account_locked"
|
||||
IpAddress VARCHAR(45) NULL,
|
||||
UserAgent VARCHAR(500) NULL,
|
||||
Metadata NVARCHAR(MAX) NULL,
|
||||
CONSTRAINT PK_SecurityEvent PRIMARY KEY CLUSTERED (OccurredAt, Id)
|
||||
ON ps_SecurityEvent_Monthly(OccurredAt),
|
||||
CONSTRAINT CK_SecurityEvent_Result CHECK (Result IN ('success','failure')),
|
||||
CONSTRAINT CK_SecurityEvent_Metadata CHECK (Metadata IS NULL OR ISJSON(Metadata) = 1)
|
||||
) ON ps_SecurityEvent_Monthly(OccurredAt);
|
||||
PRINT 'Table dbo.SecurityEvent created (partitioned monthly on OccurredAt).';
|
||||
END
|
||||
ELSE
|
||||
PRINT 'Table dbo.SecurityEvent already exists — skip.';
|
||||
GO
|
||||
|
||||
IF NOT EXISTS (SELECT 1 FROM sys.indexes WHERE name = 'IX_SecurityEvent_Actor' AND object_id = OBJECT_ID('dbo.SecurityEvent'))
|
||||
BEGIN
|
||||
CREATE NONCLUSTERED INDEX IX_SecurityEvent_Actor
|
||||
ON dbo.SecurityEvent(ActorUserId, OccurredAt DESC)
|
||||
INCLUDE (Action, Result, SessionId)
|
||||
WITH (DATA_COMPRESSION = PAGE)
|
||||
ON ps_SecurityEvent_Monthly(OccurredAt);
|
||||
PRINT 'Index IX_SecurityEvent_Actor created.';
|
||||
END
|
||||
GO
|
||||
|
||||
IF NOT EXISTS (SELECT 1 FROM sys.indexes WHERE name = 'IX_SecurityEvent_Action_Result' AND object_id = OBJECT_ID('dbo.SecurityEvent'))
|
||||
BEGIN
|
||||
CREATE NONCLUSTERED INDEX IX_SecurityEvent_Action_Result
|
||||
ON dbo.SecurityEvent(Action, Result, OccurredAt DESC)
|
||||
INCLUDE (ActorUserId, IpAddress)
|
||||
WITH (DATA_COMPRESSION = PAGE)
|
||||
ON ps_SecurityEvent_Monthly(OccurredAt);
|
||||
PRINT 'Index IX_SecurityEvent_Action_Result created.';
|
||||
END
|
||||
GO
|
||||
|
||||
IF NOT EXISTS (SELECT 1 FROM sys.indexes WHERE name = 'IX_SecurityEvent_Ip_Failure' AND object_id = OBJECT_ID('dbo.SecurityEvent'))
|
||||
BEGIN
|
||||
CREATE NONCLUSTERED INDEX IX_SecurityEvent_Ip_Failure
|
||||
ON dbo.SecurityEvent(IpAddress, OccurredAt DESC)
|
||||
WHERE Result = 'failure'
|
||||
ON ps_SecurityEvent_Monthly(OccurredAt);
|
||||
PRINT 'Index IX_SecurityEvent_Ip_Failure (filtered) created.';
|
||||
END
|
||||
GO
|
||||
|
||||
-- ═══════════════════════════════════════════════════════════════════════
|
||||
-- 5. SYSTEM_VERSIONING — Usuario, Rol, Permiso, RolPermiso
|
||||
-- ═══════════════════════════════════════════════════════════════════════
|
||||
-- Patrón: (a) agregar PERIOD FOR SYSTEM_TIME con columnas HIDDEN,
|
||||
-- (b) activar SYSTEM_VERSIONING con HISTORY_RETENTION_PERIOD 10 YEARS,
|
||||
-- (c) rebuild history con PAGE compression.
|
||||
-- Tablas con datos: los registros existentes reciben ValidFrom = instante del ALTER.
|
||||
|
||||
-- ─── Usuario ───────────────────────────────────────────────────────────
|
||||
IF COL_LENGTH('dbo.Usuario', 'ValidFrom') IS NULL
|
||||
BEGIN
|
||||
ALTER TABLE dbo.Usuario
|
||||
ADD
|
||||
ValidFrom DATETIME2(3) GENERATED ALWAYS AS ROW START HIDDEN NOT NULL
|
||||
CONSTRAINT DF_Usuario_ValidFrom DEFAULT(SYSUTCDATETIME()),
|
||||
ValidTo DATETIME2(3) GENERATED ALWAYS AS ROW END HIDDEN NOT NULL
|
||||
CONSTRAINT DF_Usuario_ValidTo DEFAULT(CONVERT(DATETIME2(3), '9999-12-31 23:59:59.999')),
|
||||
PERIOD FOR SYSTEM_TIME (ValidFrom, ValidTo);
|
||||
PRINT 'Usuario: PERIOD FOR SYSTEM_TIME added.';
|
||||
END
|
||||
GO
|
||||
|
||||
IF NOT EXISTS (SELECT 1 FROM sys.tables WHERE object_id = OBJECT_ID('dbo.Usuario') AND temporal_type = 2)
|
||||
BEGIN
|
||||
ALTER TABLE dbo.Usuario
|
||||
SET (SYSTEM_VERSIONING = ON (
|
||||
HISTORY_TABLE = dbo.Usuario_History,
|
||||
HISTORY_RETENTION_PERIOD = 10 YEARS
|
||||
));
|
||||
PRINT 'Usuario: SYSTEM_VERSIONING = ON (history: dbo.Usuario_History, retention: 10 years).';
|
||||
END
|
||||
ELSE
|
||||
PRINT 'Usuario: SYSTEM_VERSIONING already ON — skip.';
|
||||
GO
|
||||
|
||||
IF EXISTS (SELECT 1 FROM sys.tables WHERE name = 'Usuario_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 = 'Usuario_History' AND p.data_compression = 2
|
||||
)
|
||||
BEGIN
|
||||
ALTER TABLE dbo.Usuario_History REBUILD WITH (DATA_COMPRESSION = PAGE);
|
||||
PRINT 'Usuario_History: rebuilt with PAGE compression.';
|
||||
END
|
||||
GO
|
||||
|
||||
-- ─── Rol ───────────────────────────────────────────────────────────────
|
||||
IF COL_LENGTH('dbo.Rol', 'ValidFrom') IS NULL
|
||||
BEGIN
|
||||
ALTER TABLE dbo.Rol
|
||||
ADD
|
||||
ValidFrom DATETIME2(3) GENERATED ALWAYS AS ROW START HIDDEN NOT NULL
|
||||
CONSTRAINT DF_Rol_ValidFrom DEFAULT(SYSUTCDATETIME()),
|
||||
ValidTo DATETIME2(3) GENERATED ALWAYS AS ROW END HIDDEN NOT NULL
|
||||
CONSTRAINT DF_Rol_ValidTo DEFAULT(CONVERT(DATETIME2(3), '9999-12-31 23:59:59.999')),
|
||||
PERIOD FOR SYSTEM_TIME (ValidFrom, ValidTo);
|
||||
PRINT 'Rol: PERIOD FOR SYSTEM_TIME added.';
|
||||
END
|
||||
GO
|
||||
|
||||
IF NOT EXISTS (SELECT 1 FROM sys.tables WHERE object_id = OBJECT_ID('dbo.Rol') AND temporal_type = 2)
|
||||
BEGIN
|
||||
ALTER TABLE dbo.Rol
|
||||
SET (SYSTEM_VERSIONING = ON (
|
||||
HISTORY_TABLE = dbo.Rol_History,
|
||||
HISTORY_RETENTION_PERIOD = 10 YEARS
|
||||
));
|
||||
PRINT 'Rol: SYSTEM_VERSIONING = ON.';
|
||||
END
|
||||
GO
|
||||
|
||||
IF EXISTS (SELECT 1 FROM sys.tables WHERE name = 'Rol_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 = 'Rol_History' AND p.data_compression = 2
|
||||
)
|
||||
BEGIN
|
||||
ALTER TABLE dbo.Rol_History REBUILD WITH (DATA_COMPRESSION = PAGE);
|
||||
PRINT 'Rol_History: rebuilt with PAGE compression.';
|
||||
END
|
||||
GO
|
||||
|
||||
-- ─── Permiso ───────────────────────────────────────────────────────────
|
||||
IF COL_LENGTH('dbo.Permiso', 'ValidFrom') IS NULL
|
||||
BEGIN
|
||||
ALTER TABLE dbo.Permiso
|
||||
ADD
|
||||
ValidFrom DATETIME2(3) GENERATED ALWAYS AS ROW START HIDDEN NOT NULL
|
||||
CONSTRAINT DF_Permiso_ValidFrom DEFAULT(SYSUTCDATETIME()),
|
||||
ValidTo DATETIME2(3) GENERATED ALWAYS AS ROW END HIDDEN NOT NULL
|
||||
CONSTRAINT DF_Permiso_ValidTo DEFAULT(CONVERT(DATETIME2(3), '9999-12-31 23:59:59.999')),
|
||||
PERIOD FOR SYSTEM_TIME (ValidFrom, ValidTo);
|
||||
PRINT 'Permiso: PERIOD FOR SYSTEM_TIME added.';
|
||||
END
|
||||
GO
|
||||
|
||||
IF NOT EXISTS (SELECT 1 FROM sys.tables WHERE object_id = OBJECT_ID('dbo.Permiso') AND temporal_type = 2)
|
||||
BEGIN
|
||||
ALTER TABLE dbo.Permiso
|
||||
SET (SYSTEM_VERSIONING = ON (
|
||||
HISTORY_TABLE = dbo.Permiso_History,
|
||||
HISTORY_RETENTION_PERIOD = 10 YEARS
|
||||
));
|
||||
PRINT 'Permiso: SYSTEM_VERSIONING = ON.';
|
||||
END
|
||||
GO
|
||||
|
||||
IF EXISTS (SELECT 1 FROM sys.tables WHERE name = 'Permiso_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 = 'Permiso_History' AND p.data_compression = 2
|
||||
)
|
||||
BEGIN
|
||||
ALTER TABLE dbo.Permiso_History REBUILD WITH (DATA_COMPRESSION = PAGE);
|
||||
PRINT 'Permiso_History: rebuilt with PAGE compression.';
|
||||
END
|
||||
GO
|
||||
|
||||
-- ─── RolPermiso ────────────────────────────────────────────────────────
|
||||
IF COL_LENGTH('dbo.RolPermiso', 'ValidFrom') IS NULL
|
||||
BEGIN
|
||||
ALTER TABLE dbo.RolPermiso
|
||||
ADD
|
||||
ValidFrom DATETIME2(3) GENERATED ALWAYS AS ROW START HIDDEN NOT NULL
|
||||
CONSTRAINT DF_RolPermiso_ValidFrom DEFAULT(SYSUTCDATETIME()),
|
||||
ValidTo DATETIME2(3) GENERATED ALWAYS AS ROW END HIDDEN NOT NULL
|
||||
CONSTRAINT DF_RolPermiso_ValidTo DEFAULT(CONVERT(DATETIME2(3), '9999-12-31 23:59:59.999')),
|
||||
PERIOD FOR SYSTEM_TIME (ValidFrom, ValidTo);
|
||||
PRINT 'RolPermiso: PERIOD FOR SYSTEM_TIME added.';
|
||||
END
|
||||
GO
|
||||
|
||||
IF NOT EXISTS (SELECT 1 FROM sys.tables WHERE object_id = OBJECT_ID('dbo.RolPermiso') AND temporal_type = 2)
|
||||
BEGIN
|
||||
ALTER TABLE dbo.RolPermiso
|
||||
SET (SYSTEM_VERSIONING = ON (
|
||||
HISTORY_TABLE = dbo.RolPermiso_History,
|
||||
HISTORY_RETENTION_PERIOD = 10 YEARS
|
||||
));
|
||||
PRINT 'RolPermiso: SYSTEM_VERSIONING = ON.';
|
||||
END
|
||||
GO
|
||||
|
||||
IF EXISTS (SELECT 1 FROM sys.tables WHERE name = 'RolPermiso_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 = 'RolPermiso_History' AND p.data_compression = 2
|
||||
)
|
||||
BEGIN
|
||||
ALTER TABLE dbo.RolPermiso_History REBUILD WITH (DATA_COMPRESSION = PAGE);
|
||||
PRINT 'RolPermiso_History: rebuilt with PAGE compression.';
|
||||
END
|
||||
GO
|
||||
|
||||
PRINT '';
|
||||
PRINT 'V010 applied successfully — audit infrastructure + temporal tables active.';
|
||||
PRINT 'Next: runs in B2 onwards (Application.Audit abstractions).';
|
||||
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
|
||||
134
docs/smoke-test-udt-001.md
Normal file
134
docs/smoke-test-udt-001.md
Normal file
@@ -0,0 +1,134 @@
|
||||
# Smoke Test — UDT-001 Login
|
||||
|
||||
Manual checklist para verificar la integración completa del flujo de login.
|
||||
|
||||
## Pre-requisitos
|
||||
|
||||
- SQL Server TECNICA3 con base `SIGCM2` y seed admin ejecutado (`database/seeds/S001__seed_admin.sql`)
|
||||
- Claves RSA generadas: `scripts/generate-keys.ps1` ya corrido
|
||||
- `src/api/SIGCM2.Api/appsettings.Development.json` configurado con connection string y rutas de claves
|
||||
- Node.js 18+ instalado
|
||||
- .NET 10 SDK instalado
|
||||
|
||||
## Pasos
|
||||
|
||||
### 1. Arrancar el backend
|
||||
|
||||
Abrir Terminal 1 en la raíz del repositorio:
|
||||
|
||||
```bash
|
||||
dotnet run --project src/api/SIGCM2.Api
|
||||
```
|
||||
|
||||
Verificar que la consola muestre algo similar a:
|
||||
|
||||
```
|
||||
Now listening on: http://localhost:5000
|
||||
Application started. Press Ctrl+C to shut down.
|
||||
```
|
||||
|
||||
### 2. Arrancar el frontend
|
||||
|
||||
Abrir Terminal 2:
|
||||
|
||||
```bash
|
||||
cd src/web
|
||||
npm run dev
|
||||
```
|
||||
|
||||
Verificar que la consola muestre:
|
||||
|
||||
```
|
||||
VITE v8.x.x ready in Xms
|
||||
|
||||
➜ Local: http://localhost:5173/
|
||||
```
|
||||
|
||||
### 3. Verificar redirect a /login
|
||||
|
||||
- Abrir `http://localhost:5173` en el navegador
|
||||
- Debe redirigir automáticamente a `http://localhost:5173/login`
|
||||
- Debe mostrar el formulario de login con campos **Usuario** y **Contraseña**
|
||||
|
||||
**Esperado**: Formulario visible, sin errores en consola del navegador.
|
||||
|
||||
### 4. Login con credenciales válidas
|
||||
|
||||
- Ingresar `admin` en el campo **Usuario**
|
||||
- Ingresar `@Diego550@` en el campo **Contraseña**
|
||||
- Hacer click en **Ingresar**
|
||||
|
||||
**Esperado**: Botón se deshabilita brevemente mientras carga.
|
||||
|
||||
### 5. Verificar Network tab — POST /api/v1/auth/login
|
||||
|
||||
- Abrir DevTools → pestaña **Network**
|
||||
- Buscar la request `POST /api/v1/auth/login`
|
||||
- Verificar:
|
||||
- Status: `200 OK`
|
||||
- Response body contiene: `accessToken`, `refreshToken`, `expiresIn`, `usuario`
|
||||
- `usuario.username` = `"admin"`, `usuario.rol` = `"admin"`
|
||||
|
||||
**Esperado**: Respuesta 200 con JWT válido.
|
||||
|
||||
### 6. Verificar LocalStorage — auth-storage
|
||||
|
||||
- DevTools → pestaña **Application** → Storage → Local Storage → `http://localhost:5173`
|
||||
- Buscar clave `auth-storage`
|
||||
- Verificar que el JSON contenga:
|
||||
```json
|
||||
{
|
||||
"state": {
|
||||
"user": { "username": "admin", "rol": "admin", ... },
|
||||
"accessToken": "eyJ..."
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Esperado**: Token y datos de usuario persistidos correctamente.
|
||||
|
||||
### 7. Verificar redirect a Dashboard
|
||||
|
||||
- Luego del login exitoso, la URL debe cambiar a `http://localhost:5173/`
|
||||
- Debe mostrarse: **"SIG-CM2 — Dashboard — Bienvenido al SIG-CM2."**
|
||||
|
||||
**Esperado**: Placeholder de Dashboard visible.
|
||||
|
||||
### 8. Verificar firma JWT en jwt.io
|
||||
|
||||
- Copiar el valor de `accessToken` del LocalStorage
|
||||
- Abrir [https://jwt.io](https://jwt.io)
|
||||
- Pegar el token en el campo "Encoded"
|
||||
- En "VERIFY SIGNATURE" → sección "Public Key or Certificate": pegar el contenido de `src/api/SIGCM2.Api/keys/public.pem`
|
||||
- Verificar:
|
||||
- Header: `"alg": "RS256"`
|
||||
- Payload contiene: `sub`, `name` (= `"admin"`), `rol` (= `"admin"`), `permisos` (= `["*"]`), `iss`, `aud`, `exp`
|
||||
- Footer muestra: **"Signature Verified"** (fondo azul)
|
||||
|
||||
**Esperado**: Firma RS256 válida, claims correctos.
|
||||
|
||||
### 9. Probar login fallido
|
||||
|
||||
- Volver a `http://localhost:5173/login` (o hacer logout si hubiera botón)
|
||||
- Ingresar `admin` / `wrongpass`
|
||||
- Hacer click en **Ingresar**
|
||||
- Verificar en **Network**: `POST /api/v1/auth/login` → Status `401`
|
||||
- Verificar en la UI: mensaje de error visible con texto **"Credenciales inválidas"** (sin stack trace)
|
||||
|
||||
**Esperado**: Error visible en UI, sin exposición de detalles internos.
|
||||
|
||||
---
|
||||
|
||||
## Resultado esperado global
|
||||
|
||||
| Paso | Resultado |
|
||||
|------|-----------|
|
||||
| 1. Backend arranca en :5000 | ✅ / ❌ |
|
||||
| 2. Frontend arranca en :5173 | ✅ / ❌ |
|
||||
| 3. Redirect a /login | ✅ / ❌ |
|
||||
| 4. Login con admin/@Diego550@ | ✅ / ❌ |
|
||||
| 5. Network: POST 200 + JWT | ✅ / ❌ |
|
||||
| 6. LocalStorage: auth-storage con token | ✅ / ❌ |
|
||||
| 7. Redirect a / Dashboard | ✅ / ❌ |
|
||||
| 8. JWT verificado en jwt.io (RS256) | ✅ / ❌ |
|
||||
| 9. Login fallido: error en UI, 401 en Network | ✅ / ❌ |
|
||||
108
docs/smoke-test-udt-002.md
Normal file
108
docs/smoke-test-udt-002.md
Normal file
@@ -0,0 +1,108 @@
|
||||
# Smoke Test — UDT-002: Logout + Refresh Token
|
||||
|
||||
**Branch**: feature/UDT-002
|
||||
**Fecha**: 2026-04-14
|
||||
**Prerequisito**: backend corriendo en `http://localhost:5212`, BD `SIGCM2` con migración V002 aplicada.
|
||||
|
||||
---
|
||||
|
||||
## Escenario 1 — Login y persistencia de tokens
|
||||
|
||||
- [ ] Abrir la app en `http://localhost:5173`
|
||||
- [ ] Ingresar con credenciales válidas (admin / password)
|
||||
- [ ] Verificar que el login redirige al home
|
||||
- [ ] Abrir DevTools → Application → Local Storage → `auth-storage`
|
||||
- [ ] Confirmar que el objeto contiene: `accessToken`, `refreshToken`, `expiresAt`, `user`
|
||||
- [ ] Verificar que `expiresAt` es aproximadamente `Date.now() + 3600000` (1 hora)
|
||||
|
||||
---
|
||||
|
||||
## Escenario 2 — Refresh transparente en 401
|
||||
|
||||
**Opción A (esperar expiración natural — requiere token con TTL corto):**
|
||||
|
||||
- [ ] Modificar `Jwt:AccessTokenMinutes` a `1` en `appsettings.Development.json` y reiniciar el backend
|
||||
- [ ] Hacer login
|
||||
- [ ] Esperar 1 minuto para que el access token expire
|
||||
- [ ] Realizar cualquier request autenticado (ej: navegar a una sección que llame a la API)
|
||||
- [ ] Verificar que el request se completa sin error visible para el usuario
|
||||
- [ ] Verificar en DevTools → Network que hubo una llamada a `POST /api/v1/auth/refresh` seguida del request original reenviado con un nuevo Bearer
|
||||
|
||||
**Opción B (manipulación manual del token):**
|
||||
|
||||
- [ ] Después del login, abrir DevTools → Application → Local Storage → `auth-storage`
|
||||
- [ ] Editar el JSON y reemplazar `accessToken` con un valor inválido (ej: `"expired"`)
|
||||
- [ ] Realizar cualquier request autenticado
|
||||
- [ ] El interceptor de axiosClient recibe 401, llama a `/refresh` con el `refreshToken` real
|
||||
- [ ] El request original se reintenta automáticamente con el nuevo `accessToken`
|
||||
- [ ] El usuario no ve ningún error
|
||||
|
||||
---
|
||||
|
||||
## Escenario 3 — Refresh de 3 requests paralelos (singleton promise)
|
||||
|
||||
- [ ] Con el access token vencido (opción B del escenario 2)
|
||||
- [ ] Abrir una página que dispare múltiples llamadas API simultáneas
|
||||
- [ ] Verificar en DevTools → Network que hay exactamente **1** llamada a `POST /api/v1/auth/refresh`
|
||||
- [ ] Verificar que todos los requests subsiguientes retornan con éxito
|
||||
|
||||
---
|
||||
|
||||
## Escenario 4 — Logout
|
||||
|
||||
- [ ] Con sesión activa, hacer click en el botón de logout
|
||||
- [ ] Verificar que redirige a `/login`
|
||||
- [ ] Verificar en DevTools → Network que se llamó a `POST /api/v1/auth/logout`
|
||||
- [ ] Verificar en Local Storage que `auth-storage` tiene `user: null`, `accessToken: null`, `refreshToken: null`
|
||||
- [ ] Intentar navegar a una ruta protegida — debería redirigir a login
|
||||
|
||||
---
|
||||
|
||||
## Escenario 5 — Reuso de refresh token después del logout (reuse detection)
|
||||
|
||||
- [ ] Hacer login y copiar el valor de `refreshToken` del Local Storage
|
||||
- [ ] Hacer logout
|
||||
- [ ] Intentar llamar manualmente al endpoint de refresh con el token anterior:
|
||||
```bash
|
||||
curl -X POST http://localhost:5212/api/v1/auth/refresh \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"accessToken": "<access-anterior>", "refreshToken": "<refresh-anterior>"}'
|
||||
```
|
||||
- [ ] Verificar que el backend responde `401` con `{ "error": "invalid_token" }`
|
||||
- [ ] Verificar en la BD que todos los tokens de la familia fueron revocados:
|
||||
```sql
|
||||
SELECT * FROM dbo.RefreshToken WHERE RevokedAt IS NOT NULL ORDER BY Id DESC;
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Escenario 6 — Refresh token expirado (7 días)
|
||||
|
||||
- [ ] Modificar `ExpiresAt` de un token en la BD `SIGCM2_Test` a una fecha pasada
|
||||
- [ ] Intentar refresh con ese token — debería responder `401`
|
||||
- [ ] Verificar que el frontend redirige a `/login` y limpia el Local Storage
|
||||
|
||||
---
|
||||
|
||||
## Escenario 7 — Refresh con access token de otro usuario (mismatch)
|
||||
|
||||
- [ ] Crear dos usuarios en la BD (o usar admin + otro)
|
||||
- [ ] Hacer login con usuario A, guardar el `accessToken`
|
||||
- [ ] Hacer login con usuario B, guardar el `refreshToken`
|
||||
- [ ] Intentar refresh con accessToken de A + refreshToken de B:
|
||||
```bash
|
||||
curl -X POST http://localhost:5212/api/v1/auth/refresh \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"accessToken": "<access-usuario-A>", "refreshToken": "<refresh-usuario-B>"}'
|
||||
```
|
||||
- [ ] Verificar que el backend responde `401`
|
||||
|
||||
---
|
||||
|
||||
## Notas de verificación
|
||||
|
||||
| Check | Comando |
|
||||
|-------|---------|
|
||||
| Tokens en BD | `SELECT Id, UsuarioId, FamilyId, IssuedAt, ExpiresAt, RevokedAt FROM dbo.RefreshToken ORDER BY Id DESC` |
|
||||
| Familias revocadas | `SELECT FamilyId, COUNT(*) as Total, SUM(CASE WHEN RevokedAt IS NOT NULL THEN 1 ELSE 0 END) as Revoked FROM dbo.RefreshToken GROUP BY FamilyId` |
|
||||
| Usuario activo | `SELECT Id, Username, Activo FROM dbo.Usuario` |
|
||||
@@ -0,0 +1,58 @@
|
||||
using System.Text.Json;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Authorization.Policy;
|
||||
|
||||
namespace SIGCM2.Api.Authorization;
|
||||
|
||||
/// <summary>
|
||||
/// Custom IAuthorizationMiddlewareResultHandler that emits a structured ProblemDetails
|
||||
/// response for 403 Forbidden outcomes (authenticated user, missing permission).
|
||||
///
|
||||
/// For 401 Unauthorized and successful outcomes, delegates to the default handler
|
||||
/// so the existing JWT Bearer challenge flow is unaffected (REQ-B-07).
|
||||
///
|
||||
/// Registered as singleton in Program.cs — depends only on framework services.
|
||||
/// </summary>
|
||||
public sealed class ForbiddenProblemDetailsHandler : IAuthorizationMiddlewareResultHandler
|
||||
{
|
||||
private static readonly AuthorizationMiddlewareResultHandler DefaultHandler = new();
|
||||
|
||||
private static readonly JsonSerializerOptions SerializerOptions = new()
|
||||
{
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
|
||||
};
|
||||
|
||||
public async Task HandleAsync(
|
||||
RequestDelegate next,
|
||||
HttpContext context,
|
||||
AuthorizationPolicy policy,
|
||||
PolicyAuthorizationResult authorizeResult)
|
||||
{
|
||||
// Only intercept 403s for authenticated users.
|
||||
// If the user is not authenticated, the 401 challenge is handled by JwtBearer (REQ-B-07).
|
||||
if (authorizeResult.Forbidden && context.User.Identity?.IsAuthenticated == true)
|
||||
{
|
||||
var requiredPermission = context.Items["RequiredPermission"] as string;
|
||||
|
||||
context.Response.StatusCode = StatusCodes.Status403Forbidden;
|
||||
context.Response.ContentType = "application/problem+json; charset=utf-8";
|
||||
|
||||
var problem = new
|
||||
{
|
||||
type = "https://sigcm2.local/errors/forbidden",
|
||||
title = "Acceso denegado",
|
||||
status = 403,
|
||||
detail = "No tenés el permiso requerido para ejecutar esta acción.",
|
||||
permisoRequerido = requiredPermission,
|
||||
};
|
||||
|
||||
await context.Response.WriteAsync(
|
||||
JsonSerializer.Serialize(problem, SerializerOptions));
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
// Delegate 401 challenges and successful outcomes to the default handler
|
||||
await DefaultHandler.HandleAsync(next, context, policy, authorizeResult);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
using System.IdentityModel.Tokens.Jwt;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using SIGCM2.Application.Abstractions.Persistence;
|
||||
using SIGCM2.Application.Audit;
|
||||
using SIGCM2.Application.Common;
|
||||
|
||||
namespace SIGCM2.Api.Authorization;
|
||||
|
||||
/// <summary>
|
||||
/// Authorization handler for <see cref="RequirePermissionAttribute"/>.
|
||||
/// UDT-009: Reads "rol" + "sub" claims, queries both IRolPermisoRepository
|
||||
/// and IUsuarioRepository, resolves effective permissions via PermisoResolver,
|
||||
/// and succeeds if at least one required permission matches (OR semantics).
|
||||
/// No caching — always authoritative from DB (UDT-006 D1, UDT-009 D3).
|
||||
/// UDT-010: emits SecurityEvent 'permission.denied' on rejection.
|
||||
/// </summary>
|
||||
public sealed class PermissionAuthorizationHandler
|
||||
: AuthorizationHandler<RequirePermissionAttribute>
|
||||
{
|
||||
private readonly IRolPermisoRepository _rolPermisoRepo;
|
||||
private readonly IUsuarioRepository _usuarioRepo;
|
||||
private readonly ISecurityEventLogger _security;
|
||||
private readonly ILogger<PermissionAuthorizationHandler> _logger;
|
||||
|
||||
public PermissionAuthorizationHandler(
|
||||
IRolPermisoRepository rolPermisoRepo,
|
||||
IUsuarioRepository usuarioRepo,
|
||||
ISecurityEventLogger security,
|
||||
ILogger<PermissionAuthorizationHandler> logger)
|
||||
{
|
||||
_rolPermisoRepo = rolPermisoRepo;
|
||||
_usuarioRepo = usuarioRepo;
|
||||
_security = security;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
protected override async Task HandleRequirementAsync(
|
||||
AuthorizationHandlerContext context,
|
||||
RequirePermissionAttribute requirement)
|
||||
{
|
||||
// 1. Must be authenticated — defense-in-depth
|
||||
if (context.User?.Identity?.IsAuthenticated != true)
|
||||
return; // implicit Fail
|
||||
|
||||
// 2. Extract "rol" claim
|
||||
var rolCodigo = context.User.FindFirst("rol")?.Value;
|
||||
if (string.IsNullOrWhiteSpace(rolCodigo))
|
||||
{
|
||||
_logger.LogWarning(
|
||||
"Authorization failed — token missing 'rol' claim for user {User}",
|
||||
context.User.Identity?.Name);
|
||||
context.Fail(new AuthorizationFailureReason(this, "missing_rol_claim"));
|
||||
return;
|
||||
}
|
||||
|
||||
// 3. Extract "sub" claim — MapInboundClaims=false so it stays as "sub" (NOT NameIdentifier)
|
||||
var subClaim = context.User.FindFirst(JwtRegisteredClaimNames.Sub)?.Value
|
||||
?? context.User.FindFirst("sub")?.Value;
|
||||
|
||||
if (string.IsNullOrWhiteSpace(subClaim) || !int.TryParse(subClaim, out var userId))
|
||||
{
|
||||
_logger.LogWarning(
|
||||
"Authorization failed — token missing or non-numeric 'sub' claim for user {User}",
|
||||
context.User.Identity?.Name);
|
||||
context.Fail(new AuthorizationFailureReason(this, "missing_sub_claim"));
|
||||
return;
|
||||
}
|
||||
|
||||
// 4. Load role permissions — no cache (UDT-006 D1)
|
||||
var rolPermisoEntities = await _rolPermisoRepo.GetByRolCodigoAsync(rolCodigo);
|
||||
var rolPermisos = rolPermisoEntities.Select(p => p.Codigo);
|
||||
|
||||
// 5. Load user overrides — no cache (UDT-009 D3); null usuario → no overrides
|
||||
var usuario = await _usuarioRepo.GetByIdAsync(userId);
|
||||
var overrides = PermisosOverride.FromJson(usuario?.PermisosJson);
|
||||
|
||||
// 6. Resolve effective permissions
|
||||
var effective = PermisoResolver.Resolve(rolPermisos, overrides);
|
||||
|
||||
// 7. OR semantics — any single match is enough
|
||||
var matched = requirement.PermissionCodes.FirstOrDefault(effective.Contains);
|
||||
|
||||
if (matched is not null)
|
||||
{
|
||||
context.Succeed(requirement);
|
||||
return;
|
||||
}
|
||||
|
||||
// 8. Stash required permission for ForbiddenProblemDetailsHandler
|
||||
var requiredPermission = requirement.PermissionCodes[0];
|
||||
if (context.Resource is HttpContext httpContext)
|
||||
httpContext.Items["RequiredPermission"] = requiredPermission;
|
||||
|
||||
// 9. Emit SecurityEvent for the denial
|
||||
var endpoint = (context.Resource as HttpContext)?.Request?.Path.Value;
|
||||
var method = (context.Resource as HttpContext)?.Request?.Method;
|
||||
await _security.LogAsync("permission.denied", "failure",
|
||||
actorUserId: userId,
|
||||
failureReason: $"missing_permission:{requiredPermission}",
|
||||
metadata: new { permissionRequired = requiredPermission, endpoint, method });
|
||||
|
||||
context.Fail(new AuthorizationFailureReason(this,
|
||||
$"missing_permission:{string.Join('|', requirement.PermissionCodes)}"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
|
||||
namespace SIGCM2.Api.Authorization;
|
||||
|
||||
/// <summary>
|
||||
/// Authorization attribute that requires the authenticated user to have at least ONE
|
||||
/// of the declared permission codes assigned to their role (OR semantics).
|
||||
/// Implements IAuthorizationRequirementData (.NET 8+) so ASP.NET Core builds the policy
|
||||
/// on-the-fly from GetRequirements() — no AddPolicy() registration needed.
|
||||
/// </summary>
|
||||
/// <example>
|
||||
/// // Single permission
|
||||
/// [RequirePermission("administracion:usuarios:gestionar")]
|
||||
///
|
||||
/// // Multiple — OR semantics: any single match grants access
|
||||
/// [RequirePermission("ventas:contado:crear", "ventas:ctacte:crear")]
|
||||
/// </example>
|
||||
[AttributeUsage(AttributeTargets.Class | AttributeTargets.Method, AllowMultiple = false)]
|
||||
public sealed class RequirePermissionAttribute
|
||||
: AuthorizeAttribute, IAuthorizationRequirement, IAuthorizationRequirementData
|
||||
{
|
||||
/// <summary>Permission codes required (OR semantics — at least one must match).</summary>
|
||||
public string[] PermissionCodes { get; }
|
||||
|
||||
public RequirePermissionAttribute(params string[] permissionCodes)
|
||||
{
|
||||
if (permissionCodes is null || permissionCodes.Length == 0)
|
||||
throw new ArgumentException("At least one permission code is required.", nameof(permissionCodes));
|
||||
|
||||
PermissionCodes = permissionCodes;
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public IEnumerable<IAuthorizationRequirement> GetRequirements() => new[] { this };
|
||||
}
|
||||
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
|
||||
};
|
||||
}
|
||||
63
src/api/SIGCM2.Api/Controllers/AuditController.cs
Normal file
63
src/api/SIGCM2.Api/Controllers/AuditController.cs
Normal file
@@ -0,0 +1,63 @@
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using SIGCM2.Api.Authorization;
|
||||
using SIGCM2.Application.Audit;
|
||||
|
||||
namespace SIGCM2.Api.Controllers;
|
||||
|
||||
/// <summary>
|
||||
/// UDT-010: Read-only endpoint for audit events. Requires administracion:auditoria:ver.
|
||||
/// Cursor-based DESC pagination with 4 filter axes (actor/target/from/to).
|
||||
/// Rich UI (drilldown, export CSV, timeline) is deferred to ADM-004.
|
||||
/// </summary>
|
||||
[ApiController]
|
||||
[Route("api/v1/audit")]
|
||||
public sealed class AuditController : ControllerBase
|
||||
{
|
||||
private readonly IAuditEventRepository _repo;
|
||||
|
||||
public AuditController(IAuditEventRepository repo)
|
||||
{
|
||||
_repo = repo;
|
||||
}
|
||||
|
||||
/// <summary>Lists audit events with optional filters. Cursor-based DESC pagination.</summary>
|
||||
[HttpGet("events")]
|
||||
[RequirePermission("administracion:auditoria:ver")]
|
||||
[ProducesResponseType(typeof(AuditEventPageResponse), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status400BadRequest)]
|
||||
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
|
||||
[ProducesResponseType(StatusCodes.Status403Forbidden)]
|
||||
public async Task<IActionResult> GetEvents(
|
||||
[FromQuery] int? actorUserId = null,
|
||||
[FromQuery] string? targetType = null,
|
||||
[FromQuery] string? targetId = null,
|
||||
[FromQuery] DateTime? from = null,
|
||||
[FromQuery] DateTime? to = null,
|
||||
[FromQuery] string? cursor = null,
|
||||
[FromQuery] int limit = 50,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
if (limit < 1 || limit > 100)
|
||||
return BadRequest(new { error = "limit must be between 1 and 100" });
|
||||
|
||||
if (from is not null && to is not null && from > to)
|
||||
return BadRequest(new { error = "from must be <= to" });
|
||||
|
||||
var filter = new AuditEventFilter(
|
||||
ActorUserId: actorUserId,
|
||||
TargetType: targetType,
|
||||
TargetId: targetId,
|
||||
From: from,
|
||||
To: to,
|
||||
Cursor: cursor,
|
||||
Limit: limit);
|
||||
|
||||
var result = await _repo.QueryAsync(filter, ct);
|
||||
return Ok(new AuditEventPageResponse(result.Items, result.NextCursor));
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>UDT-010: Paginated response wrapper for GET /api/v1/audit/events.</summary>
|
||||
public sealed record AuditEventPageResponse(
|
||||
IReadOnlyList<AuditEventDto> Items,
|
||||
string? NextCursor);
|
||||
104
src/api/SIGCM2.Api/Controllers/AuthController.cs
Normal file
104
src/api/SIGCM2.Api/Controllers/AuthController.cs
Normal file
@@ -0,0 +1,104 @@
|
||||
using System.IdentityModel.Tokens.Jwt;
|
||||
using FluentValidation;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using SIGCM2.Application.Abstractions;
|
||||
using SIGCM2.Application.Auth.Login;
|
||||
using SIGCM2.Application.Auth.Logout;
|
||||
using SIGCM2.Application.Auth.Refresh;
|
||||
|
||||
namespace SIGCM2.Api.Controllers;
|
||||
|
||||
[ApiController]
|
||||
[Route("api/v1/auth")]
|
||||
public sealed class AuthController : ControllerBase
|
||||
{
|
||||
private readonly IDispatcher _dispatcher;
|
||||
private readonly IValidator<LoginCommand> _loginValidator;
|
||||
private readonly IValidator<RefreshCommand> _refreshValidator;
|
||||
|
||||
public AuthController(
|
||||
IDispatcher dispatcher,
|
||||
IValidator<LoginCommand> loginValidator,
|
||||
IValidator<RefreshCommand> refreshValidator)
|
||||
{
|
||||
_dispatcher = dispatcher;
|
||||
_loginValidator = loginValidator;
|
||||
_refreshValidator = refreshValidator;
|
||||
}
|
||||
|
||||
/// <summary>Authenticates a user and returns a JWT access token + refresh token.</summary>
|
||||
[HttpPost("login")]
|
||||
[AllowAnonymous]
|
||||
[ProducesResponseType(typeof(LoginResponseDto), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status400BadRequest)]
|
||||
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
|
||||
public async Task<IActionResult> Login([FromBody] LoginRequest request)
|
||||
{
|
||||
var command = new LoginCommand(request.Username ?? string.Empty, request.Password ?? string.Empty);
|
||||
|
||||
var validation = await _loginValidator.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<LoginCommand, LoginResponseDto>(command);
|
||||
return Ok(result);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Rotates a refresh token pair. Accepts an expired access token to extract the user identity.
|
||||
/// Returns a new access + refresh token pair. Does NOT require Authorization header.
|
||||
/// </summary>
|
||||
[HttpPost("refresh")]
|
||||
[AllowAnonymous]
|
||||
[ProducesResponseType(typeof(RefreshResponseDto), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status400BadRequest)]
|
||||
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
|
||||
public async Task<IActionResult> Refresh([FromBody] RefreshRequest request)
|
||||
{
|
||||
var command = new RefreshCommand(
|
||||
request.AccessToken ?? string.Empty,
|
||||
request.RefreshToken ?? string.Empty);
|
||||
|
||||
var validation = await _refreshValidator.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<RefreshCommand, RefreshResponseDto>(command);
|
||||
return Ok(result);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Revokes all active refresh tokens for the authenticated user.
|
||||
/// Requires a valid Bearer access token. Client must discard local tokens after this call.
|
||||
/// </summary>
|
||||
[HttpPost("logout")]
|
||||
[Authorize]
|
||||
[ProducesResponseType(typeof(LogoutResponseDto), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
|
||||
public async Task<IActionResult> Logout()
|
||||
{
|
||||
var sub = User.FindFirst(JwtRegisteredClaimNames.Sub)?.Value;
|
||||
if (!int.TryParse(sub, out var userId))
|
||||
return Unauthorized();
|
||||
|
||||
var result = await _dispatcher.Send<LogoutCommand, LogoutResponseDto>(new LogoutCommand(userId));
|
||||
return Ok(result);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Login request body — nullable to catch missing field scenarios.</summary>
|
||||
public sealed record LoginRequest(string? Username, string? Password);
|
||||
|
||||
/// <summary>Refresh request body.</summary>
|
||||
public sealed record RefreshRequest(string? AccessToken, string? RefreshToken);
|
||||
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);
|
||||
104
src/api/SIGCM2.Api/Controllers/PermisosController.cs
Normal file
104
src/api/SIGCM2.Api/Controllers/PermisosController.cs
Normal file
@@ -0,0 +1,104 @@
|
||||
using FluentValidation;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using SIGCM2.Api.Authorization;
|
||||
using SIGCM2.Application.Abstractions;
|
||||
using SIGCM2.Application.Permisos.Assign;
|
||||
using SIGCM2.Application.Permisos.Dtos;
|
||||
using SIGCM2.Application.Permisos.GetByRol;
|
||||
using SIGCM2.Application.Permisos.List;
|
||||
|
||||
namespace SIGCM2.Api.Controllers;
|
||||
|
||||
/// <summary>
|
||||
/// Permisos controller — granular permission per method (UDT-006).
|
||||
/// [Authorize] at class level requires a valid JWT; each method declares its specific permission.
|
||||
/// </summary>
|
||||
[ApiController]
|
||||
[Route("api/v1")]
|
||||
[Authorize] // JWT required on all methods; per-method [RequirePermission] handles authz
|
||||
public sealed class PermisosController : ControllerBase
|
||||
{
|
||||
private readonly IDispatcher _dispatcher;
|
||||
private readonly IValidator<AssignPermisosToRolCommand> _assignValidator;
|
||||
private readonly IValidator<GetRolPermisosQuery> _getRolPermisosValidator;
|
||||
|
||||
public PermisosController(
|
||||
IDispatcher dispatcher,
|
||||
IValidator<AssignPermisosToRolCommand> assignValidator,
|
||||
IValidator<GetRolPermisosQuery> getRolPermisosValidator)
|
||||
{
|
||||
_dispatcher = dispatcher;
|
||||
_assignValidator = assignValidator;
|
||||
_getRolPermisosValidator = getRolPermisosValidator;
|
||||
}
|
||||
|
||||
/// <summary>Lists all permisos in the canonical catalog.</summary>
|
||||
[HttpGet("permisos")]
|
||||
[RequirePermission("administracion:permisos:ver")]
|
||||
[ProducesResponseType(typeof(IReadOnlyList<PermisoDto>), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
|
||||
[ProducesResponseType(StatusCodes.Status403Forbidden)]
|
||||
public async Task<IActionResult> ListPermisos()
|
||||
{
|
||||
var result = await _dispatcher.Send<ListPermisosQuery, IReadOnlyList<PermisoDto>>(new ListPermisosQuery());
|
||||
return Ok(result);
|
||||
}
|
||||
|
||||
/// <summary>Gets all permisos assigned to a rol.</summary>
|
||||
[HttpGet("roles/{codigo}/permisos")]
|
||||
[RequirePermission("administracion:roles_permisos:gestionar", "administracion:permisos:ver")]
|
||||
[ProducesResponseType(typeof(IReadOnlyList<PermisoDto>), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status400BadRequest)]
|
||||
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
|
||||
[ProducesResponseType(StatusCodes.Status403Forbidden)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
public async Task<IActionResult> GetRolPermisos(string codigo)
|
||||
{
|
||||
var query = new GetRolPermisosQuery(codigo);
|
||||
var validation = await _getRolPermisosValidator.ValidateAsync(query);
|
||||
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<GetRolPermisosQuery, IReadOnlyList<PermisoDto>>(query);
|
||||
return Ok(result);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Replace-set: replaces the full permiso assignment for a rol.
|
||||
/// Returns the updated permiso set (200).
|
||||
/// </summary>
|
||||
[HttpPut("roles/{codigo}/permisos")]
|
||||
[RequirePermission("administracion:roles_permisos:gestionar")]
|
||||
[ProducesResponseType(typeof(IReadOnlyList<PermisoDto>), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status400BadRequest)]
|
||||
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
|
||||
[ProducesResponseType(StatusCodes.Status403Forbidden)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
public async Task<IActionResult> AssignPermisos(string codigo, [FromBody] AssignPermisosRequest request)
|
||||
{
|
||||
var codigos = request.Codigos ?? [];
|
||||
var command = new AssignPermisosToRolCommand(
|
||||
RolCodigo: codigo,
|
||||
Codigos: codigos);
|
||||
|
||||
var validation = await _assignValidator.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<AssignPermisosToRolCommand, IReadOnlyList<PermisoDto>>(command);
|
||||
return Ok(result);
|
||||
}
|
||||
}
|
||||
|
||||
public sealed record AssignPermisosRequest(IReadOnlyList<string>? Codigos);
|
||||
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);
|
||||
128
src/api/SIGCM2.Api/Controllers/RolesController.cs
Normal file
128
src/api/SIGCM2.Api/Controllers/RolesController.cs
Normal file
@@ -0,0 +1,128 @@
|
||||
using FluentValidation;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using SIGCM2.Api.Authorization;
|
||||
using SIGCM2.Application.Abstractions;
|
||||
using SIGCM2.Application.Roles.Create;
|
||||
using SIGCM2.Application.Roles.Deactivate;
|
||||
using SIGCM2.Application.Roles.Dtos;
|
||||
using SIGCM2.Application.Roles.Get;
|
||||
using SIGCM2.Application.Roles.List;
|
||||
using SIGCM2.Application.Roles.Update;
|
||||
|
||||
namespace SIGCM2.Api.Controllers;
|
||||
|
||||
[ApiController]
|
||||
[Route("api/v1/roles")]
|
||||
[RequirePermission("administracion:roles:gestionar")]
|
||||
public sealed class RolesController : ControllerBase
|
||||
{
|
||||
private readonly IDispatcher _dispatcher;
|
||||
private readonly IValidator<CreateRolCommand> _createValidator;
|
||||
private readonly IValidator<UpdateRolCommand> _updateValidator;
|
||||
|
||||
public RolesController(
|
||||
IDispatcher dispatcher,
|
||||
IValidator<CreateRolCommand> createValidator,
|
||||
IValidator<UpdateRolCommand> updateValidator)
|
||||
{
|
||||
_dispatcher = dispatcher;
|
||||
_createValidator = createValidator;
|
||||
_updateValidator = updateValidator;
|
||||
}
|
||||
|
||||
/// <summary>Lists all roles (including inactive). Requires admin role.</summary>
|
||||
[HttpGet]
|
||||
[ProducesResponseType(typeof(IReadOnlyList<RolDto>), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
|
||||
[ProducesResponseType(StatusCodes.Status403Forbidden)]
|
||||
public async Task<IActionResult> List()
|
||||
{
|
||||
var result = await _dispatcher.Send<ListRolesQuery, IReadOnlyList<RolDto>>(new ListRolesQuery());
|
||||
return Ok(result);
|
||||
}
|
||||
|
||||
/// <summary>Gets a role by its code. Requires admin role.</summary>
|
||||
[HttpGet("{codigo}")]
|
||||
[ProducesResponseType(typeof(RolDto), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
|
||||
[ProducesResponseType(StatusCodes.Status403Forbidden)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
public async Task<IActionResult> GetByCodigo(string codigo)
|
||||
{
|
||||
var result = await _dispatcher.Send<GetRolByCodigoQuery, RolDto>(new GetRolByCodigoQuery(codigo));
|
||||
return Ok(result);
|
||||
}
|
||||
|
||||
/// <summary>Creates a new role. Requires admin role.</summary>
|
||||
[HttpPost]
|
||||
[ProducesResponseType(typeof(RolCreatedDto), StatusCodes.Status201Created)]
|
||||
[ProducesResponseType(StatusCodes.Status400BadRequest)]
|
||||
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
|
||||
[ProducesResponseType(StatusCodes.Status403Forbidden)]
|
||||
[ProducesResponseType(StatusCodes.Status409Conflict)]
|
||||
public async Task<IActionResult> Create([FromBody] CreateRolRequest request)
|
||||
{
|
||||
var command = new CreateRolCommand(
|
||||
Codigo: request.Codigo ?? string.Empty,
|
||||
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<CreateRolCommand, RolCreatedDto>(command);
|
||||
return CreatedAtAction(nameof(GetByCodigo), new { codigo = result.Codigo }, result);
|
||||
}
|
||||
|
||||
/// <summary>Updates a role (codigo is immutable; route wins over body). Requires admin role.</summary>
|
||||
[HttpPut("{codigo}")]
|
||||
[ProducesResponseType(typeof(RolDto), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status400BadRequest)]
|
||||
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
|
||||
[ProducesResponseType(StatusCodes.Status403Forbidden)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
public async Task<IActionResult> Update(string codigo, [FromBody] UpdateRolRequest request)
|
||||
{
|
||||
// Codigo comes from the route — body.codigo (if present) is ignored by design.
|
||||
var command = new UpdateRolCommand(
|
||||
Codigo: codigo,
|
||||
Nombre: request.Nombre ?? string.Empty,
|
||||
Descripcion: request.Descripcion,
|
||||
Activo: request.Activo);
|
||||
|
||||
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<UpdateRolCommand, RolDto>(command);
|
||||
return Ok(result);
|
||||
}
|
||||
|
||||
/// <summary>Soft-deletes (deactivates) a role. 409 if active usuarios reference it. Requires admin role.</summary>
|
||||
[HttpDelete("{codigo}")]
|
||||
[ProducesResponseType(StatusCodes.Status204NoContent)]
|
||||
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
|
||||
[ProducesResponseType(StatusCodes.Status403Forbidden)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
[ProducesResponseType(StatusCodes.Status409Conflict)]
|
||||
public async Task<IActionResult> Deactivate(string codigo)
|
||||
{
|
||||
await _dispatcher.Send<DeactivateRolCommand, RolDto>(new DeactivateRolCommand(codigo));
|
||||
return NoContent();
|
||||
}
|
||||
}
|
||||
|
||||
public sealed record CreateRolRequest(string? Codigo, string? Nombre, string? Descripcion);
|
||||
public sealed record UpdateRolRequest(string? Nombre, string? Descripcion, bool Activo);
|
||||
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);
|
||||
316
src/api/SIGCM2.Api/Controllers/UsuariosController.cs
Normal file
316
src/api/SIGCM2.Api/Controllers/UsuariosController.cs
Normal file
@@ -0,0 +1,316 @@
|
||||
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.Usuarios.ChangeMyPassword;
|
||||
using SIGCM2.Application.Usuarios.Create;
|
||||
using SIGCM2.Application.Usuarios.Deactivate;
|
||||
using SIGCM2.Application.Usuarios.GetById;
|
||||
using SIGCM2.Application.Usuarios.List;
|
||||
using SIGCM2.Application.Usuarios.Reactivate;
|
||||
using SIGCM2.Application.Usuarios.Permisos;
|
||||
using SIGCM2.Application.Usuarios.ResetPassword;
|
||||
using SIGCM2.Application.Usuarios.Update;
|
||||
using System.IdentityModel.Tokens.Jwt;
|
||||
using System.Security.Claims;
|
||||
|
||||
namespace SIGCM2.Api.Controllers;
|
||||
|
||||
/// <summary>
|
||||
/// UDT-001/UDT-008: Usuario management endpoints.
|
||||
/// RequirePermission moved to method level to allow /me/password with [Authorize] only.
|
||||
/// </summary>
|
||||
[ApiController]
|
||||
[Route("api/v1/users")]
|
||||
public sealed class UsuariosController : ControllerBase
|
||||
{
|
||||
private readonly IDispatcher _dispatcher;
|
||||
private readonly IValidator<CreateUsuarioCommand> _createValidator;
|
||||
private readonly IValidator<UpdateUsuarioCommand> _updateValidator;
|
||||
private readonly IValidator<ChangeMyPasswordCommand> _changePasswordValidator;
|
||||
|
||||
public UsuariosController(
|
||||
IDispatcher dispatcher,
|
||||
IValidator<CreateUsuarioCommand> createValidator,
|
||||
IValidator<UpdateUsuarioCommand> updateValidator,
|
||||
IValidator<ChangeMyPasswordCommand> changePasswordValidator)
|
||||
{
|
||||
_dispatcher = dispatcher;
|
||||
_createValidator = createValidator;
|
||||
_updateValidator = updateValidator;
|
||||
_changePasswordValidator = changePasswordValidator;
|
||||
}
|
||||
|
||||
/// <summary>Creates a new user. Requires administracion:usuarios:gestionar.</summary>
|
||||
[HttpPost]
|
||||
[RequirePermission("administracion:usuarios:gestionar")]
|
||||
[ProducesResponseType(typeof(UsuarioCreatedDto), StatusCodes.Status201Created)]
|
||||
[ProducesResponseType(StatusCodes.Status400BadRequest)]
|
||||
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
|
||||
[ProducesResponseType(StatusCodes.Status403Forbidden)]
|
||||
[ProducesResponseType(StatusCodes.Status409Conflict)]
|
||||
public async Task<IActionResult> CreateUsuario([FromBody] CreateUsuarioRequest request)
|
||||
{
|
||||
var command = new CreateUsuarioCommand(
|
||||
Username: request.Username ?? string.Empty,
|
||||
Password: request.Password ?? string.Empty,
|
||||
Nombre: request.Nombre ?? string.Empty,
|
||||
Apellido: request.Apellido ?? string.Empty,
|
||||
Email: request.Email,
|
||||
Rol: request.Rol ?? 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<CreateUsuarioCommand, UsuarioCreatedDto>(command);
|
||||
|
||||
return CreatedAtAction(nameof(CreateUsuario), new { id = result.Id }, result);
|
||||
}
|
||||
|
||||
/// <summary>Lists usuarios with optional filters and pagination.</summary>
|
||||
[HttpGet]
|
||||
[RequirePermission("administracion:usuarios:gestionar")]
|
||||
[ProducesResponseType(typeof(PagedResult<UsuarioListItemDto>), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status400BadRequest)]
|
||||
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
|
||||
[ProducesResponseType(StatusCodes.Status403Forbidden)]
|
||||
public async Task<IActionResult> ListUsuarios(
|
||||
[FromQuery] int page = 1,
|
||||
[FromQuery] int pageSize = 20,
|
||||
[FromQuery] string? rol = null,
|
||||
[FromQuery] bool? activo = null,
|
||||
[FromQuery] string? search = 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 ListUsuariosQuery(page, pageSize, rol, activo, search);
|
||||
var result = await _dispatcher.Send<ListUsuariosQuery, PagedResult<UsuarioListItemDto>>(query);
|
||||
return Ok(result);
|
||||
}
|
||||
|
||||
/// <summary>Gets a single usuario by id.</summary>
|
||||
[HttpGet("{id:int}")]
|
||||
[RequirePermission("administracion:usuarios:gestionar")]
|
||||
[ProducesResponseType(typeof(UsuarioDetailDto), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
|
||||
[ProducesResponseType(StatusCodes.Status403Forbidden)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
public async Task<IActionResult> GetUsuarioById([FromRoute] int id)
|
||||
{
|
||||
var query = new GetUsuarioByIdQuery(id);
|
||||
var result = await _dispatcher.Send<GetUsuarioByIdQuery, UsuarioDetailDto>(query);
|
||||
return Ok(result);
|
||||
}
|
||||
|
||||
/// <summary>Updates a usuario's editable fields.</summary>
|
||||
[HttpPut("{id:int}")]
|
||||
[RequirePermission("administracion:usuarios:gestionar")]
|
||||
[ProducesResponseType(typeof(UsuarioDetailDto), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status400BadRequest)]
|
||||
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
|
||||
[ProducesResponseType(StatusCodes.Status403Forbidden)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
public async Task<IActionResult> UpdateUsuario([FromRoute] int id, [FromBody] UpdateUsuarioRequest request)
|
||||
{
|
||||
var command = new UpdateUsuarioCommand(
|
||||
Id: id,
|
||||
Nombre: request.Nombre ?? string.Empty,
|
||||
Apellido: request.Apellido ?? string.Empty,
|
||||
Email: request.Email,
|
||||
Rol: request.Rol ?? string.Empty,
|
||||
Activo: request.Activo ?? true);
|
||||
|
||||
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<UpdateUsuarioCommand, UsuarioDetailDto>(command);
|
||||
return Ok(result);
|
||||
}
|
||||
|
||||
/// <summary>Deactivates a usuario (idempotent).</summary>
|
||||
[HttpPatch("{id:int}/deactivate")]
|
||||
[RequirePermission("administracion:usuarios:gestionar")]
|
||||
[ProducesResponseType(typeof(UsuarioDetailDto), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status400BadRequest)]
|
||||
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
|
||||
[ProducesResponseType(StatusCodes.Status403Forbidden)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
public async Task<IActionResult> DeactivateUsuario([FromRoute] int id)
|
||||
{
|
||||
var command = new DeactivateUsuarioCommand(id);
|
||||
var result = await _dispatcher.Send<DeactivateUsuarioCommand, UsuarioDetailDto>(command);
|
||||
return Ok(result);
|
||||
}
|
||||
|
||||
/// <summary>Reactivates a usuario (idempotent).</summary>
|
||||
[HttpPatch("{id:int}/reactivate")]
|
||||
[RequirePermission("administracion:usuarios:gestionar")]
|
||||
[ProducesResponseType(typeof(UsuarioDetailDto), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
|
||||
[ProducesResponseType(StatusCodes.Status403Forbidden)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
public async Task<IActionResult> ReactivateUsuario([FromRoute] int id)
|
||||
{
|
||||
var command = new ReactivateUsuarioCommand(id);
|
||||
var result = await _dispatcher.Send<ReactivateUsuarioCommand, UsuarioDetailDto>(command);
|
||||
return Ok(result);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Changes the authenticated user's own password.
|
||||
/// Declared BEFORE /{id:int} route to avoid routing ambiguity (though :int constraint handles it).
|
||||
/// Requires only authentication (no specific permission).
|
||||
/// </summary>
|
||||
[HttpPut("me/password")]
|
||||
[Authorize]
|
||||
[ProducesResponseType(StatusCodes.Status204NoContent)]
|
||||
[ProducesResponseType(StatusCodes.Status400BadRequest)]
|
||||
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
|
||||
public async Task<IActionResult> ChangeMyPassword([FromBody] ChangeMyPasswordRequest request)
|
||||
{
|
||||
var sub = User.FindFirstValue(JwtRegisteredClaimNames.Sub)
|
||||
?? User.FindFirstValue(ClaimTypes.NameIdentifier)
|
||||
?? throw new UnauthorizedAccessException();
|
||||
|
||||
var command = new ChangeMyPasswordCommand(
|
||||
UsuarioId: int.Parse(sub),
|
||||
OldPassword: request.OldPassword ?? string.Empty,
|
||||
NewPassword: request.NewPassword ?? string.Empty);
|
||||
|
||||
var validation = await _changePasswordValidator.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 });
|
||||
}
|
||||
|
||||
await _dispatcher.Send<ChangeMyPasswordCommand, Unit>(command);
|
||||
return NoContent();
|
||||
}
|
||||
|
||||
/// <summary>Resets a usuario's password (admin only). Returns a one-time temp password.</summary>
|
||||
[HttpPost("{id:int}/password/reset")]
|
||||
[RequirePermission("administracion:usuarios:gestionar")]
|
||||
[ProducesResponseType(typeof(ResetUsuarioPasswordResponse), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status400BadRequest)]
|
||||
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
|
||||
[ProducesResponseType(StatusCodes.Status403Forbidden)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
public async Task<IActionResult> ResetUsuarioPassword([FromRoute] int id)
|
||||
{
|
||||
var sub = User.FindFirstValue(JwtRegisteredClaimNames.Sub)
|
||||
?? User.FindFirstValue(ClaimTypes.NameIdentifier)
|
||||
?? throw new UnauthorizedAccessException();
|
||||
|
||||
var command = new ResetUsuarioPasswordCommand(
|
||||
TargetId: id,
|
||||
CallerId: int.Parse(sub));
|
||||
|
||||
var result = await _dispatcher.Send<ResetUsuarioPasswordCommand, ResetUsuarioPasswordResponse>(command);
|
||||
return Ok(result);
|
||||
}
|
||||
|
||||
// ── UDT-009: Permisos endpoints ───────────────────────────────────────────
|
||||
|
||||
/// <summary>
|
||||
/// Gets a usuario's role permissions, explicit grant/deny overrides, and computed effective set.
|
||||
/// Requires administracion:usuarios:gestionar.
|
||||
/// </summary>
|
||||
[HttpGet("{id:int}/permisos")]
|
||||
[RequirePermission("administracion:usuarios:gestionar")]
|
||||
[ProducesResponseType(typeof(UsuarioPermisosResponse), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
|
||||
[ProducesResponseType(StatusCodes.Status403Forbidden)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
public async Task<IActionResult> GetPermisos([FromRoute] int id)
|
||||
{
|
||||
var result = await _dispatcher.Send<GetUsuarioPermisosQuery, UsuarioPermisosDto>(
|
||||
new GetUsuarioPermisosQuery(id));
|
||||
return Ok(MapToPermisosResponse(result));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Replaces the grant/deny override sets for a usuario.
|
||||
/// Requires administracion:usuarios:gestionar.
|
||||
/// </summary>
|
||||
[HttpPut("{id:int}/permisos/overrides")]
|
||||
[RequirePermission("administracion:usuarios:gestionar")]
|
||||
[ProducesResponseType(typeof(UsuarioPermisosResponse), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status400BadRequest)]
|
||||
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
|
||||
[ProducesResponseType(StatusCodes.Status403Forbidden)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
public async Task<IActionResult> UpdatePermisosOverrides(
|
||||
[FromRoute] int id,
|
||||
[FromBody] UpdatePermisosOverridesRequest request)
|
||||
{
|
||||
var command = new UpdateUsuarioPermisosOverridesCommand(
|
||||
Id: id,
|
||||
Grant: request.Grant ?? [],
|
||||
Deny: request.Deny ?? []);
|
||||
|
||||
var result = await _dispatcher.Send<UpdateUsuarioPermisosOverridesCommand, UsuarioPermisosDto>(command);
|
||||
return Ok(MapToPermisosResponse(result));
|
||||
}
|
||||
|
||||
private static UsuarioPermisosResponse MapToPermisosResponse(UsuarioPermisosDto dto)
|
||||
=> new(
|
||||
RolPermisos: dto.RolPermisos,
|
||||
Overrides: new PermisosOverridesShape(dto.Grant, dto.Deny),
|
||||
Effective: dto.Effective);
|
||||
}
|
||||
|
||||
// ── request body records ──────────────────────────────────────────────────────
|
||||
|
||||
/// <summary>UDT-009: Response shape for permisos endpoints.</summary>
|
||||
public sealed record UsuarioPermisosResponse(
|
||||
IReadOnlyList<string> RolPermisos,
|
||||
PermisosOverridesShape Overrides,
|
||||
IReadOnlyList<string> Effective);
|
||||
|
||||
/// <summary>UDT-009: The grant/deny override shape nested in UsuarioPermisosResponse.</summary>
|
||||
public sealed record PermisosOverridesShape(
|
||||
IReadOnlyList<string> Grant,
|
||||
IReadOnlyList<string> Deny);
|
||||
|
||||
/// <summary>UDT-009: PUT permisos/overrides request body.</summary>
|
||||
public sealed record UpdatePermisosOverridesRequest(
|
||||
IReadOnlyList<string>? Grant,
|
||||
IReadOnlyList<string>? Deny);
|
||||
|
||||
/// <summary>Create user request body — nullable to catch missing field scenarios.</summary>
|
||||
public sealed record CreateUsuarioRequest(
|
||||
string? Username,
|
||||
string? Password,
|
||||
string? Nombre,
|
||||
string? Apellido,
|
||||
string? Email,
|
||||
string? Rol);
|
||||
|
||||
public sealed record UpdateUsuarioRequest(
|
||||
string? Nombre,
|
||||
string? Apellido,
|
||||
string? Email,
|
||||
string? Rol,
|
||||
bool? Activo);
|
||||
|
||||
public sealed record ChangeMyPasswordRequest(
|
||||
string? OldPassword,
|
||||
string? NewPassword);
|
||||
758
src/api/SIGCM2.Api/Filters/ExceptionFilter.cs
Normal file
758
src/api/SIGCM2.Api/Filters/ExceptionFilter.cs
Normal file
@@ -0,0 +1,758 @@
|
||||
using FluentValidation;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.AspNetCore.Mvc.Filters;
|
||||
using Microsoft.Data.SqlClient;
|
||||
using SIGCM2.Domain.Exceptions;
|
||||
using SIGCM2.Domain.Pricing.Exceptions;
|
||||
|
||||
namespace SIGCM2.Api.Filters;
|
||||
|
||||
public sealed class ExceptionFilter : IExceptionFilter
|
||||
{
|
||||
private readonly ILogger<ExceptionFilter> _logger;
|
||||
|
||||
public ExceptionFilter(ILogger<ExceptionFilter> logger)
|
||||
{
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public void OnException(ExceptionContext context)
|
||||
{
|
||||
switch (context.Exception)
|
||||
{
|
||||
case UsuarioNotFoundException usuarioNotFoundEx:
|
||||
context.Result = new ObjectResult(new
|
||||
{
|
||||
error = "usuario_not_found",
|
||||
message = usuarioNotFoundEx.Message
|
||||
})
|
||||
{
|
||||
StatusCode = StatusCodes.Status404NotFound
|
||||
};
|
||||
context.ExceptionHandled = true;
|
||||
break;
|
||||
|
||||
case LastAdminLockoutException:
|
||||
context.Result = new ObjectResult(new Microsoft.AspNetCore.Mvc.ProblemDetails
|
||||
{
|
||||
Type = "about:blank",
|
||||
Title = "last-admin-lockout",
|
||||
Status = 400,
|
||||
Detail = "No se puede desactivar o cambiar el rol del último administrador activo."
|
||||
})
|
||||
{
|
||||
StatusCode = StatusCodes.Status400BadRequest
|
||||
};
|
||||
context.ExceptionHandled = true;
|
||||
break;
|
||||
|
||||
case CannotSelfResetException:
|
||||
context.Result = new ObjectResult(new
|
||||
{
|
||||
error = "cannot-self-reset",
|
||||
message = "Un administrador no puede resetear su propia contraseña. Use el endpoint de cambio de contraseña propio."
|
||||
})
|
||||
{
|
||||
StatusCode = StatusCodes.Status400BadRequest
|
||||
};
|
||||
context.ExceptionHandled = true;
|
||||
break;
|
||||
|
||||
case InvalidOldPasswordException:
|
||||
context.Result = new ObjectResult(new
|
||||
{
|
||||
error = "invalid-old-password",
|
||||
message = "La contraseña actual es incorrecta."
|
||||
})
|
||||
{
|
||||
StatusCode = StatusCodes.Status400BadRequest
|
||||
};
|
||||
context.ExceptionHandled = true;
|
||||
break;
|
||||
|
||||
case UsernameAlreadyExistsException usernameEx:
|
||||
context.Result = new ObjectResult(new
|
||||
{
|
||||
error = "username_taken",
|
||||
message = usernameEx.Message
|
||||
})
|
||||
{
|
||||
StatusCode = StatusCodes.Status409Conflict
|
||||
};
|
||||
context.ExceptionHandled = true;
|
||||
break;
|
||||
|
||||
case SqlException sqlEx when sqlEx.Number == 2627:
|
||||
// Safety net: UQ constraint violation from a race condition
|
||||
context.Result = new ObjectResult(new
|
||||
{
|
||||
error = "username_taken",
|
||||
message = "El nombre de usuario ya está en uso."
|
||||
})
|
||||
{
|
||||
StatusCode = StatusCodes.Status409Conflict
|
||||
};
|
||||
context.ExceptionHandled = true;
|
||||
break;
|
||||
|
||||
case InvalidCredentialsException:
|
||||
context.Result = new ObjectResult(new { error = "Credenciales inválidas" })
|
||||
{
|
||||
StatusCode = StatusCodes.Status401Unauthorized
|
||||
};
|
||||
context.ExceptionHandled = true;
|
||||
break;
|
||||
|
||||
case TokenReuseDetectedException reuseEx:
|
||||
// Log with detail on the backend but return generic 401 to client
|
||||
_logger.LogWarning("Token reuse detected — possible session compromise: {Message}", reuseEx.Message);
|
||||
context.Result = new ObjectResult(new { error = "Token inválido" })
|
||||
{
|
||||
StatusCode = StatusCodes.Status401Unauthorized
|
||||
};
|
||||
context.ExceptionHandled = true;
|
||||
break;
|
||||
|
||||
case InvalidRefreshTokenException:
|
||||
// Generic 401 — do NOT reveal if token was expired, not found, or mismatched
|
||||
context.Result = new ObjectResult(new { error = "Token inválido" })
|
||||
{
|
||||
StatusCode = StatusCodes.Status401Unauthorized
|
||||
};
|
||||
context.ExceptionHandled = true;
|
||||
break;
|
||||
|
||||
case RolNotFoundException rolNotFoundEx:
|
||||
context.Result = new ObjectResult(new
|
||||
{
|
||||
error = "rol_not_found",
|
||||
message = rolNotFoundEx.Message
|
||||
})
|
||||
{
|
||||
StatusCode = StatusCodes.Status404NotFound
|
||||
};
|
||||
context.ExceptionHandled = true;
|
||||
break;
|
||||
|
||||
case PermisoNotFoundException permisoNotFoundEx:
|
||||
context.Result = new ObjectResult(new
|
||||
{
|
||||
error = "permiso_not_found",
|
||||
message = permisoNotFoundEx.Message
|
||||
})
|
||||
{
|
||||
StatusCode = StatusCodes.Status404NotFound
|
||||
};
|
||||
context.ExceptionHandled = true;
|
||||
break;
|
||||
|
||||
case RolAlreadyExistsException rolExistsEx:
|
||||
context.Result = new ObjectResult(new
|
||||
{
|
||||
error = "rol_already_exists",
|
||||
message = rolExistsEx.Message
|
||||
})
|
||||
{
|
||||
StatusCode = StatusCodes.Status409Conflict
|
||||
};
|
||||
context.ExceptionHandled = true;
|
||||
break;
|
||||
|
||||
case RolInUseException rolInUseEx:
|
||||
context.Result = new ObjectResult(new
|
||||
{
|
||||
error = "rol_in_use",
|
||||
message = rolInUseEx.Message
|
||||
})
|
||||
{
|
||||
StatusCode = StatusCodes.Status409Conflict
|
||||
};
|
||||
context.ExceptionHandled = true;
|
||||
break;
|
||||
|
||||
// CAT-001: Rubro exceptions
|
||||
case RubroNotFoundException rubroNotFoundEx:
|
||||
context.Result = new ObjectResult(new
|
||||
{
|
||||
error = "rubro_not_found",
|
||||
message = rubroNotFoundEx.Message
|
||||
})
|
||||
{
|
||||
StatusCode = StatusCodes.Status404NotFound
|
||||
};
|
||||
context.ExceptionHandled = true;
|
||||
break;
|
||||
|
||||
case RubroNombreDuplicadoEnPadreException rubroDupEx:
|
||||
context.Result = new ObjectResult(new
|
||||
{
|
||||
error = "rubro_nombre_duplicado",
|
||||
message = rubroDupEx.Message
|
||||
})
|
||||
{
|
||||
StatusCode = StatusCodes.Status409Conflict
|
||||
};
|
||||
context.ExceptionHandled = true;
|
||||
break;
|
||||
|
||||
case RubroTieneHijosActivosException rubroHijosEx:
|
||||
context.Result = new ObjectResult(new
|
||||
{
|
||||
error = "rubro_tiene_hijos_activos",
|
||||
message = rubroHijosEx.Message
|
||||
})
|
||||
{
|
||||
StatusCode = StatusCodes.Status409Conflict
|
||||
};
|
||||
context.ExceptionHandled = true;
|
||||
break;
|
||||
|
||||
case RubroPadreInactivoException rubroPadreEx:
|
||||
context.Result = new ObjectResult(new
|
||||
{
|
||||
error = "rubro_padre_inactivo",
|
||||
message = rubroPadreEx.Message
|
||||
})
|
||||
{
|
||||
StatusCode = StatusCodes.Status400BadRequest
|
||||
};
|
||||
context.ExceptionHandled = true;
|
||||
break;
|
||||
|
||||
case RubroMaxDepthExceededException rubroDepthEx:
|
||||
context.Result = new ObjectResult(new
|
||||
{
|
||||
error = "rubro_max_depth_exceeded",
|
||||
message = rubroDepthEx.Message
|
||||
})
|
||||
{
|
||||
StatusCode = StatusCodes.Status422UnprocessableEntity
|
||||
};
|
||||
context.ExceptionHandled = true;
|
||||
break;
|
||||
|
||||
case RubroCycleDetectedException rubroCycleEx:
|
||||
context.Result = new ObjectResult(new
|
||||
{
|
||||
error = "rubro_cycle_detected",
|
||||
message = rubroCycleEx.Message
|
||||
})
|
||||
{
|
||||
StatusCode = StatusCodes.Status400BadRequest
|
||||
};
|
||||
context.ExceptionHandled = true;
|
||||
break;
|
||||
|
||||
// CAT-002: Rubro Regla de Oro (rama vs hoja)
|
||||
case RubroPadreEsHojaConAvisosException rubroPadreHojaEx:
|
||||
context.Result = new ObjectResult(new
|
||||
{
|
||||
error = "rubro_padre_es_hoja_con_avisos",
|
||||
message = rubroPadreHojaEx.Message
|
||||
})
|
||||
{
|
||||
StatusCode = StatusCodes.Status409Conflict
|
||||
};
|
||||
context.ExceptionHandled = true;
|
||||
break;
|
||||
|
||||
case RubroEsRamaConHijosActivosException rubroRamaHijosEx:
|
||||
context.Result = new ObjectResult(new
|
||||
{
|
||||
error = "rubro_es_rama_con_hijos_activos",
|
||||
message = rubroRamaHijosEx.Message
|
||||
})
|
||||
{
|
||||
StatusCode = StatusCodes.Status409Conflict
|
||||
};
|
||||
context.ExceptionHandled = true;
|
||||
break;
|
||||
|
||||
case RubroConProductosActivosException rubroProductosEx:
|
||||
context.Result = new ObjectResult(new
|
||||
{
|
||||
error = "rubro_con_productos_activos",
|
||||
message = rubroProductosEx.Message
|
||||
})
|
||||
{
|
||||
StatusCode = StatusCodes.Status409Conflict
|
||||
};
|
||||
context.ExceptionHandled = true;
|
||||
break;
|
||||
|
||||
// ADM-001: Medio exceptions
|
||||
case MedioCodigoDuplicadoException medioCodDupEx:
|
||||
context.Result = new ObjectResult(new
|
||||
{
|
||||
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
|
||||
case InvalidPermisoCodesException ipce:
|
||||
context.Result = new ObjectResult(new Microsoft.AspNetCore.Mvc.ProblemDetails
|
||||
{
|
||||
Type = "about:blank",
|
||||
Title = "invalid-permiso-codes",
|
||||
Status = 400,
|
||||
Extensions = { ["invalidCodes"] = ipce.InvalidCodes }
|
||||
})
|
||||
{
|
||||
StatusCode = StatusCodes.Status400BadRequest
|
||||
};
|
||||
context.ExceptionHandled = true;
|
||||
break;
|
||||
|
||||
case GrantDenyOverlapException gdoe:
|
||||
context.Result = new ObjectResult(new Microsoft.AspNetCore.Mvc.ProblemDetails
|
||||
{
|
||||
Type = "about:blank",
|
||||
Title = "grant-deny-overlap",
|
||||
Status = 400,
|
||||
Extensions = { ["overlap"] = gdoe.Overlap }
|
||||
})
|
||||
{
|
||||
StatusCode = StatusCodes.Status400BadRequest
|
||||
};
|
||||
context.ExceptionHandled = true;
|
||||
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:
|
||||
var errors = validationEx.Errors
|
||||
.GroupBy(e => e.PropertyName)
|
||||
.ToDictionary(
|
||||
g => g.Key,
|
||||
g => g.Select(e => e.ErrorMessage).ToArray());
|
||||
|
||||
context.Result = new BadRequestObjectResult(new { errors });
|
||||
context.ExceptionHandled = true;
|
||||
break;
|
||||
|
||||
default:
|
||||
_logger.LogError(context.Exception, "Unhandled exception");
|
||||
context.Result = new ObjectResult(new { error = "Internal server error" })
|
||||
{
|
||||
StatusCode = StatusCodes.Status500InternalServerError
|
||||
};
|
||||
context.ExceptionHandled = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
126
src/api/SIGCM2.Api/HealthChecks/AuditHealthCheck.cs
Normal file
126
src/api/SIGCM2.Api/HealthChecks/AuditHealthCheck.cs
Normal file
@@ -0,0 +1,126 @@
|
||||
using Dapper;
|
||||
using Microsoft.Extensions.Diagnostics.HealthChecks;
|
||||
using SIGCM2.Infrastructure.Persistence;
|
||||
|
||||
namespace SIGCM2.Api.HealthChecks;
|
||||
|
||||
/// <summary>
|
||||
/// UDT-010 (#REQ-AUD-8): health check for audit infrastructure.
|
||||
/// Validates:
|
||||
/// - SYSTEM_VERSIONING is ON for Usuario/Rol/Permiso/RolPermiso.
|
||||
/// - Monthly partitions exist for the next 3 months on AuditEvent + SecurityEvent.
|
||||
/// - Last AuditEvent is recent enough (< 24h) — relaxed from 1h spec to accommodate
|
||||
/// quiet dev/test environments; prod deployments should tighten to 1h via config.
|
||||
/// - HISTORY_RETENTION_PERIOD matches 10 years for the 4 versioned catalog tables.
|
||||
/// Returns Unhealthy with details when any check fails.
|
||||
/// </summary>
|
||||
public sealed class AuditHealthCheck : IHealthCheck
|
||||
{
|
||||
private readonly SqlConnectionFactory _factory;
|
||||
|
||||
public AuditHealthCheck(SqlConnectionFactory factory)
|
||||
{
|
||||
_factory = factory;
|
||||
}
|
||||
|
||||
public async Task<HealthCheckResult> CheckHealthAsync(
|
||||
HealthCheckContext context,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
try
|
||||
{
|
||||
await using var conn = _factory.CreateConnection();
|
||||
await conn.OpenAsync(cancellationToken);
|
||||
|
||||
// 1. SYSTEM_VERSIONING checks
|
||||
var versionedMissing = (await conn.QueryAsync<string>("""
|
||||
SELECT t.name
|
||||
FROM sys.tables t
|
||||
WHERE t.name IN ('Usuario','Rol','Permiso','RolPermiso')
|
||||
AND t.temporal_type <> 2;
|
||||
""")).ToList();
|
||||
|
||||
if (versionedMissing.Any())
|
||||
{
|
||||
return HealthCheckResult.Unhealthy(
|
||||
$"SYSTEM_VERSIONING missing on: {string.Join(",", versionedMissing)}");
|
||||
}
|
||||
|
||||
// 2. Partitions for next 3 months in both event tables
|
||||
var now = DateTime.UtcNow;
|
||||
var requiredBoundaries = new[]
|
||||
{
|
||||
new DateTime(now.Year, now.Month, 1).AddMonths(1),
|
||||
new DateTime(now.Year, now.Month, 1).AddMonths(2),
|
||||
new DateTime(now.Year, now.Month, 1).AddMonths(3),
|
||||
};
|
||||
|
||||
foreach (var pfName in new[] { "pf_AuditEvent_Monthly", "pf_SecurityEvent_Monthly" })
|
||||
{
|
||||
var values = (await conn.QueryAsync<DateTime>("""
|
||||
SELECT CAST(prv.value AS DATETIME2(3))
|
||||
FROM sys.partition_functions pf
|
||||
JOIN sys.partition_range_values prv ON prv.function_id = pf.function_id
|
||||
WHERE pf.name = @Name;
|
||||
""", new { Name = pfName })).ToHashSet();
|
||||
|
||||
foreach (var req in requiredBoundaries)
|
||||
{
|
||||
if (!values.Contains(req))
|
||||
{
|
||||
return HealthCheckResult.Unhealthy(
|
||||
$"Partition boundary missing in {pfName}: {req:yyyy-MM-dd}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Recent audit activity — lenient 24h to avoid false positives in quiet envs
|
||||
var lastEventAt = await conn.ExecuteScalarAsync<DateTime?>(
|
||||
"SELECT MAX(OccurredAt) FROM dbo.AuditEvent;");
|
||||
var recentMessage = lastEventAt is null
|
||||
? "no audit events yet (acceptable on fresh DB)"
|
||||
: (now - lastEventAt.Value).TotalHours < 24
|
||||
? "recent"
|
||||
: $"stale: last event {(now - lastEventAt.Value).TotalHours:F1}h ago";
|
||||
|
||||
// 4. Retention period check.
|
||||
// sys.tables.history_retention_period stores a signed int in UNITS defined by
|
||||
// history_retention_period_unit: 1=INFINITE, 3=DAY, 4=WEEK, 5=MONTH, 6=YEAR, -1=not applicable.
|
||||
// V010 sets HISTORY_RETENTION_PERIOD = 10 YEARS → period=10, unit=6.
|
||||
var retentionRows = (await conn.QueryAsync<(string TableName, int? Period, int? Unit)>("""
|
||||
SELECT t.name AS TableName,
|
||||
t.history_retention_period AS Period,
|
||||
t.history_retention_period_unit AS Unit
|
||||
FROM sys.tables t
|
||||
WHERE t.name IN ('Usuario','Rol','Permiso','RolPermiso')
|
||||
AND t.temporal_type = 2;
|
||||
""")).ToList();
|
||||
|
||||
var badRetention = retentionRows
|
||||
.Where(r => !(r.Period == 10 && r.Unit == 6)) // not 10 YEARS
|
||||
.Select(r => r.TableName)
|
||||
.ToList();
|
||||
|
||||
var data = new Dictionary<string, object>
|
||||
{
|
||||
["versionedTables"] = "Usuario, Rol, Permiso, RolPermiso",
|
||||
["lastAuditEvent"] = (object?)lastEventAt ?? "none",
|
||||
["lastAuditEventStatus"] = recentMessage,
|
||||
["retentionOk"] = badRetention.Count == 0,
|
||||
};
|
||||
|
||||
if (badRetention.Any())
|
||||
{
|
||||
return HealthCheckResult.Degraded(
|
||||
$"Retention != 10 YEARS for: {string.Join(",", badRetention)}",
|
||||
data: data);
|
||||
}
|
||||
|
||||
return HealthCheckResult.Healthy("audit infrastructure OK", data);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return HealthCheckResult.Unhealthy("audit health check threw", ex);
|
||||
}
|
||||
}
|
||||
}
|
||||
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));
|
||||
}
|
||||
}
|
||||
32
src/api/SIGCM2.Api/Middleware/AuditActorMiddleware.cs
Normal file
32
src/api/SIGCM2.Api/Middleware/AuditActorMiddleware.cs
Normal file
@@ -0,0 +1,32 @@
|
||||
using Microsoft.AspNetCore.Http;
|
||||
|
||||
namespace SIGCM2.Api.Middleware;
|
||||
|
||||
/// UDT-010 — post-auth middleware that reads the JWT "sub" claim and stores the
|
||||
/// resolved ActorUserId in HttpContext.Items. Anonymous requests leave it unset.
|
||||
/// ActorRoleId is reserved for a future batch (rol code → id resolution).
|
||||
public sealed class AuditActorMiddleware
|
||||
{
|
||||
public const string ItemActorUserId = "audit:actorUserId";
|
||||
|
||||
private readonly RequestDelegate _next;
|
||||
|
||||
public AuditActorMiddleware(RequestDelegate next)
|
||||
{
|
||||
_next = next;
|
||||
}
|
||||
|
||||
public async Task InvokeAsync(HttpContext ctx)
|
||||
{
|
||||
if (ctx.User.Identity?.IsAuthenticated == true)
|
||||
{
|
||||
var sub = ctx.User.FindFirst("sub")?.Value;
|
||||
if (int.TryParse(sub, out var userId))
|
||||
{
|
||||
ctx.Items[ItemActorUserId] = userId;
|
||||
}
|
||||
}
|
||||
|
||||
await _next(ctx);
|
||||
}
|
||||
}
|
||||
51
src/api/SIGCM2.Api/Middleware/CorrelationIdMiddleware.cs
Normal file
51
src/api/SIGCM2.Api/Middleware/CorrelationIdMiddleware.cs
Normal file
@@ -0,0 +1,51 @@
|
||||
using Microsoft.AspNetCore.Http;
|
||||
|
||||
namespace SIGCM2.Api.Middleware;
|
||||
|
||||
/// UDT-010 — pre-auth middleware that stamps every request with a correlation ID,
|
||||
/// preserves one sent by the client via X-Correlation-Id, and exposes it on the response.
|
||||
/// Also captures Ip + UserAgent for downstream IAuditContext consumers.
|
||||
public sealed class CorrelationIdMiddleware
|
||||
{
|
||||
public const string HeaderName = "X-Correlation-Id";
|
||||
public const string ItemCorrelationId = "audit:correlationId";
|
||||
public const string ItemIp = "audit:ip";
|
||||
public const string ItemUserAgent = "audit:userAgent";
|
||||
|
||||
private readonly RequestDelegate _next;
|
||||
|
||||
public CorrelationIdMiddleware(RequestDelegate next)
|
||||
{
|
||||
_next = next;
|
||||
}
|
||||
|
||||
public async Task InvokeAsync(HttpContext ctx)
|
||||
{
|
||||
Guid correlationId;
|
||||
if (ctx.Request.Headers.TryGetValue(HeaderName, out var incoming)
|
||||
&& Guid.TryParse(incoming.ToString(), out var parsed))
|
||||
{
|
||||
correlationId = parsed;
|
||||
}
|
||||
else
|
||||
{
|
||||
correlationId = Guid.NewGuid();
|
||||
}
|
||||
|
||||
ctx.Items[ItemCorrelationId] = correlationId;
|
||||
ctx.Items[ItemIp] = ctx.Connection.RemoteIpAddress?.ToString();
|
||||
ctx.Items[ItemUserAgent] = ctx.Request.Headers.UserAgent.ToString();
|
||||
|
||||
ctx.Response.OnStarting(() =>
|
||||
{
|
||||
ctx.Response.Headers[HeaderName] = correlationId.ToString("D");
|
||||
return Task.CompletedTask;
|
||||
});
|
||||
|
||||
// Also set immediately for testability — DefaultHttpContext does not trigger OnStarting
|
||||
// in unit tests because no body is written through the pipeline.
|
||||
ctx.Response.Headers[HeaderName] = correlationId.ToString("D");
|
||||
|
||||
await _next(ctx);
|
||||
}
|
||||
}
|
||||
105
src/api/SIGCM2.Api/Program.cs
Normal file
105
src/api/SIGCM2.Api/Program.cs
Normal file
@@ -0,0 +1,105 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Serilog;
|
||||
using Scalar.AspNetCore;
|
||||
using SIGCM2.Api.Authorization;
|
||||
using SIGCM2.Api.Filters;
|
||||
using SIGCM2.Api.HealthChecks;
|
||||
using SIGCM2.Api.Json;
|
||||
using SIGCM2.Api.Middleware;
|
||||
using SIGCM2.Application;
|
||||
using SIGCM2.Infrastructure;
|
||||
using SIGCM2.Infrastructure.Audit.Jobs;
|
||||
|
||||
// Bootstrap logger — before DI is built
|
||||
Log.Logger = new LoggerConfiguration()
|
||||
.WriteTo.Console()
|
||||
.CreateBootstrapLogger();
|
||||
|
||||
Log.Information("Starting SIGCM2 API");
|
||||
|
||||
var builder = WebApplication.CreateBuilder(args);
|
||||
|
||||
// Serilog — reads from appsettings.json "Serilog" section
|
||||
builder.Host.UseSerilog((ctx, lc) => lc
|
||||
.ReadFrom.Configuration(ctx.Configuration));
|
||||
|
||||
// Application + Infrastructure DI
|
||||
builder.Services.AddApplication();
|
||||
builder.Services.AddInfrastructure(builder.Configuration);
|
||||
|
||||
// UDT-010: Quartz.NET + 3 audit maintenance jobs (partition, retention, integrity).
|
||||
// Disabled in Testing environment to keep integration tests deterministic.
|
||||
if (!builder.Environment.IsEnvironment("Testing"))
|
||||
builder.Services.AddAuditMaintenance(builder.Configuration);
|
||||
|
||||
// Authorization — handler lives in Api layer; DO NOT move to Infrastructure DI
|
||||
builder.Services.AddAuthorization();
|
||||
builder.Services.AddScoped<IAuthorizationHandler, PermissionAuthorizationHandler>();
|
||||
builder.Services.AddSingleton<IAuthorizationMiddlewareResultHandler, ForbiddenProblemDetailsHandler>();
|
||||
|
||||
// Controllers with exception filter + JSON options
|
||||
// UDT-011: DateOnlyJsonConverter ensures Cat2 date fields serialize as "yyyy-MM-dd"
|
||||
// and never as "2026-05-01T00:00:00" or with a UTC "Z" suffix.
|
||||
builder.Services.AddControllers(opts =>
|
||||
{
|
||||
opts.Filters.Add<ExceptionFilter>();
|
||||
}).AddJsonOptions(jsonOpts =>
|
||||
{
|
||||
jsonOpts.JsonSerializerOptions.Converters.Add(new DateOnlyJsonConverter());
|
||||
});
|
||||
|
||||
// OpenAPI / Scalar
|
||||
builder.Services.AddOpenApi();
|
||||
|
||||
// UDT-010: Audit infrastructure health check
|
||||
builder.Services.AddHealthChecks()
|
||||
.AddCheck<AuditHealthCheck>("audit", tags: new[] { "audit" });
|
||||
|
||||
// CORS
|
||||
var allowedOrigins = builder.Configuration
|
||||
.GetSection("Cors:AllowedOrigins")
|
||||
.Get<string[]>() ?? [];
|
||||
|
||||
builder.Services.AddCors(opts =>
|
||||
{
|
||||
opts.AddDefaultPolicy(policy =>
|
||||
policy.WithOrigins(allowedOrigins)
|
||||
.AllowAnyHeader()
|
||||
.AllowAnyMethod());
|
||||
});
|
||||
|
||||
var app = builder.Build();
|
||||
|
||||
// Middleware pipeline
|
||||
app.UseSerilogRequestLogging();
|
||||
|
||||
if (app.Environment.IsDevelopment() || app.Environment.IsEnvironment("Testing"))
|
||||
{
|
||||
app.MapOpenApi();
|
||||
app.MapScalarApiReference(opts =>
|
||||
{
|
||||
opts.Title = "SIGCM2 API";
|
||||
});
|
||||
}
|
||||
|
||||
app.UseHttpsRedirection();
|
||||
app.UseCors();
|
||||
// UDT-010: correlation id + ip/ua capture runs BEFORE auth so anonymous requests
|
||||
// still get a correlation id and so logs can tie pre-auth events to the request.
|
||||
app.UseMiddleware<CorrelationIdMiddleware>();
|
||||
app.UseAuthentication();
|
||||
// UDT-010: actor extraction runs AFTER auth to read the JWT sub claim.
|
||||
app.UseMiddleware<AuditActorMiddleware>();
|
||||
app.UseAuthorization();
|
||||
app.MapControllers();
|
||||
|
||||
// UDT-010: /health/audit returns the audit check status (public but sparse data).
|
||||
app.MapHealthChecks("/health/audit", new Microsoft.AspNetCore.Diagnostics.HealthChecks.HealthCheckOptions
|
||||
{
|
||||
Predicate = r => r.Tags.Contains("audit"),
|
||||
});
|
||||
|
||||
app.Run();
|
||||
|
||||
// Exposed for WebApplicationFactory in integration tests
|
||||
public partial class Program { }
|
||||
23
src/api/SIGCM2.Api/Properties/launchSettings.json
Normal file
23
src/api/SIGCM2.Api/Properties/launchSettings.json
Normal file
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/launchsettings.json",
|
||||
"profiles": {
|
||||
"http": {
|
||||
"commandName": "Project",
|
||||
"dotnetRunMessages": true,
|
||||
"launchBrowser": false,
|
||||
"applicationUrl": "http://localhost:5212",
|
||||
"environmentVariables": {
|
||||
"ASPNETCORE_ENVIRONMENT": "Development"
|
||||
}
|
||||
},
|
||||
"https": {
|
||||
"commandName": "Project",
|
||||
"dotnetRunMessages": true,
|
||||
"launchBrowser": false,
|
||||
"applicationUrl": "https://localhost:7280;http://localhost:5212",
|
||||
"environmentVariables": {
|
||||
"ASPNETCORE_ENVIRONMENT": "Development"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
25
src/api/SIGCM2.Api/SIGCM2.Api.csproj
Normal file
25
src/api/SIGCM2.Api/SIGCM2.Api.csproj
Normal file
@@ -0,0 +1,25 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk.Web">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<RootNamespace>SIGCM2.Api</RootNamespace>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.AspNetCore.OpenApi" />
|
||||
<PackageReference Include="Serilog.AspNetCore" />
|
||||
<PackageReference Include="Serilog.Sinks.Seq" />
|
||||
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" />
|
||||
<PackageReference Include="Scalar.AspNetCore" />
|
||||
<PackageReference Include="FluentValidation.AspNetCore" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\SIGCM2.Application\SIGCM2.Application.csproj" />
|
||||
<ProjectReference Include="..\SIGCM2.Infrastructure\SIGCM2.Infrastructure.csproj" />
|
||||
<ProjectReference Include="..\SIGCM2.Domain\SIGCM2.Domain.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
6
src/api/SIGCM2.Api/SIGCM2.Api.http
Normal file
6
src/api/SIGCM2.Api/SIGCM2.Api.http
Normal file
@@ -0,0 +1,6 @@
|
||||
@SIGCM2.Api_HostAddress = http://localhost:5212
|
||||
|
||||
GET {{SIGCM2.Api_HostAddress}}/weatherforecast/
|
||||
Accept: application/json
|
||||
|
||||
###
|
||||
13
src/api/SIGCM2.Api/appsettings.Development.json.example
Normal file
13
src/api/SIGCM2.Api/appsettings.Development.json.example
Normal file
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"ConnectionStrings": {
|
||||
"SqlServer": "Server=<YOUR_SERVER>;Database=SIGCM2;User Id=<YOUR_USER>;Password=<YOUR_PASSWORD>;TrustServerCertificate=True;"
|
||||
},
|
||||
"Serilog": {
|
||||
"MinimumLevel": {
|
||||
"Default": "Debug",
|
||||
"Override": {
|
||||
"Microsoft.AspNetCore": "Information"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
39
src/api/SIGCM2.Api/appsettings.json
Normal file
39
src/api/SIGCM2.Api/appsettings.json
Normal file
@@ -0,0 +1,39 @@
|
||||
{
|
||||
"ConnectionStrings": {
|
||||
"SqlServer": "Server=__SET_IN_DEV_OR_ENV__;Database=SIGCM2;User Id=__SET_IN_DEV_OR_ENV__;Password=__SET_IN_DEV_OR_ENV__;TrustServerCertificate=True;"
|
||||
},
|
||||
"Jwt": {
|
||||
"Issuer": "sigcm2.api",
|
||||
"Audience": "sigcm2.web",
|
||||
"AccessTokenMinutes": 60,
|
||||
"RefreshTokenDays": 7,
|
||||
"PrivateKeyPath": "keys/private.pem",
|
||||
"PublicKeyPath": "keys/public.pem",
|
||||
"PrivateKey": null,
|
||||
"PublicKey": null
|
||||
},
|
||||
"Cors": {
|
||||
"AllowedOrigins": [ "http://localhost:5173" ]
|
||||
},
|
||||
"Serilog": {
|
||||
"MinimumLevel": {
|
||||
"Default": "Information",
|
||||
"Override": {
|
||||
"Microsoft.AspNetCore": "Warning",
|
||||
"Microsoft.EntityFrameworkCore": "Warning"
|
||||
}
|
||||
},
|
||||
"WriteTo": [
|
||||
{ "Name": "Console" },
|
||||
{
|
||||
"Name": "Seq",
|
||||
"Args": { "serverUrl": "http://localhost:5341" }
|
||||
}
|
||||
],
|
||||
"Enrich": [ "FromLogContext", "WithMachineName", "WithThreadId" ]
|
||||
},
|
||||
"Rubros": {
|
||||
"MaxDepth": 10
|
||||
},
|
||||
"AllowedHosts": "*"
|
||||
}
|
||||
0
src/api/SIGCM2.Api/keys/.gitkeep
Normal file
0
src/api/SIGCM2.Api/keys/.gitkeep
Normal file
12
src/api/SIGCM2.Application/Abstractions/IClientContext.cs
Normal file
12
src/api/SIGCM2.Application/Abstractions/IClientContext.cs
Normal file
@@ -0,0 +1,12 @@
|
||||
namespace SIGCM2.Application.Abstractions;
|
||||
|
||||
/// <summary>
|
||||
/// Provides HTTP client metadata (IP address and User-Agent) from the current request context.
|
||||
/// Implemented in Infrastructure via IHttpContextAccessor.
|
||||
/// Mockable in tests without HTTP stack.
|
||||
/// </summary>
|
||||
public interface IClientContext
|
||||
{
|
||||
string Ip { get; }
|
||||
string? UserAgent { get; }
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
namespace SIGCM2.Application.Abstractions;
|
||||
|
||||
public interface ICommandHandler<TCommand, TResult>
|
||||
{
|
||||
Task<TResult> Handle(TCommand command);
|
||||
}
|
||||
6
src/api/SIGCM2.Application/Abstractions/IDispatcher.cs
Normal file
6
src/api/SIGCM2.Application/Abstractions/IDispatcher.cs
Normal file
@@ -0,0 +1,6 @@
|
||||
namespace SIGCM2.Application.Abstractions;
|
||||
|
||||
public interface IDispatcher
|
||||
{
|
||||
Task<TResult> Send<TCommand, TResult>(TCommand command);
|
||||
}
|
||||
6
src/api/SIGCM2.Application/Abstractions/IQueryHandler.cs
Normal file
6
src/api/SIGCM2.Application/Abstractions/IQueryHandler.cs
Normal file
@@ -0,0 +1,6 @@
|
||||
namespace SIGCM2.Application.Abstractions;
|
||||
|
||||
public interface IQueryHandler<TQuery, TResult>
|
||||
{
|
||||
Task<TResult> Handle(TQuery query);
|
||||
}
|
||||
@@ -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,10 @@
|
||||
using SIGCM2.Domain.Entities;
|
||||
|
||||
namespace SIGCM2.Application.Abstractions.Persistence;
|
||||
|
||||
public interface IPermisoRepository
|
||||
{
|
||||
Task<IReadOnlyList<Permiso>> ListAsync(CancellationToken ct = default);
|
||||
Task<Permiso?> GetByCodigoAsync(string codigo, CancellationToken ct = default);
|
||||
Task<IReadOnlyList<Permiso>> GetByCodigosAsync(IEnumerable<string> codigos, 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,33 @@
|
||||
using SIGCM2.Domain.Entities;
|
||||
|
||||
namespace SIGCM2.Application.Abstractions.Persistence;
|
||||
|
||||
public interface IRefreshTokenRepository
|
||||
{
|
||||
/// <summary>
|
||||
/// Finds a refresh token record by its SHA-256 hash.
|
||||
/// Returns the record even if it is revoked or expired — callers decide what to do.
|
||||
/// Returns null if no record matches the hash.
|
||||
/// </summary>
|
||||
Task<RefreshToken?> GetByHashAsync(string tokenHash, CancellationToken ct = default);
|
||||
|
||||
/// <summary>Persists a new refresh token and returns its generated Id.</summary>
|
||||
Task<int> AddAsync(RefreshToken token, CancellationToken ct = default);
|
||||
|
||||
/// <summary>Marks a single token as revoked and optionally records its successor.</summary>
|
||||
Task RevokeAsync(int id, int? replacedById, DateTime revokedAt, CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// Revokes all active (RevokedAt IS NULL) tokens in a family.
|
||||
/// Used for chain revocation on reuse detection.
|
||||
/// Returns the count of rows affected.
|
||||
/// </summary>
|
||||
Task<int> RevokeFamilyAsync(Guid familyId, DateTime revokedAt, CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// Revokes all active tokens for a user across all families.
|
||||
/// Used for logout.
|
||||
/// Returns the count of rows affected.
|
||||
/// </summary>
|
||||
Task<int> RevokeAllActiveForUserAsync(int usuarioId, DateTime revokedAt, CancellationToken ct = default);
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
using SIGCM2.Domain.Entities;
|
||||
|
||||
namespace SIGCM2.Application.Abstractions.Persistence;
|
||||
|
||||
public interface IRolPermisoRepository
|
||||
{
|
||||
Task<IReadOnlyList<Permiso>> GetByRolCodigoAsync(string rolCodigo, CancellationToken ct = default);
|
||||
Task ReplaceForRolAsync(int rolId, IEnumerable<int> permisoIds, CancellationToken ct = default);
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
using SIGCM2.Domain.Entities;
|
||||
|
||||
namespace SIGCM2.Application.Abstractions.Persistence;
|
||||
|
||||
public interface IRolRepository
|
||||
{
|
||||
Task<IReadOnlyList<Rol>> ListAsync(CancellationToken ct = default);
|
||||
Task<Rol?> GetByCodigoAsync(string codigo, CancellationToken ct = default);
|
||||
Task<bool> ExistsActiveByCodigoAsync(string codigo, CancellationToken ct = default);
|
||||
Task<int> AddAsync(Rol rol, CancellationToken ct = default);
|
||||
Task<bool> UpdateAsync(string codigo, string nombre, string? descripcion, bool activo, CancellationToken ct = default);
|
||||
Task<bool> HasActiveUsuariosAsync(string codigo, 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);
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user