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,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).