feat(F-144): completed feature

This commit is contained in:
chattie
2026-08-22 12:40:23 +02:00
parent 32489107ab
commit 2ea628fd62
14 changed files with 494 additions and 106 deletions

View File

@@ -1,40 +1,39 @@
# F-143Acceptance
# F-144Criterios de aceptación
### 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`.
## AC1 — store_id multi-tienda (snapshot, no rewrite)
- `orders_orders.store_id` es `uuid NOT NULL DEFAULT '00000000-0000-0000-0000-000000000001'::uuid`.
- FK `orders_orders_store_id_fkey → pos_stores(id)` existe y es `VALID`.
- Índice `orders_orders_store_id_created_at_idx ON orders_orders(store_id, created_at)` existe.
- Filas existentes heredan el default store (no table rewrite de datos).
- Un `INSERT INTO orders_orders DEFAULT VALUES` persiste `store_id = DEFAULT_STORE_ID`.
### 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.
## AC2 — shipping_cents separado
- `orders_orders.shipping_cents` es `integer NOT NULL DEFAULT 0`.
- Filas existentes mantienen su `total_cents` (no se altera; shipping_cents = 0).
- El `INSERT ... DEFAULT VALUES` registra `shipping_cents = 0`.
### 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.
## AC3 — snapshots de línea (cost_at_sale_cents, vat_rate)
- `orders_items.cost_at_sale_cents` es `bigint`, nullable (NULL para filas históricas → margen `unavailable`).
- `orders_items.vat_rate` es `text`, nullable (snapshot del tipo IVA aplicado; NULL → IVA-por-tipo `unavailable`).
- No se reescribe la historia: columnas nuevas no tocan datos existentes.
### 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.
## AC4 — migración idempotente y reversible
- `up()` es no-op si se re-ejecuta (DDL `IF NOT EXISTS` / `DO $$` guards).
- `down()` elimina índice, constraint y columnas nuevas.
### 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`.
## AC5 — itest de snapshots (DB real)
- `reporting-snapshots.itest.ts` recrea la DB, migra (aplica 048), y verifica
columnas, nullabilidad, defaults, FK (contype='f') e índice vía
`information_schema`/`pg_constraint`/`pg_indexes`, más un insert
`DEFAULT VALUES` con backfill de `store_id`/`shipping_cents`.
### 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.
## AC6 — verificación de gates
- `npx tsc --noEmit` → 0 errores.
- `TEST_DATABASE_URL=... npx vitest run` → itest F-144 3/3 verde + sin regresiones
(orders/catalog/checkout/sales/etc.) 0 fallos.
- `node scripts/check-module-boundaries.mjs src` → 0 violaciones nuevas.
### 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`.
## AC7 — sin regresión en flujos existentes
- El seed y el checkout siguen funcionando: `orders_orders` `INSERT` sin
`store_id` explícito sigue válido (column DEFAULT).
- `orders.itest.ts`, `catalog.itest.ts`, `checkout-flow.itest.ts` siguen verdes.

View File

@@ -1,30 +1,36 @@
# F-143Reporting: contracts, filters and RBAC
# F-144Historias de producto
## Estado
Diseño aprobado (ver `work/artifacts/F-143/architect.md`). Implementación backend-only.
## Reporting: snapshots de tienda, IVA, coste y envío
## 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+).
**Alcance:** backend-only. Añade las columnas mínimas de *snapshot* que el
módulo `reporting` (F-143↑, F-146 services) necesita para reportar ventas
multi-tienda sin reescribir la historia (REPORTING_ARCHITECTURE.md §5).
## 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`).
## Historias
## 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).
- Como **analista multi-tienda**, quiero que cada pedido (`orders_orders`)
tenga `store_id` (tienda donde se vendió), para poder filtrar y agrupar
reportes por tienda sin reescribir histórico.
- Como **reportista de márgenes**, quiero que el importe del envío
(`shipping_cents`) esté separado del `total_cents`, para poder exponer
“ventas de mercancía” y “net sales” con nombres correctos (§5.6).
- Como **analista financiero**, quiero un *snapshot* del tipo de IVA
(`vat_rate`) y del coste (`cost_at_sale_cents`) por línea, para poder
calcular margen histórico e IVA por tipo (§5.2). Estos snapshots son
**nullable**: hasta que se poblén, margen/IVA-por-tipo permanecen
`unavailable` (nunca se publican como 0).
## 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`.
## Non-goals (fuera de F-144)
- Poblar `cost_at_sale_cents`/`vat_rate` en el flujo de venta (F-146 /
reporting-service snapshots).
- Crear la tabla de payment lines ni refunds (F-145 y el roadmap §5.3-4).
- Cualquier endpoint de reporte ni cálculo de métricas (F-146).
- Resolver explícitamente la tienda en el checkout (writes app-layered) — se
usa el DEFAULT de columna como mínimo; F-146 introduce la resolución de
tienda por request.
## 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`.
## Fuente de verdad
`orders_orders` y `orders_items` en PostgreSQL (`postgres:16`). La tienda
default (`id = 00000000-0000-0000-0000-000000000001`) es sembrada por
`043_pos_basics` y reutilizada desde `src/modules/inventory/index.ts`
(`DEFAULT_STORE_ID`).

View File

@@ -1,42 +1,56 @@
# F-143Technical spec
# F-144Diseño técnico
## 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)
```
## Enfoque
Una única migrations `048_reporting_store_shipping_snapshots.js` (node-pg-migrate,
estilo `043_pos_basics`/`047_*`) idempotente y reversible. **No hay migración de
datos costosa**: `store_id` usa column `DEFAULT` del default store (sembrado por
043), por lo que filas existentes e inserts sin store_id heredan el default sin
table rewrite ni toque en el app-layer.
## 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.
## Columnas nuevas
## 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`).
| Tabla | Columna | Tipo | Nullable | Default | Restricción |
|---|---|---|---|---|---|
| orders_orders | store_id | uuid | NOT NULL | `'00000000-0000-0000-0000-000000000001'::uuid` | FK → pos_stores(id) |
| orders_orders | shipping_cents | integer | NOT NULL | 0 | CHECK (>=0) |
| orders_items | cost_at_sale_cents | bigint | YES (snapshot) | — | — |
| orders_items | vat_rate | text | YES (snapshot) | — | — |
## 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.
Índice: `orders_orders_store_id_created_at_idx ON orders_orders(store_id, created_at)`
(por el patrón de reporting §7: consultas por tienda + rango de fechas).
## 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.
## Idempotencia + reversibilidad
- Toda la DDL: `ADD COLUMN IF NOT EXISTS` / `DO $$ IF NOT EXISTS` sobre
`pg_constraint`. Re-ejecutar es no-op.
- `down()`: `DROP INDEX`, `DROP CONSTRAINT`, `DROP COLUMN` por cada nueva
columna (orden inverso de dependencias).
## 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+).
## Por qué DEFAULT (no NOT NULL sin default + UPDATE)
- `store_id` es una columna nueva: no hay histórico que "reescribir". Un
`DEFAULT` constante backfilla silenciosamente y evita un `UPDATE` table-scan
sobre tabla potencialmente grande — alineado con §11 "no añadir índices
duplicados indiscriminadamente" y con no-rewrite-history.
- El app **inserta pedidos** vía `PgOrderRepository` (raw SQL `INSERT INTO
orders_orders (...)`). Con column `DEFAULT`, ese INSERT sigue funcionando
(la column no aparece en la lista) → cero regresión en checkout/orders.itest.
La resolución explícita de tienda (writes app-layered) se deja a F-146.
## Test
- itest `reporting-snapshots.itest.ts` (mirror `inventory.itest.ts`):
`recreateDatabase(url)` + `runMigrations(url,'up')` (aplica 048 contra
`mercadodevida_test`) + `createPool`, con `describe.skipIf(!hasDb)`.
- Asocia columnas/nullabilidad/default/FK/índice vía `information_schema` /
`pg_constraint` / `pg_indexes`, e inserta `INSERT INTO orders_orders DEFAULT
VALUES RETURNING store_id, shipping_cents` → `store_id = DEFAULT_STORE_ID`,
`shipping_cents = 0`.
- Harness: `TEST_DATABASE_URL=postgres://mdv:mdv_dev_only@localhost:5432/mercadodevida_test`
(verificado: `orders.itest.ts` recrea+migra+tests en ~450ms con el
`mdv` superuser).
## Verificación esperada
- `npx tsc --noEmit` → 0 errores.
- `TEST_DATABASE_URL=... npx vitest run src/app/tests/reporting-snapshots.itest.ts` → 3/3.
- `TEST_DATABASE_URL=... npx vitest run` → 1 archivo itest nuevo + regresión
(orders/catalog/checkout/sales itests) verde, 0 fallos.
- `node scripts/check-module-boundaries.mjs src` → 0 violaciones nuevas
(F-144 añade 1 migration .js + 1 .ts en tests; sin imports inter-módulo).