feat(F-144): completed feature
This commit is contained in:
@@ -1,40 +1,39 @@
|
||||
# F-143 — Acceptance
|
||||
# F-144 — Criterios 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.
|
||||
|
||||
@@ -1,30 +1,36 @@
|
||||
# F-143 — Reporting: contracts, filters and RBAC
|
||||
# F-144 — Historias 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`).
|
||||
|
||||
86
spec/tech.md
86
spec/tech.md
@@ -1,42 +1,56 @@
|
||||
# F-143 — Technical spec
|
||||
# F-144 — Diseñ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).
|
||||
|
||||
Reference in New Issue
Block a user