feat: PRD-003 ProductPrices históricos (ValidFrom/ValidTo) #45

Merged
dmolinari merged 7 commits from feature/PRD-003 into main 2026-04-19 22:07:22 +00:00
Owner

Summary

PRD-003 introduces product price history with civil vigency (PriceValidFrom/PriceValidTo), SYSTEM_VERSIONING temporal audit (10 years retention), and forward-only price modifications. Enables future PRC-001 pricing resolution and historical price lookups via GetPriceAt(productId, date).

Commits (7)

  • 59f30cd feat(bd): V019 crea dbo.ProductPrices + SP + índices (PRD-003)
  • 54b0265 feat(domain): ProductPrice entity + exceptions (PRD-003)
  • 4b0567d feat(application): commands/queries + IProductPricingService (PRD-003)
  • 2d2e90f feat(infrastructure): ProductPriceRepository Dapper + SP invocation (PRD-003)
  • f6f24bc feat(api): ProductPricesController + DI + ExceptionFilter integration (PRD-003)
  • 6a9818b feat(frontend): productPrices feature — history + dialog (PRD-003)
  • 7cabb67 test(integration): concurrency + SYSTEM_VERSIONING + e2e extra (PRD-003)

Tests

Backend: 1098 Application.Tests + 306 Api.Tests = 1404 0 rojos
Frontend: 453 Vitest 0 rojos
Total: 1857 tests (baseline PRD-002: 1708, delta +149)

Spec Compliance

19/19 scenarios fully compliant:

  • REQ-1: Unicidad de precio activo (filtered unique index + SP SERIALIZABLE)
  • REQ-2: Forward-only estricto (new PriceValidFrom > active PriceValidFrom)
  • REQ-3: Validaciones de entrada (price > 0, fecha >= hoy AR, producto activo)
  • REQ-4: GetPriceAt resolución determinista (covered by IX_ProductPrices_Lookup)
  • REQ-5: API Contract GET/POST endpoints con Cat2 yyyy-MM-dd
  • REQ-6: Auditoría append-only (product_price.created, fail-closed TransactionScope)

Key Design Decisions

  • Column naming: PriceValidFrom/PriceValidTo (not ValidFrom/ValidTo) avoids collision with SYSTEM_VERSIONING hidden cols (SysStartTime/SysEndTime).
  • SP isolation: usp_AddProductPrice runs SERIALIZABLE + UPDLOCK for atomic close-and-insert; THROW 50409 on forward-only violation.
  • Price scale: DECIMAL(12,2) — distinct from Product.BasePrice(18,4) per spec decision D6 (retail vs reference price).
  • Service contract: ProductPricingService.GetPriceAtAsync returns decimal? (null if no history) — OQ-B: Product.BasePrice remains orthogonal, fallback decision left to PRC-001.
  • Fail-closed audit: IAuditLogger.LogAsync inside same TransactionScope — if logger fails, INSERT rollsback completely.
  • Concurrency hardening: 3-task race test verifies exactly 1 active price always, no duplicates despite simultaneous POST.

Colateral Bug Fix

AddProductPriceCommandHandler previously read repository result within using(tx) scope after tx.Complete() — illegal in distributed transactions. Restructured with explicit braces: DB read occurs within scope (before Complete), response construction occurs after scope exit. Caught in e2e, not by mocks — validates value of integration testing.

Follow-ups Created (Non-Blocking)

  • S1: Unify dateFormat.ts + numberFormat.ts formatters
  • S2: Implement cursor pagination for GET /api/v1/products/{id}/prices (MVP assumes <50 rows/product)
  • S3: Add coverlet coverage instrumentation for C# backend

Ready for Merge

All 52 tasks complete (7 batches, Strict TDD). 1857 tests, 0 rojos. Verify report: PASS. No CRITICAL or WARNING issues. Zero deuda técnica in PRD-003 code.

## Summary PRD-003 introduces product price history with civil vigency (PriceValidFrom/PriceValidTo), SYSTEM_VERSIONING temporal audit (10 years retention), and forward-only price modifications. Enables future PRC-001 pricing resolution and historical price lookups via GetPriceAt(productId, date). ## Commits (7) - `59f30cd` feat(bd): V019 crea dbo.ProductPrices + SP + índices (PRD-003) - `54b0265` feat(domain): ProductPrice entity + exceptions (PRD-003) - `4b0567d` feat(application): commands/queries + IProductPricingService (PRD-003) - `2d2e90f` feat(infrastructure): ProductPriceRepository Dapper + SP invocation (PRD-003) - `f6f24bc` feat(api): ProductPricesController + DI + ExceptionFilter integration (PRD-003) - `6a9818b` feat(frontend): productPrices feature — history + dialog (PRD-003) - `7cabb67` test(integration): concurrency + SYSTEM_VERSIONING + e2e extra (PRD-003) ## Tests **Backend**: 1098 Application.Tests + 306 Api.Tests = 1404 ✅ 0 rojos **Frontend**: 453 Vitest ✅ 0 rojos **Total**: 1857 tests (baseline PRD-002: 1708, delta +149) ✅ ## Spec Compliance 19/19 scenarios fully compliant: - REQ-1: Unicidad de precio activo (filtered unique index + SP SERIALIZABLE) - REQ-2: Forward-only estricto (new PriceValidFrom > active PriceValidFrom) - REQ-3: Validaciones de entrada (price > 0, fecha >= hoy AR, producto activo) - REQ-4: GetPriceAt resolución determinista (covered by IX_ProductPrices_Lookup) - REQ-5: API Contract GET/POST endpoints con Cat2 yyyy-MM-dd - REQ-6: Auditoría append-only (product_price.created, fail-closed TransactionScope) ## Key Design Decisions - **Column naming**: PriceValidFrom/PriceValidTo (not ValidFrom/ValidTo) avoids collision with SYSTEM_VERSIONING hidden cols (SysStartTime/SysEndTime). - **SP isolation**: usp_AddProductPrice runs SERIALIZABLE + UPDLOCK for atomic close-and-insert; THROW 50409 on forward-only violation. - **Price scale**: DECIMAL(12,2) — distinct from Product.BasePrice(18,4) per spec decision D6 (retail vs reference price). - **Service contract**: ProductPricingService.GetPriceAtAsync returns decimal? (null if no history) — OQ-B: Product.BasePrice remains orthogonal, fallback decision left to PRC-001. - **Fail-closed audit**: IAuditLogger.LogAsync inside same TransactionScope — if logger fails, INSERT rollsback completely. - **Concurrency hardening**: 3-task race test verifies exactly 1 active price always, no duplicates despite simultaneous POST. ## Colateral Bug Fix AddProductPriceCommandHandler previously read repository result within `using(tx)` scope after `tx.Complete()` — illegal in distributed transactions. Restructured with explicit braces: DB read occurs within scope (before Complete), response construction occurs after scope exit. Caught in e2e, not by mocks — validates value of integration testing. ## Follow-ups Created (Non-Blocking) - **S1**: Unify dateFormat.ts + numberFormat.ts formatters - **S2**: Implement cursor pagination for GET /api/v1/products/{id}/prices (MVP assumes <50 rows/product) - **S3**: Add coverlet coverage instrumentation for C# backend ## Ready for Merge All 52 tasks complete (7 batches, Strict TDD). 1857 tests, 0 rojos. Verify report: PASS. No CRITICAL or WARNING issues. Zero deuda técnica in PRD-003 code.
dmolinari added 7 commits 2026-04-19 22:07:18 +00:00
- 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
- 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
- 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
dmolinari merged commit dd0e5e4fe8 into main 2026-04-19 22:07:22 +00:00
Sign in to join this conversation.
No Reviewers
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: dmolinari/SIG-CM2.0#45