Compare commits

...

124 Commits

Author SHA1 Message Date
5a231c206e Merge pull request 'feat(frontend): sidebar colapsable por secciones + fly-out en modo colapsado' (#62) from chore/sidebar-collapsible-sections into main 2026-04-21 17:09:07 +00:00
bcb0c94fc5 feat(frontend): sidebar secciones colapsables + fly-out en modo colapsado
Mejora UX post-refactor (PR #61): las 4 secciones del sidebar expandido son
ahora colapsables individualmente, y el modo colapsado reemplaza la lista
larga de iconos por un icono por grupo con fly-out panel on hover.

Expandido (240px):
- Click en header de sección (Seguridad/Maestros/Catálogo/Tasación) toggle
  collapse con chevron que rota.
- Default: todas colapsadas EXCEPTO la que contiene la ruta activa
  (auto-expand override).
- Sección activa tiene el header disabled + sin chevron (no se puede colapsar
  mientras estás ahí — evita esconder items de la ruta actual).
- Preferencia per-sección persistida en localStorage.

Colapsado (68px):
- Un icono por grupo en lugar de listar TODOS los items (evitando scroll
  largo en usuarios con muchos permisos).
- Hover sobre el grupo despliega un fly-out panel al lado derecho con el
  título del grupo + sus items clickeables.
- Grupo que contiene la ruta activa tiene un dot indicator.
- Icons de grupo: ShieldCheck (Seguridad), Building2 (Maestros),
  Package (Catálogo), Calculator (Tasación).

Accessibility:
- Headers expandidos: aria-expanded refleja estado.
- Fly-out: aria-haspopup='menu' + role='menu' + keyboard focus.

z-index management (pedido explícito del user):
- aside wrapper en ProtectedLayout: z-10 -> z-30 (sobre contenido).
- HoverCardContent del fly-out: z-[60] (sobre cualquier overlay app-level,
  excepto modal dialogs que siguen siendo z-50 por convención Radix).
- hover-card.tsx: envuelto en HoverCardPrimitive.Portal (faltaba en el
  shadcn generated) — previene que el fly-out quede cortado por overflow
  del aside.

Dependencies:
- shadcn hover-card agregado via 'npx shadcn@latest add' (+ @radix-ui/react-hover-card).

Tests:
- 16 tests (antes 10) — agregados 6 casos: default collapsed except active,
  click toggle expand/collapse, aria-expanded reflection, disabled header
  when active, root route collapses all, localStorage persistence.
2026-04-21 14:07:12 -03:00
2aae873a4b Merge pull request 'chore(frontend): reorganizar sidebar en secciones + quitar items disabled' (#61) from chore/sidebar-categorization into main 2026-04-21 16:38:17 +00:00
3a534f7ad3 chore(frontend): reorganize sidebar into grouped sections + remove disabled items
Problem: sidebar was growing unwieldy — 4 top-level disabled items marked
'Próx.' acted as visual noise, and 12 admin items sat in a flat list with
no grouping (hard to scan).

Changes:
- Remove the 4 disabled top-level items (Ventas, Tasación, Integraciones,
  Administración-as-link). Those features will surface via the admin
  subsections when actually implemented, not as placeholder ghosts.
- Group the 12 admin items into 4 domain-aligned sections:
  - Seguridad: Usuarios, Crear Usuario, Roles, Permisos, Auditoría
  - Maestros:  Medios, Secciones, Puntos de Venta
  - Catálogo:  Rubros, Tipos de Producto, Productos
  - Tasación:  Caracteres Tasables
- Sections auto-hide when no item passes the permission filter, preventing
  empty headers for users with limited roles.
- Dashboard remains as the single top-level nav item (always visible).

TDD: new AppSidebar.test.tsx covers 10 scenarios — section rendering,
permission filtering, section auto-hide, role gating, active-route marking,
and section ordering.
2026-04-21 13:37:53 -03:00
dfeb5fb7e1 Merge pull request 'chore(prc-001): followups #54 #55 #57 #58 + cierre #52 #53' (#60) from chore/prc-001-followups into main 2026-04-21 16:28:29 +00:00
3e7c4bfde9 chore(prc-001): followups #54 #55 #57 #58 — emoji validator + opt-in pricing + demo seed + tsconfig
Resuelve 4 de los followups creados post-archive de PRC-001:

#55 — Decisión de negocio (2026-04-21): emojis NO se permiten en Symbol config.
  - WordCounterService.ContainsEmoji(string): helper publico que reutiliza los
    rangos Unicode de IsEmojiRune (Emoticons, Pictographs, Dingbats, VS-16, ZWJ).
  - CreateChargeableCharConfigCommandValidator: regla .Must que rechaza emoji
    en Symbol con mensaje claro. Defensiva: cubre clientes directos al API
    (Postman, adversariales) mas alla del SymbolInput blocker del frontend.
  - Tests: 5 emojis positivos (smile/car/fire/heart VS-16/sun) + 8 plain symbols
    ($, %, !, ¡, @, €, ##, ABCD) + actualizacion del Api test E2E (Post_WithEmojiSymbol).

#57 — Alineacion FluentValidation con opt-in billing (CK_Price_NonNegative >= 0).
  - CreateChargeableCharConfigCommandValidator.PricePerUnit: GreaterThan(0)
    -> GreaterThanOrEqualTo(0). Mensaje explica el significado: 0 = no cobra.
  - Tests actualizados: PricePerUnit_Zero ahora Passes (era Fails). Negative
    sigue fallando. Api e2e usa -1 para el caso de rechazo.

#58 — tsconfig ignoreDeprecations + MSW handler (parte a).
  - src/web/tsconfig.json: agrega "ignoreDeprecations": "6.0" para silenciar
    el warning TS5101 del baseUrl deprecated en TS 6.x.
  - (El MSW handler de /api/v1/admin/product-types no aplica — los tests ya
    mockean ProductTypeSelect directamente; warning residual no existe.)

#54 — Seeder demo de overrides ficticios per-ProductType (V025).
  - database/migrations/V025__seed_chargeable_char_overrides_demo.sql:
    MERGE idempotente que crea overrides de ChargeableCharConfig para
    ProductTypes Clasificado/Notables/Fúnebres si existen en la DB.
    Precios ficticios ($ 5-8, % 3-5, ! 2-4, ¡ 2-4). No-op si los tipos no
    estan seedados (sera cuando PRD-008 haga seed de los 12 legacy).
  - V025_ROLLBACK.sql: elimina overrides demo preservando globales.
  - Aplicado en SIGCM2, SIGCM2_Test_App, SIGCM2_Test_Api.
  - database/README.md: V025 agregada al indice.

Tests: 1659 .NET (1310 Application + 349 Api) + 510 vitest — todos GREEN.

Closes #54, #55, #57, #58.
2026-04-21 13:27:54 -03:00
0eab947975 Merge pull request #59: PRC-001 WordCounter + ChargeableCharConfig (SPIKE + scope delta)
Closes PR #59. Retroactive PR documenting the full PRC-001 scope + final README commit.

Scope: WordCounterService (25 golden cases) + ChargeableCharConfig ABM per-ProductType + Reactivate (A+guard) + Delete (guard diferido FAC-001) + UI condicional. 14 commits total (8 core SPIKE + 6 refinement). 1634 .NET + 510 vitest tests green. Verify: PASS-WITH-FOLLOWUPS. Followups #52-#58.
2026-04-21 16:06:20 +00:00
ee36d86b5a docs(bd): V018..V024 entries in database/README.md (PRC-001 archive)
Archive follow-up: updates the migrations table in database/README.md with
V018 (PRD-002), V019 (PRD-003), and V020..V024 (PRC-001 + scope delta).
The table was stale since V017 (PRD-001). This entry keeps the onboarding
doc aligned with the actual migration chain applied on all three DBs.
2026-04-21 13:04:54 -03:00
0e2e4c9c94 Merge PRC-001: WordCounter + ChargeableCharConfig (SPIKE) + refinement
Full scope delivered:
- WordCounterService (25/25 golden cases, pure domain, anti-fraud algorithm)
- ChargeableCharConfig entity per ProductType + global fallback + forward-only history
- Admin CRUD + Reactivate (A+guard) + Delete endpoints
- Temporal Tables + IAuditLogger integration (fail-closed)
- Permission tasacion:caracteres_especiales:gestionar
- Frontend CMS feature (SymbolInput emoji blocker, conditional actions, ProductTypeSelect)
- V020+V021+V022+V023+V024 migrations
- 1634 .NET + 510 vitest tests green

Engram artifacts: sdd/prc-001-word-counter-spike/*
Verify report: sdd/prc-001-word-counter-spike/verify-report-v2 (PASS-WITH-FOLLOWUPS)
2026-04-21 12:58:07 -03:00
3a596080cb fix(frontend): generic delete warning without FAC-001 reference (PRC-001)
User feedback from smoke test: the FAC-001 reference is future-coupled — when
the invoicing module lands we would need to remember to update the dialog text.
Switched to a timeless message that describes the current behavior: 'La
eliminación es posible porque este carácter no está en uso.' It stays accurate
before and after the FAC-001 usage guard ships.
2026-04-21 12:47:07 -03:00
d7c6cbd4ff fix(backend+tests): reactivate endpoint 500 + test schema mismatches (PRC-001)
Three bugs surfaced while user smoke-testing Reactivate:

1. ReactivateAsync opened a SECOND connection for GetByIdAsync after the SP
   call, inside the ambient TransactionScope. This promoted the tx to DTC
   (distributed) which requires MSDTC — typically not enabled on dev/prod
   servers. The API returned an opaque 500. Fix: run the post-SP SELECT on
   the SAME connection (local tx stays lightweight / LTM).

2. Agent 1's V023 test refactor wrote 'INSERT INTO dbo.ProductType (Nombre,
   Codigo, Activo)' in 2 test files — but dbo.ProductType has no 'Codigo' or
   'Activo' columns (schema is Nombre + IsActive + flags + multimedia limits).
   Fix: use '(Nombre, HasDuration, RequiresText, RequiresCategory, IsBundle,
   AllowImages)' matching the other test files (ProductQueryRepositoryTests,
   ProductRepositoryTests, ProductPriceRepositoryIntegrationTests).

3. SqlTestFixture.EnsureV021SchemaAsync unconditionally ALTERed the V021-era
   SPs with '@MedioId' body. On second fixture run after V023 had already
   refactored the table, ALTER PROCEDURE body referenced a MedioId column
   that no longer existed — 'Invalid column name MedioId'. Fix: guard the
   V021 SP ALTERs + seedV022 behind 'MedioId column exists' check. If V023
   already dropped MedioId, skip V021 re-install; EnsureV023SchemaAsync
   still recreates SPs with @ProductTypeId.

4. PricingExceptionTests still used 'medioId:' named-arg + '.MedioId' — Agent 2
   renamed the exception property but not these 6 test references.

Tests: 1297/1297 Application.Tests green.
2026-04-21 11:32:23 -03:00
40b5f3904a fix(bd): V023 idempotent guard for SYSTEM_VERSIONING OFF (PRC-001)
Wrap 'ALTER TABLE ... SET SYSTEM_VERSIONING = OFF' in temporal_type=2 check.
Without this guard, re-running V023 on a DB where a previous partial run
already turned SYSTEM_VERSIONING OFF fails with:
  'SYSTEM_VERSIONING is not turned ON for table...'

Found while applying on SIGCM2 dev after a prior interrupted run. Now truly
idempotent: safe to re-run in any state (fully applied, never applied, or
partially applied with SYSTEM_VERSIONING already OFF).
2026-04-21 11:18:53 -03:00
3eecb05634 refactor+feat(frontend): chargeableChars por ProductType + Reactivate/Delete/UI condicional (PRC-001)
Part of feature/PRC-001 pre-merge refinement.

REFACTOR:
- types, API client, hooks, components renamed MedioId -> ProductTypeId
- CopyToAllMediaDialog -> CopyToAllProductTypesDialog
- ProductTypeSelect reused from features/product-types (or created minimal stub)
- Form validation + test mocks updated

FEATURES:
- Conditional action buttons per row.isActive:
  - Active: Desactivar + Eliminar
  - Inactive: Reactivar + Eliminar
- Reactivate: useReactivateChargeableCharConfig hook, 409 reason surfaces
  localized error message
- Delete: useDeleteChargeableCharConfig hook + DeleteChargeableCharConfigDialog
  with confirmation warning + FAC-001 disclaimer
- ProductType column in ChargeableCharsTable (fallback "Global" when null)

Tests:
- Conditional rendering tests (5 new)
- Reactivate/Delete hook tests (5 new)
- Updated mocks for all existing tests
- DeleteChargeableCharConfigDialog tests (3 new)
- CopyToAllProductTypesDialog tests (3 new)
2026-04-21 11:08:17 -03:00
f7fb76219a refactor+feat(backend): ChargeableCharConfig por ProductType + Reactivate + Delete endpoints (PRC-001)
Part A — MedioId → ProductTypeId rename across all C# layers:
  Domain, Application, Infrastructure, API, all test projects.
  Solution was non-compilable after BD refactor (5c1675e); now compiles clean (0 errors).

Part B — PATCH /api/v1/admin/chargeable-chars/{id}/reactivate:
  ReactivateChargeableCharConfigCommand/Handler, SP guard maps 50410/50411/50412
  → ChargeableCharConfigReactivationNotAllowedException(Reason) → HTTP 409.

Part C — DELETE /api/v1/admin/chargeable-chars/{id}:
  DeleteChargeableCharConfigCommand/Handler, physical DELETE on SYSTEM_VERSIONED table.
  KeyNotFoundException → 404 via ExceptionFilter.

Tests: +30 unit tests (TDD RED→GREEN). All 1266 unit tests pass.
2026-04-21 10:54:47 -03:00
5c1675e59a refactor(bd): V023+V024 ChargeableCharConfig por ProductType + SP ReactivateWithGuard (PRC-001)
BREAKING: schema refactor pre-merge. Backend+frontend do not compile yet;
subsequent commits in this PR restore compilation. Acceptable only because
feature/PRC-001 is not yet merged to main.

- V023: drop MedioId + FK_Medio, add ProductTypeId + FK_ProductType, rename
  indexes, drop+create SPs InsertWithClose (now @ProductTypeId) and
  GetActiveForProductType (renamed from GetActiveForMedio). NEW SP
  ReactivateWithGuard (A+guard pattern for feature 3 of scope delta).
  Drop CK_Price_Positive, add CK_Price_NonNegative (>= 0 for opt-in billing).
- V024: reseed global rows with PricePerUnit = 0.0000 (opt-in billing).
- V023_ROLLBACK + V024_ROLLBACK scripts.
- SqlTestFixture: EnsureV023SchemaAsync, EnsureV024SeedAsync, renamed seed
  method signature (ProductTypeId=NULL + PricePerUnit=0), history table
  TablesToIgnore preserved. HardeningTests seeds dbo.ProductType (not Medio).
- MigrationTests: updated SP existence + column + FK + price assertions.
- RepositoryIntegrationTests + HardeningTests: SQL-level assertions updated;
  C# method/property renames deferred to Agent 2 (backend refactor).
2026-04-21 10:35:38 -03:00
5175cc1ece test(integration): concurrency + SYSTEM_VERSIONING + e2e extra (PRC-001)
Batch 7 hardening tests:
- T7.1 Concurrency: SemaphoreSlim barrier + Task.WhenAll; exactly 1 winner,
  2 losers receive SqlException; post-race vigente count = 1.
- T7.2 SYSTEM_VERSIONING: exact 0-before / 1-after history row count on close;
  history captures pre-close state (ValidTo was NULL at snapshot).
- T7.3 FOR SYSTEM_TIME AS OF: temporal snapshot at T0 returns row as it existed
  before the close UPDATE (ValidTo=NULL, original price).
- T7.4 Per-medio + global fallback: ELDIA override for % wins over global;
  ELPLATA falls back to V022 global seed at 1.00; service-layer priority verified.
- T7.6 WordCounterService x ChargeableCharConfig integration contract (pure unit):
  documents PRC-002+ billing pattern; asserts charge computation for 6 scenarios.

Total .NET tests: 1603 (was 1591; +12 new).
2026-04-20 13:21:59 -03:00
c2a0612a70 feat(frontend): chargeableChars feature — table + dialog + copy-to-all (PRC-001)
- types.ts: ChargeableCharConfig, PagedResult, requests (validFrom/validTo as yyyy-MM-dd strings, UDT-011)
- categories.ts: CHARGEABLE_CHAR_CATEGORIES + CATEGORY_LABELS
- api/: 5 functions (list, getById, create, schedulePriceChange, deactivate) via axiosClient
- hooks/: 5 TanStack Query hooks; mutations invalidate ['chargeableChars','list'] + byId
- SymbolInput.tsx: emoji-blocking input (/\p{Extended_Pictographic}/u), max 4 chars
- ChargeableCharsTable.tsx: shadcn DataTable; medio filter + activeOnly toggle; Vigente/Cerrada badges; formatCivilDate (UDT-011)
- ChargeableCharFormDialog.tsx: dual-mode create/schedulePrice; Zod schema; todayArgentina() min date; 409 inline error
- CopyToAllMediaDialog.tsx: Promise.allSettled over active medios; preview symbol/price/date
- ChargeableCharsPage.tsx: orchestrates table + dialogs + state
- routes.tsx: path/permission constants
- router.tsx: route /admin/tasacion/chargeable-chars registered
- AppSidebar.tsx: nav item "Caracteres Tasables" with Hash icon
- Tests: 22 new RTL/vitest tests (5 test files) — strict TDD RED→GREEN→REFACTOR
2026-04-20 12:59:27 -03:00
8fc7b363d5 feat(api): ChargeableCharConfigController + DI + ExceptionFilter integration (PRC-001) 2026-04-20 12:46:07 -03:00
3b1edfd696 feat(infrastructure): ChargeableCharConfigRepository Dapper + SP invocation (PRC-001)
- ChargeableCharConfigRepository implements IChargeableCharConfigRepository via Dapper
- InsertWithCloseAsync calls usp_ChargeableCharConfig_InsertWithClose with OUTPUT params;
  maps SqlException 50409 → ChargeableCharConfigForwardOnlyException, 50404 → ChargeableCharConfigInvalidException
- GetActiveForMedioAsync calls usp_ChargeableCharConfig_GetActiveForMedio; returns all rows
  (global + per-medio) — Application service handles priority resolution
- ListAsync / CountAsync use parameterized SQL with OFFSET/FETCH and NULL-aware MedioId filter
- GetByIdAsync / DeactivateAsync cover single-entity read and idempotent deactivation
- DateOnly mapping: DateTime → DateOnly.FromDateTime() pattern, same as ProductPriceRepository
- Registered IChargeableCharConfigRepository → ChargeableCharConfigRepository in DI
- 14 integration tests against SIGCM2_Test_App (all GREEN); 1571/1571 total tests pass
2026-04-20 12:32:17 -03:00
f1b38cd9ce feat(application): commands/queries + IChargeableCharConfigService (PRC-001) 2026-04-20 12:24:06 -03:00
ded76fcdc7 feat(domain): WordCounterService + WordCountResult + ChargeableCharConfig entity + exceptions (PRC-001)
- WordCounterService: pure domain service, 7-step algorithm (null/empty fast path → length check → emoji detection via Rune.EnumerateRunes → count specials before replace → replace specials+hyphens → collapse whitespace → tokenize)
- WordCountResult: sealed record with TotalWords + IReadOnlyDictionary<string,int> SpecialCharCounts
- 4 domain exceptions extending DomainException: EmojiDetectedException, WordCountValidationException, ChargeableCharConfigInvalidException, ChargeableCharConfigForwardOnlyException
- ChargeableCharConfig: rich entity with Create factory (invariants), Rehydrate reconstructor, ScheduleNewPrice (forward-only, returns new entity), Deactivate (idempotent)
- ChargeableCharCategories: enum-as-string constants (Currency, Percentage, Exclamation, Question, Other)
- DomainTimeProviderExtensions: internal GetArgentinaToday helper (mirrors Application.Common without creating Domain→Application dependency)
- 60 new tests: 25 golden cases all GREEN, 12 entity invariant tests, 12 exception tests, 5 WordCountResult tests, 6 ChargeableCharConfig entity tests
2026-04-20 12:13:06 -03:00
8ac91a13aa feat(bd): V020 permiso + V022 seed ChargeableCharConfig (PRC-001) 2026-04-20 12:01:55 -03:00
9144c2e89e feat(bd): V021 crea dbo.ChargeableCharConfig + SPs + índices (PRC-001) 2026-04-20 12:01:49 -03:00
dd4d4a1673 Merge pull request 'feat: paginación en GET /api/v1/products/{id}/prices (closes #47)' (#51) from feature/prd-003-prices-pagination into main 2026-04-19 23:08:31 +00:00
e997409e95 test(integration): pagination edge cases (prd-003-prices-pagination) 2026-04-19 20:01:09 -03:00
34b07a1d55 feat(frontend): pagination UI on product prices history (refs #47) 2026-04-19 19:52:45 -03:00
0dce3ee4ac feat(api): pagination on GET product prices (closes #47)
- GET /api/v1/products/{id}/prices now returns PagedResult<ProductPriceDto>
  with OFFSET/FETCH + COUNT via Dapper (two queries on same connection)
- Query params: ?page (default 1) and ?pageSize (default 20, max 100)
- Clamping: Math.Max(1, page) + Math.Clamp(pageSize, 1, 100) in handler
- Auth upgraded from [Authorize] to [RequirePermission("catalogo:productos:gestionar")]
- IProductPriceRepository.GetByProductIdAsync signature updated to paginated form
- AddProductPriceCommandHandler adapted to read back via page=1, pageSize=2
- TDD cycle: RED (tests updated to PagedResult shape) -> GREEN (implementation) -> REFACTOR
- Tests: 1418 total (1106 Application + 312 Api), 0 failures

closes #47
2026-04-19 19:47:18 -03:00
da063ad677 Merge pull request 'refactor(frontend): unify dateFormat + numberFormat into formatters' (#50) from refactor/unify-formatters into main 2026-04-19 22:26:48 +00:00
7d06ac721b refactor(frontend): unify dateFormat + numberFormat into formatters (closes #46)
- Create src/web/src/lib/formatters.ts with all exports from both modules
- Migrate all 14 import sites to @/lib/formatters (Opción A — immediate migration)
- Replace dateFormat.test.ts with formatters.test.ts including 10 smoke tests + full suite
- Delete src/web/src/lib/dateFormat.ts and numberFormat.ts
- 464 tests green, tsc clean (TS5101 warning is pre-existing)
2026-04-19 19:26:24 -03:00
5a55fdaaae Merge pull request 'chore(infra): configure coverlet for backend C# coverage' (#49) from infra/coverlet-setup into main
chore(infra): configure coverlet for backend C# coverage (#49)
2026-04-19 22:22:05 +00:00
9f1a312bb9 chore(infra): configure coverlet for backend C# coverage
Add coverlet.runsettings with Cobertura format, exclusions for migrations,
DI wiring, Program.cs and auto-props. Document coverage commands in README.

coverlet.collector 6.0.4 was already present via Directory.Packages.props.

Coverage baseline (Application.Tests + Api.Tests combined):
- Application.Tests: line 80.9%, branch 65.3%
- Api.Tests: line 64.9%, branch 57.8%

Closes #48
2026-04-19 19:21:45 -03:00
dd0e5e4fe8 Merge pull request 'feat: PRD-003 ProductPrices históricos (ValidFrom/ValidTo)' (#45) from feature/PRD-003 into main 2026-04-19 22:07:21 +00:00
7cabb677f3 test(integration): concurrency + SYSTEM_VERSIONING + e2e extra (PRD-003) 2026-04-19 18:43:11 -03:00
6a9818b0ae feat(frontend): productPrices feature — history + dialog (PRD-003)
- API layer: getProductPrices + addProductPrice (axiosClient)
- Hooks: useProductPrices (useQuery, staleTime 30s, enabled productId>0)
         useAddProductPrice (useMutation, invalidates ['products', id, 'prices'])
- Components: ProductPriceHistory (shadcn Table + Badge Vigente, formatCivilDate, formatCurrency)
              AddProductPriceDialog (shadcn Dialog + Form, Zod schema con priceValidFrom>=todayArgentina())
- Integration: ProductsPage gets "Ver precios" per row opening prices dialog
- lib/numberFormat.ts: formatCurrency() con Intl.NumberFormat ARS
- types.ts extended: ProductPrice, AddProductPriceRequest, AddProductPriceResponse
- Tests (Vitest + RTL): 19 tests — RED→GREEN confirmed
  - ProductPriceHistory: loading/error/empty/data/Badge Vigente/dialog/permissions
  - AddProductPriceDialog: validation (fecha pasada, precio=0, precio negativo),
    happy path payload + close, server 409 inline error, vi.useFakeTimers ART
  - hooks: useProductPrices caching + disabled when productId=0,
           useAddProductPrice invalidateQueries + error 409
- 453 total tests, 0 rojos
2026-04-19 18:36:17 -03:00
f6f24bc4be feat(api): ProductPricesController + DI + ExceptionFilter integration (PRD-003)
- GET /api/v1/products/{id}/prices [Authorize] → 200 IReadOnlyList<ProductPriceDto>
- POST /api/v1/admin/products/{id}/prices [RequirePermission catalogo:productos:gestionar] → 201 AddProductPriceResponse + Location header
- ExceptionFilter: 3 new cases (ProductPriceForwardOnlyException→409, ProductPriceInvalidException→400, ProductSinPrecioActivoException→404)
- Fix AddProductPriceCommandHandler: move GetByProductIdAsync outside TransactionScope using block to avoid InvalidOperationException (scope already complete)
- 16 e2e tests in ProductPricesControllerTests: 401/403, 200 history ordered DESC, 404 not found, 201 first/second price, 400 validation, 409 forward-only, audit event, DateOnly yyyy-MM-dd roundtrip
- 305 Api.Tests + 1088 Application.Tests = 1393 total, 0 red
2026-04-19 18:26:24 -03:00
2d2e90fa3c feat(infrastructure): ProductPriceRepository Dapper + SP invocation (PRD-003) 2026-04-19 18:15:30 -03:00
4b0567d252 feat(application): commands/queries + IProductPricingService (PRD-003)
- IProductPriceRepository (AddAsync/GetByProductIdAsync/GetActiveAsync)
- ProductPriceDto, AddProductPriceCommand/Response, GetProductPricesQuery
- AddProductPriceCommandValidator (FluentValidation + TimeProvider, fecha >= hoy_AR)
- AddProductPriceCommandHandler (TransactionScope AsyncFlow, audit fail-closed)
- GetProductPricesQueryHandler (verifica producto existe, lista vacía válida)
- IProductPricingService + ProductPricingService (GetPriceAtAsync → decimal?)
- DI wiring en DependencyInjection.cs
- 29 tests NSubstitute + FakeTimeProvider, 1081 Application.Tests GREEN
2026-04-19 18:08:16 -03:00
54b0265994 feat(domain): ProductPrice entity + exceptions (PRD-003) 2026-04-19 17:59:43 -03:00
59f30cddfb feat(bd): V019 crea dbo.ProductPrices + SP + índices (PRD-003) 2026-04-19 17:53:58 -03:00
e735afb5b4 Merge pull request 'feat(domain): RubroConProductosActivosException + guard en DeactivateRubro (closes #41)' (#44) from fix/issue-41-rubro-deactivation-guard into main 2026-04-19 20:09:38 +00:00
50a5118a78 feat(api): ExceptionFilter + e2e 409 para RubroConProductosActivos (closes #41)
Mapea RubroConProductosActivosException → HTTP 409 con error code
rubro_con_productos_activos. Test e2e usa DI override (patrón issue #36)
para stub IProductQueryRepository sin sembrar Products reales en DB.
2026-04-19 17:08:42 -03:00
c974e824e0 feat(infrastructure): ProductQueryRepository.CountActiveByRubroAsync + integration test
Implementa SELECT COUNT(1) FROM dbo.Product WHERE RubroId = @RubroId AND IsActive = 1.
Tests de integración verifican: 0 sin productos, count correcto con mix
activos/inactivos/otro rubro, y solo inactivos retorna 0.
2026-04-19 17:08:35 -03:00
900fd5e975 feat(application): DeactivateRubroCommandHandler guard contra Products activos
Extiende IProductQueryRepository con CountActiveByRubroAsync, inyecta
el repositorio en el handler e intercala el chequeo después del guard
de hijos activos. Tests de unidad cubren: throw, success con 0 productos,
y estabilidad del orden de guardas (hijos primero).
2026-04-19 17:08:30 -03:00
e9d1e3237d feat(domain): RubroConProductosActivosException + test (closes #41)
Co-authored-by: fix/issue-41-rubro-deactivation-guard
2026-04-19 17:08:23 -03:00
e33e9f332e Merge pull request 'refactor(tests): TestWebAppFactory.CreateClientWithOverrides para DI override scoped (closes #36)' (#43) from fix/issue-36-rsa-singleton-override into main 2026-04-19 20:00:47 +00:00
0e363d1cfc refactor(tests): TestWebAppFactory.CreateClientWithOverrides para DI override por test (closes #36)
Agrega helper CreateClientWithOverrides en TestWebAppFactory que envuelve
WithWebHostBuilder+ConfigureTestServices para inyectar stubs por test sin
tocar la fábrica compartida. Usa el patrón para agregar 2 tests e2e:
Deactivate_WhenProductQueryReturnsInUse_Returns409WithErrorCode (PRD-001/PRD-002)
y CreateRubro_WhenParentHasAvisos_Returns409WithErrorCode (CAT-002).
Remueve el comentario TODO PRD-002. 287 Api tests verdes.
2026-04-19 16:59:53 -03:00
c5a8cd9edd Merge pull request 'fix: openEdit fetch ProductTypeDetail antes de abrir dialog (closes #37)' (#42) from fix/issue-37-openedit-fetch-detail into main 2026-04-19 19:53:47 +00:00
616f6432d1 fix(frontend): openEdit fetch ProductTypeDetail antes de abrir dialog (closes #37)
Reemplaza el stub con nulls por queryClient.fetchQuery con getProductTypeById,
deshabilitando el botón durante la carga y mostrando toast.error si falla.
2026-04-19 16:53:00 -03:00
1730b0623e Merge pull request 'feat: PRD-002 Product CRUD' (#40) from feature/PRD-002 into main 2026-04-19 16:49:58 +00:00
d7fb3105fa feat(bd): V018 crea dbo.Product + SqlTestFixture consolida V018 + permisos catalogo (PRD-002 W6) 2026-04-19 13:46:11 -03:00
b4f17d6961 refactor: eliminar NullProductQueryRepository dead code + EXISTS en ProductQueryRepository (PRD-002 S1 S2) 2026-04-19 13:37:10 -03:00
a7cfcdb683 test(frontend): ProductsPage pagination + filter tests (PRD-002 W5) 2026-04-19 13:36:48 -03:00
0f5455aba6 test(frontend): ProductFormDialog + DeactivateProductDialog tests (PRD-002 W3 W4) 2026-04-19 13:35:23 -03:00
2b79b6f769 feat(frontend): ProductForm reactivo a flags ProductType (PRD-002 W2) 2026-04-19 13:33:53 -03:00
d262454b28 fix(api): ExceptionFilter 409 para ProductTypeInactivo y RubroInactivo (PRD-002 W1) 2026-04-19 13:31:38 -03:00
08a4738daf feat(frontend): Products feature — CRUD page, form, dialogs, hooks (PRD-002)
Implements full frontend for PRD-002: 5 API fns, 5 hooks (useProducts,
useCreateProduct, useUpdateProduct, useDeactivateProduct), ProductForm,
ProductFormDialog, DeactivateProductDialog, ProductsPage with CanPerform
gating. Router entry at /admin/products and sidebar link added. 19 Vitest
tests GREEN (api, hooks, page).
2026-04-19 13:24:42 -03:00
a41a4ea341 test(api): guard proof — ProductType deactivation returns 409 when active Products exist (PRD-002) 2026-04-19 13:18:21 -03:00
165abc8245 feat(api): ProductsController + ExceptionFilter Product cases, fix permiso count to 27 (PRD-002) 2026-04-19 13:17:31 -03:00
733ca0e2e2 test(infrastructure): ProductRepository integration tests — roundtrip, update, deactivate history, UQ (PRD-002) 2026-04-19 13:11:21 -03:00
8c9a50504d feat(infrastructure): ProductRepository + ProductQueryRepository, DI swap activates guard (PRD-002) 2026-04-19 13:10:21 -03:00
bb455be745 feat(application): Product handlers + DI registration, fix permiso count to 27 (PRD-002) 2026-04-19 13:07:59 -03:00
8b555e1f8b feat(application): Product commands, DTOs, IProductRepository, validators (PRD-002) 2026-04-19 13:02:42 -03:00
16197cf242 feat(domain): Product entity + 5 domain exceptions (PRD-002) 2026-04-19 12:59:58 -03:00
0462970ea1 Merge pull request 'feat: PRD-001 ProductType (flags + multimedia)' (#38) from feature/PRD-001 into main 2026-04-19 15:18:53 +00:00
d6ec618ff2 docs(tests): TODO W1 PRD-002 en ProductTypesControllerTests + audit doc ya completo (PRD-001)
Agrega comentario TODO antes del bloque DELETE explicando el gap e2e
para 409 IsInUse (bloqueado por RSA singleton, issue #36, PRD-002).
Auditoría.md ya tenía las entradas producto_tipo.* desde apply anterior.
2026-04-19 12:10:35 -03:00
230405e056 feat(frontend): wire dialogs en ProductTypesPage (PRD-001 W3)
Conecta ProductTypeFormDialog (create/edit) y DeactivateProductTypeDialog
en ProductTypesPage: botón "Nuevo Tipo", acción Editar por fila, acción
Desactivar por fila, empty state CTA "Crear primer tipo".

9 tests nuevos de page integration. Total: 390.
2026-04-19 12:10:09 -03:00
9cb1e84ec0 feat(frontend): ProductTypeForm + Dialog + DeactivateDialog con TDD (PRD-001 W3)
Implementa los 3 componentes de UI faltantes con enfoque Red→Green:
- ProductTypeForm: zod schema con transforms para multimedia numérica,
  lógica condicional (multimedia deshabilitada cuando allowImages=false),
  normalización en submit.
- ProductTypeFormDialog: mode create/edit, inline error 409, aria-describedby (NFR8).
- DeactivateProductTypeDialog: AlertDialog confirmar soft-delete, inline error 409 EnUso.

18 tests nuevos (8 form + 6 dialog + 4 deactivate). Total: 381.
2026-04-19 12:08:36 -03:00
3db4dedb91 feat(frontend): feature product-types completa (PRD-001)
types, api, hooks, ProductTypesPage, router /admin/product-types,
sidebar Tipos de Producto; 11 vitest tests nuevos — suite total 363 GREEN.
2026-04-19 10:01:12 -03:00
170789886b feat(api): ProductTypesController + ExceptionFilter 4 casos PRD-001
CRUD endpoints con validación FluentValidation inline; 4 nuevas excepciones mapeadas
en ExceptionFilter; conteos de permisos 25→26 actualizados; 12 e2e tests nuevos.
2026-04-19 09:57:11 -03:00
936d1dc353 feat(infrastructure): ProductTypeRepository Dapper + DI wiring (PRD-001)
CRUD + paginado con filtros sobre dbo.ProductType; history temporal verificada en tests.
11 integration tests nuevos, suite total 935 GREEN.
2026-04-19 09:49:08 -03:00
5c8f19bf39 feat(application): CRUD handlers + validators + DI de ProductType (PRD-001)
Create/Update/Deactivate handlers con TransactionScope + audit; validators FluentValidation;
DI wiring NullProductQueryRepository + 5 handlers; SqlTestFixture V017 + permiso count 25→26.
2026-04-19 09:46:31 -03:00
3c9e852379 feat(application): IProductTypeRepository + IProductQueryRepository stub + queries (PRD-001) 2026-04-19 09:38:51 -03:00
132d17c99f feat(domain): ProductType entity + domain exceptions (PRD-001) 2026-04-19 09:36:29 -03:00
de70152d3e feat(bd): V017 crea dbo.ProductType con SYSTEM_VERSIONING + permiso catalogo:tipos:gestionar (PRD-001) 2026-04-19 09:34:23 -03:00
d8d1da8ea4 Merge pull request 'feat: CAT-002 Regla de Oro Rama vs Hoja + validaciones' (#35) from feature/CAT-002 into main 2026-04-19 11:56:32 +00:00
a0a1874ac2 test(frontend): apretar match exacto del title en CategoryTree (CAT-002 W2) 2026-04-19 08:52:34 -03:00
4f25233bab feat(frontend): tieneAvisos en RubroTreeNode + disable btn subrubro (CAT-002) 2026-04-19 08:35:42 -03:00
bb5dde6e24 feat(api): ExceptionFilter 409 para regla de oro + DTO delta (CAT-002) 2026-04-19 08:31:39 -03:00
f861dfa826 feat(application): RubroTreeBuilder + GetRubroTree con tieneAvisos (CAT-002) 2026-04-19 08:25:13 -03:00
c03aad8c5a feat(application): guard avisos en MoveRubroCommandHandler (CAT-002) 2026-04-19 08:24:07 -03:00
216983623a feat(application): guard avisos en CreateRubroCommandHandler (CAT-002) 2026-04-19 08:22:55 -03:00
9e50a929ae feat(application): RubroTreeBuilder + GetRubroTree con tieneAvisos (CAT-002) 2026-04-19 08:20:36 -03:00
673194e249 feat(application): IAvisoQueryRepository + NullAvisoQueryRepository (CAT-002) 2026-04-19 08:18:56 -03:00
ddd28ea4d5 feat(domain): excepciones regla de oro rama/hoja (CAT-002) 2026-04-19 08:17:45 -03:00
205f9c76ad Merge pull request 'feat: CAT-001 Árbol N-ario de Rubros' (#30) from feature/CAT-001 into main 2026-04-19 10:49:37 +00:00
389dda6e5e fix(tests): consolidar V016 en SqlTestFixture post issue #29
Rebase de CAT-001 sobre main (post #29) requiere:
- EnsureV016SchemaAsync en SqlTestFixture
- Rubro_History en TablesToIgnore central (el commit original b1be4a5 se skipeo por ser obsoleto post consolidacion)
- catalogo:rubros:gestionar en seed canonical de Permiso + RolPermiso admin
- RubroRepositoryTests refactorizado al patron [Collection] + SqlTestFixture
- RubrosControllerTests apunta a TestConnectionStrings.ApiTestDb
- Counts de permisos admin actualizados 24 -> 25 en 5 tests

Verify: App 819/819 + Api 251/251 + vitest 349/349 verde post-rebase.
2026-04-19 07:49:18 -03:00
bd2febf411 fix(frontend): MoveRubroDialog type cast para zodResolver output (CAT-001) 2026-04-19 07:42:56 -03:00
46ef3878de feat(frontend): MoveRubroDialog + wire en RubrosPage + aria-describedby (CAT-001)
Implementa MoveRubroDialog con flattenExcludingSubtree para prevenir ciclos en UI,
lo conecta en RubrosPage y agrega DialogDescription en RubroFormDialog.
2026-04-19 07:42:55 -03:00
022a36a90c test(application): GetRubroByIdQueryHandlerTests dedicado (CAT-001) 2026-04-19 07:42:55 -03:00
f07802f769 fix(frontend): corregir tipos zodResolver en RubroFormDialog (CAT-001)
- Reemplaza z.union([z.coerce.number(), z.literal('')]) por z.string().transform+pipe para evitar inferencia unknown en zodResolver
- Simplifica RubroFormValues a {nombre: string, tarifarioBaseId?: number | null}
- Actualiza RubrosPage: tarifarioId ya llega como number|null del schema transform
2026-04-19 07:42:55 -03:00
b22e9fe59a feat(frontend): rubros feature + CategoryTree + CRUD dialogs (CAT-001)
Co-Authored-By: none
2026-04-19 07:42:54 -03:00
5e2323e0bc feat(api): RubrosController + integration tests e2e + audit verification (CAT-001) 2026-04-19 07:42:54 -03:00
f8e9d18379 feat(infrastructure): RubroRepository Dapper + DI + integration tests (CAT-001) 2026-04-19 07:42:53 -03:00
d9fc9a2867 feat(application): Rubros commands/queries + RubroTreeBuilder + audit (CAT-001) 2026-04-19 07:42:26 -03:00
dcb2e5ada6 feat(domain): Rubro entity + domain exceptions (CAT-001) 2026-04-19 07:42:26 -03:00
9f78425a93 fix(bd): V016 COLLATE order — SQL Server requiere COLLATE antes de NOT NULL (CAT-001) 2026-04-19 07:42:25 -03:00
0d50d4f3cc feat(bd): V016 create Rubro table con SYSTEM_VERSIONING (CAT-001)
- dbo.Rubro: adjacency list, self-FK, soft-delete, temporal retention 10y
- Filtered unique index UQ_Rubro_ParentId_Nombre_Activo + covering IX_Rubro_ParentId_Activo
- Permission catalogo:rubros:gestionar seeded + assigned to admin role
- V016_ROLLBACK.sql: full reversal script
- RubrosOptions class (MaxDepth=10) + appsettings.json Rubros section
- services.Configure<RubrosOptions> registered in Infrastructure DI
- database/README.md updated with V013-V016 entries
2026-04-19 07:42:25 -03:00
9886524645 Merge pull request 'fix: issue #29 — integration tests flakiness (DB split + SqlTestFixture consolidado)' (#34) from fix/issue-29-flakiness into main 2026-04-19 10:41:27 +00:00
bcbba2c012 Merge pull request 'chore(frontend): limpiar lint errors pre-existentes' (#33) from chore/frontend-lint-preexisting into main 2026-04-19 10:41:16 +00:00
3cb89f80a3 Merge pull request 'chore(tests): dotnet format sobre archivos pre-existentes' (#32) from chore/dotnet-format-testfixtures into main 2026-04-19 10:41:14 +00:00
18ce4f6841 Merge pull request 'chore(frontend): DialogDescription en dialogs para a11y' (#31) from chore/dialog-aria-describedby into main 2026-04-19 10:41:09 +00:00
8daadc8a77 fix(tests): timestamp determinístico en QueryAsync_Limit_EmitsCursor
DATETIME2(3) + cursor roundtrip via O format perdía sub-ms de
DateTime.UtcNow causando ~37% flake rate. Timestamp fijo con sub-ms=0
elimina la ambigüedad.

Fixes residual flake del issue #29.
2026-04-19 07:40:32 -03:00
a0dcc7258b docs(database): actualiza README con V013-V015 y sección Test DBs
Agrega filas V013, V014, V015 a la tabla de migraciones. Actualiza
convención de "3 bases" (SIGCM2, SIGCM2_Test_App, SIGCM2_Test_Api).
Añade sección "Bases de datos de integration tests" con tabla de
propósito y referencia al script de creación.
2026-04-18 21:44:45 -03:00
e5b6c06f64 refactor(tests): Api.Tests apunta a SIGCM2_Test_Api via TestConnectionStrings
Todos los archivos de Api.Tests reemplazan la connection string hardcodeada
por TestConnectionStrings.ApiTestDb. Cada proyecto de tests ahora tiene su
propia base de datos aislada, eliminando la contención entre Application.Tests
y Api.Tests que causaba flakiness.
2026-04-18 21:44:40 -03:00
e0b9cba948 refactor(tests): Application.Tests elimina Respawner inline; usa SqlTestFixture compartido
6 clases que instanciaban Respawner directamente migran a recibir SqlTestFixture
vía ICollectionFixture. 8 clases restantes solo actualizan ConnectionString a
TestConnectionStrings.AppTestDb. Cada clase ahora es responsable únicamente de
sus seeds específicos; la limpieza de la base queda centralizada en el fixture.
2026-04-18 21:44:36 -03:00
03a695feb9 refactor(tests): DatabaseCollection centraliza ICollectionFixture<SqlTestFixture>
Registra la colección "Database" con SqlTestFixture como fixture compartido
para Application.Tests (elimina el ctor-con-string inline en cada test class).
Agrega Using global a ambos proyectos para evitar usings por archivo.
2026-04-18 21:44:24 -03:00
e987228f14 refactor(tests): SqlTestFixture usa TestConnectionStrings; ctor interno para Api.Tests
Agrega ctor parameterless que apunta a SIGCM2_Test_App (requerido por
xUnit ICollectionFixture<T>). El ctor con string se marca internal y
expone via InternalsVisibleTo a SIGCM2.Api.Tests. TestWebAppFactory
apunta a SIGCM2_Test_Api. Se agrega propiedad Connection pública para
que los tests que necesitan queries ad-hoc la usen.
2026-04-18 21:44:19 -03:00
d4a2b3bc3e feat(tests): añade TestConnectionStrings y script de creación de DBs de test
Introduce SIGCM2_Test_App y SIGCM2_Test_Api como bases aisladas para
Application.Tests y Api.Tests respectivamente. TestConnectionStrings.cs
centraliza las connection strings; create-test-api-db.sql documenta
el setup idempotente de ambas bases con COLLATE Modern_Spanish_CI_AS.
2026-04-18 21:44:12 -03:00
50a3c87b14 chore(frontend): limpiar lint errors pre-existentes
11 errores en archivos pre-existentes (0 en rubros/). Categorización:
2 bugs reales removidos, 1 FP con disable comentado, 8 FPs suprimidos con eslint-disable-next-line.

Files:
- src/web/src/components/ui/badge.tsx — react-refresh/only-export-components (FP: shadcn/ui co-ubica badgeVariants con el componente por diseño)
- src/web/src/components/ui/button.tsx — react-refresh/only-export-components (FP: ídem, buttonVariants)
- src/web/src/components/ui/form.tsx — react-refresh/only-export-components (FP: shadcn/ui co-ubica useFormField hook)
- src/web/src/pages/admin/audit/AuditFilters.tsx — react-refresh/only-export-components x2 (FP: EMPTY_FILTERS y toApiFilter co-ubicados con el componente que los consume)
- src/web/src/features/permisos/components/RolPermisosEditor.tsx — react-hooks/set-state-in-effect (FP: patrón válido de derived state desde prop externa asignados)
- src/web/src/features/users/components/PermisosEditor.tsx — react-hooks/set-state-in-effect (FP: ídem, permisoData → mapa local de overrides)
- src/web/src/pages/admin/audit/AuditPage.tsx — react-hooks/set-state-in-effect (FP: acumulación de páginas paginadas desde query externa)
- src/web/src/features/users/pages/CreateUserPage.tsx — @typescript-eslint/no-unused-vars (FP: _created existe por contrato de callback, no se necesita el valor)
- src/web/src/lib/dateFormat.ts — @typescript-eslint/no-unused-vars (FP: _opts reservado para extensibilidad futura; formato hardcodeado por compatibilidad Intl)
- src/web/src/tests/api/axiosClient.test.ts — @typescript-eslint/no-unused-vars (bug real: requestCount incrementado en mock handler pero nunca asercionado; variable eliminada)
2026-04-18 21:00:00 -03:00
9957724c40 chore(tests): dotnet format sobre archivos pre-existentes (surfaced durante CAT-001)
Fix mecánico de whitespace detectado por dotnet format --verify-no-changes durante la verify phase de CAT-001 (PR #30). Sin cambios funcionales.
2026-04-18 20:56:23 -03:00
1cb69cbaf3 chore(frontend): DialogDescription en dialogs para a11y (silencia Radix warning) 2026-04-18 20:55:36 -03:00
8353f73230 Merge pull request 'refactor(udt-011): Quartz jobs usan TimeProvider (closes #24)' (#28) from fix/UDT-011-quartz-jobs-timeprovider into main 2026-04-18 14:08:11 +00:00
01ad4cbfbc test(udt-011): Quartz jobs verifican TimeProvider injection 2026-04-18 11:07:47 -03:00
67da544bb4 refactor(udt-011): AuditRetentionEnforcerJob usa TimeProvider inyectado 2026-04-18 11:07:43 -03:00
b79dfb2f34 refactor(udt-011): AuditPartitionManagerJob usa TimeProvider inyectado 2026-04-18 11:07:40 -03:00
ff912cc6a9 refactor(udt-011): AuditIntegrityCheckJob usa TimeProvider inyectado 2026-04-18 11:07:36 -03:00
8d2618e6e5 Merge pull request 'UDT-011: Localización Temporal Argentina (infra transversal)' (#25) from feature/UDT-011 into main 2026-04-18 13:57:49 +00:00
a5fd3e90fb Merge branch 'main' into feature/UDT-011 2026-04-18 10:56:09 -03:00
50f713dc10 Merge pull request 'fix(web): cleanup 25 TS errors preexistentes en main (closes #26)' (#27) from fix/ADM-011-web-ts-cleanup into main
fix(web): cleanup 25 TS errors preexistentes en main (closes #26)
2026-04-18 13:55:33 +00:00
b5ec0c25a9 fix(web/tests): alinear updateUserPermisosOverrides mock con UsuarioPermisos shape (TS2339) 2026-04-18 10:54:44 -03:00
a39427865f fix(web/tests): eliminar imports no usados en tests (TS6133) 2026-04-18 10:54:40 -03:00
202d267e16 fix(web): migrar SeccionForm a sintaxis Zod v4 (errorMap → error, coerce.number<number>()) 2026-04-18 10:54:28 -03:00
8b369b69ee fix(web): migrar MedioForm a sintaxis Zod v4 (TS2322 — coerce.number<number>()) 2026-04-18 10:54:23 -03:00
d16da502f4 fix(web): corregir import type-only de ButtonProps en pagination.tsx (TS1484) 2026-04-18 10:54:19 -03:00
443 changed files with 35802 additions and 966 deletions

View File

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

48
coverlet.runsettings Normal file
View 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>

View File

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

View 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

View 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

View 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

View 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

View 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

View 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

View 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

View 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

View 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

View 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

View File

@@ -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

View 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

View 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

View 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

View 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

View 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

View File

@@ -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

View 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

View 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

View 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

View File

@@ -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

View 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);

View File

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

View 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);

View 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);

View 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);

View 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);

View File

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

View File

@@ -32,5 +32,8 @@
],
"Enrich": [ "FromLogContext", "WithMachineName", "WithThreadId" ]
},
"Rubros": {
"MaxDepth": 10
},
"AllowedHosts": "*"
}

View File

@@ -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);
}

View File

@@ -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);
}

View File

@@ -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 (1100) before calling.
/// Returns PagedResult with empty Items when the product has no price history or page is beyond total.
/// </summary>
Task<PagedResult<ProductPrice>> GetByProductIdAsync(
int productId,
int page,
int pageSize,
CancellationToken ct = default);
/// <summary>
/// Returns the ProductPrice row whose window [PriceValidFrom, PriceValidTo] covers the given
/// civil date, or null if no row matches (no history, or date is before any recorded price).
/// Used by ProductPricingService.GetPriceAtAsync.
/// </summary>
Task<ProductPrice?> GetActiveAsync(
int productId,
DateOnly date,
CancellationToken ct = default);
}

View File

@@ -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);
}

View File

@@ -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);
}

View File

@@ -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);
}

View File

@@ -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);
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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