Compare commits

...

64 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
248 changed files with 23090 additions and 376 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

@@ -34,6 +34,14 @@ database/
| 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

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

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

@@ -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;
@@ -267,6 +268,18 @@ public sealed class ExceptionFilter : IExceptionFilter
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
@@ -463,6 +476,107 @@ public sealed class ExceptionFilter : IExceptionFilter
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
@@ -532,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

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

@@ -12,4 +12,10 @@ public interface IProductQueryRepository
/// 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,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

@@ -69,12 +69,28 @@ using SIGCM2.Application.Rubros.GetById;
using SIGCM2.Application.Rubros.Dtos;
using SIGCM2.Application.Abstractions.Persistence;
using SIGCM2.Application.Avisos;
using SIGCM2.Application.Products;
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;
@@ -171,9 +187,20 @@ public static class DependencyInjection
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)
// PRD-002 reemplaza IProductQueryRepository con implementación Dapper contra dbo.Product.
services.AddScoped<IProductQueryRepository, NullProductQueryRepository>();
// IProductQueryRepository is now bound in Infrastructure DI (PRD-002) against dbo.Product.
services.AddScoped<ICommandHandler<CreateProductTypeCommand, ProductTypeCreatedDto>, CreateProductTypeCommandHandler>();
services.AddScoped<ICommandHandler<UpdateProductTypeCommand, ProductTypeUpdatedDto>, UpdateProductTypeCommandHandler>();
@@ -181,6 +208,16 @@ public static class DependencyInjection
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

@@ -37,12 +37,10 @@ public sealed class DeactivateProductTypeCommandHandler
if (!target.IsActive)
return new ProductTypeStatusDto(command.Id, false);
// 3. Guard: check if any active product uses this type (guard before audit — ordering matters)
// 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);
// Note: count=-1 sentinel because Products table doesn't exist in PRD-001.
// PRD-002 will update this with the actual count.
throw new ProductTypeEnUsoException(command.Id, productsActivos: 1);
// 4. Deactivate (immutable — returns new instance)
var deactivated = target.WithDeactivated(_timeProvider);

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

@@ -1,14 +0,0 @@
using SIGCM2.Application.Abstractions.Persistence;
namespace SIGCM2.Application.Products;
/// <summary>
/// STUB — PRD-002 replaces the DI binding with a real Dapper impl against dbo.Product.
/// Returns false for all queries so DeactivateProductTypeCommandHandler guard always passes.
/// This is intentional for PRD-001: the mechanism is installed; the data feed arrives in PRD-002.
/// </summary>
public sealed class NullProductQueryRepository : IProductQueryRepository
{
public Task<bool> ExistsActiveByProductTypeAsync(int productTypeId, CancellationToken ct = default)
=> Task.FromResult(false);
}

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,13 @@
namespace SIGCM2.Application.Products.Prices;
/// <summary>
/// PRD-003 — DTO de lectura para un registro de precio histórico de Product.
/// IsActive = true cuando PriceValidTo is null (precio vigente en curso).
/// </summary>
public sealed record ProductPriceDto(
long Id,
int ProductId,
decimal Price,
DateOnly PriceValidFrom,
DateOnly? PriceValidTo,
bool IsActive);

View File

@@ -0,0 +1,18 @@
namespace SIGCM2.Application.Products.Pricing;
/// <summary>
/// PRD-003 — Servicio de consulta de precio vigente de un Product para una fecha civil (Cat2).
/// Contrato forward para PRC-001 (tasación).
///
/// Retorna null si no existe historial de precios para el producto en la fecha indicada.
/// La política de fallback (usar Product.BasePrice o lanzar ProductSinPrecioActivoException)
/// queda en el consumidor (OQ-B: Product.BasePrice es ortogonal a ProductPrices).
/// </summary>
public interface IProductPricingService
{
/// <summary>
/// Devuelve el precio cuya ventana [PriceValidFrom, PriceValidTo] cubre la fecha civil dada,
/// o null si ningún registro de precio cubre esa fecha.
/// </summary>
Task<decimal?> GetPriceAtAsync(int productId, DateOnly date, CancellationToken ct = default);
}

View File

@@ -0,0 +1,24 @@
using SIGCM2.Application.Abstractions.Persistence;
namespace SIGCM2.Application.Products.Pricing;
/// <summary>
/// PRD-003 — Implementación de IProductPricingService.
/// Delega en IProductPriceRepository.GetActiveAsync para el lookup de ventana civil.
/// </summary>
public sealed class ProductPricingService : IProductPricingService
{
private readonly IProductPriceRepository _repo;
public ProductPricingService(IProductPriceRepository repo)
{
_repo = repo;
}
/// <inheritdoc />
public async Task<decimal?> GetPriceAtAsync(int productId, DateOnly date, CancellationToken ct = default)
{
var price = await _repo.GetActiveAsync(productId, date, ct);
return price?.Price;
}
}

View File

@@ -0,0 +1,13 @@
namespace SIGCM2.Application.Products.Update;
public sealed record ProductUpdatedDto(
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,8 @@
namespace SIGCM2.Application.Products.Update;
public sealed record UpdateProductCommand(
int Id,
string Nombre,
int? RubroId,
decimal BasePrice,
int? PriceDurationDays);

View File

@@ -0,0 +1,97 @@
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.Update;
public sealed class UpdateProductCommandHandler
: ICommandHandler<UpdateProductCommand, ProductUpdatedDto>
{
private readonly IProductRepository _repo;
private readonly IProductTypeRepository _ptRepo;
private readonly IRubroRepository _rubroRepo;
private readonly IAuditLogger _audit;
private readonly TimeProvider _timeProvider;
public UpdateProductCommandHandler(
IProductRepository repo,
IProductTypeRepository ptRepo,
IRubroRepository rubroRepo,
IAuditLogger audit,
TimeProvider timeProvider)
{
_repo = repo;
_ptRepo = ptRepo;
_rubroRepo = rubroRepo;
_audit = audit;
_timeProvider = timeProvider;
}
public async Task<ProductUpdatedDto> Handle(UpdateProductCommand command)
{
// 1. Load entity
var target = await _repo.GetByIdAsync(command.Id)
?? throw new ProductNotFoundException(command.Id);
// 2. Load ProductType (MedioId + ProductTypeId are immutable post-creation)
var productType = await _ptRepo.GetByIdAsync(target.ProductTypeId)
?? throw new ProductTypeNotFoundException(target.ProductTypeId);
// 3. Flags coherence
if (productType.RequiresCategory && !command.RubroId.HasValue)
throw new ProductTipoFlagsIncoherentesException(
$"El tipo '{productType.Nombre}' requiere RubroId (RequiresCategory=true)", "rubroId");
if (productType.HasDuration && !command.PriceDurationDays.HasValue)
throw new ProductTipoFlagsIncoherentesException(
$"El tipo '{productType.Nombre}' requiere PriceDurationDays (HasDuration=true)", "priceDurationDays");
// 4. 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);
}
// 5. Duplicate nombre check (skip if name unchanged — optimization)
if (!string.Equals(command.Nombre, target.Nombre, StringComparison.OrdinalIgnoreCase))
{
var exists = await _repo.ExistsByNombreAsync(command.Nombre, target.MedioId, target.ProductTypeId, excludeId: command.Id);
if (exists)
throw new ProductNombreDuplicadoEnMedioTipoException(target.MedioId, target.ProductTypeId, command.Nombre);
}
// 6. Apply mutation (immutable)
var updated = target.WithUpdated(command.Nombre, command.RubroId, command.BasePrice, command.PriceDurationDays, _timeProvider);
// 7. Persist + audit
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.updated",
targetType: "Product",
targetId: command.Id.ToString(),
metadata: new
{
before = new { target.Nombre, target.RubroId, target.BasePrice, target.PriceDurationDays },
after = new { updated.Nombre, updated.RubroId, updated.BasePrice, updated.PriceDurationDays }
});
tx.Complete();
return new ProductUpdatedDto(
updated.Id, updated.Nombre,
updated.MedioId, updated.ProductTypeId, updated.RubroId,
updated.BasePrice, updated.PriceDurationDays,
updated.IsActive, updated.FechaCreacion, updated.FechaModificacion);
}
}

View File

@@ -0,0 +1,23 @@
using FluentValidation;
namespace SIGCM2.Application.Products.Update;
public sealed class UpdateProductCommandValidator : AbstractValidator<UpdateProductCommand>
{
public UpdateProductCommandValidator()
{
RuleFor(x => x.Id)
.GreaterThan(0).WithMessage("Id debe ser un entero positivo.");
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.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

@@ -10,15 +10,18 @@ public sealed class DeactivateRubroCommandHandler : ICommandHandler<DeactivateRu
{
private readonly IRubroRepository _repo;
private readonly IAuditLogger _audit;
private readonly IProductQueryRepository _productQuery;
private readonly TimeProvider _timeProvider;
public DeactivateRubroCommandHandler(
IRubroRepository repo,
IAuditLogger audit,
IProductQueryRepository productQuery,
TimeProvider timeProvider)
{
_repo = repo;
_audit = audit;
_productQuery = productQuery;
_timeProvider = timeProvider;
}
@@ -31,6 +34,10 @@ public sealed class DeactivateRubroCommandHandler : ICommandHandler<DeactivateRu
if (activeChildren > 0)
throw new RubroTieneHijosActivosException(command.Id, activeChildren);
var productosActivos = await _productQuery.CountActiveByRubroAsync(command.Id);
if (productosActivos > 0)
throw new RubroConProductosActivosException(command.Id, productosActivos);
var deactivated = target.WithActivo(false, _timeProvider);
using var tx = new TransactionScope(

View File

@@ -0,0 +1,172 @@
namespace SIGCM2.Domain.Entities;
/// <summary>
/// Immutable product entity for the commercial catalog.
/// Factory method ForCreation creates new products (Id=0).
/// Mutation methods (With*) return new instances — original is never modified.
/// Flag coherence (RequiresCategory/HasDuration) is enforced by Application handlers
/// at creation/update time against the ProductType, NOT here in the entity.
/// MedioId and ProductTypeId are immutable post-creation by design.
/// </summary>
public sealed class Product
{
private const int NombreMaxLength = 300;
public int Id { get; }
public string Nombre { get; }
public int MedioId { get; }
public int ProductTypeId { get; }
public int? RubroId { get; }
public decimal BasePrice { get; }
public int? PriceDurationDays { get; }
public bool IsActive { get; }
public DateTime FechaCreacion { get; }
public DateTime? FechaModificacion { get; }
/// <summary>Full hydration constructor — used by the repository to reconstruct from DB rows.</summary>
public Product(
int id,
string nombre,
int medioId,
int productTypeId,
int? rubroId,
decimal basePrice,
int? priceDurationDays,
bool isActive,
DateTime fechaCreacion,
DateTime? fechaModificacion)
{
Id = id;
Nombre = nombre;
MedioId = medioId;
ProductTypeId = productTypeId;
RubroId = rubroId;
BasePrice = basePrice;
PriceDurationDays = priceDurationDays;
IsActive = isActive;
FechaCreacion = fechaCreacion;
FechaModificacion = fechaModificacion;
}
/// <summary>
/// Factory for a new Product. Id=0 — DB assigns via IDENTITY.
/// IsActive=true, FechaModificacion=null.
/// </summary>
public static Product ForCreation(
string nombre,
int medioId,
int productTypeId,
int? rubroId,
decimal basePrice,
int? priceDurationDays,
TimeProvider timeProvider)
{
ValidateNombre(nombre);
ValidateMedioId(medioId);
ValidateProductTypeId(productTypeId);
ValidateRubroId(rubroId);
ValidateBasePrice(basePrice);
ValidatePriceDurationDays(priceDurationDays);
return new Product(
id: 0,
nombre: nombre.Trim(),
medioId: medioId,
productTypeId: productTypeId,
rubroId: rubroId,
basePrice: basePrice,
priceDurationDays: priceDurationDays,
isActive: true,
fechaCreacion: timeProvider.GetUtcNow().UtcDateTime,
fechaModificacion: null);
}
public Product WithRenamed(string nuevoNombre, TimeProvider timeProvider)
{
ValidateNombre(nuevoNombre);
return new Product(Id, nuevoNombre.Trim(), MedioId, ProductTypeId, RubroId,
BasePrice, PriceDurationDays, IsActive, FechaCreacion,
timeProvider.GetUtcNow().UtcDateTime);
}
public Product WithUpdatedPrice(decimal basePrice, int? priceDurationDays, TimeProvider timeProvider)
{
ValidateBasePrice(basePrice);
ValidatePriceDurationDays(priceDurationDays);
return new Product(Id, Nombre, MedioId, ProductTypeId, RubroId,
basePrice, priceDurationDays, IsActive, FechaCreacion,
timeProvider.GetUtcNow().UtcDateTime);
}
public Product WithUpdatedCategory(int? rubroId, TimeProvider timeProvider)
{
ValidateRubroId(rubroId);
return new Product(Id, Nombre, MedioId, ProductTypeId, rubroId,
BasePrice, PriceDurationDays, IsActive, FechaCreacion,
timeProvider.GetUtcNow().UtcDateTime);
}
/// <summary>
/// Combo mutator: renames, updates price and category in one call.
/// Used by UpdateProductCommandHandler.
/// </summary>
public Product WithUpdated(string nombre, int? rubroId, decimal basePrice, int? priceDurationDays, TimeProvider timeProvider)
{
ValidateNombre(nombre);
ValidateRubroId(rubroId);
ValidateBasePrice(basePrice);
ValidatePriceDurationDays(priceDurationDays);
return new Product(Id, nombre.Trim(), MedioId, ProductTypeId, rubroId,
basePrice, priceDurationDays, IsActive, FechaCreacion,
timeProvider.GetUtcNow().UtcDateTime);
}
public Product WithDeactivated(TimeProvider timeProvider)
=> new(Id, Nombre, MedioId, ProductTypeId, RubroId,
BasePrice, PriceDurationDays, isActive: false, FechaCreacion,
timeProvider.GetUtcNow().UtcDateTime);
// ── Private validators ────────────────────────────────────────────────────
private static void ValidateNombre(string nombre)
{
if (string.IsNullOrWhiteSpace(nombre))
throw new ArgumentException(
"El Nombre del producto no puede estar vacío o ser solo espacios.", nameof(nombre));
if (nombre.Length > NombreMaxLength)
throw new ArgumentException(
$"El Nombre del producto no puede superar los {NombreMaxLength} caracteres.", nameof(nombre));
}
private static void ValidateMedioId(int medioId)
{
if (medioId <= 0)
throw new ArgumentException("medioId debe ser un entero positivo.", nameof(medioId));
}
private static void ValidateProductTypeId(int productTypeId)
{
if (productTypeId <= 0)
throw new ArgumentException("productTypeId debe ser un entero positivo.", nameof(productTypeId));
}
private static void ValidateRubroId(int? rubroId)
{
if (rubroId.HasValue && rubroId.Value <= 0)
throw new ArgumentException(
"rubroId debe ser un entero positivo cuando no es nulo.", nameof(rubroId));
}
private static void ValidateBasePrice(decimal basePrice)
{
if (basePrice < 0m)
throw new ArgumentException("basePrice no puede ser negativo.", nameof(basePrice));
}
private static void ValidatePriceDurationDays(int? priceDurationDays)
{
if (priceDurationDays.HasValue && priceDurationDays.Value <= 0)
throw new ArgumentException(
"priceDurationDays debe ser >= 1 cuando se provee.", nameof(priceDurationDays));
}
}

View File

@@ -0,0 +1,25 @@
namespace SIGCM2.Domain.Entities;
/// <summary>
/// PRD-003 — Immutable record representing a price snapshot for a Product.
/// Business dates are Cat2 (civil Argentina dates, DateOnly). Forward-only — no mutations.
/// PriceValidTo = null means the row is the currently active price.
/// </summary>
public sealed record ProductPrice(
long Id,
int ProductId,
decimal Price,
DateOnly PriceValidFrom,
DateOnly? PriceValidTo,
DateTime FechaCreacion)
{
/// <summary>True if this row is the currently active price (PriceValidTo is null).</summary>
public bool IsActive => PriceValidTo is null;
/// <summary>
/// True if this price's window covers the given civil date (inclusive on both ends).
/// An active row (PriceValidTo = null) covers any date on or after PriceValidFrom.
/// </summary>
public bool CoversDate(DateOnly date)
=> PriceValidFrom <= date && (PriceValidTo is null || PriceValidTo >= date);
}

View File

@@ -0,0 +1,19 @@
namespace SIGCM2.Domain.Exceptions;
/// <summary>
/// Thrown when a Product with the same Nombre already exists for a given MedioId+ProductTypeId. → HTTP 409
/// </summary>
public sealed class ProductNombreDuplicadoEnMedioTipoException : DomainException
{
public int MedioId { get; }
public int ProductTypeId { get; }
public string Nombre { get; }
public ProductNombreDuplicadoEnMedioTipoException(int medioId, int productTypeId, string nombre)
: base($"Ya existe un producto activo con nombre '{nombre}' para medioId={medioId} y productTypeId={productTypeId}.")
{
MedioId = medioId;
ProductTypeId = productTypeId;
Nombre = nombre;
}
}

View File

@@ -0,0 +1,15 @@
namespace SIGCM2.Domain.Exceptions;
/// <summary>
/// Thrown when a requested Product does not exist. → HTTP 404
/// </summary>
public sealed class ProductNotFoundException : DomainException
{
public int ProductId { get; }
public ProductNotFoundException(int id)
: base($"El producto con id={id} no existe.")
{
ProductId = id;
}
}

View File

@@ -0,0 +1,23 @@
namespace SIGCM2.Domain.Exceptions;
/// <summary>
/// Thrown when attempting to add a ProductPrice with a PriceValidFrom that is not strictly
/// greater than the currently active price's PriceValidFrom. → HTTP 409
/// </summary>
public sealed class ProductPriceForwardOnlyException : DomainException
{
public int ProductId { get; }
public DateOnly NewPriceValidFrom { get; }
public DateOnly ActivePriceValidFrom { get; }
public ProductPriceForwardOnlyException(
int productId,
DateOnly newPriceValidFrom,
DateOnly activePriceValidFrom)
: base($"El nuevo PriceValidFrom ({newPriceValidFrom:yyyy-MM-dd}) debe ser estrictamente mayor al PriceValidFrom del precio activo ({activePriceValidFrom:yyyy-MM-dd}).")
{
ProductId = productId;
NewPriceValidFrom = newPriceValidFrom;
ActivePriceValidFrom = activePriceValidFrom;
}
}

View File

@@ -0,0 +1,19 @@
namespace SIGCM2.Domain.Exceptions;
/// <summary>
/// Thrown when a ProductPrice value fails domain business validation
/// (e.g., Price &lt;= 0, PriceValidFrom in the past). → HTTP 400
/// Used as defense-in-depth alongside FluentValidation in the Application layer.
/// </summary>
public sealed class ProductPriceInvalidException : DomainException
{
public string Field { get; }
public string Reason { get; }
public ProductPriceInvalidException(string field, string reason)
: base($"Valor inválido para {field}: {reason}")
{
Field = field;
Reason = reason;
}
}

View File

@@ -0,0 +1,18 @@
namespace SIGCM2.Domain.Exceptions;
/// <summary>
/// Thrown when no ProductPrice row covers the requested date for the given product. → HTTP 404
/// Consumers of IProductPricingService may throw this when GetPriceAtAsync returns null.
/// </summary>
public sealed class ProductSinPrecioActivoException : DomainException
{
public int ProductId { get; }
public DateOnly Date { get; }
public ProductSinPrecioActivoException(int productId, DateOnly date)
: base($"No existe precio registrado para el producto {productId} aplicable a la fecha {date:yyyy-MM-dd}.")
{
ProductId = productId;
Date = date;
}
}

View File

@@ -0,0 +1,16 @@
namespace SIGCM2.Domain.Exceptions;
/// <summary>
/// Thrown when a Product's field violates the flags coherence rules of its ProductType
/// (e.g. RequiresCategory=true but RubroId is null, or HasDuration=true but PriceDurationDays is null). → HTTP 422
/// </summary>
public sealed class ProductTipoFlagsIncoherentesException : DomainException
{
public string Field { get; }
public ProductTipoFlagsIncoherentesException(string reason, string field)
: base($"Incoherencia de flags del tipo de producto: {reason}.")
{
Field = field;
}
}

View File

@@ -0,0 +1,15 @@
namespace SIGCM2.Domain.Exceptions;
/// <summary>
/// Thrown when attempting to create/update a Product referencing an inactive ProductType. → HTTP 422
/// </summary>
public sealed class ProductTypeInactivoException : DomainException
{
public int ProductTypeId { get; }
public ProductTypeInactivoException(int productTypeId)
: base($"El tipo de producto con id={productTypeId} está inactivo y no puede asignarse a un producto.")
{
ProductTypeId = productTypeId;
}
}

View File

@@ -0,0 +1,17 @@
namespace SIGCM2.Domain.Exceptions;
/// <summary>
/// Thrown when attempting to soft-delete a Rubro that still has active Products referencing it via RubroId. → HTTP 409
/// </summary>
public sealed class RubroConProductosActivosException : DomainException
{
public int RubroId { get; }
public int ProductosActivosCount { get; }
public RubroConProductosActivosException(int rubroId, int productosActivosCount)
: base($"No se puede desactivar el rubro {rubroId}: tiene {productosActivosCount} producto(s) activo(s) referenciándolo.")
{
RubroId = rubroId;
ProductosActivosCount = productosActivosCount;
}
}

View File

@@ -0,0 +1,15 @@
namespace SIGCM2.Domain.Exceptions;
/// <summary>
/// Thrown when attempting to create/update a Product referencing an inactive Rubro. → HTTP 422
/// </summary>
public sealed class RubroInactivoException : DomainException
{
public int RubroId { get; }
public RubroInactivoException(int rubroId)
: base($"El rubro con id={rubroId} está inactivo y no puede asignarse a un producto.")
{
RubroId = rubroId;
}
}

View File

@@ -0,0 +1,22 @@
namespace SIGCM2.Domain.Pricing.ChargeableChars;
/// <summary>
/// PRC-001 — Canonical category names for chargeable characters.
/// Persisted as nvarchar(32) in the database (enum-as-string).
/// </summary>
public static class ChargeableCharCategories
{
public const string Currency = "Currency";
public const string Percentage = "Percentage";
public const string Exclamation = "Exclamation";
public const string Question = "Question";
public const string Other = "Other";
private static readonly HashSet<string> Valid = new(StringComparer.Ordinal)
{
Currency, Percentage, Exclamation, Question, Other
};
/// <summary>Returns true if the given category string is a known valid category.</summary>
public static bool IsValid(string? category) => category != null && Valid.Contains(category);
}

View File

@@ -0,0 +1,112 @@
using SIGCM2.Domain.Pricing.Exceptions;
namespace SIGCM2.Domain.Pricing.ChargeableChars;
/// <summary>
/// PRC-001 — Rich domain entity for chargeable character configuration.
/// Represents a price-per-occurrence for a special character in classified ad text,
/// scoped to a ProductType (ProductTypeId) or global (ProductTypeId = null).
///
/// Forward-only price history: each new price schedules a NEW row; the current row
/// is closed via SP (ValidTo = newValidFrom - 1 day). ScheduleNewPrice does NOT mutate
/// this instance — it returns a new one. The actual close+insert happens in the repository.
///
/// ProductTypeId = null → global default (lowest priority, overridden by per-ProductType row).
/// </summary>
public sealed class ChargeableCharConfig
{
public long Id { get; }
public int? ProductTypeId { get; }
public string Symbol { get; }
public string Category { get; }
public decimal PricePerUnit { get; private set; }
public DateOnly ValidFrom { get; }
public DateOnly? ValidTo { get; private set; }
public bool IsActive { get; private set; }
private ChargeableCharConfig(
long id, int? productTypeId, string symbol, string category,
decimal price, DateOnly validFrom, DateOnly? validTo, bool isActive)
{
Id = id;
ProductTypeId = productTypeId;
Symbol = symbol;
Category = category;
PricePerUnit = price;
ValidFrom = validFrom;
ValidTo = validTo;
IsActive = isActive;
}
/// <summary>
/// Factory for new configs. Enforces all domain invariants.
/// Id is set to 0 until the entity is persisted.
/// </summary>
public static ChargeableCharConfig Create(
int? productTypeId, string symbol, string category, decimal price, DateOnly validFrom)
{
if (string.IsNullOrWhiteSpace(symbol))
throw new ChargeableCharConfigInvalidException(
nameof(Symbol), "Symbol no puede estar vacío.");
if (symbol.Length > 4)
throw new ChargeableCharConfigInvalidException(
nameof(Symbol), "Symbol no puede exceder 4 caracteres.");
if (price <= 0m)
throw new ChargeableCharConfigInvalidException(
nameof(PricePerUnit), "PricePerUnit debe ser > 0.");
if (!ChargeableCharCategories.IsValid(category))
throw new ChargeableCharConfigInvalidException(
nameof(Category), $"Category '{category}' inválida. Valores válidos: Currency, Percentage, Exclamation, Question, Other.");
return new ChargeableCharConfig(0, productTypeId, symbol, category, price, validFrom, null, true);
}
/// <summary>
/// Reconstructor from database (no validation). Used by the repository mapper only.
/// Allows creating entities with any state (e.g., IsActive=false, ValidTo set).
/// </summary>
public static ChargeableCharConfig Rehydrate(
long id, int? productTypeId, string symbol, string category,
decimal price, DateOnly validFrom, DateOnly? validTo, bool isActive)
=> new(id, productTypeId, symbol, category, price, validFrom, validTo, isActive);
/// <summary>
/// Schedules a new price (forward-only semantics).
/// Returns a NEW ChargeableCharConfig instance with the updated price and validFrom.
/// This instance is NOT mutated — the close+insert of rows happens in the repository via SP.
///
/// Validates:
/// - newValidFrom >= today (Argentina) via TimeProvider
/// - newValidFrom > current ValidFrom (strictly greater — forward-only)
/// - newPrice > 0
/// </summary>
public ChargeableCharConfig ScheduleNewPrice(decimal newPrice, DateOnly newValidFrom, TimeProvider timeProvider)
{
var today = timeProvider.GetArgentinaToday();
if (newValidFrom < today)
throw new ChargeableCharConfigInvalidException(
nameof(ValidFrom),
$"newValidFrom ({newValidFrom:yyyy-MM-dd}) debe ser >= hoy_AR ({today:yyyy-MM-dd}).");
if (newValidFrom <= ValidFrom)
throw new ChargeableCharConfigForwardOnlyException(ProductTypeId, Symbol, newValidFrom, ValidFrom);
// Create validates price > 0 and category — reuse factory
return Create(ProductTypeId, Symbol, Category, newPrice, newValidFrom);
}
/// <summary>
/// Deactivates this config row. Sets IsActive = false and ValidTo = today.
/// Idempotent: no-op if already inactive.
/// </summary>
public void Deactivate(DateOnly today)
{
if (!IsActive) return; // idempotent
IsActive = false;
ValidTo = today;
}
}

View File

@@ -0,0 +1,28 @@
using SIGCM2.Domain.Exceptions;
namespace SIGCM2.Domain.Pricing.Exceptions;
/// <summary>
/// PRC-001 — Thrown when attempting to schedule a new price with a ValidFrom that is
/// not strictly greater than the currently active row's ValidFrom. → HTTP 409
/// </summary>
public sealed class ChargeableCharConfigForwardOnlyException : DomainException
{
public int? ProductTypeId { get; }
public string Symbol { get; }
public DateOnly NewValidFrom { get; }
public DateOnly ActiveValidFrom { get; }
public ChargeableCharConfigForwardOnlyException(
int? productTypeId,
string symbol,
DateOnly newValidFrom,
DateOnly activeValidFrom)
: base($"El nuevo ValidFrom ({newValidFrom:yyyy-MM-dd}) debe ser estrictamente mayor al ValidFrom del activo ({activeValidFrom:yyyy-MM-dd}).")
{
ProductTypeId = productTypeId;
Symbol = symbol;
NewValidFrom = newValidFrom;
ActiveValidFrom = activeValidFrom;
}
}

View File

@@ -0,0 +1,22 @@
using SIGCM2.Domain.Exceptions;
namespace SIGCM2.Domain.Pricing.Exceptions;
/// <summary>
/// PRC-001 — Thrown when a ChargeableCharConfig value fails domain business validation
/// (e.g., PricePerUnit ≤ 0, Symbol empty/too long, Category unknown, ValidFrom in the past).
/// → HTTP 400
/// Used as defense-in-depth alongside FluentValidation in the Application layer.
/// </summary>
public sealed class ChargeableCharConfigInvalidException : DomainException
{
public string Field { get; }
public string Reason { get; }
public ChargeableCharConfigInvalidException(string field, string reason)
: base($"Valor inválido para {field}: {reason}")
{
Field = field;
Reason = reason;
}
}

View File

@@ -0,0 +1,29 @@
using SIGCM2.Domain.Exceptions;
namespace SIGCM2.Domain.Pricing.Exceptions;
/// <summary>
/// PRC-001 — Thrown when a reactivation attempt is blocked by a guard rule.
/// Maps to HTTP 409.
///
/// Reason codes:
/// ALREADY_ACTIVE — target row is currently active (50410)
/// VIGENTE_EXISTS — a different active row exists for (ProductTypeId, Symbol) (50411)
/// POSTERIOR_ROWS_EXIST — rows with higher ValidFrom exist after the target row (50412)
/// </summary>
public sealed class ChargeableCharConfigReactivationNotAllowedException : DomainException
{
public long Id { get; }
/// <summary>
/// "ALREADY_ACTIVE" | "VIGENTE_EXISTS" | "POSTERIOR_ROWS_EXIST"
/// </summary>
public string Reason { get; }
public ChargeableCharConfigReactivationNotAllowedException(long id, string reason)
: base($"Reactivation not allowed for config {id}: {reason}")
{
Id = id;
Reason = reason;
}
}

View File

@@ -0,0 +1,19 @@
using SIGCM2.Domain.Exceptions;
namespace SIGCM2.Domain.Pricing.Exceptions;
/// <summary>
/// PRC-001 — Thrown when the input text contains any Unicode emoji codepoint.
/// Emoji detection occurs BEFORE normalization. → HTTP 400
/// </summary>
public sealed class EmojiDetectedException : DomainException
{
/// <summary>The Unicode codepoint value of the first detected emoji rune.</summary>
public int DetectedCodepoint { get; }
public EmojiDetectedException(int detectedCodepoint)
: base($"El texto contiene emojis (U+{detectedCodepoint:X4}), que no son tarifables. Eliminálos antes de continuar.")
{
DetectedCodepoint = detectedCodepoint;
}
}

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