Files
mercadodevida/work/artifacts/F-146/architect.md
2026-08-22 12:52:13 +02:00

4.0 KiB

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)

// src/modules/reporting/application/reporting-service.ts

interface ReportingServiceDeps {
  pool: pg.Pool;
}

class ReportingService {
  constructor(private deps: ReportingServiceDeps) {}
  
  async summary(filters: ReportingFilters): Promise<SummaryResponse>
  async sales(filters: ReportingFilters): Promise<SalesResponse>
}

SQL base (CTE de §7 arquitectura)

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)

-- Para groupBy=day (agrupación por día UTC)
DATE_TRUNC('day', o.created_at) AS period

dataAvailability (según modelo actual)

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.