feat(F-144): completed feature
This commit is contained in:
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