feat(F-143): completed feature
This commit is contained in:
79
spec/tech.md
79
spec/tech.md
@@ -1,51 +1,42 @@
|
||||
# F-138 — Especificació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+).
|
||||
|
||||
Reference in New Issue
Block a user