feat(F-143): completed feature

This commit is contained in:
chattie
2026-08-22 11:43:42 +02:00
parent fb015932b2
commit 3cc51477aa
22 changed files with 1328 additions and 127 deletions

View File

@@ -1,26 +1,40 @@
# F-138Criterios de aceptación
# F-143 — Acceptance
## AC1 — Sembrado inmediato de precio
Tras crear una variante, `GET /pricing/variants/:variantId` devuelve **200** (no 404) con una fila default: `netUnitAmountCents = 0`, `vatRate = 'general'`, `currency = 'EUR'`.
- **Unit:** `CreateProductVariant.execute` llama a `PricingService.seedVariantPrice(variant.id)` tras `variants.create`.
- **Itest (skip sin DB):** POST `/products/:id/variants` → GET `/pricing/variants/:variantId` = 200.
### AC1 — contrato de filtros
- `GET /reporting/filters/schema` (admin) → 200 → `{ filterSchema, comparison, dataAvailability, permissions }`.
- `comparison.modes` incluye `none`, `previous_equal`, `previous_calendar`.
- `comparison.rangeBounds === 'inclusive_start_exclusive_end'`.
- `dataAvailability` refleja el baseline F-142 (grossSales/discounts/tax/unitsSold/orders/customers `available`; netSales/margin/paymentMethod/refunds/shipping `unavailable`).
- `filterSchema.filters` incluye `storeId` (repeatable, uuid), `compare`, `channel`, `groupBy`, `page`, `pageSize`.
## AC2 — Idempotente (re-sembbrado no-op)
Si la fila de precio ya existe, re-sembrar no lanza ni duplica: `ON CONFLICT (variant_id) DO NOTHING`.
- **Unit:** segunda llamada a `seedVariantPrice` no arroja; `seedCalls` contiene el id una sola vez (o ambas, sin error).
### AC2 — RBAC (backend-authority)
- `customer` (role customer) → 403 en `/reporting/filters/schema` y `/reporting/filters/validate`.
- `admin` y `editor` → 200 en `/reporting/filters/schema` (tienen `REPORTING_VIEW`).
- `admin`/`editor`/`pos_manager`/`pos_cashier` → 200 en `/reporting/filters/validate` (tienen `REPORTING_SALES`).
- `customer` NO aparece en `REPORTING_ROLE_PERMISSIONS` con permisos.
## AC3 — Best-effort (no rompe la creación)
Si `seedVariantPrice` lanza, `CreateProductVariant.execute` **sigue devolviendo la variante creada** (no propaga el error).
- **Unit:** con `FakePricingService.seedShouldThrow = true`, `execute` devuelve el `ProductVariant` sin lanzar.
### AC3 — parseo + rango `[from,to)`
- `GET /reporting/filters/validate?from=2026-08-01T00:00:00Z&to=2026-08-31T23:59:59Z&compare=previous_equal` → 200 → `filters.range.from/to` normalizados; `comparison.range.from` < `filters.range.from` < `filters.range.to`; `comparison.range.to` === `filters.range.from`.
- `pageSize` y `page` vienen por defecto (1 y 50) cuando no se pasan.
## AC4 — Los 3 call sites crean la variante con precio
Los 3 puntos que crean variantes dejan fila de precio:
1. `POST /products` (autovariante `SKU-MV-{id}`).
2. `GET /products/:id/variants` (lazy, admin).
3. `POST /products/:id/variants`.
- Todos comparten la misma instancia `createVariant` (inyecta `pricing`) → todos sembran.
### AC4 — validación
- `from > to` → 400 (`VALIDATION_ERROR` / 400).
- `?storeId=<single uuid>` parsea a array de 1 elemento; `?storeId=a&storeId=b` a array de 2.
- UUID inválido → 400.
## Gates
- **reviewer:** arquitectura limpia (pricing owning su tabla; inyección de servicio público; build-app ordering safe).
- **security:** SQL con parámetro (`$1`), literales `'general'`/`0`/`NULL` (no user input); no inyección.
- **qa:** tests unitarios 3/3 verdes; `npm test` no rompe; tsc 0 errores; verify.sh green.
### AC5 — comparison modes (unit)
- `comparisonRange(range, 'none')` === `null`.
- `previous_equal`: `to_prev === from_actual`, `from_prev === from_actual - duration`.
- `previous_calendar`: ventana alineada a UTC, `to_prev <= from_actual`.
### AC6 — granularidad de permisos
- `REPORTING_FINANCIAL` concedido solo a `admin` (editor/pos_manager/pos_cashier → 403).
- `requireReportingPermission` lanza AppError(403) para roles sin el permiso.
### AC7 — tests unitarios (sin DB)
- `parseReportingFilters`: defaults, repeatable uuid arrays, from>to rechazado.
- `comparisonRange`: 3 modos.
- Matriz de permisos role→perms.
- Tests: ≥8 unit + ≥6 route. `tsc --noEmit` 0 errores; `npm test` sin regresiones; `lint:boundaries` sin violaciones nuevas.
> `lint:boundaries` (scripts/check-module-boundaries.mjs) — reporting NO aparece todavía en la lista de módulos existentes; confirma que reporting importa solo `shared/*`/`zod`.

View File

@@ -1,29 +1,30 @@
# F-138Auto-sembrar fila de precio en creación de variante
# F-143 — Reporting: contracts, filters and RBAC
## Título
Auto-sembrar fila de precio en creación de variante para evitar la ventana 404 en `GET /pricing/variants/:id`.
## Estado
Diseño aprobado (ver `work/artifacts/F-143/architect.md`). Implementación backend-only.
## Contexto / Problema
- `GET /pricing/variants/:variantId` (modulo `pricing`, `PricingService.getVariantPrice``PgPricingRepository.findByVariantId`) devuelve **404** cuando `pricing_variant_prices` no tiene fila para `variant_id`.
- `CreateProductVariant.execute` (`catalog/application/variant-use-cases.ts`) solo llama a `variants.create` (inserta en `catalog_product_variants`) y **nunca** inserta en `pricing_variant_prices`.
- 3 call sites disparan `createVariant.execute`:
1. Creación de producto con variante por defecto (`POST /products`, autovariante `SKU-MV-{id}`).
2. Lazy migration en `GET /products/:id/variants` (producto legacy sin variantes → crea variante default para admin).
3. `POST /products/:id/variants` (creación explícita de variante).
- En todos los casos, la variante existe en `catalog_product_variants` pero `GET /pricing/variants/:variantId` 404ea **hasta que un admin no asocie un precio** → la carrotera/pos pueden romper ("el precio no existe").
## Producto (alcance)
El módulo `reporting` expone el **contrato compartido** de filtros de reporte y la **matriz de permisos `REPORTING_*`** como código, para que los endpoints de reporte futuros (F-144+) consuman un parser único y estén consistentes. **No genera reportes ni lectura de datos** (esas son F-144+).
## Solución
Sembrar (seed) una fila de precio por defecto **inmediatamente después de crear la variante**, con valores neutros: `net_unit_amount_cents = 0`, `vat_rate = 'general'`, `currency = 'EUR'` (default DDL). El sembrado es **idempotente** (`ON CONFLICT (variant_id) DO NOTHING`) y **best-effort**: si falla, la creación de la variante no se anula (la variante primaria es la prioridad; el precio es secundario).
## Alcance (entregable)
- Schema zod reusable `reportingFiltersSchema` (`from`, `to`, `compare`, `channel`, `storeId`, `terminalId`, `cashierId`, `paymentMethodId`, `productId`, `categoryId`, `brandId`, `customerId`, `state`, `groupBy`, `page`, `pageSize`, `sort`) con validación `from<to` y semántica `[from,to)`.
- Parser `parseReportingFilters` + helper `comparisonRange` (`none | previous_equal | previous_calendar`).
- Metadato `REPORTING_FILTER_META` (campos, modos de comparación, groupBy, paginación, `dataAvailability`) servido a `GET /reporting/filters/schema`.
- Permisos backend `REPORTING_*` basados en roles (`requireReportingPermission`) con matriz `admin/editor/pos_manager/pos_cashier/customer`.
- Rutas: `GET /reporting/filters/schema` (requiere `REPORTING_VIEW`), `GET /reporting/filters/validate` (requiere `REPORTING_SALES`).
## Alcance
- Backend: `pricing` (nuevo método `seedVariantPrice`) + `catalog` (inyección en `CreateProductVariant` + wiring build-app).
- Frontend: N/A (no hay cambios de UI).
- Migración: N/A — `pricing_variant_prices.variant_id` ya es UNIQUE/PK (lo demuestra `setVariantPrice` usando `ON CONFLICT (variant_id)`); no se requiere migración ni columna nueva.
## Fuera de alcance (F-144+)
- Lectura de datos transaccionales, agregaciones, dashboards, exportaciones, tabla de permisos en BD (F-142 §9 la marca como migración futura; hoy es role-based con los mismos call sites).
## Definición de terminado
- [x] `PricingService.seedVariantPrice(variantId)` existe + persiste fila default.
- [x] `CreateProductVariant` llama a `seedVariantPrice` tras `variants.create`.
- [x] Los 3 call sites dejan fila de precio tras crear variante.
- [x] Re-sembrar es no-op (idempotente).
- [x] Tests unitarios (sin DB) pasan; itest de AC skipped sin `TEST_DATABASE_URL`.
- [x] `npm run typecheck` 0 errores; `npm test` (targeted) verde; `verify.sh` green.
## API (contrato HTTP)
- `GET /reporting/filters/schema``{ filterSchema, comparison, dataAvailability, permissions: { role, grants } }`. Requiere `REPORTING_VIEW`.
- `GET /reporting/filters/validate?from=...&to=...&compare=...&...``{ ok, filters, comparison: { range } }`. Requiere `REPORTING_SALES`.
## Seguridad (F-143 es parte de éste)
- La matriz RBAC se impone en backend (`requireReportingPermission` sobre `CurrentUser.role`). El frontend no es autoridad.
- `customer` 0 permisos → 403 siempre.
- Futuro: migrar a tabla `backoffice_permissions` sin cambiar `requireReportingPermission(user, permission)`.
## Referencias
- Arquitectura Reporting (F-142): `docs/reporting/REPORTING_ARCHITECTURE.md` (§4§11).
- Patrones: `src/modules/pricing/api/pricing.routes.ts`, `src/shared/auth.ts`, `src/shared/http-input.ts`.

View File

@@ -1,51 +1,42 @@
# F-138Especificación técnica
# F-143 — Technical spec
## Contrato de separación (F-154 precedent)
- `pricing` **es dueño** de `pricing_variant_prices` (tabla a su módulo). No se inserta desde `catalog` con SQL crudo — se expone un método en el **servicio público** `PricingService` (patrón idéntico al que `cart` ya inyecta en build-app: cross-module write vía servicio público, R1 legal).
## Cambios
### 1. `pricing/domain/ports.ts`
- Añadir a `PricingRepository`: `seedVariantPrice(variantId: string): Promise<void>`.
- Añadir a `PricingService`: `seedVariantPrice(variantId: string): Promise<void>`.
### 2. `pricing/application/pricing-service.ts`
- `PricingService.seedVariantPrice(variantId)` → delega a `this.repository.seedVariantPrice(variantId)`. Sin validación extra (el `variantId` ya proviene de una variante creada en la misma transacción lógica).
### 3. `pricing/infrastructure/pg-pricing-repository.ts`
```ts
async seedVariantPrice(variantId: string): Promise<void> {
await this.pool.query(
`INSERT INTO pricing_variant_prices (variant_id, net_unit_amount_cents, offer_cents, cost_cents, vat_rate)
VALUES ($1, 0, NULL, NULL, 'general')
ON CONFLICT (variant_id) DO NOTHING`,
[variantId],
);
}
## Module layout
```text
src/modules/reporting/
domain/filters.ts — pure types (ReportingFilters, ComparisonMode, GroupBy, ComparisonRange, DataAvailability, ReportingFilterMeta)
domain/permissions.ts — ReportingPermission + REPORTING_ROLE_PERMISSIONS + requireReportingPermission (imports shared/auth + shared/errors)
application/filters.ts — reportingFiltersSchema (zod), parseReportingFilters, comparisonRange, REPORTING_FILTER_META (imports domain)
api/reporting.routes.ts — registerReportingRoutes(app, deps:{authenticate}) (imports application + domain + shared)
index.ts — public surface (re-exports only)
tests/filters.test.ts — unit: parser + comparisonRange
tests/permissions.test.ts— unit: role matrix + requireReportingPermission
api/reporting.routes.test.ts — HTTP: schema/validate + RBAC (mirror security.routes.test.ts)
```
- Columnas idénticas al INSERT de `setVariantPrice` (omite `currency` → DDL default `'EUR'`; `created_at`/`updated_at``now()` DDL default). Reutiliza `VatRate = 'general'`.
- `ON CONFLICT (variant_id)` válido: `variant_id` es UNIQUE/PK (usado por `setVariantPrice`). → **idempotente / re-sembbrado no-op**.
### 4. `catalog/application/variant-use-cases.ts`
- Import (type, público, R1): `import type { PricingService } from '../../pricing/index.js';` (catalog/application → ../../pricing/index = modules/pricing/index ✓ R1 a index público).
- `CreateProductVariant` recibe `pricing: PricingService` en ctor.
- `execute`: tras `this.variants.create(productId, input)` (y solo si el producto existe), `await this.pricing.seedVariantPrice(variant.id)` **best-effort** (try/catch silencioso: la variante ya persistió; un fallo del seed no revierte la creación).
## Boundaries (R1/R2)
- `reporting` importa SOLO `shared/*` + `zod`**ningún otro módulo**. ✓ R1.
- `src/app/build-app.ts` importa `registerReportingRoutes` (+ tipos) desde `modules/reporting/index.js` (R2). ✓.
- Registrado dentro de `if (deps.pool)` con `authenticate: combinedAuth` (backplane backoffice), junto al resto de módulos backoffice.
### 5. `catalog/api/catalog.routes.ts`
- `CatalogRoutesDeps` += `pricing: PricingService` (import type desde pricing index — R1).
- `const createVariant = new CreateProductVariant(repository, variants, pricing);` (único constructor; cubre los 3 call sites: POST /products autovariante, GET /products/:id/variants lazy, POST /products/:id/variants).
## Filter contract (F-142 §6)
- Rango `[from,to)`: `from` inclusivo, `to` exclusivo → evita doble conteo.
- `from`/`to`: ISO datetime with offset → `z.string().datetime({ offset: true })`.
- Arrays repetibles de UUID aceptan single OR array vía `z.preprocess((v)=>Array.isArray(v)?v:v===undefined?undefined:[v], z.array(z.uuid()).optional())`.
- `compare` default `none`; `channel` default `all`; `page` (1..); `pageSize` (1..200, default 50).
- `from > to` → refine → AppError(400) (mapeado por `parseJson`).
### 6. `build-app.ts`
- Mover `const pricing = createPricingService(deps.pool);` **antes** del bloque `registerCatalogRoutes` (actualmente está después → orden L247 catalog, L264 pricing). Crear pricing antes del registro de catalog permite pasarlo a `CatalogRoutesDeps`.
- Pasar `pricing` en deps de `registerCatalogRoutes`.
- La ruta de pricing (`registerPricingRoutes`) y cart siguen usando `pricing` (sin cambios; pricing sigue definido). `createPricingService` ya está importado (L33).
## Comparison (`comparisonRange`)
- `none``null`.
- `previous_equal` → shift ventana atrás por la duración exacta (`[start-duration, start)`).
- `previous_calendar` → shift atrás por los días calendario transcurridos, alineado a UTC (`00:00`) → `[prevStart, prevStart+spanDays)`. Documented como aproximación calendar-aligned.
## Riesgos / mitigaciones
- **Orden en build-app (timing):** mover `const pricing` antes de catalog es seguro (constructor puro, `deps.pool` disponible). Precio routes lo vuelve a usar → no se rompe.
- **Fallo del seed:** best-effort (try/catch) → no hace 500 en variant creation. AC3 lo prueba.
- **Doble creación (createVariant en autovariante + retry):** `ON CONFLICT DO NOTHING` → idempotente. AC2 lo prueba.
- **Boundary R1:** catalog→pricing público index ✓ (cart ya lo usa). Deep imports prohibidos — usar `../../pricing/index.js`.
## RBAC (role-based, F-142 §9)
- admin → todos los `REPORTING_*`.
- editor → VIEW+SALES+PRODUCTS+CUSTOMERS+INVENTORY+DISCOUNTS+REFUNDS+TAXES (sin FINANCIAL/EXPORT/ADMIN).
- pos_manager → VIEW+SALES+PAYMENTS+CASH.
- pos_cashier → VIEW+SALES.
- customer → [] (403).
- `requireReportingPermission(user, permission)` lanza AppError(403). Futuro: tabla `backoffice_permissions`; la firma no cambia.
## Tests
- **Unitario (runnable, sin DB):** `catalog/tests/variant-use-cases.test.ts` — FakeProductRepository, FakeProductVariantRepository, FakePricingService; assert (a) seedVariantPrice llamado con variant.id tras create, (b) no se sembran si producto no existe, (c) create sigue devolviendo variante si seed lanza.
- **Integración (AC):** itest en `catalog.itest.ts` skipIf(!hasDb) — POST /products/:id/variants → GET /pricing/variants/:id = 200 con `netUnitAmountCents=0`, `vatRate='general'`. Skipped sin `TEST_DATABASE_URL` (no bloquea verify.sh).
## Data availability (F-142 §4 baseline, server-truth)
`grossSales/discounts/tax/unitsSold/orders/customers = available`; `netSales/margin/paymentMethod/refunds/shipping = unavailable`. Se expone via `REPORTING_FILTER_META.dataAvailability` (no cálculos aún — F-144+).