Compare commits

..

49 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
187 changed files with 17584 additions and 348 deletions

View File

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

48
coverlet.runsettings Normal file
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 | | 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`** | | **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`** | | **V017** | **`V017__create_product_type.sql`** | **PRD-001** | **ProductType (flags + multimedia limits, temporal 10y) + permiso `catalogo:tipos:gestionar`** |
| V018 | `V018__create_product.sql` | PRD-002 | Product (temporal 10y) + permiso `catalogo:productos:gestionar` + índices |
| V019 | `V019__create_product_prices.sql` | PRD-003 | ProductPrices (temporal 10y, forward-only) + SP `sp_ProductPrices_InsertWithClose` + permiso implícito |
| V020 | `V020__add_chargeable_chars_permission.sql` | PRC-001 | Permiso `tasacion:caracteres_especiales:gestionar` + asignación a admin |
| V021 | `V021__create_chargeable_char_config.sql` | PRC-001 | ChargeableCharConfig + ChargeableCharConfig_History (temporal 10y) + 2 SPs (`InsertWithClose`, `GetActiveForProductType`) + 2 índices |
| V022 | `V022__seed_chargeable_char_config.sql` | PRC-001 | Seed 4 filas globales (`$`, `%`, `!`, `¡`) con PricePerUnit=1.0000 |
| V023 | `V023__refactor_chargeable_char_config_to_product_type.sql` | PRC-001 (scope delta) | Refactor MedioId→ProductTypeId + nuevo SP `ReactivateWithGuard` + CK_Price_NonNegative (>= 0) |
| V024 | `V024__reseed_global_with_zero_price.sql` | PRC-001 (scope delta) | Reseed 4 globales a PricePerUnit=0.0000 (opt-in billing) |
| V025 | `V025__seed_chargeable_char_overrides_demo.sql` | PRC-001 (followup #54) | Seed demo de overrides ficticios per-ProductType (Clasificado/Notables/Fúnebres). Idempotente: no-op si los tipos no existen |
## Convenciones ## Convenciones

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

@@ -3,6 +3,7 @@ using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.Filters; using Microsoft.AspNetCore.Mvc.Filters;
using Microsoft.Data.SqlClient; using Microsoft.Data.SqlClient;
using SIGCM2.Domain.Exceptions; using SIGCM2.Domain.Exceptions;
using SIGCM2.Domain.Pricing.Exceptions;
namespace SIGCM2.Api.Filters; namespace SIGCM2.Api.Filters;
@@ -267,6 +268,18 @@ public sealed class ExceptionFilter : IExceptionFilter
context.ExceptionHandled = true; context.ExceptionHandled = true;
break; 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 // ADM-001: Medio exceptions
case MedioCodigoDuplicadoException medioCodDupEx: case MedioCodigoDuplicadoException medioCodDupEx:
context.Result = new ObjectResult(new context.Result = new ObjectResult(new
@@ -463,6 +476,45 @@ public sealed class ExceptionFilter : IExceptionFilter
context.ExceptionHandled = true; context.ExceptionHandled = true;
break; 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 // PRD-002: Product exceptions
case ProductNotFoundException productNotFoundEx: case ProductNotFoundException productNotFoundEx:
context.Result = new ObjectResult(new context.Result = new ObjectResult(new
@@ -594,6 +646,94 @@ public sealed class ExceptionFilter : IExceptionFilter
context.ExceptionHandled = true; context.ExceptionHandled = true;
break; break;
// PRC-001: WordCounter + ChargeableCharConfig exceptions
case EmojiDetectedException emojiEx:
context.Result = new ObjectResult(new
{
error = "emoji_not_allowed",
code = "EMOJI_NOT_ALLOWED",
message = emojiEx.Message
})
{
StatusCode = StatusCodes.Status400BadRequest
};
context.ExceptionHandled = true;
break;
case WordCountValidationException wordEx:
context.Result = new ObjectResult(new
{
error = "word_count_validation",
code = "WORD_COUNT_VALIDATION",
field = wordEx.Field,
reason = wordEx.Reason,
message = wordEx.Message
})
{
StatusCode = StatusCodes.Status400BadRequest
};
context.ExceptionHandled = true;
break;
case ChargeableCharConfigInvalidException configInvalidEx:
context.Result = new ObjectResult(new
{
error = "chargeable_char_invalid",
code = "CHARGEABLE_CHAR_INVALID",
field = configInvalidEx.Field,
reason = configInvalidEx.Reason,
message = configInvalidEx.Message
})
{
StatusCode = StatusCodes.Status400BadRequest
};
context.ExceptionHandled = true;
break;
case ChargeableCharConfigForwardOnlyException forwardOnlyCharEx:
context.Result = new ObjectResult(new
{
error = "chargeable_char_forward_only",
code = "CHARGEABLE_CHAR_FORWARD_ONLY",
productTypeId = forwardOnlyCharEx.ProductTypeId,
symbol = forwardOnlyCharEx.Symbol,
newValidFrom = forwardOnlyCharEx.NewValidFrom,
activeValidFrom = forwardOnlyCharEx.ActiveValidFrom,
message = forwardOnlyCharEx.Message
})
{
StatusCode = StatusCodes.Status409Conflict
};
context.ExceptionHandled = true;
break;
case ChargeableCharConfigReactivationNotAllowedException reactivationEx:
context.Result = new ObjectResult(new
{
error = "chargeable_char_reactivation_not_allowed",
code = "CHARGEABLE_CHAR_REACTIVATION_NOT_ALLOWED",
id = reactivationEx.Id,
reason = reactivationEx.Reason,
message = reactivationEx.Message
})
{
StatusCode = StatusCodes.Status409Conflict
};
context.ExceptionHandled = true;
break;
case KeyNotFoundException keyNotFoundEx:
context.Result = new ObjectResult(new
{
error = "not_found",
message = keyNotFoundEx.Message
})
{
StatusCode = StatusCodes.Status404NotFound
};
context.ExceptionHandled = true;
break;
case ValidationException validationEx: case ValidationException validationEx:
var errors = validationEx.Errors var errors = validationEx.Errors
.GroupBy(e => e.PropertyName) .GroupBy(e => e.PropertyName)

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. /// Used by DeactivateProductTypeCommandHandler to guard against orphaning active products.
/// </summary> /// </summary>
Task<bool> ExistsActiveByProductTypeAsync(int productTypeId, CancellationToken ct = default); 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

@@ -74,11 +74,23 @@ using SIGCM2.Application.Products.Update;
using SIGCM2.Application.Products.Deactivate; using SIGCM2.Application.Products.Deactivate;
using SIGCM2.Application.Products.GetById; using SIGCM2.Application.Products.GetById;
using SIGCM2.Application.Products.List; 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.Create;
using SIGCM2.Application.ProductTypes.Update; using SIGCM2.Application.ProductTypes.Update;
using SIGCM2.Application.ProductTypes.Deactivate; using SIGCM2.Application.ProductTypes.Deactivate;
using SIGCM2.Application.ProductTypes.List; using SIGCM2.Application.ProductTypes.List;
using SIGCM2.Application.ProductTypes.GetById; using SIGCM2.Application.ProductTypes.GetById;
using SIGCM2.Application.Pricing.ChargeableChars;
using SIGCM2.Application.Pricing.ChargeableChars.Create;
using SIGCM2.Application.Pricing.ChargeableChars.SchedulePrice;
using SIGCM2.Application.Pricing.ChargeableChars.Deactivate;
using SIGCM2.Application.Pricing.ChargeableChars.Reactivate;
using SIGCM2.Application.Pricing.ChargeableChars.Delete;
using SIGCM2.Application.Pricing.ChargeableChars.List;
using SIGCM2.Application.Pricing.ChargeableChars.GetById;
namespace SIGCM2.Application; namespace SIGCM2.Application;
@@ -182,6 +194,11 @@ public static class DependencyInjection
services.AddScoped<ICommandHandler<GetProductByIdQuery, ProductDetailDto>, GetProductByIdQueryHandler>(); services.AddScoped<ICommandHandler<GetProductByIdQuery, ProductDetailDto>, GetProductByIdQueryHandler>();
services.AddScoped<ICommandHandler<ListProductsQuery, PagedResult<ProductListItemDto>>, ListProductsQueryHandler>(); 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) // ProductTypes (PRD-001)
// IProductQueryRepository is now bound in Infrastructure DI (PRD-002) against dbo.Product. // IProductQueryRepository is now bound in Infrastructure DI (PRD-002) against dbo.Product.
@@ -191,6 +208,16 @@ public static class DependencyInjection
services.AddScoped<ICommandHandler<ListProductTypesQuery, PagedResult<ProductTypeListItemDto>>, ListProductTypesQueryHandler>(); services.AddScoped<ICommandHandler<ListProductTypesQuery, PagedResult<ProductTypeListItemDto>>, ListProductTypesQueryHandler>();
services.AddScoped<ICommandHandler<GetProductTypeByIdQuery, ProductTypeDetailDto>, GetProductTypeByIdQueryHandler>(); services.AddScoped<ICommandHandler<GetProductTypeByIdQuery, ProductTypeDetailDto>, GetProductTypeByIdQueryHandler>();
// ChargeableCharConfig (PRC-001)
services.AddScoped<ICommandHandler<CreateChargeableCharConfigCommand, CreateChargeableCharConfigResponse>, CreateChargeableCharConfigCommandHandler>();
services.AddScoped<ICommandHandler<SchedulePriceChangeCommand, SchedulePriceChangeResponse>, SchedulePriceChangeCommandHandler>();
services.AddScoped<ICommandHandler<DeactivateChargeableCharConfigCommand, DeactivateChargeableCharConfigResponse>, DeactivateChargeableCharConfigCommandHandler>();
services.AddScoped<ICommandHandler<ReactivateChargeableCharConfigCommand, ReactivateChargeableCharConfigResponse>, ReactivateChargeableCharConfigCommandHandler>();
services.AddScoped<ICommandHandler<DeleteChargeableCharConfigCommand, DeleteChargeableCharConfigResponse>, DeleteChargeableCharConfigCommandHandler>();
services.AddScoped<ICommandHandler<ListChargeableCharConfigQuery, PagedResult<ChargeableCharConfigDto>>, ListChargeableCharConfigQueryHandler>();
services.AddScoped<ICommandHandler<GetChargeableCharConfigByIdQuery, ChargeableCharConfigDto?>, GetChargeableCharConfigByIdQueryHandler>();
services.AddScoped<IChargeableCharConfigService, ChargeableCharConfigService>();
// FluentValidation validators (scans entire Application assembly) // FluentValidation validators (scans entire Application assembly)
services.AddValidatorsFromAssemblyContaining<LoginCommandValidator>(); services.AddValidatorsFromAssemblyContaining<LoginCommandValidator>();

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,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

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

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,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,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,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;
}
}

View File

@@ -0,0 +1,21 @@
using SIGCM2.Domain.Exceptions;
namespace SIGCM2.Domain.Pricing.Exceptions;
/// <summary>
/// PRC-001 — Thrown when WordCounterService input fails validation
/// (e.g., exceeds maximum length of 2000 chars). → HTTP 400
/// Used as defense-in-depth alongside FluentValidation in the Application layer.
/// </summary>
public sealed class WordCountValidationException : DomainException
{
public string Field { get; }
public string Reason { get; }
public WordCountValidationException(string field, string reason)
: base($"Valor inválido para {field}: {reason}")
{
Field = field;
Reason = reason;
}
}

View File

@@ -0,0 +1,34 @@
namespace SIGCM2.Domain.Pricing;
/// <summary>
/// Domain-layer extension for TimeProvider: returns Argentina civil date.
/// Mirrors SIGCM2.Application.Common.TimeProviderArgentinaExtensions
/// but lives in Domain to avoid a Domain → Application dependency.
/// Domain layer is pure — no Application references allowed.
/// </summary>
internal static class DomainTimeProviderExtensions
{
private const string ArgentinaTimeZoneId = "America/Argentina/Buenos_Aires";
private const string ArgentinaTimeZoneIdWindows = "Argentina Standard Time";
private static readonly TimeZoneInfo ArgentinaTz = LoadArgentinaTz();
internal static DateOnly GetArgentinaToday(this TimeProvider timeProvider)
{
var utcNow = timeProvider.GetUtcNow();
var argentinaNow = TimeZoneInfo.ConvertTime(utcNow, ArgentinaTz);
return DateOnly.FromDateTime(argentinaNow.DateTime);
}
private static TimeZoneInfo LoadArgentinaTz()
{
try
{
return TimeZoneInfo.FindSystemTimeZoneById(ArgentinaTimeZoneId);
}
catch (TimeZoneNotFoundException)
{
return TimeZoneInfo.FindSystemTimeZoneById(ArgentinaTimeZoneIdWindows);
}
}
}

View File

@@ -0,0 +1,10 @@
namespace SIGCM2.Domain.Pricing.WordCounter;
/// <summary>
/// PRC-001 — Immutable value object representing the result of a word count operation.
/// TotalWords: number of whitespace-separated tokens after normalization.
/// SpecialCharCounts: map of category name → occurrence count in the ORIGINAL (pre-normalization) text.
/// </summary>
public sealed record WordCountResult(
int TotalWords,
IReadOnlyDictionary<string, int> SpecialCharCounts);

View File

@@ -0,0 +1,144 @@
using System.Text;
using System.Text.RegularExpressions;
using SIGCM2.Domain.Pricing.Exceptions;
namespace SIGCM2.Domain.Pricing.WordCounter;
/// <summary>
/// PRC-001 — Pure domain service for counting words in classified ad text.
///
/// Algorithm (in order):
/// 1. Null/empty → WordCountResult(0, empty)
/// 2. Length check: rawText.Length > 2000 → WordCountValidationException
/// 3. Emoji detection via Rune.EnumerateRunes + IsEmojiRune → EmojiDetectedException
/// 4. Count special chars by category (BEFORE replacement — anti-fraud ordering)
/// 5. Replace specials with space; normalize line breaks; collapse whitespace; trim
/// 6. Split(' ', RemoveEmptyEntries) → token count
/// 7. Return WordCountResult
///
/// Tildes (á é í ó ú ñ etc.) are regular word letters — NOT specials.
/// Hyphens are NOT specials — they split words naturally via whitespace split only when
/// they appear as separators between non-whitespace chars (e.g. "buen-estado" → the hyphen
/// itself becomes a word boundary because Split splits on spaces only, so hyphenated words
/// are NOT split by default). Wait — spec GC-18: "buen-estado casi-nuevo" → TotalWords=4.
/// This means hyphen DOES split. The tokenizer must split on hyphen too.
///
/// Design resolution: after normalization, split on whitespace OR hyphen.
/// Hyphens are treated as word boundaries (split token), not as specials counted in SpecialCharCounts.
/// </summary>
public sealed class WordCounterService
{
public const int MaxInputLength = 2000;
// Category patterns — order matters for counting (BEFORE replacement)
private static readonly (string Category, Regex Pattern)[] CategoryPatterns =
[
("Currency", new Regex(@"[\$€¥£]", RegexOptions.Compiled)),
("Percentage", new Regex(@"%", RegexOptions.Compiled)),
("Exclamation", new Regex(@"[!¡]", RegexOptions.Compiled)),
("Question", new Regex(@"[?¿]", RegexOptions.Compiled)),
];
// Collapses any run of spaces/tabs into a single space
private static readonly Regex WhitespaceCollapseRegex = new(@"[ \t]+", RegexOptions.Compiled);
public WordCountResult Count(string? rawText)
{
// Step 1: null/empty fast path
if (string.IsNullOrEmpty(rawText))
return new WordCountResult(0, new Dictionary<string, int>());
// Step 2: length check (on raw input, pre-normalization)
if (rawText.Length > MaxInputLength)
throw new WordCountValidationException(
nameof(rawText),
$"El texto supera el máximo de {MaxInputLength} caracteres.");
// Step 3: emoji detection — fail-fast on first emoji rune found
foreach (var rune in rawText.EnumerateRunes())
{
if (IsEmojiRune(rune))
throw new EmojiDetectedException(rune.Value);
}
// Step 4: count specials by category on ORIGINAL text (anti-fraud ordering)
var counts = new Dictionary<string, int>();
foreach (var (category, pattern) in CategoryPatterns)
{
var matchCount = pattern.Matches(rawText).Count;
if (matchCount > 0)
counts[category] = matchCount;
}
// Step 5: normalize
// 5a. Replace specials with space (each occurrence → space, enabling anti-fraud split)
var normalized = rawText;
foreach (var (_, pattern) in CategoryPatterns)
normalized = pattern.Replace(normalized, " ");
// 5b. Normalize line endings to space
normalized = normalized.Replace("\r\n", " ").Replace('\r', ' ').Replace('\n', ' ');
// 5c. Replace hyphens with space (GC-18: "buen-estado" → 2 tokens)
// Hyphens are word-boundary separators, not special counted chars.
normalized = normalized.Replace('-', ' ');
// 5d. Collapse consecutive spaces/tabs to single space, then trim
normalized = WhitespaceCollapseRegex.Replace(normalized, " ").Trim();
// Step 6: tokenize on space
if (string.IsNullOrEmpty(normalized))
return new WordCountResult(0, counts);
var tokens = normalized.Split(' ', StringSplitOptions.RemoveEmptyEntries);
// Step 7: return result
return new WordCountResult(tokens.Length, counts);
}
/// <summary>
/// Returns true if the given string contains any emoji codepoint.
/// Used by validators that must reject emojis in user-facing identifiers
/// (e.g. ChargeableCharConfig.Symbol) where the frontend blocker can be bypassed
/// by direct API calls. Shares the same IsEmojiRune Unicode ranges used by Count().
/// </summary>
public static bool ContainsEmoji(string? text)
{
if (string.IsNullOrEmpty(text)) return false;
foreach (var rune in text.EnumerateRunes())
{
if (IsEmojiRune(rune)) return true;
}
return false;
}
/// <summary>
/// Returns true if the given rune is an emoji codepoint.
/// Covers: Extended Pictographics, Misc Symbols, Dingbats, Variation Selector-16, ZWJ.
/// </summary>
internal static bool IsEmojiRune(Rune r)
{
int v = r.Value;
// Main emoji blocks
if (v >= 0x1F300 && v <= 0x1F5FF) return true; // Misc Symbols & Pictographs
if (v >= 0x1F600 && v <= 0x1F64F) return true; // Emoticons
if (v >= 0x1F680 && v <= 0x1F6FF) return true; // Transport & Map
if (v >= 0x1F700 && v <= 0x1F77F) return true; // Alchemical Symbols
if (v >= 0x1F900 && v <= 0x1F9FF) return true; // Supplemental Symbols & Pictographs
if (v >= 0x1FA00 && v <= 0x1FAFF) return true; // Symbols and Pictographs Extended-A
// Misc Symbols and Dingbats (conditional — many non-emoji chars here too,
// but the design includes these ranges)
if (v >= 0x2600 && v <= 0x26FF) return true; // Misc Symbols (⚡☀etc.)
if (v >= 0x2700 && v <= 0x27BF) return true; // Dingbats (✂etc.)
// Variation Selector-16 (U+FE0F) — used to force emoji presentation
if (v == 0xFE0F) return true;
// Zero Width Joiner (U+200D) — used in compound emoji (👨‍👩‍👧, 🧑‍🤝‍🧑)
if (v == 0x200D) return true;
return false;
}
}

View File

@@ -43,6 +43,10 @@ public static class DependencyInjection
services.AddScoped<IProductRepository, ProductRepository>(); services.AddScoped<IProductRepository, ProductRepository>();
// PRD-002: replaces NullProductQueryRepository from Application DI // PRD-002: replaces NullProductQueryRepository from Application DI
services.AddScoped<IProductQueryRepository, ProductQueryRepository>(); services.AddScoped<IProductQueryRepository, ProductQueryRepository>();
// PRD-003: ProductPrices históricos
services.AddScoped<IProductPriceRepository, ProductPriceRepository>();
// PRC-001: ChargeableCharConfig — caracteres especiales tasables
services.AddScoped<IChargeableCharConfigRepository, ChargeableCharConfigRepository>();
// JWT Options — bound lazily via IOptions so tests can override via ConfigureWebHost // JWT Options — bound lazily via IOptions so tests can override via ConfigureWebHost
services.Configure<JwtOptions>(configuration.GetSection("Jwt")); services.Configure<JwtOptions>(configuration.GetSection("Jwt"));

View File

@@ -0,0 +1,340 @@
using System.Data;
using Dapper;
using Microsoft.Data.SqlClient;
using SIGCM2.Application.Abstractions.Persistence;
using SIGCM2.Domain.Pricing.ChargeableChars;
using SIGCM2.Domain.Pricing.Exceptions;
namespace SIGCM2.Infrastructure.Persistence;
/// <summary>
/// PRC-001 — Dapper implementation of IChargeableCharConfigRepository against dbo.ChargeableCharConfig.
///
/// InsertWithCloseAsync: invokes usp_ChargeableCharConfig_InsertWithClose and maps:
/// - SqlException 50404 → ChargeableCharConfigInvalidException (ProductType not found)
/// - SqlException 50409 → ChargeableCharConfigForwardOnlyException
///
/// GetActiveForProductTypeAsync: invokes usp_ChargeableCharConfig_GetActiveForProductType.
/// Returns all rows (global + per-ProductType) — the Application service applies priority.
///
/// ReactivateAsync: invokes usp_ChargeableCharConfig_ReactivateWithGuard and maps:
/// - SqlException 50410 → ChargeableCharConfigReactivationNotAllowedException(ALREADY_ACTIVE)
/// - SqlException 50411 → ChargeableCharConfigReactivationNotAllowedException(VIGENTE_EXISTS)
/// - SqlException 50412 → ChargeableCharConfigReactivationNotAllowedException(POSTERIOR_ROWS_EXIST)
/// - SqlException 50404 → ChargeableCharConfigInvalidException (row not found)
///
/// DeleteAsync: simple parameterized DELETE. If 0 rows affected, throws KeyNotFoundException.
/// NOTE: With SYSTEM_VERSIONING ON, the DELETE physically removes the row from the current
/// table and SQL Server moves it to the history table (_History) with SysEndTime set to the
/// deletion time. The row is still queryable via FOR SYSTEM_TIME. Temporal audit preserved.
///
/// DateOnly mapping: SQL DATE columns are received as DateTime by Dapper; converted via
/// DateOnly.FromDateTime() in the row mapper — same pattern as ProductPriceRepository.
///
/// ProductTypeId: the SP accepts INT NULL; int? cast from long? is performed in this layer.
/// </summary>
public sealed class ChargeableCharConfigRepository : IChargeableCharConfigRepository
{
private readonly SqlConnectionFactory _factory;
public ChargeableCharConfigRepository(SqlConnectionFactory factory)
{
_factory = factory;
}
/// <inheritdoc/>
public async Task<long> InsertWithCloseAsync(
long? productTypeId,
string symbol,
string category,
decimal price,
DateOnly validFrom,
CancellationToken ct = default)
{
var p = new DynamicParameters();
// SP parameter is INT NULL — cast long? → int? here; DB uses INT for ProductTypeId (V023)
p.Add("@ProductTypeId", productTypeId.HasValue ? (int?)checked((int)productTypeId.Value) : null, DbType.Int32);
p.Add("@Symbol", symbol, DbType.String, size: 4);
p.Add("@Category", category, DbType.String, size: 32);
p.Add("@PricePerUnit", price, DbType.Decimal, precision: 18, scale: 4);
p.Add("@ValidFrom", validFrom.ToDateTime(TimeOnly.MinValue), DbType.Date);
p.Add("@NewId", dbType: DbType.Int64, direction: ParameterDirection.Output);
p.Add("@ClosedId", dbType: DbType.Int64, direction: ParameterDirection.Output);
await using var connection = _factory.CreateConnection();
await connection.OpenAsync(ct);
try
{
await connection.ExecuteAsync(
new CommandDefinition(
"dbo.usp_ChargeableCharConfig_InsertWithClose",
p,
commandType: CommandType.StoredProcedure,
cancellationToken: ct));
}
catch (SqlException ex) when (ex.Number == 50404)
{
// ProductType not found (SP validates ProductTypeId when not null)
throw new ChargeableCharConfigInvalidException(
nameof(productTypeId),
$"ProductType with Id={productTypeId} not found.");
}
catch (SqlException ex) when (ex.Number == 50409)
{
// Forward-only violation: new ValidFrom <= active.ValidFrom
throw new ChargeableCharConfigForwardOnlyException(
productTypeId.HasValue ? (int?)checked((int)productTypeId.Value) : null,
symbol,
validFrom,
DateOnly.MinValue); // active.ValidFrom not returned by SP; safe placeholder
}
return p.Get<long>("@NewId");
}
/// <inheritdoc/>
public async Task<IReadOnlyList<ChargeableCharConfig>> GetActiveForProductTypeAsync(
long productTypeId,
DateOnly asOfDate,
CancellationToken ct = default)
{
var p = new DynamicParameters();
// SP @ProductTypeId is INT
p.Add("@ProductTypeId", checked((int)productTypeId), DbType.Int32);
p.Add("@AsOfDate", asOfDate.ToDateTime(TimeOnly.MinValue), DbType.Date);
await using var connection = _factory.CreateConnection();
await connection.OpenAsync(ct);
var rows = await connection.QueryAsync<ChargeableCharConfigRow>(
new CommandDefinition(
"dbo.usp_ChargeableCharConfig_GetActiveForProductType",
p,
commandType: CommandType.StoredProcedure,
cancellationToken: ct));
return rows.Select(MapRow).ToList();
}
/// <inheritdoc/>
public async Task<IReadOnlyList<ChargeableCharConfig>> ListAsync(
long? productTypeId,
bool activeOnly,
int skip,
int take,
CancellationToken ct = default)
{
// NULL-aware ProductTypeId filter:
// - productTypeId provided → filter to that ProductType only
// - productTypeId null → return all rows regardless of ProductType
// activeOnly filters by IsActive = 1.
const string sql = """
SELECT Id, ProductTypeId, Symbol, Category, PricePerUnit, ValidFrom, ValidTo, IsActive
FROM dbo.ChargeableCharConfig
WHERE (@ProductTypeId IS NULL OR ProductTypeId = @ProductTypeId)
AND (@ActiveOnly = 0 OR IsActive = 1)
ORDER BY ValidFrom DESC, Id DESC
OFFSET @Skip ROWS FETCH NEXT @Take ROWS ONLY
""";
await using var connection = _factory.CreateConnection();
await connection.OpenAsync(ct);
var rows = await connection.QueryAsync<ChargeableCharConfigRow>(
new CommandDefinition(
sql,
new
{
ProductTypeId = productTypeId.HasValue ? (int?)checked((int)productTypeId.Value) : (int?)null,
ActiveOnly = activeOnly ? 1 : 0,
Skip = skip,
Take = take
},
cancellationToken: ct));
return rows.Select(MapRow).ToList();
}
/// <inheritdoc/>
public async Task<int> CountAsync(
long? productTypeId,
bool activeOnly,
CancellationToken ct = default)
{
const string sql = """
SELECT COUNT(1)
FROM dbo.ChargeableCharConfig
WHERE (@ProductTypeId IS NULL OR ProductTypeId = @ProductTypeId)
AND (@ActiveOnly = 0 OR IsActive = 1)
""";
await using var connection = _factory.CreateConnection();
await connection.OpenAsync(ct);
return await connection.ExecuteScalarAsync<int>(
new CommandDefinition(
sql,
new
{
ProductTypeId = productTypeId.HasValue ? (int?)checked((int)productTypeId.Value) : (int?)null,
ActiveOnly = activeOnly ? 1 : 0
},
cancellationToken: ct));
}
/// <inheritdoc/>
public async Task<ChargeableCharConfig?> GetByIdAsync(
long id,
CancellationToken ct = default)
{
const string sql = """
SELECT Id, ProductTypeId, Symbol, Category, PricePerUnit, ValidFrom, ValidTo, IsActive
FROM dbo.ChargeableCharConfig
WHERE Id = @Id
""";
await using var connection = _factory.CreateConnection();
await connection.OpenAsync(ct);
var row = await connection.QuerySingleOrDefaultAsync<ChargeableCharConfigRow>(
new CommandDefinition(sql, new { Id = id }, cancellationToken: ct));
return row is null ? null : MapRow(row);
}
/// <inheritdoc/>
public async Task DeactivateAsync(
long id,
DateOnly today,
CancellationToken ct = default)
{
// Idempotent: WHERE ... AND IsActive = 1 — no-op if already inactive.
const string sql = """
UPDATE dbo.ChargeableCharConfig
SET IsActive = 0,
ValidTo = @Today
WHERE Id = @Id
AND IsActive = 1
""";
await using var connection = _factory.CreateConnection();
await connection.OpenAsync(ct);
await connection.ExecuteAsync(
new CommandDefinition(
sql,
new
{
Id = id,
Today = today.ToDateTime(TimeOnly.MinValue)
},
cancellationToken: ct));
}
/// <inheritdoc/>
public async Task<ChargeableCharConfig> ReactivateAsync(
long id,
CancellationToken ct = default)
{
// IMPORTANT: the SP invocation and the subsequent SELECT MUST run on the SAME connection.
// Opening a second connection within the ambient TransactionScope would promote the
// transaction to DTC (distributed) — and MSDTC is typically not enabled on dev/prod
// servers. That promotion surfaces as an opaque 500 at the API boundary. Keeping both
// commands on a single SqlConnection keeps the tx as a local LTM (lightweight transaction).
var p = new DynamicParameters();
p.Add("@Id", id, DbType.Int64);
await using var connection = _factory.CreateConnection();
await connection.OpenAsync(ct);
try
{
await connection.ExecuteAsync(
new CommandDefinition(
"dbo.usp_ChargeableCharConfig_ReactivateWithGuard",
p,
commandType: CommandType.StoredProcedure,
cancellationToken: ct));
}
catch (SqlException ex) when (ex.Number == 50404)
{
throw new ChargeableCharConfigInvalidException(
nameof(id), $"ChargeableCharConfig with Id={id} not found.");
}
catch (SqlException ex) when (ex.Number == 50410)
{
throw new ChargeableCharConfigReactivationNotAllowedException(id, "ALREADY_ACTIVE");
}
catch (SqlException ex) when (ex.Number == 50411)
{
throw new ChargeableCharConfigReactivationNotAllowedException(id, "VIGENTE_EXISTS");
}
catch (SqlException ex) when (ex.Number == 50412)
{
throw new ChargeableCharConfigReactivationNotAllowedException(id, "POSTERIOR_ROWS_EXIST");
}
// Fetch the reactivated row on the SAME connection (avoids DTC promotion).
const string selectSql = """
SELECT Id, ProductTypeId, Symbol, Category, PricePerUnit, ValidFrom, ValidTo, IsActive
FROM dbo.ChargeableCharConfig
WHERE Id = @Id
""";
var row = await connection.QuerySingleOrDefaultAsync<ChargeableCharConfigRow>(
new CommandDefinition(selectSql, new { Id = id }, cancellationToken: ct));
return row is null
? throw new ChargeableCharConfigInvalidException(
nameof(id), $"ChargeableCharConfig with Id={id} not found after reactivation (unexpected).")
: MapRow(row);
}
/// <inheritdoc/>
public async Task DeleteAsync(
long id,
CancellationToken ct = default)
{
// NOTE: With SYSTEM_VERSIONING ON on dbo.ChargeableCharConfig, this DELETE moves
// the row to dbo.ChargeableCharConfig_History (SysEndTime = deletion timestamp).
// The row disappears from current-state queries but is still queryable via
// FOR SYSTEM_TIME. Temporal audit trail is preserved.
// Future FAC-001 will add a guard to block delete if the row was used in invoicing.
const string sql = "DELETE FROM dbo.ChargeableCharConfig WHERE Id = @Id";
await using var connection = _factory.CreateConnection();
await connection.OpenAsync(ct);
var rowsAffected = await connection.ExecuteAsync(
new CommandDefinition(sql, new { Id = id }, cancellationToken: ct));
if (rowsAffected == 0)
throw new KeyNotFoundException($"ChargeableCharConfig with Id={id} not found.");
}
// ── Row mapper ────────────────────────────────────────────────────────────
// Dapper maps SQL DATE columns to DateTime; we convert to DateOnly here.
// Same pattern as ProductPriceRepository.
private static ChargeableCharConfig MapRow(ChargeableCharConfigRow r)
=> ChargeableCharConfig.Rehydrate(
id: r.Id,
productTypeId: r.ProductTypeId,
symbol: r.Symbol,
category: r.Category,
price: r.PricePerUnit,
validFrom: DateOnly.FromDateTime(r.ValidFrom),
validTo: r.ValidTo.HasValue ? DateOnly.FromDateTime(r.ValidTo.Value) : (DateOnly?)null,
isActive: r.IsActive);
private sealed record ChargeableCharConfigRow(
long Id,
int? ProductTypeId,
string Symbol,
string Category,
decimal PricePerUnit,
DateTime ValidFrom,
DateTime? ValidTo,
bool IsActive);
}

View File

@@ -0,0 +1,162 @@
using System.Data;
using Dapper;
using Microsoft.Data.SqlClient;
using SIGCM2.Application.Abstractions.Persistence;
using SIGCM2.Application.Common;
using SIGCM2.Domain.Entities;
using SIGCM2.Domain.Exceptions;
namespace SIGCM2.Infrastructure.Persistence;
/// <summary>
/// PRD-003 — Dapper implementation of IProductPriceRepository against dbo.ProductPrices.
/// AddAsync invokes dbo.usp_AddProductPrice and maps SqlException numbers to domain exceptions.
/// GetByProductIdAsync and GetActiveAsync run direct SQL and map DateTime columns to DateOnly.
/// </summary>
public sealed class ProductPriceRepository : IProductPriceRepository
{
private readonly SqlConnectionFactory _factory;
public ProductPriceRepository(SqlConnectionFactory factory)
{
_factory = factory;
}
/// <inheritdoc/>
public async Task<(long NewId, long? ClosedId)> AddAsync(
int productId,
decimal price,
DateOnly priceValidFrom,
CancellationToken ct = default)
{
var p = new DynamicParameters();
p.Add("@ProductId", productId, DbType.Int32);
p.Add("@Price", price, DbType.Decimal, precision: 12, scale: 2);
p.Add("@PriceValidFrom", priceValidFrom.ToDateTime(TimeOnly.MinValue), DbType.Date);
p.Add("@NewId", dbType: DbType.Int64, direction: ParameterDirection.Output);
p.Add("@ClosedId", dbType: DbType.Int64, direction: ParameterDirection.Output);
await using var connection = _factory.CreateConnection();
await connection.OpenAsync(ct);
try
{
await connection.ExecuteAsync(
new CommandDefinition(
"dbo.usp_AddProductPrice",
p,
commandType: CommandType.StoredProcedure,
cancellationToken: ct));
}
catch (SqlException ex) when (ex.Number == 50404)
{
throw new ProductNotFoundException(productId);
}
catch (SqlException ex) when (ex.Number == 50409)
{
// Forward-only violation detected by SP (new PVF <= active PVF).
// activePriceValidFrom is not returned by the SP; use MinValue as safe placeholder.
throw new ProductPriceForwardOnlyException(productId, priceValidFrom, DateOnly.MinValue);
}
catch (SqlException ex) when (ex.Number == 2601 || ex.Number == 2627)
{
// Race condition: two concurrent inserts with PriceValidTo IS NULL slipped through
// the SERIALIZABLE guard. Defense-in-depth: surface as forward-only.
throw new ProductPriceForwardOnlyException(productId, priceValidFrom, DateOnly.MinValue);
}
var newId = p.Get<long>("@NewId");
var closedId = p.Get<long?>("@ClosedId");
return (newId, closedId);
}
/// <inheritdoc/>
public async Task<PagedResult<ProductPrice>> GetByProductIdAsync(
int productId,
int page,
int pageSize,
CancellationToken ct = default)
{
// Uses IX_ProductPrices_Lookup (ProductId, PriceValidFrom DESC).
// Two separate queries on the same open connection: COUNT first, then paginated DATA.
const string countSql = """
SELECT COUNT(1) FROM dbo.ProductPrices WHERE ProductId = @ProductId
""";
const string dataSql = """
SELECT Id, ProductId, Price, PriceValidFrom, PriceValidTo, FechaCreacion
FROM dbo.ProductPrices
WHERE ProductId = @ProductId
ORDER BY PriceValidFrom DESC, Id DESC
OFFSET @Offset ROWS FETCH NEXT @PageSize ROWS ONLY
""";
var offset = (page - 1) * pageSize;
await using var connection = _factory.CreateConnection();
await connection.OpenAsync(ct);
var total = await connection.ExecuteScalarAsync<int>(
new CommandDefinition(countSql, new { ProductId = productId }, cancellationToken: ct));
var rows = await connection.QueryAsync<ProductPriceRow>(
new CommandDefinition(dataSql,
new { ProductId = productId, Offset = offset, PageSize = pageSize },
cancellationToken: ct));
var items = rows.Select(MapRow).ToList();
return new PagedResult<ProductPrice>(items, page, pageSize, total);
}
/// <inheritdoc/>
public async Task<ProductPrice?> GetActiveAsync(
int productId,
DateOnly date,
CancellationToken ct = default)
{
// Uses IX_ProductPrices_Lookup (ProductId, PriceValidFrom DESC) INCLUDE(Price, PriceValidTo).
// TOP 1 ordered DESC by PriceValidFrom returns the most-recent row whose window covers date.
const string sql = """
SELECT TOP 1 Id, ProductId, Price, PriceValidFrom, PriceValidTo, FechaCreacion
FROM dbo.ProductPrices
WHERE ProductId = @ProductId
AND PriceValidFrom <= @Date
AND (PriceValidTo IS NULL OR PriceValidTo >= @Date)
ORDER BY PriceValidFrom DESC, Id DESC
""";
await using var connection = _factory.CreateConnection();
await connection.OpenAsync(ct);
var row = await connection.QuerySingleOrDefaultAsync<ProductPriceRow>(
new CommandDefinition(
sql,
new { ProductId = productId, Date = date.ToDateTime(TimeOnly.MinValue) },
cancellationToken: ct));
return row is null ? null : MapRow(row);
}
// ── Mapping ───────────────────────────────────────────────────────────────
// Dapper maps SQL DATE columns to DateTime; we convert to DateOnly here.
private static ProductPrice MapRow(ProductPriceRow r)
=> new(
Id: r.Id,
ProductId: r.ProductId,
Price: r.Price,
PriceValidFrom: DateOnly.FromDateTime(r.PriceValidFrom),
PriceValidTo: r.PriceValidTo.HasValue
? DateOnly.FromDateTime(r.PriceValidTo.Value)
: (DateOnly?)null,
FechaCreacion: r.FechaCreacion);
private sealed record ProductPriceRow(
long Id,
int ProductId,
decimal Price,
DateTime PriceValidFrom,
DateTime? PriceValidTo,
DateTime FechaCreacion);
}

View File

@@ -34,4 +34,16 @@ public sealed class ProductQueryRepository : IProductQueryRepository
var result = await connection.ExecuteScalarAsync<int>(sql, new { ProductTypeId = productTypeId }); var result = await connection.ExecuteScalarAsync<int>(sql, new { ProductTypeId = productTypeId });
return result == 1; return result == 1;
} }
public async Task<int> CountActiveByRubroAsync(int rubroId, CancellationToken ct = default)
{
const string sql = """
SELECT COUNT(1) FROM dbo.Product WHERE RubroId = @RubroId AND IsActive = 1
""";
await using var connection = _factory.CreateConnection();
await connection.OpenAsync(ct);
return await connection.ExecuteScalarAsync<int>(sql, new { RubroId = rubroId });
}
} }

View File

@@ -16,6 +16,7 @@
"@radix-ui/react-collapsible": "^1.1.12", "@radix-ui/react-collapsible": "^1.1.12",
"@radix-ui/react-dialog": "^1.1.15", "@radix-ui/react-dialog": "^1.1.15",
"@radix-ui/react-dropdown-menu": "^2.1.16", "@radix-ui/react-dropdown-menu": "^2.1.16",
"@radix-ui/react-hover-card": "^1.1.15",
"@radix-ui/react-label": "^2.1.8", "@radix-ui/react-label": "^2.1.8",
"@radix-ui/react-navigation-menu": "^1.2.14", "@radix-ui/react-navigation-menu": "^1.2.14",
"@radix-ui/react-popover": "^1.1.15", "@radix-ui/react-popover": "^1.1.15",
@@ -2264,6 +2265,93 @@
} }
} }
}, },
"node_modules/@radix-ui/react-hover-card": {
"version": "1.1.15",
"resolved": "https://registry.npmjs.org/@radix-ui/react-hover-card/-/react-hover-card-1.1.15.tgz",
"integrity": "sha512-qgTkjNT1CfKMoP0rcasmlH2r1DAiYicWsDsufxl940sT2wHNEWWv6FMWIQXWhVdmC1d/HYfbhQx60KYyAtKxjg==",
"license": "MIT",
"dependencies": {
"@radix-ui/primitive": "1.1.3",
"@radix-ui/react-compose-refs": "1.1.2",
"@radix-ui/react-context": "1.1.2",
"@radix-ui/react-dismissable-layer": "1.1.11",
"@radix-ui/react-popper": "1.2.8",
"@radix-ui/react-portal": "1.1.9",
"@radix-ui/react-presence": "1.1.5",
"@radix-ui/react-primitive": "2.1.3",
"@radix-ui/react-use-controllable-state": "1.2.2"
},
"peerDependencies": {
"@types/react": "*",
"@types/react-dom": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@types/react-dom": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-hover-card/node_modules/@radix-ui/react-context": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.1.2.tgz",
"integrity": "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA==",
"license": "MIT",
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-hover-card/node_modules/@radix-ui/react-primitive": {
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz",
"integrity": "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==",
"license": "MIT",
"dependencies": {
"@radix-ui/react-slot": "1.2.3"
},
"peerDependencies": {
"@types/react": "*",
"@types/react-dom": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@types/react-dom": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-hover-card/node_modules/@radix-ui/react-slot": {
"version": "1.2.3",
"resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz",
"integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==",
"license": "MIT",
"dependencies": {
"@radix-ui/react-compose-refs": "1.1.2"
},
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-id": { "node_modules/@radix-ui/react-id": {
"version": "1.1.1", "version": "1.1.1",
"resolved": "https://registry.npmjs.org/@radix-ui/react-id/-/react-id-1.1.1.tgz", "resolved": "https://registry.npmjs.org/@radix-ui/react-id/-/react-id-1.1.1.tgz",

View File

@@ -21,6 +21,7 @@
"@radix-ui/react-collapsible": "^1.1.12", "@radix-ui/react-collapsible": "^1.1.12",
"@radix-ui/react-dialog": "^1.1.15", "@radix-ui/react-dialog": "^1.1.15",
"@radix-ui/react-dropdown-menu": "^2.1.16", "@radix-ui/react-dropdown-menu": "^2.1.16",
"@radix-ui/react-hover-card": "^1.1.15",
"@radix-ui/react-label": "^2.1.8", "@radix-ui/react-label": "^2.1.8",
"@radix-ui/react-navigation-menu": "^1.2.14", "@radix-ui/react-navigation-menu": "^1.2.14",
"@radix-ui/react-popover": "^1.1.15", "@radix-ui/react-popover": "^1.1.15",

View File

@@ -1,12 +1,8 @@
import { Link, useLocation } from 'react-router-dom' import { Link, useLocation } from 'react-router-dom'
import { import {
LayoutDashboard, LayoutDashboard,
ShoppingCart,
Calculator,
Zap,
Settings,
UserPlus,
Users, Users,
UserPlus,
ShieldCheck, ShieldCheck,
KeyRound, KeyRound,
FileClock, FileClock,
@@ -18,31 +14,50 @@ import {
Tag, Tag,
Layers, Layers,
Package, Package,
Hash,
Building2,
Calculator,
ChevronDown,
} from 'lucide-react' } from 'lucide-react'
import { cn } from '@/lib/utils' import { cn } from '@/lib/utils'
import { Badge } from '@/components/ui/badge'
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip' import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
import { HoverCard, HoverCardContent, HoverCardTrigger } from '@/components/ui/hover-card'
import { useAuthStore } from '@/stores/authStore' import { useAuthStore } from '@/stores/authStore'
import { useSidebar } from '@/hooks/useSidebar' import { useSidebar } from '@/hooks/useSidebar'
import { useSidebarSections } from '@/hooks/useSidebarSections'
interface NavItem { interface NavItem {
label: string label: string
href: string href: string
icon: React.ElementType icon: React.ElementType
disabled?: boolean
/** Si se define, el item solo se muestra si el user tiene este permiso. */ /** Si se define, el item solo se muestra si el user tiene este permiso. */
requiredPermission?: string requiredPermission?: string
} }
const navItems: NavItem[] = [ interface NavSection {
{ label: 'Dashboard', href: '/', icon: LayoutDashboard }, label: string
{ label: 'Ventas', href: '/ventas', icon: ShoppingCart, disabled: true }, /** Icon de grupo, usado en el modo colapsado como trigger del fly-out. */
{ label: 'Tasación', href: '/tasacion', icon: Calculator, disabled: true }, icon: React.ElementType
{ label: 'Integraciones', href: '/integraciones', icon: Zap, disabled: true }, /** Si true, la sección solo se muestra si el user tiene rol admin. */
{ label: 'Administración', href: '/administracion', icon: Settings, disabled: true }, adminOnly?: boolean
] items: NavItem[]
}
const adminItems: NavItem[] = [ // Item principal — siempre visible para usuarios autenticados
const dashboardItem: NavItem = {
label: 'Dashboard',
href: '/',
icon: LayoutDashboard,
}
// Secciones agrupadas por dominio (Seguridad → Maestros → Catálogo → Tasación).
// Cada sección se oculta si todos sus items están filtrados por permisos.
const navSections: NavSection[] = [
{
label: 'Seguridad',
icon: ShieldCheck,
adminOnly: true,
items: [
{ label: 'Usuarios', href: '/usuarios', icon: Users }, { label: 'Usuarios', href: '/usuarios', icon: Users },
{ label: 'Crear Usuario', href: '/usuarios/nuevo', icon: UserPlus }, { label: 'Crear Usuario', href: '/usuarios/nuevo', icon: UserPlus },
{ label: 'Roles', href: '/admin/roles', icon: ShieldCheck }, { label: 'Roles', href: '/admin/roles', icon: ShieldCheck },
@@ -53,6 +68,13 @@ const adminItems: NavItem[] = [
icon: FileClock, icon: FileClock,
requiredPermission: 'administracion:auditoria:ver', requiredPermission: 'administracion:auditoria:ver',
}, },
],
},
{
label: 'Maestros',
icon: Building2,
adminOnly: true,
items: [
{ {
label: 'Medios', label: 'Medios',
href: '/admin/medios', href: '/admin/medios',
@@ -71,6 +93,13 @@ const adminItems: NavItem[] = [
icon: Store, icon: Store,
requiredPermission: 'administracion:puntos_de_venta:gestionar', requiredPermission: 'administracion:puntos_de_venta:gestionar',
}, },
],
},
{
label: 'Catálogo',
icon: Package,
adminOnly: true,
items: [
{ {
label: 'Rubros', label: 'Rubros',
href: '/admin/rubros', href: '/admin/rubros',
@@ -89,6 +118,21 @@ const adminItems: NavItem[] = [
icon: Package, icon: Package,
requiredPermission: 'catalogo:productos:gestionar', requiredPermission: 'catalogo:productos:gestionar',
}, },
],
},
{
label: 'Tasación',
icon: Calculator,
adminOnly: true,
items: [
{
label: 'Caracteres Tasables',
href: '/admin/tasacion/chargeable-chars',
icon: Hash,
requiredPermission: 'tasacion:caracteres_especiales:gestionar',
},
],
},
] ]
interface SidebarNavProps { interface SidebarNavProps {
@@ -100,11 +144,11 @@ export function SidebarNav({ forceExpanded = false }: SidebarNavProps) {
const { pathname } = useLocation() const { pathname } = useLocation()
const user = useAuthStore((s) => s.user) const user = useAuthStore((s) => s.user)
const isAdmin = user?.rol === 'admin' const isAdmin = user?.rol === 'admin'
const { collapsed: persisted, toggle } = useSidebar() const { collapsed: persisted, toggle: toggleSidebar } = useSidebar()
const collapsed = forceExpanded ? false : persisted const collapsed = forceExpanded ? false : persisted
const { isCollapsed: isSectionCollapsed, toggle: toggleSection } = useSidebarSections()
function isItemActive(item: NavItem): boolean { function isItemActive(item: NavItem): boolean {
if (item.disabled) return false
if (item.href === '/') return pathname === '/' if (item.href === '/') return pathname === '/'
if (item.href === '/usuarios') { if (item.href === '/usuarios') {
return pathname.startsWith('/usuarios') && pathname !== '/usuarios/nuevo' return pathname.startsWith('/usuarios') && pathname !== '/usuarios/nuevo'
@@ -113,6 +157,24 @@ export function SidebarNav({ forceExpanded = false }: SidebarNavProps) {
return pathname.startsWith(item.href) return pathname.startsWith(item.href)
} }
function hasAccess(item: NavItem): boolean {
return !item.requiredPermission || (user?.permisos.includes(item.requiredPermission) ?? false)
}
/** Returns true if any item in the section matches the active route. */
function sectionContainsActive(section: NavSection): boolean {
return section.items.some(isItemActive)
}
// Filter sections + items by role + permissions. Empty sections are hidden.
const visibleSections = navSections
.filter((section) => !section.adminOnly || isAdmin)
.map((section) => ({
...section,
items: section.items.filter(hasAccess),
}))
.filter((section) => section.items.length > 0)
return ( return (
<aside <aside
className={cn( className={cn(
@@ -121,7 +183,7 @@ export function SidebarNav({ forceExpanded = false }: SidebarNavProps) {
)} )}
data-collapsed={collapsed} data-collapsed={collapsed}
> >
{/* Brand + Toggle (top header) */} {/* Brand + Toggle */}
<div <div
className={cn( className={cn(
'flex h-14 items-center border-b border-border shrink-0', 'flex h-14 items-center border-b border-border shrink-0',
@@ -133,7 +195,7 @@ export function SidebarNav({ forceExpanded = false }: SidebarNavProps) {
<TooltipTrigger asChild> <TooltipTrigger asChild>
<button <button
type="button" type="button"
onClick={toggle} onClick={toggleSidebar}
aria-label={collapsed ? 'Expandir sidebar' : 'Colapsar sidebar'} aria-label={collapsed ? 'Expandir sidebar' : 'Colapsar sidebar'}
className="flex h-9 w-9 items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-accent hover:text-accent-foreground shrink-0" className="flex h-9 w-9 items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-accent hover:text-accent-foreground shrink-0"
> >
@@ -159,34 +221,38 @@ export function SidebarNav({ forceExpanded = false }: SidebarNavProps) {
{/* Nav */} {/* Nav */}
<nav className="flex-1 overflow-y-auto overflow-x-hidden py-3 px-2 space-y-1"> <nav className="flex-1 overflow-y-auto overflow-x-hidden py-3 px-2 space-y-1">
{navItems.map((item) => (
<NavRow <NavRow
key={item.href} item={dashboardItem}
item={item}
collapsed={collapsed} collapsed={collapsed}
active={isItemActive(item)} active={isItemActive(dashboardItem)}
/> />
))}
{isAdmin && ( {collapsed
<> ? visibleSections.map((section) => (
<SectionLabel collapsed={collapsed}>Administración</SectionLabel> <CollapsedSectionFlyout
{adminItems key={section.label}
.filter( section={section}
(item) => isItemActive={isItemActive}
!item.requiredPermission || active={sectionContainsActive(section)}
user?.permisos.includes(item.requiredPermission),
)
.map((item) => (
<NavRow
key={item.href}
item={item}
collapsed={collapsed}
active={isItemActive(item)}
/> />
))} ))
</> : visibleSections.map((section) => {
)} const containsActive = sectionContainsActive(section)
const userPrefCollapsed = isSectionCollapsed(section.label)
// Auto-expand override: if the active route lives here, keep it open
// regardless of persisted preference.
const expanded = containsActive ? true : !userPrefCollapsed
return (
<ExpandedSection
key={section.label}
section={section}
expanded={expanded}
onToggle={() => toggleSection(section.label)}
toggleDisabled={containsActive}
isItemActive={isItemActive}
/>
)
})}
</nav> </nav>
</aside> </aside>
) )
@@ -210,41 +276,6 @@ function NavRow({ item, collapsed, active }: NavRowProps) {
collapsed ? 'justify-center h-10 w-10 mx-auto' : 'gap-3 px-3 py-2', collapsed ? 'justify-center h-10 w-10 mx-auto' : 'gap-3 px-3 py-2',
) )
// Disabled item
if (item.disabled) {
const content = (
<div
className={cn(
baseClasses,
'text-muted-foreground/70 cursor-not-allowed opacity-60',
)}
aria-disabled="true"
>
<Icon className="h-4 w-4 shrink-0" />
{!collapsed && (
<>
<span className="flex-1 truncate">{item.label}</span>
<Badge variant="secondary" className="text-[10px] px-1.5 py-0 shrink-0">
Próx.
</Badge>
</>
)}
</div>
)
if (!collapsed) return content
return (
<Tooltip>
<TooltipTrigger asChild>{content}</TooltipTrigger>
<TooltipContent side="right">
{item.label}{' '}
<span className="text-muted-foreground">· Próximamente</span>
</TooltipContent>
</Tooltip>
)
}
// Active link
const link = ( const link = (
<Link <Link
to={item.href} to={item.href}
@@ -256,7 +287,6 @@ function NavRow({ item, collapsed, active }: NavRowProps) {
)} )}
aria-current={active ? 'page' : undefined} aria-current={active ? 'page' : undefined}
> >
{/* Active indicator bar (left edge) when expanded */}
{active && !collapsed && ( {active && !collapsed && (
<span className="absolute left-0 top-1/2 -translate-y-1/2 h-5 w-0.5 rounded-r-full bg-primary" /> <span className="absolute left-0 top-1/2 -translate-y-1/2 h-5 w-0.5 rounded-r-full bg-primary" />
)} )}
@@ -274,21 +304,138 @@ function NavRow({ item, collapsed, active }: NavRowProps) {
) )
} }
function SectionLabel({ /* ── Expanded mode — collapsible section with chevron toggle ──────── */
collapsed,
children, interface ExpandedSectionProps {
}: { section: NavSection
collapsed: boolean expanded: boolean
children: React.ReactNode onToggle: () => void
}) { /** When true, the header is not clickable (because the section contains the active route and must stay open). */
if (collapsed) { toggleDisabled: boolean
return <div className="my-2 mx-2 border-t border-border" aria-hidden="true" /> isItemActive: (item: NavItem) => boolean
} }
function ExpandedSection({
section,
expanded,
onToggle,
toggleDisabled,
isItemActive,
}: ExpandedSectionProps) {
return ( return (
<div className="pt-3 pb-1 px-3"> <div className="pt-2">
<span className="text-[10px] font-semibold uppercase tracking-wider text-muted-foreground/60"> <button
{children} type="button"
</span> onClick={toggleDisabled ? undefined : onToggle}
aria-expanded={expanded}
disabled={toggleDisabled}
className={cn(
'w-full flex items-center justify-between pt-1 pb-1 px-3 rounded-md',
'text-[10px] font-semibold uppercase tracking-wider text-muted-foreground/60',
toggleDisabled
? 'cursor-default'
: 'hover:text-muted-foreground transition-colors cursor-pointer',
)}
>
<span>{section.label}</span>
{!toggleDisabled && (
<ChevronDown
className={cn(
'h-3 w-3 shrink-0 transition-transform duration-200',
expanded ? 'rotate-0' : '-rotate-90',
)}
/>
)}
</button>
{expanded && (
<div className="space-y-1 mt-1">
{section.items.map((item) => (
<NavRow
key={item.href}
item={item}
collapsed={false}
active={isItemActive(item)}
/>
))}
</div>
)}
</div> </div>
) )
} }
/* ── Collapsed mode — group icon + fly-out hover panel ──────────── */
interface CollapsedSectionFlyoutProps {
section: NavSection
isItemActive: (item: NavItem) => boolean
/** When true, the group icon shows an active indicator (contains the active route). */
active: boolean
}
function CollapsedSectionFlyout({
section,
isItemActive,
active,
}: CollapsedSectionFlyoutProps) {
const GroupIcon = section.icon
return (
<HoverCard openDelay={120} closeDelay={80}>
<HoverCardTrigger asChild>
<button
type="button"
aria-label={section.label}
aria-haspopup="menu"
className={cn(
'relative flex items-center justify-center h-10 w-10 mx-auto rounded-md transition-colors',
active
? 'bg-accent text-accent-foreground'
: 'text-muted-foreground hover:bg-accent hover:text-accent-foreground',
)}
>
{active && (
<span
aria-hidden="true"
className="absolute right-1 top-1 h-1.5 w-1.5 rounded-full bg-primary"
/>
)}
<GroupIcon className="h-4 w-4 shrink-0" />
</button>
</HoverCardTrigger>
<HoverCardContent
side="right"
align="start"
sideOffset={8}
className="w-56 p-2 z-[60]"
role="menu"
>
<div className="px-2 pt-1 pb-2 text-[10px] font-semibold uppercase tracking-wider text-muted-foreground/70">
{section.label}
</div>
<div className="space-y-0.5">
{section.items.map((item) => {
const Icon = item.icon
const isActive = isItemActive(item)
return (
<Link
key={item.href}
to={item.href}
role="menuitem"
className={cn(
'flex items-center gap-3 px-2 py-1.5 rounded-md text-sm transition-colors',
isActive
? 'bg-accent text-accent-foreground font-medium'
: 'text-muted-foreground hover:bg-accent hover:text-accent-foreground',
)}
aria-current={isActive ? 'page' : undefined}
>
<Icon className="h-4 w-4 shrink-0" />
<span className="truncate">{item.label}</span>
</Link>
)
})}
</div>
</HoverCardContent>
</HoverCard>
)
}

View File

@@ -0,0 +1,238 @@
import { describe, it, expect, beforeEach } from 'vitest'
import { render, screen, within } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { MemoryRouter } from 'react-router-dom'
import { SidebarNav } from '../AppSidebar'
import { useAuthStore } from '@/stores/authStore'
/**
* Estado inicial del authStore para cada test.
* El rol admin + set de permisos completos activa todas las secciones.
*/
function setUser(overrides: Partial<{ rol: string; permisos: string[] }> = {}) {
useAuthStore.setState({
user: {
id: 1,
username: 'admin',
nombre: 'Admin Test',
rol: 'admin',
permisos: [
'administracion:auditoria:ver',
'administracion:medios:gestionar',
'administracion:secciones:gestionar',
'administracion:puntos_de_venta:gestionar',
'catalogo:rubros:gestionar',
'catalogo:tipos:gestionar',
'catalogo:productos:gestionar',
'tasacion:caracteres_especiales:gestionar',
],
mustChangePassword: false,
...overrides,
} as never,
})
}
function renderSidebar(initialPath = '/') {
return render(
<MemoryRouter initialEntries={[initialPath]}>
<SidebarNav forceExpanded />
</MemoryRouter>,
)
}
describe('AppSidebar', () => {
beforeEach(() => {
setUser()
// Clean per-section collapse preferences between tests
window.localStorage.removeItem('sidebar-sections-collapsed')
})
it('Dashboard visible para todo usuario autenticado', () => {
renderSidebar()
expect(screen.getByRole('link', { name: /dashboard/i })).toBeInTheDocument()
})
it('No muestra items disabled con badge "Próx." (limpieza)', () => {
renderSidebar()
// Los 4 items removidos del sidebar: Ventas, Tasación (nivel top), Integraciones, Administración (nivel top)
expect(screen.queryByText(/próx/i)).not.toBeInTheDocument()
expect(screen.queryByRole('link', { name: /^ventas$/i })).not.toBeInTheDocument()
expect(screen.queryByRole('link', { name: /^integraciones$/i })).not.toBeInTheDocument()
// "Administración" como link top-level también se elimina (queda solo como label de sección)
expect(screen.queryByRole('link', { name: /^administración$/i })).not.toBeInTheDocument()
})
it('Muestra las 4 secciones agrupadas para admin con todos los permisos', () => {
renderSidebar()
expect(screen.getByText('Seguridad')).toBeInTheDocument()
expect(screen.getByText('Maestros')).toBeInTheDocument()
expect(screen.getByText('Catálogo')).toBeInTheDocument()
expect(screen.getByText('Tasación')).toBeInTheDocument()
})
it('Cada item vive en la sección correcta (tras expandir todas las secciones)', async () => {
const user = userEvent.setup()
// Expandir manualmente las 4 secciones (arrancan colapsadas por default)
window.localStorage.setItem(
'sidebar-sections-collapsed',
JSON.stringify({ Seguridad: false, Maestros: false, Catálogo: false, Tasación: false }),
)
renderSidebar()
void user // keep setup import used
// Seguridad
expect(screen.getByRole('link', { name: /usuarios/i })).toBeInTheDocument()
expect(screen.getByRole('link', { name: /crear usuario/i })).toBeInTheDocument()
expect(screen.getByRole('link', { name: /roles/i })).toBeInTheDocument()
expect(screen.getByRole('link', { name: /permisos/i })).toBeInTheDocument()
expect(screen.getByRole('link', { name: /auditoría/i })).toBeInTheDocument()
// Maestros
expect(screen.getByRole('link', { name: /^medios$/i })).toBeInTheDocument()
expect(screen.getByRole('link', { name: /^secciones$/i })).toBeInTheDocument()
expect(screen.getByRole('link', { name: /puntos de venta/i })).toBeInTheDocument()
// Catálogo
expect(screen.getByRole('link', { name: /rubros/i })).toBeInTheDocument()
expect(screen.getByRole('link', { name: /tipos de producto/i })).toBeInTheDocument()
expect(screen.getByRole('link', { name: /^productos$/i })).toBeInTheDocument()
// Tasación
expect(screen.getByRole('link', { name: /caracteres tasables/i })).toBeInTheDocument()
})
it('Oculta secciones sin items permitidos (ej: user sin permisos de catálogo)', () => {
setUser({
rol: 'admin',
permisos: [
// Solo permisos de Seguridad + Maestros
'administracion:medios:gestionar',
],
})
renderSidebar()
// Seguridad se muestra (Usuarios/Crear Usuario/Roles/Permisos no requieren permiso custom)
expect(screen.getByText('Seguridad')).toBeInTheDocument()
// Maestros se muestra (tiene Medios con su permiso)
expect(screen.getByText('Maestros')).toBeInTheDocument()
// Catálogo desaparece (ningún permiso catalogo:*)
expect(screen.queryByText('Catálogo')).not.toBeInTheDocument()
// Tasación desaparece
expect(screen.queryByText('Tasación')).not.toBeInTheDocument()
// Items filtrados
expect(screen.queryByRole('link', { name: /rubros/i })).not.toBeInTheDocument()
expect(screen.queryByRole('link', { name: /caracteres tasables/i })).not.toBeInTheDocument()
expect(screen.queryByRole('link', { name: /^secciones$/i })).not.toBeInTheDocument()
})
it('Usuario no-admin no ve ninguna sección adminOnly', () => {
setUser({ rol: 'cajero', permisos: [] })
renderSidebar()
expect(screen.getByRole('link', { name: /dashboard/i })).toBeInTheDocument()
expect(screen.queryByText('Seguridad')).not.toBeInTheDocument()
expect(screen.queryByText('Maestros')).not.toBeInTheDocument()
expect(screen.queryByText('Catálogo')).not.toBeInTheDocument()
expect(screen.queryByText('Tasación')).not.toBeInTheDocument()
})
it('Marca el item activo según la ruta actual (Caracteres Tasables)', () => {
// Ruta activa contenida en Tasación → auto-expand
renderSidebar('/admin/tasacion/chargeable-chars')
const link = screen.getByRole('link', { name: /caracteres tasables/i })
expect(link).toHaveAttribute('aria-current', 'page')
})
it('Dashboard activo solo en raíz exacta', () => {
const { unmount } = renderSidebar('/')
expect(screen.getByRole('link', { name: /dashboard/i })).toHaveAttribute('aria-current', 'page')
unmount()
renderSidebar('/admin/medios')
expect(screen.getByRole('link', { name: /dashboard/i })).not.toHaveAttribute('aria-current', 'page')
})
it('Header "SIG-CM 2.0" visible en modo expandido', () => {
renderSidebar()
expect(screen.getByText('SIG-CM 2.0')).toBeInTheDocument()
})
it('Orden de secciones: Seguridad → Maestros → Catálogo → Tasación', () => {
renderSidebar()
const nav = screen.getByRole('navigation')
const labels = within(nav).getAllByText(/Seguridad|Maestros|Catálogo|Tasación/)
expect(labels.map((n) => n.textContent)).toEqual([
'Seguridad',
'Maestros',
'Catálogo',
'Tasación',
])
})
// ── Collapse per-section (modo expandido) ──────────────────────────────
it('Default: todas las secciones arrancan colapsadas EXCEPTO la que contiene la ruta activa', () => {
renderSidebar('/admin/medios')
// Maestros contiene la ruta activa → expandida → Medios visible
expect(screen.getByRole('link', { name: /^medios$/i })).toBeInTheDocument()
// Otras secciones colapsadas → sus items NO visibles
expect(screen.queryByRole('link', { name: /^usuarios$/i })).not.toBeInTheDocument()
expect(screen.queryByRole('link', { name: /rubros/i })).not.toBeInTheDocument()
expect(screen.queryByRole('link', { name: /caracteres tasables/i })).not.toBeInTheDocument()
})
it('Header de sección activa NO es toggleable (sin chevron, disabled)', () => {
renderSidebar('/admin/medios')
const maestrosBtn = screen.getByRole('button', { name: /maestros/i })
expect(maestrosBtn).toBeDisabled()
expect(maestrosBtn).toHaveAttribute('aria-expanded', 'true')
})
it('Click en header de sección no-activa expande/colapsa sus items', async () => {
const user = userEvent.setup()
renderSidebar('/admin/medios')
// Catálogo arranca colapsado → Rubros NO visible
expect(screen.queryByRole('link', { name: /rubros/i })).not.toBeInTheDocument()
// Click en "Catálogo" → expande
await user.click(screen.getByRole('button', { name: /catálogo/i }))
expect(screen.getByRole('link', { name: /rubros/i })).toBeInTheDocument()
// Click de nuevo → colapsa
await user.click(screen.getByRole('button', { name: /catálogo/i }))
expect(screen.queryByRole('link', { name: /rubros/i })).not.toBeInTheDocument()
})
it('aria-expanded refleja estado expandido/colapsado', async () => {
const user = userEvent.setup()
renderSidebar('/admin/medios')
const catalogoBtn = screen.getByRole('button', { name: /catálogo/i })
expect(catalogoBtn).toHaveAttribute('aria-expanded', 'false')
await user.click(catalogoBtn)
expect(catalogoBtn).toHaveAttribute('aria-expanded', 'true')
})
it('Ruta en Dashboard (raíz): ninguna sección contiene active → todas colapsadas', () => {
renderSidebar('/')
expect(screen.queryByRole('link', { name: /^medios$/i })).not.toBeInTheDocument()
expect(screen.queryByRole('link', { name: /rubros/i })).not.toBeInTheDocument()
expect(screen.queryByRole('link', { name: /caracteres tasables/i })).not.toBeInTheDocument()
})
it('Preferencia de collapse persiste en localStorage', async () => {
const user = userEvent.setup()
renderSidebar('/')
// Expandir Catálogo → guarda preferencia
await user.click(screen.getByRole('button', { name: /catálogo/i }))
const stored = window.localStorage.getItem('sidebar-sections-collapsed')
expect(stored).toBeTruthy()
const parsed = JSON.parse(stored!)
expect(parsed['Catálogo']).toBe(false) // false = expandido
})
})

View File

@@ -0,0 +1,29 @@
import * as React from "react"
import * as HoverCardPrimitive from "@radix-ui/react-hover-card"
import { cn } from "@/lib/utils"
const HoverCard = HoverCardPrimitive.Root
const HoverCardTrigger = HoverCardPrimitive.Trigger
const HoverCardContent = React.forwardRef<
React.ElementRef<typeof HoverCardPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof HoverCardPrimitive.Content>
>(({ className, align = "center", sideOffset = 4, ...props }, ref) => (
<HoverCardPrimitive.Portal>
<HoverCardPrimitive.Content
ref={ref}
align={align}
sideOffset={sideOffset}
className={cn(
"z-50 w-64 rounded-md border bg-popover p-4 text-popover-foreground shadow-md outline-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 origin-[--radix-hover-card-content-transform-origin]",
className
)}
{...props}
/>
</HoverCardPrimitive.Portal>
))
HoverCardContent.displayName = HoverCardPrimitive.Content.displayName
export { HoverCard, HoverCardTrigger, HoverCardContent }

View File

@@ -0,0 +1,252 @@
import { describe, it, expect, vi, afterEach, beforeAll, afterAll, beforeEach } from 'vitest'
import { render, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { http, HttpResponse } from 'msw'
import { setupServer } from 'msw/node'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import React from 'react'
import { ChargeableCharFormDialog } from '../components/ChargeableCharFormDialog'
import type { ChargeableCharConfig } from '../types'
vi.mock('sonner', () => ({ toast: { success: vi.fn(), error: vi.fn() } }))
// Mock ProductTypeSelect so it renders a simple select with a "Global" option
// This avoids fetching product-types in form dialog tests
vi.mock('../components/ProductTypeSelect', () => ({
ProductTypeSelect: ({ value, onValueChange, disabled }: {
value: number | null | undefined
onValueChange: (v: number | null | undefined) => void
disabled?: boolean
}) => (
<select
aria-label="Tipo de producto"
value={value === null ? '__global__' : value === undefined ? '__all__' : String(value)}
onChange={(e) => {
const v = e.target.value
onValueChange(v === '__global__' ? null : v === '__all__' ? undefined : Number(v))
}}
disabled={disabled}
>
<option value="__global__">Global (todos los tipos)</option>
<option value="1">Clasificados</option>
<option value="2">Notables</option>
</select>
),
}))
const API_URL = 'http://localhost:5000'
const server = setupServer()
beforeAll(() => server.listen({ onUnhandledRequest: 'warn' }))
afterEach(() => { server.resetHandlers(); vi.clearAllMocks(); vi.useRealTimers() })
afterAll(() => server.close())
function setupFakeTimers() {
// Fix today to 2026-04-20 ART
// 2026-04-20T15:00:00-03:00 = 2026-04-20T18:00:00Z
vi.useFakeTimers({ shouldAdvanceTime: true })
vi.setSystemTime(new Date('2026-04-20T18:00:00.000Z'))
}
function renderDialog(
mode: 'create' | 'schedulePrice' = 'create',
config?: ChargeableCharConfig,
onOpenChange = vi.fn(),
) {
const qc = new QueryClient({ defaultOptions: { queries: { retry: false }, mutations: { retry: false } } })
return render(
<QueryClientProvider client={qc}>
<ChargeableCharFormDialog
open={true}
mode={mode}
config={config}
onOpenChange={onOpenChange}
/>
</QueryClientProvider>,
)
}
describe('ChargeableCharFormDialog — create mode', () => {
beforeEach(() => setupFakeTimers())
it('shows validation error when pricePerUnit is 0', async () => {
renderDialog('create')
const priceInput = screen.getByRole('spinbutton', { name: /precio/i })
await userEvent.clear(priceInput)
await userEvent.type(priceInput, '0')
const dateInput = screen.getByLabelText(/vigente desde/i)
await userEvent.clear(dateInput)
await userEvent.type(dateInput, '2026-04-25')
await userEvent.click(screen.getByRole('button', { name: /guardar/i }))
await waitFor(() =>
expect(screen.getByText(/debe ser mayor/i)).toBeInTheDocument(),
{ timeout: 3000 })
})
it('shows validation error when validFrom is in the past', async () => {
renderDialog('create')
const priceInput = screen.getByRole('spinbutton', { name: /precio/i })
await userEvent.clear(priceInput)
await userEvent.type(priceInput, '1.5')
const dateInput = screen.getByLabelText(/vigente desde/i)
await userEvent.clear(dateInput)
await userEvent.type(dateInput, '2026-04-19')
await userEvent.click(screen.getByRole('button', { name: /guardar/i }))
await waitFor(() =>
expect(screen.getByText(/anterior a hoy/i)).toBeInTheDocument(),
{ timeout: 3000 })
})
it('happy path calls mutation with correct yyyy-MM-dd string payload (productTypeId: null)', async () => {
let capturedBody: unknown = null
server.use(
http.post(`${API_URL}/api/v1/admin/chargeable-chars`, async ({ request }) => {
capturedBody = await request.json()
return HttpResponse.json(
{
id: 1, productTypeId: null, symbol: '$', category: 'Currency',
pricePerUnit: 1.5, validFrom: '2026-04-25', validTo: null, isActive: true,
},
{ status: 201 },
)
}),
)
const onOpenChange = vi.fn()
renderDialog('create', undefined, onOpenChange)
// Fill symbol
const symbolInput = screen.getByRole('textbox', { name: /símbolo/i })
await userEvent.clear(symbolInput)
await userEvent.type(symbolInput, '$')
// Select category via Radix Select
await userEvent.click(screen.getByRole('combobox', { name: /categoría/i }))
await userEvent.click(screen.getByRole('option', { name: /moneda/i }))
// Fill price
const priceInput = screen.getByRole('spinbutton', { name: /precio/i })
await userEvent.clear(priceInput)
await userEvent.type(priceInput, '1.5')
// Fill date
const dateInput = screen.getByLabelText(/vigente desde/i)
await userEvent.clear(dateInput)
await userEvent.type(dateInput, '2026-04-25')
await userEvent.click(screen.getByRole('button', { name: /^guardar$/i }))
await waitFor(() => {
expect(capturedBody).toBeTruthy()
const body = capturedBody as Record<string, unknown>
expect(body['validFrom']).toBe('2026-04-25')
expect(typeof body['validFrom']).toBe('string')
// productTypeId defaults to null (Global)
expect(body['productTypeId']).toBeNull()
}, { timeout: 8000 })
await waitFor(() => expect(onOpenChange).toHaveBeenCalledWith(false), { timeout: 8000 })
}, 15000)
it('shows inline message on server 409 (ForwardOnly)', async () => {
server.use(
http.post(`${API_URL}/api/v1/admin/chargeable-chars`, () =>
HttpResponse.json(
{ error: 'chargeable_char_forward_only', message: 'No se pueden retrodatar precios.' },
{ status: 409 },
),
),
)
renderDialog('create')
// Fill symbol
const symbolInput = screen.getByRole('textbox', { name: /símbolo/i })
await userEvent.clear(symbolInput)
await userEvent.type(symbolInput, '$')
// Select category
await userEvent.click(screen.getByRole('combobox', { name: /categoría/i }))
await userEvent.click(screen.getByRole('option', { name: /moneda/i }))
const priceInput = screen.getByRole('spinbutton', { name: /precio/i })
await userEvent.clear(priceInput)
await userEvent.type(priceInput, '1.5')
const dateInput = screen.getByLabelText(/vigente desde/i)
await userEvent.clear(dateInput)
await userEvent.type(dateInput, '2026-04-25')
await userEvent.click(screen.getByRole('button', { name: /^guardar$/i }))
await waitFor(() =>
expect(screen.getByText(/retrodatar/i)).toBeInTheDocument(),
{ timeout: 8000 })
}, 15000)
})
describe('ChargeableCharFormDialog — schedulePrice mode', () => {
beforeEach(() => setupFakeTimers())
it('hides symbol and category inputs (read-only mode)', () => {
const existingConfig: ChargeableCharConfig = {
id: 5, productTypeId: null, symbol: '%', category: 'Percentage',
pricePerUnit: 1.0, validFrom: '2026-01-01', validTo: null, isActive: true,
}
renderDialog('schedulePrice', existingConfig)
// Should not show editable symbol/category inputs in schedulePrice mode
expect(screen.queryByLabelText(/símbolo/i)).not.toBeInTheDocument()
expect(screen.queryByLabelText(/categoría/i)).not.toBeInTheDocument()
// Price and date should still be present
expect(screen.getByRole('spinbutton', { name: /precio/i })).toBeInTheDocument()
expect(screen.getByLabelText(/vigente desde/i)).toBeInTheDocument()
})
it('happy path schedulePrice calls PUT endpoint with correct payload', async () => {
let capturedBody: unknown = null
server.use(
http.put(`${API_URL}/api/v1/admin/chargeable-chars/5/price`, async ({ request }) => {
capturedBody = await request.json()
return HttpResponse.json(
{ created: { id: 6, productTypeId: null, symbol: '%', category: 'Percentage', pricePerUnit: 2.0, validFrom: '2026-04-25', validTo: null, isActive: true }, closed: null },
)
}),
)
const existingConfig: ChargeableCharConfig = {
id: 5, productTypeId: null, symbol: '%', category: 'Percentage',
pricePerUnit: 1.0, validFrom: '2026-01-01', validTo: null, isActive: true,
}
const onOpenChange = vi.fn()
renderDialog('schedulePrice', existingConfig, onOpenChange)
const priceInput = screen.getByRole('spinbutton', { name: /precio/i })
await userEvent.clear(priceInput)
await userEvent.type(priceInput, '2')
const dateInput = screen.getByLabelText(/vigente desde/i)
await userEvent.clear(dateInput)
await userEvent.type(dateInput, '2026-04-25')
await userEvent.click(screen.getByRole('button', { name: /guardar/i }))
await waitFor(() => {
expect(capturedBody).toBeTruthy()
const body = capturedBody as Record<string, unknown>
expect(body['newValidFrom']).toBe('2026-04-25')
}, { timeout: 5000 })
await waitFor(() => expect(onOpenChange).toHaveBeenCalledWith(false), { timeout: 5000 })
})
})

View File

@@ -0,0 +1,168 @@
import { describe, it, expect, vi, afterEach, beforeAll, afterAll } from 'vitest'
import { render, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { http, HttpResponse } from 'msw'
import { setupServer } from 'msw/node'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import React from 'react'
import { ChargeableCharsTable } from '../components/ChargeableCharsTable'
import type { ChargeableCharConfig } from '../types'
vi.mock('sonner', () => ({ toast: { success: vi.fn(), error: vi.fn() } }))
// Mock ProductTypeSelect to avoid fetching product-types in table tests
vi.mock('../components/ProductTypeSelect', () => ({
ProductTypeSelect: ({ value, onValueChange, 'aria-label': ariaLabel }: {
value: number | null | undefined
onValueChange: (v: number | null | undefined) => void
'aria-label'?: string
}) => (
<select
aria-label={ariaLabel ?? 'Tipo de producto'}
value={value === null ? '__global__' : value === undefined ? '__all__' : String(value)}
onChange={(e) => {
const v = e.target.value
onValueChange(v === '__global__' ? null : v === '__all__' ? undefined : Number(v))
}}
>
<option value="__all__">Todos los tipos</option>
<option value="__global__">Global</option>
<option value="1">Clasificados</option>
</select>
),
}))
const API_URL = 'http://localhost:5000'
function makeConfig(overrides: Partial<ChargeableCharConfig> = {}): ChargeableCharConfig {
return {
id: 1,
productTypeId: null,
symbol: '$',
category: 'Currency',
pricePerUnit: 1.5,
validFrom: '2026-01-01',
validTo: null,
isActive: true,
...overrides,
}
}
const server = setupServer()
beforeAll(() => server.listen({ onUnhandledRequest: 'warn' }))
afterEach(() => { server.resetHandlers(); vi.clearAllMocks() })
afterAll(() => server.close())
function renderTable(
configs: ChargeableCharConfig[],
onSchedulePrice = vi.fn(),
onDeactivate = vi.fn(),
) {
const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } })
return render(
<QueryClientProvider client={qc}>
<ChargeableCharsTable
configs={configs}
total={configs.length}
page={1}
pageSize={20}
onPageChange={vi.fn()}
productTypeId={undefined}
activeOnly={true}
onProductTypeChange={vi.fn()}
onActiveOnlyChange={vi.fn()}
onSchedulePrice={onSchedulePrice}
onDeactivate={onDeactivate}
/>
</QueryClientProvider>,
)
}
describe('ChargeableCharsTable', () => {
it('renders rows from query result — symbol and category visible', () => {
renderTable([
makeConfig({ id: 1, symbol: '$', category: 'Currency' }),
makeConfig({ id: 2, symbol: '%', category: 'Percentage' }),
])
expect(screen.getByText('$')).toBeInTheDocument()
expect(screen.getByText('%')).toBeInTheDocument()
// Category is displayed with localized label "Moneda ($)"
expect(screen.getByText('Moneda ($)')).toBeInTheDocument()
})
it('displays "Global" when productTypeId is null', () => {
renderTable([makeConfig({ productTypeId: null })])
// Multiple "Global" texts may exist (table cell + select option) — assert at least one is present
expect(screen.getAllByText('Global').length).toBeGreaterThanOrEqual(1)
})
it('shows "Vigente" badge for rows with validTo === null', () => {
renderTable([makeConfig({ validTo: null, isActive: true })])
expect(screen.getByText('Vigente')).toBeInTheDocument()
})
it('shows "Cerrada" badge for rows with validTo set', () => {
renderTable([makeConfig({ validTo: '2026-03-31', isActive: false })])
expect(screen.getByText('Cerrada')).toBeInTheDocument()
})
it('formats validFrom using formatCivilDate — shows dd/MM/yyyy', () => {
renderTable([makeConfig({ validFrom: '2026-01-15' })])
expect(screen.getByText('15/01/2026')).toBeInTheDocument()
})
// ── Conditional buttons ────────────────────────────────────────────────────
it('active row shows Desactivar button but NOT Reactivar', () => {
renderTable([makeConfig({ id: 1, isActive: true })])
expect(screen.getByRole('button', { name: /desactivar/i })).toBeInTheDocument()
expect(screen.queryByRole('button', { name: /reactivar/i })).not.toBeInTheDocument()
})
it('inactive row shows Reactivar button but NOT Desactivar', () => {
renderTable([makeConfig({ id: 1, isActive: false, validTo: '2026-03-31' })])
expect(screen.getByRole('button', { name: /reactivar/i })).toBeInTheDocument()
expect(screen.queryByRole('button', { name: /desactivar/i })).not.toBeInTheDocument()
})
it('Eliminar button is visible for both active and inactive rows', () => {
renderTable([
makeConfig({ id: 1, isActive: true }),
makeConfig({ id: 2, isActive: false, validTo: '2026-03-31' }),
])
const eliminarBtns = screen.getAllByRole('button', { name: /eliminar/i })
expect(eliminarBtns).toHaveLength(2)
})
it('clicking Desactivar calls onDeactivate with the correct config', async () => {
const onDeactivate = vi.fn()
const config = makeConfig({ id: 5, isActive: true, symbol: '€' })
renderTable([config], vi.fn(), onDeactivate)
await userEvent.click(screen.getByRole('button', { name: /desactivar/i }))
expect(onDeactivate).toHaveBeenCalledWith(expect.objectContaining({ id: 5, symbol: '€' }))
})
it('clicking Reactivar calls PATCH /reactivate endpoint', async () => {
const calls: unknown[] = []
server.use(
http.patch(`${API_URL}/api/v1/admin/chargeable-chars/7/reactivate`, () => {
calls.push(true)
return HttpResponse.json({ id: 7, symbol: '$', productTypeId: null, pricePerUnit: 1.5, validFrom: '2026-01-01' })
}),
)
// Also mock the list invalidation re-fetch
server.use(
http.get(`${API_URL}/api/v1/admin/chargeable-chars`, () =>
HttpResponse.json({ items: [], page: 1, pageSize: 20, total: 0 }),
),
)
const config = makeConfig({ id: 7, isActive: false, validTo: '2026-03-31' })
renderTable([config])
await userEvent.click(screen.getByRole('button', { name: /reactivar/i }))
await waitFor(() => expect(calls.length).toBe(1), { timeout: 5000 })
})
})

View File

@@ -0,0 +1,121 @@
/**
* @deprecated This file is kept for reference. Tests have moved to CopyToAllProductTypesDialog.test.tsx
* The CopyToAllMediaDialog component has been renamed to CopyToAllProductTypesDialog.
* This file re-exports the new test suite so the old path doesn't break CI.
*/
// Re-run the same suite but importing the new component
import { describe, it, expect, vi, afterEach, beforeAll, afterAll } from 'vitest'
import { render, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { http, HttpResponse } from 'msw'
import { setupServer } from 'msw/node'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { CopyToAllProductTypesDialog } from '../components/CopyToAllProductTypesDialog'
import type { ProductTypeListItem } from '../../product-types/types'
vi.mock('sonner', () => ({ toast: { success: vi.fn(), error: vi.fn() } }))
const API_URL = 'http://localhost:5000'
function makeProductType(id: number, nombre: string): ProductTypeListItem {
return {
id,
nombre,
hasDuration: false,
requiresText: false,
requiresCategory: false,
isBundle: false,
allowImages: false,
isActive: true,
}
}
const productTypes = [
makeProductType(1, 'La Nación'),
makeProductType(2, 'Clarín'),
makeProductType(3, 'Infobae'),
]
const server = setupServer()
beforeAll(() => server.listen({ onUnhandledRequest: 'warn' }))
afterEach(() => { server.resetHandlers(); vi.clearAllMocks() })
afterAll(() => server.close())
function renderDialog(onOpenChange = vi.fn()) {
const qc = new QueryClient({ defaultOptions: { queries: { retry: false }, mutations: { retry: false } } })
server.use(
http.get(`${API_URL}/api/v1/product-types`, () =>
HttpResponse.json({ items: productTypes, page: 1, pageSize: 200, total: 3 }),
),
)
return render(
<QueryClientProvider client={qc}>
<CopyToAllProductTypesDialog
open={true}
onOpenChange={onOpenChange}
symbol="$"
pricePerUnit={1.5}
validFrom="2026-04-25"
category="Currency"
/>
</QueryClientProvider>,
)
}
describe('CopyToAllMediaDialog (renamed → CopyToAllProductTypesDialog)', () => {
it('shows preview of symbol, price, and validFrom', async () => {
renderDialog()
await waitFor(() => expect(screen.getByRole('dialog')).toBeInTheDocument())
expect(screen.getByText('$')).toBeInTheDocument()
expect(screen.getByText('1.5')).toBeInTheDocument()
expect(screen.getByText('25/04/2026')).toBeInTheDocument()
})
it('confirm with 3 product types selected calls create mutation 3 times', async () => {
const createCalls: unknown[] = []
server.use(
http.post(`${API_URL}/api/v1/admin/chargeable-chars`, async ({ request }) => {
const body = await request.json()
createCalls.push(body)
return HttpResponse.json(
{ id: createCalls.length, productTypeId: (body as Record<string, unknown>)['productTypeId'], symbol: '$', category: 'Currency', pricePerUnit: 1.5, validFrom: '2026-04-25', validTo: null, isActive: true },
{ status: 201 },
)
}),
)
renderDialog()
await waitFor(() => expect(screen.getByText('La Nación')).toBeInTheDocument())
const confirmBtn = screen.getByRole('button', { name: /confirmar|copiar/i })
await userEvent.click(confirmBtn)
await waitFor(() => expect(createCalls.length).toBe(3), { timeout: 5000 })
})
it('cancel button closes without making API calls', async () => {
const createCalls: unknown[] = []
server.use(
http.post(`${API_URL}/api/v1/admin/chargeable-chars`, async ({ request }) => {
createCalls.push(await request.json())
return HttpResponse.json({}, { status: 201 })
}),
)
const onOpenChange = vi.fn()
renderDialog(onOpenChange)
await waitFor(() => expect(screen.getByRole('dialog')).toBeInTheDocument())
const cancelBtn = screen.getByRole('button', { name: /cancelar/i })
await userEvent.click(cancelBtn)
await waitFor(() => expect(onOpenChange).toHaveBeenCalledWith(false))
expect(createCalls.length).toBe(0)
})
})

View File

@@ -0,0 +1,116 @@
import { describe, it, expect, vi, afterEach, beforeAll, afterAll } from 'vitest'
import { render, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { http, HttpResponse } from 'msw'
import { setupServer } from 'msw/node'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { CopyToAllProductTypesDialog } from '../components/CopyToAllProductTypesDialog'
import type { ProductTypeListItem } from '../../product-types/types'
vi.mock('sonner', () => ({ toast: { success: vi.fn(), error: vi.fn() } }))
const API_URL = 'http://localhost:5000'
function makeProductType(id: number, nombre: string): ProductTypeListItem {
return {
id,
nombre,
hasDuration: false,
requiresText: false,
requiresCategory: false,
isBundle: false,
allowImages: false,
isActive: true,
}
}
const productTypes = [
makeProductType(1, 'Clasificados'),
makeProductType(2, 'Notables'),
makeProductType(3, 'Fúnebres'),
]
const server = setupServer()
beforeAll(() => server.listen({ onUnhandledRequest: 'warn' }))
afterEach(() => { server.resetHandlers(); vi.clearAllMocks() })
afterAll(() => server.close())
function renderDialog(onOpenChange = vi.fn()) {
const qc = new QueryClient({ defaultOptions: { queries: { retry: false }, mutations: { retry: false } } })
server.use(
http.get(`${API_URL}/api/v1/product-types`, () =>
HttpResponse.json({ items: productTypes, page: 1, pageSize: 200, total: 3 }),
),
)
return render(
<QueryClientProvider client={qc}>
<CopyToAllProductTypesDialog
open={true}
onOpenChange={onOpenChange}
symbol="$"
pricePerUnit={1.5}
validFrom="2026-04-25"
category="Currency"
/>
</QueryClientProvider>,
)
}
describe('CopyToAllProductTypesDialog', () => {
it('shows preview of symbol, price, and validFrom', async () => {
renderDialog()
await waitFor(() => expect(screen.getByRole('dialog')).toBeInTheDocument())
// Preview info
expect(screen.getByText('$')).toBeInTheDocument()
expect(screen.getByText('1.5')).toBeInTheDocument()
expect(screen.getByText('25/04/2026')).toBeInTheDocument()
})
it('confirm with 3 product types calls create mutation 3 times', async () => {
const createCalls: unknown[] = []
server.use(
http.post(`${API_URL}/api/v1/admin/chargeable-chars`, async ({ request }) => {
const body = await request.json()
createCalls.push(body)
return HttpResponse.json(
{ id: createCalls.length, productTypeId: (body as Record<string, unknown>)['productTypeId'], symbol: '$', category: 'Currency', pricePerUnit: 1.5, validFrom: '2026-04-25', validTo: null, isActive: true },
{ status: 201 },
)
}),
)
renderDialog()
await waitFor(() => expect(screen.getByText('Clasificados')).toBeInTheDocument())
const confirmBtn = screen.getByRole('button', { name: /confirmar|copiar/i })
await userEvent.click(confirmBtn)
await waitFor(() => expect(createCalls.length).toBe(3), { timeout: 5000 })
})
it('cancel button closes without making API calls', async () => {
const createCalls: unknown[] = []
server.use(
http.post(`${API_URL}/api/v1/admin/chargeable-chars`, async ({ request }) => {
createCalls.push(await request.json())
return HttpResponse.json({}, { status: 201 })
}),
)
const onOpenChange = vi.fn()
renderDialog(onOpenChange)
await waitFor(() => expect(screen.getByRole('dialog')).toBeInTheDocument())
const cancelBtn = screen.getByRole('button', { name: /cancelar/i })
await userEvent.click(cancelBtn)
await waitFor(() => expect(onOpenChange).toHaveBeenCalledWith(false))
expect(createCalls.length).toBe(0)
})
})

View File

@@ -0,0 +1,84 @@
import { describe, it, expect, vi, afterEach, beforeAll, afterAll } from 'vitest'
import { render, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { http, HttpResponse } from 'msw'
import { setupServer } from 'msw/node'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { DeleteChargeableCharConfigDialog } from '../components/DeleteChargeableCharConfigDialog'
vi.mock('sonner', () => ({ toast: { success: vi.fn(), error: vi.fn() } }))
const API_URL = 'http://localhost:5000'
const server = setupServer()
beforeAll(() => server.listen({ onUnhandledRequest: 'warn' }))
afterEach(() => { server.resetHandlers(); vi.clearAllMocks() })
afterAll(() => server.close())
function renderDialog(
configId = 1,
symbol = '$',
onOpenChange = vi.fn(),
) {
const qc = new QueryClient({ defaultOptions: { queries: { retry: false }, mutations: { retry: false } } })
return render(
<QueryClientProvider client={qc}>
<DeleteChargeableCharConfigDialog
open={true}
onOpenChange={onOpenChange}
configId={configId}
symbol={symbol}
/>
</QueryClientProvider>,
)
}
describe('DeleteChargeableCharConfigDialog', () => {
it('renders dialog with symbol in warning text', async () => {
renderDialog(1, '$')
await waitFor(() => expect(screen.getByRole('dialog')).toBeInTheDocument())
expect(screen.getByText(/eliminará permanentemente/i)).toBeInTheDocument()
expect(screen.getByText(/'\$'/)).toBeInTheDocument()
expect(screen.getByText(/no está en uso/i)).toBeInTheDocument()
})
it('confirm button calls delete mutation and shows success toast', async () => {
const { toast } = await import('sonner')
server.use(
http.delete(`${API_URL}/api/v1/admin/chargeable-chars/5`, () =>
HttpResponse.json({ id: 5 }),
),
)
const onOpenChange = vi.fn()
renderDialog(5, '%', onOpenChange)
await waitFor(() => expect(screen.getByRole('dialog')).toBeInTheDocument())
const confirmBtn = screen.getByRole('button', { name: /eliminar/i })
await userEvent.click(confirmBtn)
await waitFor(() => expect(onOpenChange).toHaveBeenCalledWith(false), { timeout: 5000 })
expect(toast.success).toHaveBeenCalledWith(expect.stringContaining('%'))
})
it('cancel button closes dialog without calling mutation', async () => {
const deleteCalls: unknown[] = []
server.use(
http.delete(`${API_URL}/api/v1/admin/chargeable-chars/1`, async () => {
deleteCalls.push(true)
return HttpResponse.json({ id: 1 })
}),
)
const onOpenChange = vi.fn()
renderDialog(1, '$', onOpenChange)
await waitFor(() => expect(screen.getByRole('dialog')).toBeInTheDocument())
const cancelBtn = screen.getByRole('button', { name: /cancelar/i })
await userEvent.click(cancelBtn)
await waitFor(() => expect(onOpenChange).toHaveBeenCalledWith(false))
expect(deleteCalls.length).toBe(0)
})
})

View File

@@ -0,0 +1,78 @@
import { describe, it, expect, vi, afterEach } from 'vitest'
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { SymbolInput } from '../components/SymbolInput'
afterEach(() => vi.clearAllMocks())
describe('SymbolInput — emoji blocking', () => {
it('typing ASCII chars updates value via onChange', async () => {
const onChange = vi.fn()
render(<SymbolInput value="" onChange={onChange} />)
const input = screen.getByRole('textbox')
await userEvent.type(input, '$')
expect(onChange).toHaveBeenCalledWith('$')
})
it('typing an emoji does NOT call onChange with emoji content (emoji blocked)', async () => {
const onChange = vi.fn()
render(<SymbolInput value="" onChange={onChange} />)
const input = screen.getByRole('textbox')
// userEvent.type fires change events for each char; emoji chars may be split into surrogates.
// The contract: onChange must never be called with a value containing an Extended_Pictographic char.
await userEvent.type(input, '😀')
const calls = onChange.mock.calls.map(([v]: [string]) => v)
const hasEmoji = calls.some((v) => /\p{Extended_Pictographic}/u.test(v))
expect(hasEmoji).toBe(false)
})
it('pasting a string with emoji — onChange NOT called with emoji content', async () => {
const onChange = vi.fn()
render(<SymbolInput value="" onChange={onChange} />)
const input = screen.getByRole('textbox')
await userEvent.click(input)
// userEvent.paste triggers onPaste handler with the given text
await userEvent.paste('😀')
// onChange must NOT have been called with an emoji
const calls = onChange.mock.calls.map(([v]: [string]) => v)
const hasEmoji = calls.some((v) => /\p{Extended_Pictographic}/u.test(v))
expect(hasEmoji).toBe(false)
})
it('pasting normal text (no emoji) allows value update', async () => {
const onChange = vi.fn()
render(<SymbolInput value="" onChange={onChange} />)
const input = screen.getByRole('textbox')
await userEvent.click(input)
// Paste normal ASCII — should go through onChange
await userEvent.paste('$')
// onChange may be called with '$' or the merged result
// The key assertion: no rejection for non-emoji
const calls = onChange.mock.calls.map(([v]: [string]) => v)
const allNonEmoji = calls.every((v) => !/\p{Extended_Pictographic}/u.test(v))
expect(allNonEmoji).toBe(true)
})
it('value is capped at 4 characters — 5th char is rejected via onChange not called with 5+ chars', async () => {
// Start with value of 4 chars already set
const onChange = vi.fn()
render(<SymbolInput value="$$$$" onChange={onChange} />)
const input = screen.getByRole('textbox')
// DOM value is controlled at 4 chars; any additional char should be blocked
await userEvent.type(input, '$')
// onChange should NOT be called with a 5-char string
const calls = onChange.mock.calls.map(([v]: [string]) => v)
const tooLong = calls.some((v) => v.length > 4)
expect(tooLong).toBe(false)
})
})

View File

@@ -0,0 +1,193 @@
import { describe, it, expect, vi, afterEach, beforeAll, afterAll } from 'vitest'
import { renderHook, waitFor, act } from '@testing-library/react'
import { http, HttpResponse } from 'msw'
import { setupServer } from 'msw/node'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import React from 'react'
import { useChargeableCharConfigs } from '../hooks/useChargeableCharConfigs'
import { useSchedulePriceChange } from '../hooks/useSchedulePriceChange'
import { useReactivateChargeableCharConfig } from '../hooks/useReactivateChargeableCharConfig'
import { useDeleteChargeableCharConfig } from '../hooks/useDeleteChargeableCharConfig'
import { ReactivationNotAllowedError } from '../api/reactivateChargeableCharConfig'
vi.mock('sonner', () => ({ toast: { success: vi.fn(), error: vi.fn() } }))
const API_URL = 'http://localhost:5000'
const server = setupServer()
beforeAll(() => server.listen({ onUnhandledRequest: 'warn' }))
afterEach(() => { server.resetHandlers(); vi.clearAllMocks() })
afterAll(() => server.close())
function makeWrapper() {
const qc = new QueryClient({ defaultOptions: { queries: { retry: false }, mutations: { retry: false } } })
return {
qc,
wrapper: ({ children }: { children: React.ReactNode }) =>
React.createElement(QueryClientProvider, { client: qc }, children),
}
}
describe('useChargeableCharConfigs', () => {
it('fetches list and returns paged result', async () => {
server.use(
http.get(`${API_URL}/api/v1/admin/chargeable-chars`, () =>
HttpResponse.json({
items: [{ id: 1, productTypeId: null, symbol: '$', category: 'Currency', pricePerUnit: 1.5, validFrom: '2026-01-01', validTo: null, isActive: true }],
page: 1, pageSize: 20, total: 1,
}),
),
)
const { wrapper } = makeWrapper()
const { result } = renderHook(() => useChargeableCharConfigs({}), { wrapper })
await waitFor(() => expect(result.current.isSuccess).toBe(true))
expect(result.current.data?.items).toHaveLength(1)
expect(result.current.data?.items[0].symbol).toBe('$')
})
it('sends productTypeId and activeOnly as query params', async () => {
let capturedUrl: string | null = null
server.use(
http.get(`${API_URL}/api/v1/admin/chargeable-chars`, ({ request }) => {
capturedUrl = request.url
return HttpResponse.json({ items: [], page: 1, pageSize: 20, total: 0 })
}),
)
const { wrapper } = makeWrapper()
const { result } = renderHook(
() => useChargeableCharConfigs({ productTypeId: 3, activeOnly: true }),
{ wrapper },
)
await waitFor(() => expect(result.current.isSuccess).toBe(true))
expect(capturedUrl).toContain('productTypeId=3')
expect(capturedUrl).toContain('activeOnly=true')
})
})
describe('useSchedulePriceChange', () => {
it('on success invalidates both list and byId query keys', async () => {
server.use(
http.put(`${API_URL}/api/v1/admin/chargeable-chars/5/price`, () =>
HttpResponse.json({
created: { id: 6, productTypeId: null, symbol: '$', category: 'Currency', pricePerUnit: 2.0, validFrom: '2026-05-01', validTo: null, isActive: true },
closed: null,
}),
),
)
const { qc, wrapper } = makeWrapper()
const invalidateSpy = vi.spyOn(qc, 'invalidateQueries')
const { result } = renderHook(() => useSchedulePriceChange(5), { wrapper })
await act(async () => {
result.current.mutate({ newPricePerUnit: 2.0, newValidFrom: '2026-05-01' })
})
await waitFor(() => expect(result.current.isSuccess).toBe(true))
const listInvalidated = invalidateSpy.mock.calls.some(
([q]) => JSON.stringify((q as { queryKey: unknown }).queryKey).includes('chargeableChars'),
)
expect(listInvalidated).toBe(true)
})
})
describe('useReactivateChargeableCharConfig', () => {
it('happy path — returns response and invalidates list + byId', async () => {
server.use(
http.patch(`${API_URL}/api/v1/admin/chargeable-chars/7/reactivate`, () =>
HttpResponse.json({ id: 7, symbol: '$', productTypeId: null, pricePerUnit: 1.5, validFrom: '2026-01-01' }),
),
)
const { qc, wrapper } = makeWrapper()
const invalidateSpy = vi.spyOn(qc, 'invalidateQueries')
const { result } = renderHook(() => useReactivateChargeableCharConfig(), { wrapper })
await act(async () => { result.current.mutate(7) })
await waitFor(() => expect(result.current.isSuccess).toBe(true))
expect(result.current.data?.id).toBe(7)
const listInvalidated = invalidateSpy.mock.calls.some(
([q]) => JSON.stringify((q as { queryKey: unknown }).queryKey).includes('list'),
)
expect(listInvalidated).toBe(true)
})
it('409 ALREADY_ACTIVE — throws ReactivationNotAllowedError with correct reason', async () => {
server.use(
http.patch(`${API_URL}/api/v1/admin/chargeable-chars/8/reactivate`, () =>
HttpResponse.json(
{ code: 'CHARGEABLE_CHAR_REACTIVATION_NOT_ALLOWED', reason: 'ALREADY_ACTIVE', message: 'El registro ya está activo' },
{ status: 409 },
),
),
)
const { wrapper } = makeWrapper()
const { result } = renderHook(() => useReactivateChargeableCharConfig(), { wrapper })
await act(async () => { result.current.mutate(8) })
await waitFor(() => expect(result.current.isError).toBe(true))
expect(result.current.error).toBeInstanceOf(ReactivationNotAllowedError)
expect((result.current.error as ReactivationNotAllowedError).reason).toBe('ALREADY_ACTIVE')
})
it('409 VIGENTE_EXISTS — throws ReactivationNotAllowedError with correct reason', async () => {
server.use(
http.patch(`${API_URL}/api/v1/admin/chargeable-chars/9/reactivate`, () =>
HttpResponse.json(
{ code: 'CHARGEABLE_CHAR_REACTIVATION_NOT_ALLOWED', reason: 'VIGENTE_EXISTS', message: 'Ya existe un registro activo' },
{ status: 409 },
),
),
)
const { wrapper } = makeWrapper()
const { result } = renderHook(() => useReactivateChargeableCharConfig(), { wrapper })
await act(async () => { result.current.mutate(9) })
await waitFor(() => expect(result.current.isError).toBe(true))
expect(result.current.error).toBeInstanceOf(ReactivationNotAllowedError)
expect((result.current.error as ReactivationNotAllowedError).reason).toBe('VIGENTE_EXISTS')
})
})
describe('useDeleteChargeableCharConfig', () => {
it('happy path — returns {id} and invalidates list + removes byId', async () => {
server.use(
http.delete(`${API_URL}/api/v1/admin/chargeable-chars/10`, () =>
HttpResponse.json({ id: 10 }),
),
)
const { qc, wrapper } = makeWrapper()
const invalidateSpy = vi.spyOn(qc, 'invalidateQueries')
const removeSpy = vi.spyOn(qc, 'removeQueries')
const { result } = renderHook(() => useDeleteChargeableCharConfig(), { wrapper })
await act(async () => { result.current.mutate(10) })
await waitFor(() => expect(result.current.isSuccess).toBe(true))
expect(result.current.data?.id).toBe(10)
const listInvalidated = invalidateSpy.mock.calls.some(
([q]) => JSON.stringify((q as { queryKey: unknown }).queryKey).includes('list'),
)
expect(listInvalidated).toBe(true)
expect(removeSpy).toHaveBeenCalled()
})
it('404 — mutation transitions to error state', async () => {
server.use(
http.delete(`${API_URL}/api/v1/admin/chargeable-chars/99`, () =>
HttpResponse.json({ message: 'Not found' }, { status: 404 }),
),
)
const { wrapper } = makeWrapper()
const { result } = renderHook(() => useDeleteChargeableCharConfig(), { wrapper })
await act(async () => { result.current.mutate(99) })
await waitFor(() => expect(result.current.isError).toBe(true))
})
})

View File

@@ -0,0 +1,12 @@
import { axiosClient } from '@/api/axiosClient'
import type { ChargeableCharConfig, CreateChargeableCharConfigRequest } from '../types'
export async function createChargeableCharConfig(
payload: CreateChargeableCharConfigRequest,
): Promise<ChargeableCharConfig> {
const response = await axiosClient.post<ChargeableCharConfig>(
'/api/v1/admin/chargeable-chars',
payload,
)
return response.data
}

View File

@@ -0,0 +1,5 @@
import { axiosClient } from '@/api/axiosClient'
export async function deactivateChargeableCharConfig(id: number): Promise<void> {
await axiosClient.patch(`/api/v1/admin/chargeable-chars/${id}/deactivate`)
}

View File

@@ -0,0 +1,11 @@
import { axiosClient } from '@/api/axiosClient'
import type { DeleteChargeableCharConfigResponse } from '../types'
export async function deleteChargeableCharConfig(
id: number,
): Promise<DeleteChargeableCharConfigResponse> {
const response = await axiosClient.delete<DeleteChargeableCharConfigResponse>(
`/api/v1/admin/chargeable-chars/${id}`,
)
return response.data
}

View File

@@ -0,0 +1,9 @@
import { axiosClient } from '@/api/axiosClient'
import type { ChargeableCharConfig } from '../types'
export async function getChargeableCharConfig(id: number): Promise<ChargeableCharConfig> {
const response = await axiosClient.get<ChargeableCharConfig>(
`/api/v1/admin/chargeable-chars/${id}`,
)
return response.data
}

View File

@@ -0,0 +1,18 @@
import { axiosClient } from '@/api/axiosClient'
import type { ChargeableCharConfig, ChargeableCharConfigsQuery, PagedResult } from '../types'
export async function listChargeableCharConfigs(
query: ChargeableCharConfigsQuery,
): Promise<PagedResult<ChargeableCharConfig>> {
const params = new URLSearchParams()
if (query.productTypeId !== undefined) params.set('productTypeId', String(query.productTypeId))
if (query.activeOnly !== undefined) params.set('activeOnly', String(query.activeOnly))
if (query.page !== undefined) params.set('page', String(query.page))
if (query.pageSize !== undefined) params.set('pageSize', String(query.pageSize))
const response = await axiosClient.get<PagedResult<ChargeableCharConfig>>(
'/api/v1/admin/chargeable-chars',
{ params },
)
return response.data
}

View File

@@ -0,0 +1,40 @@
import { axiosClient } from '@/api/axiosClient'
import { isAxiosError } from 'axios'
import type { ReactivateChargeableCharConfigResponse } from '../types'
export type ReactivationNotAllowedReason =
| 'ALREADY_ACTIVE'
| 'VIGENTE_EXISTS'
| 'POSTERIOR_ROWS_EXIST'
export class ReactivationNotAllowedError extends Error {
reason: ReactivationNotAllowedReason
constructor(reason: ReactivationNotAllowedReason, message: string) {
super(message)
this.name = 'ReactivationNotAllowedError'
this.reason = reason
}
}
export async function reactivateChargeableCharConfig(
id: number,
): Promise<ReactivateChargeableCharConfigResponse> {
try {
const response = await axiosClient.patch<ReactivateChargeableCharConfigResponse>(
`/api/v1/admin/chargeable-chars/${id}/reactivate`,
)
return response.data
} catch (err) {
if (
isAxiosError(err) &&
err.response?.status === 409 &&
err.response.data?.code === 'CHARGEABLE_CHAR_REACTIVATION_NOT_ALLOWED'
) {
const reason: ReactivationNotAllowedReason = err.response.data?.reason ?? 'ALREADY_ACTIVE'
const message: string = err.response.data?.message ?? 'Reactivación no permitida.'
throw new ReactivationNotAllowedError(reason, message)
}
throw err
}
}

View File

@@ -0,0 +1,13 @@
import { axiosClient } from '@/api/axiosClient'
import type { SchedulePriceChangeRequest, SchedulePriceChangeResponse } from '../types'
export async function schedulePriceChange(
id: number,
payload: SchedulePriceChangeRequest,
): Promise<SchedulePriceChangeResponse> {
const response = await axiosClient.put<SchedulePriceChangeResponse>(
`/api/v1/admin/chargeable-chars/${id}/price`,
payload,
)
return response.data
}

View File

@@ -0,0 +1,18 @@
// PRC-001 — ChargeableCharCategory constants
import type { ChargeableCharCategory } from './types'
export const CHARGEABLE_CHAR_CATEGORIES: ChargeableCharCategory[] = [
'Currency',
'Percentage',
'Exclamation',
'Question',
'Other',
]
export const CATEGORY_LABELS: Record<ChargeableCharCategory, string> = {
Currency: 'Moneda ($)',
Percentage: 'Porcentaje (%)',
Exclamation: 'Exclamación (!)',
Question: 'Pregunta (?)',
Other: 'Otro',
}

View File

@@ -0,0 +1,443 @@
import { useEffect } from 'react'
import { useForm } from 'react-hook-form'
import { zodResolver } from '@hookform/resolvers/zod'
import { z } from 'zod'
import { isAxiosError } from 'axios'
import { AlertCircle } from 'lucide-react'
import { Alert, AlertDescription } from '@/components/ui/alert'
import { Button } from '@/components/ui/button'
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from '@/components/ui/form'
import { Input } from '@/components/ui/input'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import { todayArgentina } from '@/lib/formatters'
import { CHARGEABLE_CHAR_CATEGORIES, CATEGORY_LABELS } from '../categories'
import { useCreateChargeableCharConfig } from '../hooks/useCreateChargeableCharConfig'
import { useSchedulePriceChange } from '../hooks/useSchedulePriceChange'
import { SymbolInput } from './SymbolInput'
import { ProductTypeSelect } from './ProductTypeSelect'
import type { ChargeableCharConfig } from '../types'
// ─── Emoji regex (same as SymbolInput) ───────────────────────────────────────
const EMOJI_REGEX = /\p{Extended_Pictographic}/u
// ─── Schemas ─────────────────────────────────────────────────────────────────
const createSchema = z.object({
productTypeId: z.number().nullable().optional(),
symbol: z
.string()
.min(1, 'El símbolo es requerido.')
.max(4, 'Máximo 4 caracteres.')
.refine((s) => !EMOJI_REGEX.test(s), 'Los emojis no están permitidos.'),
category: z.enum(['Currency', 'Percentage', 'Exclamation', 'Question', 'Other'], {
error: 'La categoría es requerida.',
}),
pricePerUnit: z.coerce
.number('Debe ser un número.')
.positive('El precio debe ser mayor a cero.'),
validFrom: z
.string()
.min(1, 'La fecha es requerida.')
.regex(/^\d{4}-\d{2}-\d{2}$/, 'Formato yyyy-MM-dd requerido.')
.refine((v) => v >= todayArgentina(), 'La fecha no puede ser anterior a hoy.'),
})
const schedulePriceSchema = z.object({
pricePerUnit: z.coerce
.number('Debe ser un número.')
.positive('El precio debe ser mayor a cero.'),
validFrom: z
.string()
.min(1, 'La fecha es requerida.')
.regex(/^\d{4}-\d{2}-\d{2}$/, 'Formato yyyy-MM-dd requerido.')
.refine((v) => v >= todayArgentina(), 'La fecha no puede ser anterior a hoy.'),
})
type CreateFormRaw = {
productTypeId?: number | null
symbol: string
category: string
pricePerUnit: string
validFrom: string
}
type SchedulePriceFormRaw = {
pricePerUnit: string
validFrom: string
}
// ─── Error resolver ────────────────────────────────────────────────────────────
function resolveBackendError(err: unknown): string | null {
if (!err) return null
if (isAxiosError(err) && err.response?.data) {
const data = err.response.data as { error?: string; message?: string; code?: string }
if (err.response.status === 409) {
return data.message ?? 'No se pueden retrodatar precios. Elegí una fecha posterior.'
}
if (err.response.status === 400 && data.code === 'CHARGEABLE_CHAR_FORWARD_ONLY') {
return 'No se pueden retrodatar precios.'
}
return data.message ?? data.error ?? 'Error al guardar.'
}
return 'Error al guardar. Intentá de nuevo.'
}
// ─── Props ────────────────────────────────────────────────────────────────────
interface ChargeableCharFormDialogProps {
open: boolean
onOpenChange: (open: boolean) => void
mode: 'create' | 'schedulePrice'
/** Required when mode is 'schedulePrice' */
config?: ChargeableCharConfig
}
// ─── Component ────────────────────────────────────────────────────────────────
export function ChargeableCharFormDialog({
open,
onOpenChange,
mode,
config,
}: ChargeableCharFormDialogProps) {
const createMutation = useCreateChargeableCharConfig()
const scheduleMutation = useSchedulePriceChange(config?.id ?? 0)
const isSchedule = mode === 'schedulePrice'
const activeMutation = isSchedule ? scheduleMutation : createMutation
// ── Create form ──────────────────────────────────────────────────────────
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const createForm = useForm<CreateFormRaw>({
resolver: zodResolver(createSchema) as any,
defaultValues: { productTypeId: null, symbol: '', category: '', pricePerUnit: '', validFrom: '' },
mode: 'onSubmit',
})
// ── SchedulePrice form ───────────────────────────────────────────────────
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const scheduleForm = useForm<SchedulePriceFormRaw>({
resolver: zodResolver(schedulePriceSchema) as any,
defaultValues: { pricePerUnit: '', validFrom: '' },
mode: 'onSubmit',
})
useEffect(() => {
if (open) {
createForm.reset({ productTypeId: null, symbol: '', category: '', pricePerUnit: '', validFrom: '' })
scheduleForm.reset({ pricePerUnit: '', validFrom: '' })
createMutation.reset()
scheduleMutation.reset()
}
}, [open]) // eslint-disable-line react-hooks/exhaustive-deps
const backendError = resolveBackendError(activeMutation.error)
const today = todayArgentina()
function handleCreateSubmit(values: z.infer<typeof createSchema>) {
createMutation.mutate(
{
productTypeId: values.productTypeId ?? null,
symbol: values.symbol,
category: values.category as ChargeableCharConfig['category'],
pricePerUnit: values.pricePerUnit,
validFrom: values.validFrom,
},
{
onSuccess: () => onOpenChange(false),
},
)
}
function handleScheduleSubmit(values: z.infer<typeof schedulePriceSchema>) {
scheduleMutation.mutate(
{
newPricePerUnit: values.pricePerUnit,
newValidFrom: values.validFrom,
},
{
onSuccess: () => onOpenChange(false),
},
)
}
const isPending = activeMutation.isPending
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>
{isSchedule ? 'Programar cambio de precio' : 'Nuevo carácter tasable'}
</DialogTitle>
<DialogDescription>
{isSchedule
? `Programá un nuevo precio para "${config?.symbol}" a partir de la fecha elegida.`
: 'Completá los datos para crear un nuevo carácter tasable.'}
</DialogDescription>
</DialogHeader>
{backendError && (
<Alert variant="destructive">
<AlertCircle className="h-4 w-4" />
<AlertDescription>{backendError}</AlertDescription>
</Alert>
)}
{/* ── CREATE MODE ─────────────────────────────────────────────────── */}
{!isSchedule && (
<Form {...createForm}>
<form
onSubmit={createForm.handleSubmit(
handleCreateSubmit as unknown as Parameters<typeof createForm.handleSubmit>[0],
)}
className="space-y-4"
noValidate
>
{/* Tipo de producto */}
<FormField
control={createForm.control}
name="productTypeId"
render={({ field }) => (
<FormItem>
<FormLabel>Tipo de producto</FormLabel>
<FormControl>
<ProductTypeSelect
value={field.value}
onValueChange={(v) => field.onChange(v ?? null)}
globalOptionLabel="Global (todos los tipos)"
placeholder="Seleccioná un tipo de producto"
disabled={isPending}
aria-label="Tipo de producto"
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
{/* Símbolo */}
<FormField
control={createForm.control}
name="symbol"
render={({ field, fieldState }) => (
<FormItem>
<FormLabel htmlFor="symbol-input">Símbolo</FormLabel>
<FormControl>
<SymbolInput
id="symbol-input"
aria-label="Símbolo"
name={field.name}
value={field.value}
onChange={field.onChange}
error={fieldState.error?.message}
disabled={isPending}
placeholder="ej: $"
/>
</FormControl>
{!fieldState.error && <FormMessage />}
</FormItem>
)}
/>
{/* Categoría */}
<FormField
control={createForm.control}
name="category"
render={({ field }) => (
<FormItem>
<FormLabel>Categoría</FormLabel>
<FormControl>
<Select
value={field.value}
onValueChange={field.onChange}
disabled={isPending}
>
<SelectTrigger aria-label="Categoría">
<SelectValue placeholder="Seleccioná una categoría" />
</SelectTrigger>
<SelectContent>
{CHARGEABLE_CHAR_CATEGORIES.map((cat) => (
<SelectItem key={cat} value={cat}>
{CATEGORY_LABELS[cat]}
</SelectItem>
))}
</SelectContent>
</Select>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
{/* Precio */}
<FormField
control={createForm.control}
name="pricePerUnit"
render={({ field }) => (
<FormItem>
<FormLabel>Precio por unidad</FormLabel>
<FormControl>
<Input
{...field}
type="number"
step="0.0001"
min="0.0001"
placeholder="0.0000"
aria-label="Precio por unidad"
disabled={isPending}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
{/* Vigente desde */}
<FormField
control={createForm.control}
name="validFrom"
render={({ field }) => (
<FormItem>
<FormLabel>Vigente desde</FormLabel>
<FormControl>
<Input
{...field}
type="date"
min={today}
aria-label="Vigente desde"
disabled={isPending}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<DialogFooter className="gap-2">
<Button
type="button"
variant="ghost"
onClick={() => onOpenChange(false)}
disabled={isPending}
>
Cancelar
</Button>
<Button type="submit" disabled={isPending}>
{isPending ? 'Guardando...' : 'Guardar'}
</Button>
</DialogFooter>
</form>
</Form>
)}
{/* ── SCHEDULE PRICE MODE ─────────────────────────────────────────── */}
{isSchedule && (
<Form {...scheduleForm}>
<form
onSubmit={scheduleForm.handleSubmit(
handleScheduleSubmit as unknown as Parameters<typeof scheduleForm.handleSubmit>[0],
)}
className="space-y-4"
noValidate
>
{/* Read-only info */}
{config && (
<div className="rounded-md bg-muted p-3 text-sm space-y-1">
<div>
<span className="text-muted-foreground">Símbolo: </span>
<span className="font-mono font-semibold">{config.symbol}</span>
</div>
<div>
<span className="text-muted-foreground">Categoría: </span>
<span>{CATEGORY_LABELS[config.category]}</span>
</div>
</div>
)}
{/* Nuevo precio */}
<FormField
control={scheduleForm.control}
name="pricePerUnit"
render={({ field }) => (
<FormItem>
<FormLabel>Nuevo precio por unidad</FormLabel>
<FormControl>
<Input
{...field}
type="number"
step="0.0001"
min="0.0001"
placeholder="0.0000"
aria-label="Precio por unidad"
disabled={isPending}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
{/* Vigente desde */}
<FormField
control={scheduleForm.control}
name="validFrom"
render={({ field }) => (
<FormItem>
<FormLabel>Vigente desde</FormLabel>
<FormControl>
<Input
{...field}
type="date"
min={today}
aria-label="Vigente desde"
disabled={isPending}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<DialogFooter className="gap-2">
<Button
type="button"
variant="ghost"
onClick={() => onOpenChange(false)}
disabled={isPending}
>
Cancelar
</Button>
<Button type="submit" disabled={isPending}>
{isPending ? 'Guardando...' : 'Guardar'}
</Button>
</DialogFooter>
</form>
</Form>
)}
</DialogContent>
</Dialog>
)
}

View File

@@ -0,0 +1,274 @@
import { useMemo, useState } from 'react'
import type { ColumnDef } from '@tanstack/react-table'
import { toast } from 'sonner'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import { DataTable } from '@/components/ui/data-table'
import { Switch } from '@/components/ui/switch'
import { Label } from '@/components/ui/label'
import { formatCivilDate } from '@/lib/formatters'
import type { ChargeableCharConfig } from '../types'
import { CATEGORY_LABELS } from '../categories'
import { useProductTypes } from '../../product-types/hooks/useProductTypes'
import { useReactivateChargeableCharConfig } from '../hooks/useReactivateChargeableCharConfig'
import { ReactivationNotAllowedError } from '../api/reactivateChargeableCharConfig'
import { ProductTypeSelect } from './ProductTypeSelect'
import { DeleteChargeableCharConfigDialog } from './DeleteChargeableCharConfigDialog'
interface ChargeableCharsTableProps {
configs: ChargeableCharConfig[]
total: number
page: number
pageSize: number
onPageChange: (page: number) => void
productTypeId: number | undefined
activeOnly: boolean
onProductTypeChange: (productTypeId: number | undefined) => void
onActiveOnlyChange: (value: boolean) => void
onSchedulePrice: (config: ChargeableCharConfig) => void
onDeactivate: (config: ChargeableCharConfig) => void
}
function resolveReactivationError(err: unknown): string {
if (err instanceof ReactivationNotAllowedError) {
switch (err.reason) {
case 'ALREADY_ACTIVE':
return 'El registro ya está activo.'
case 'VIGENTE_EXISTS':
return 'Ya existe un registro activo para este tipo de producto y símbolo. Modificá ese registro en su lugar.'
case 'POSTERIOR_ROWS_EXIST':
return 'Existen cambios posteriores al cierre de este registro. Para modificar el precio, usá "Programar cambio de precio".'
}
}
return 'No se pudo reactivar el símbolo. Intentá de nuevo.'
}
export function ChargeableCharsTable({
configs,
total,
page,
pageSize,
onPageChange,
productTypeId,
activeOnly,
onProductTypeChange,
onActiveOnlyChange,
onSchedulePrice,
onDeactivate,
}: ChargeableCharsTableProps) {
const { data: ptData } = useProductTypes({ activo: true, pageSize: 200 })
const productTypes = ptData?.items ?? []
const reactivateMutation = useReactivateChargeableCharConfig()
const [deleteTarget, setDeleteTarget] = useState<ChargeableCharConfig | null>(null)
const totalPages = Math.max(1, Math.ceil(total / pageSize))
const hasPrev = page > 1
const hasNext = page < totalPages
function handleReactivate(config: ChargeableCharConfig) {
reactivateMutation.mutate(config.id, {
onError: (err) => {
toast.error(resolveReactivationError(err))
},
})
}
const columns = useMemo<ColumnDef<ChargeableCharConfig>[]>(
() => [
{
accessorKey: 'productTypeId',
header: 'Tipo de Producto',
cell: ({ row }) => {
const ptId = row.original.productTypeId
if (ptId === null) return <span className="text-muted-foreground">Global</span>
const pt = productTypes.find((p) => p.id === ptId)
return <span>{pt?.nombre ?? `Tipo ${ptId}`}</span>
},
},
{
accessorKey: 'symbol',
header: 'Símbolo',
cell: ({ row }) => (
<span className="font-mono text-lg">{row.original.symbol}</span>
),
},
{
accessorKey: 'category',
header: 'Categoría',
cell: ({ row }) => (
<Badge variant="secondary">
{CATEGORY_LABELS[row.original.category] ?? row.original.category}
</Badge>
),
},
{
accessorKey: 'pricePerUnit',
header: 'Precio/unidad',
cell: ({ row }) => (
<span>
{new Intl.NumberFormat('es-AR', {
minimumFractionDigits: 4,
maximumFractionDigits: 4,
}).format(row.original.pricePerUnit)}
</span>
),
},
{
accessorKey: 'validFrom',
header: 'Desde',
cell: ({ row }) => <span>{formatCivilDate(row.original.validFrom)}</span>,
},
{
accessorKey: 'validTo',
header: 'Hasta',
cell: ({ row }) => (
<span>
{row.original.validTo ? formatCivilDate(row.original.validTo) : '—'}
</span>
),
},
{
accessorKey: 'isActive',
header: 'Estado',
cell: ({ row }) =>
row.original.isActive ? (
<Badge variant="secondary" className="bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200">
Vigente
</Badge>
) : (
<Badge variant="secondary" className="bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-200">
Cerrada
</Badge>
),
},
{
id: 'acciones',
header: 'Acciones',
cell: ({ row }) => {
const config = row.original
return (
<div className="flex gap-1" onClick={(e) => e.stopPropagation()}>
{config.isActive ? (
<>
<Button
variant="outline"
size="sm"
onClick={() => onSchedulePrice(config)}
aria-label="Programar cambio de precio"
>
Programar precio
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => onDeactivate(config)}
className="text-destructive hover:text-destructive"
aria-label="Desactivar"
>
Desactivar
</Button>
</>
) : (
<Button
variant="ghost"
size="sm"
onClick={() => handleReactivate(config)}
disabled={reactivateMutation.isPending && reactivateMutation.variables === config.id}
aria-label="Reactivar"
>
Reactivar
</Button>
)}
<Button
variant="ghost"
size="sm"
onClick={() => setDeleteTarget(config)}
className="text-destructive hover:text-destructive"
aria-label="Eliminar"
>
Eliminar
</Button>
</div>
)
},
},
],
[productTypes, onSchedulePrice, onDeactivate, reactivateMutation],
)
return (
<div className="space-y-4">
{/* Filters */}
<div className="flex flex-wrap gap-3 items-center">
<ProductTypeSelect
value={productTypeId}
onValueChange={(v) => onProductTypeChange(v === null ? undefined : v)}
showAllOption={true}
allOptionLabel="Todos los tipos"
globalOptionLabel="Global"
placeholder="Todos los tipos"
aria-label="Tipo de producto"
/>
<div className="flex items-center gap-2">
<Switch
id="activeOnly"
checked={activeOnly}
onCheckedChange={onActiveOnlyChange}
aria-label="Solo activos"
/>
<Label htmlFor="activeOnly" className="text-sm">Solo activos</Label>
</div>
</div>
<DataTable
columns={columns}
data={configs}
getRowId={(row) => String(row.id)}
emptyMessage="Todavía no hay caracteres tasables configurados."
/>
{/* Pagination */}
<div className="flex items-center justify-between pt-2">
<span className="text-sm text-muted-foreground">
{total} resultado{total !== 1 ? 's' : ''}
</span>
<div className="flex gap-2">
<Button
variant="outline"
size="sm"
disabled={!hasPrev}
onClick={() => onPageChange(page - 1)}
aria-label="Anterior"
>
Anterior
</Button>
<span className="flex items-center px-2 text-sm text-muted-foreground">
{page} / {totalPages}
</span>
<Button
variant="outline"
size="sm"
disabled={!hasNext}
onClick={() => onPageChange(page + 1)}
aria-label="Siguiente"
>
Siguiente
</Button>
</div>
</div>
{/* Delete confirmation dialog */}
{deleteTarget && (
<DeleteChargeableCharConfigDialog
open={!!deleteTarget}
onOpenChange={(open) => { if (!open) setDeleteTarget(null) }}
configId={deleteTarget.id}
symbol={deleteTarget.symbol}
/>
)}
</div>
)
}

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