# F-146 — Architect ## Feature Reporting: service summary and sales API. ## Background F-143 set up the RBAC and filter contracts. F-144 added store_id/shipping_cents snapshots. F-145 added payment lines. F-146 wires up the ReportingService that reads the actual data using those columns. ## Objetivo Implementar ReportingService con métodos `summary()` y `sales()` que: - Usan el CTE `filtered_orders` del architecture doc §7 - Aplican todos los filtros del schema de F-143 - Devuelven métricas con `dataAvailability` correcto - Soportan `groupBy`, `pagination` y `compare` ## Diseño ### ReportingService (application layer) ```typescript // src/modules/reporting/application/reporting-service.ts interface ReportingServiceDeps { pool: pg.Pool; } class ReportingService { constructor(private deps: ReportingServiceDeps) {} async summary(filters: ReportingFilters): Promise async sales(filters: ReportingFilters): Promise } ``` ### SQL base (CTE de §7 arquitectura) ```sql WITH filtered_orders AS ( SELECT o.* FROM orders_orders o WHERE o.created_at >= $1 -- from (UTC) AND o.created_at < $2 -- to (UTC) AND o.state IN ('PAID','PROCESSING','SHIPPED','DELIVERED','COMPLETED') AND ($3::text IS NULL OR o.source = $3) -- channel AND (cardinality($4::uuid[]) = 0 OR o.store_id = ANY($4::uuid[])) AND (cardinality($5::uuid[]) = 0 OR o.terminal_id = ANY($5::uuid[])) AND (cardinality($6::text[]) = 0 OR o.state = ANY($6::text[])) ), filtered_items AS ( SELECT i.*, o.source, o.store_id, o.terminal_id FROM orders_items i JOIN filtered_orders o ON o.id = i.order_id ) SELECT COUNT(DISTINCT o.id)::int AS orders, COUNT(DISTINCT o.user_id) FILTER (WHERE o.user_id IS NOT NULL)::int AS customers, COALESCE(SUM(i.unit_price_cents * i.quantity), 0)::bigint AS gross_sales_cents, COALESCE(SUM(i.discount_cents), 0)::bigint AS discounts_cents, COALESCE(SUM(i.tax_cents), 0)::bigint AS tax_cents, COALESCE(SUM(i.quantity), 0)::int AS units_sold FROM filtered_orders o LEFT JOIN filtered_items i ON i.order_id = o.id ``` ### groupBy extension (sales endpoint) ```sql -- Para groupBy=day (agrupación por día UTC) DATE_TRUNC('day', o.created_at) AS period ``` ### dataAvailability (según modelo actual) ```typescript const dataAvailability: DataAvailability = { grossSales: 'available', netSales: 'unavailable', // sin shipping_cents aún (F-144 existe, service aún no lo usa) discounts: 'available', tax: 'available', unitsSold: 'available', orders: 'available', customers: 'available', margin: 'unavailable', // sin cost_at_sale en orders_items paymentMethod: 'unavailable', // sin JOIN con reporting_payment_lines aún refunds: 'unavailable', // necesita state=REFUNDED/PARTIALLY_REFUNDED en filtro shipping: 'available', // shipping_cents en orders_orders (F-144) }; ``` ### Rutas - `GET /reporting/summary?from=&to=&channel=&storeId=&...` → SummaryResponse - `GET /reporting/sales?from=&to=&groupBy=day&page=1&...` → SalesResponse (con pagination) ### Caching F-146 NO implementa caché (postergado a F-148 post-dashboard). La respuesta incluye `updatedAt: new Date().toISOString()` y `cache: { hit: false, maxAgeSeconds: 0 }` como placeholder. ## Acceptance Criteria AC1: GET /reporting/summary devuelve métricas agregadas (orders, customers, grossSales, discounts, tax, units) con rango filtrado. AC2: GET /reporting/sales devuelve filas agrupadas por día/semana/mes/hora/store/channel. AC3: Filtros storeId/terminalId/state/channel se aplican correctamente. AC4: Paginación funciona (page/pageSize). AC5: dataAvailability refleja correctamente qué métricas son calculables. AC6: Compare previous_equal devuelve rango previo del mismo tamaño. AC7: Errores de validación (fechas invertidas, IDs inválidos) devuelven 400. AC8: Permisos RBAC aplicados (REPORTING_VIEW para summary, REPORTING_SALES para sales). AC9: tsc 0, itest pasa, verify.sh verde.