Pular para o conteúdo principal

Motor genérico de metas: arquitetura

Resumo da arquitetura do backend do motor genérico de metas, entregue na TEC-1275 (originalmente TEC-1006, branch feat/TEC-1006-motor-metas-modelo-e-gravacao). Este documento reflete o estado como implementado, incluindo as correções feitas na revisão do PR #547.

O que é​

Pacote @kit/goals (packages/features/goals/) que representa objetivos (metas), avalia condições contra valores reais e devolve um painel de progresso, sem conhecer nenhum produto do monorepo. Cada produto (Belite hoje; Sempre no futuro) contribui um adapter que traduz dimensao/filtro em números vindos do seu próprio Data Warehouse.

Nesta entrega só o backend foi feito: modelo de dados, RPCs de gravação, núcleo de cálculo, service de leitura, contrato do adapter e um adapter Belite nulo (sempre retorna dado indisponível). A consulta real ao DW é a TEC-1007; a UI de cadastro e de painel são a TEC-1011/TEC-1012.

Onde mora cada peça​

CamadaLocalConhece produto?
Motor genéricopackages/features/goals/Não: zero import de apps/web
Composition rootapps/web/lib/goals/adapters.tsSim: é o único lugar que liga os dois lados
Adapter do Beliteapps/web/app/[locale]/belite/_lib/server/belite-goal-adapter.tsSim: vive dentro do módulo do produto
Catálogo de kind/dimensões válidas (futuro, TEC-1011)dentro do módulo do produto, ainda não escritoSim

A regra de dependência é sempre produto → motor, nunca o contrário, garantida em compilação porque @kit/goals não declara apps/web como dependência.

Modelo de dados​

Três tabelas novas (apps/web/supabase/schemas/30-objectives.sql, migration 20261028193326_tec1275_objectives.sql):

  • objectives: o container. account_id, kind (texto livre, ver "Por que kind/dimensao são texto" abaixo), label, operador_entre_condicoes (AND/OR), status (rascunho/ativo/suspenso), vigência (data_inicio/data_inativacao) e data_ativacao.
  • objective_conditions: 1..N por objetivo. dimensao, sub_dimensao?, container_id?, filtro (jsonb), operador de comparação, target (> 0), target_type (absolute/percent), created_at. Única por (objective_id, ordem).
  • objective_condition_steps: 1..5 degraus por condição, com created_at. Única por (condition_id, ordem).

Integridade no banco:

  • objectives_data_inativacao_after_inicio: CHECK (data_inativacao is null or data_inativacao >= data_inicio).
  • objectives_no_overlapping_vigencia: EXCLUDE USING gist por (account_id, kind, daterange(vigência)), mesmo padrão de public.goals. Impede duas vigências sobrepostas do mesmo kind na mesma conta, em qualquer status, e fecha o buraco de retry duplicado na criação.
  • FKs compostas: objective_conditions (objective_id, account_id) → objectives (id, account_id) e objective_condition_steps (condition_id, account_id) → objective_conditions (id, account_id). Uma condição ou degrau não consegue ficar com account_id diferente do pai.
  • Gatilho set_data_ativacao: preenche data_ativacao com now() sempre que o objetivo passa a ativo (no insert ou quando o status anterior não era ativo), por qualquer caminho de escrita. Editar um objetivo que já está ativo não sobrescreve a data.

A permissão objectives.manage é declarada em schemas/01-enums.sql e concedida a owner, admin e consultor_fi em schemas/17-roles-seed.sql.

RLS habilitada nas três tabelas, uma policy por comando (SELECT/INSERT/UPDATE/DELETE), sempre to authenticated. SELECT permite qualquer membro do time ou is_product_admin(). Escrita exige can_manage_objectives(account_id), que libera o admin MeResolve (is_product_admin()) ou quem é membro da conta com objectives.manage. As tabelas filhas têm account_id denormalizado para permitir policies diretas sem JOIN com o pai.

RPCs de gravação​

upsert_objective_with_conditions​

Função transacional (security invoker, set search_path = '') que:

  1. Checa can_manage_objectives. Sem permissão, retorna {"error": "forbidden"} antes de qualquer escrita ou lock.
  2. Valida a entrada, sem confiar só no Zod do service. Em falha, retorna {"error": "dados_invalidos", "detalhe": ...} sem gravar nada:
    • sem_condicoes: lista de condições nula ou vazia (impede apagar todas as condições de um objetivo existente);
    • label_obrigatorio: mais de uma condição sem label;
    • degraus_invalidos: condição com 0 ou mais de 5 degraus;
    • degraus_fora_de_ordem: degraus não estritamente crescentes;
    • percentual_invalido: target_type = percent fora de 0.01 a 100.
  3. Toma um pg_advisory_xact_lock por (account_id, kind) para serializar upserts concorrentes.
  4. Insere (se p_objective_id é null) ou atualiza (todos os campos, incluindo kind) o objetivo. Atualizar um id de outra conta retorna {"error": "not_found"}.
  5. Faz replace-all dos filhos: apaga as condições do objetivo (os degraus caem em cascata) e reinsere a partir do jsonb de entrada, preservando a ordem do array via jsonb_array_elements(...) with ordinality.
  6. Captura exclusion_violation (choque de vigência) e devolve {"error": "vigencia_conflict"}.

Contrato de retorno: sucesso → { objectiveId, conditionsCount }; falha controlada → { error: 'forbidden' | 'not_found' | 'vigencia_conflict' } ou { error: 'dados_invalidos', detalhe }.

upsert_objectives_bulk​

Itera sobre um array de objetivos, delegando cada item para upsert_objective_with_conditions (sempre criação). Cada iteração roda em subtransação (BEGIN ... EXCEPTION): uma falha isolada não aborta o lote. Retorno: { criados, conflitos: [{ index, kind, motivo }] }, com motivo sempre mapeado para um código conhecido (forbidden, vigencia_conflict, dados_invalidos, erro_inesperado), nunca a mensagem crua do Postgres.

No service, upsertBulk valida todos os itens com Zod antes da RPC: um item inválido nessa etapa lança erro e nenhum item é criado.

Núcleo de cálculo (packages/features/goals/src/core/)​

100% puro: sem I/O, sem mutação, sem exceção em fluxo normal.

  • resolveSteps(steps, valorAtual): acha o degrau já atingido, o próximo e o progresso dentro do segmento atual ([0,1]). Compara sempre valorAtual >= degrau.
  • evaluateCondition(cond, valorAtual, referencia): calcula target_efetivo (direto em absolute; (target/100) × referencia em percent) e o status da condição, nesta ordem: indisponivel (dado ausente) → atingida → em_andamento → nao_atingida. progresso é clampado em [0, 1]; para <= é 1 dentro do limite e target/valor acima dele. O operador = compara com tolerância de ponto flutuante.
  • aggregate(results, operador): combina as condições em objetivo_atingido: boolean | null com lógica de três valores. OR com alguma condição atingida é true; AND com alguma condição não atingida é false. null só quando a condição indisponível é a que decide o resultado.
  • resolveObjectiveStatus(status, data_inativacao, hoje): devolve expirado quando data_inativacao < hoje; caso contrário, o status gravado.
  • legacyGoalToObjective(goal): converte uma linha de public.goals em objetivo virtual (ver retrocompatibilidade).

Leitura (objectives.service.ts)​

  • listObjectives({ accountId, client, hoje }): lista os objetivos do motor e as metas legadas da conta, cada um com status_calculado (rascunho/ativo/suspenso/expirado) e origem (motor/legado).
  • listActiveObjectives(...): só os com status_calculado = ativo e data_inicio <= hoje. Conta sem objetivos retorna [].
  • getPanel({ accountId, objectiveId, periodo, client, productId }): carrega o objetivo da conta informada (id de outra conta resulta em "Objective not found"), chama o adapter registrado para cada condição em paralelo e agrega com aggregate. Aceita ids de metas legadas (legacy-<uuid>).

O hoje é responsabilidade de quem chama; consumidores reais devem calculá-lo no fuso America/Sao_Paulo, não em UTC.

Retrocompatibilidade com public.goals (lida, nunca gravada)​

Cada linha de public.goals da conta vira um objetivo virtual com id legacy-<id>, origem legado e 1 condição: dimensao = kpi_kind, operador >=, degrau único com o target original e o recorte antigo no filtro (grupo_sk, recorte = kind, e fabricante/banco quando preenchidos). O recorte vai no filtro porque metas legadas de recortes diferentes do mesmo kpi_kind precisam continuar distinguíveis.

Toda meta legada entra como ativo e a vigência é decidida pela data (resolveObjectiveStatus). Não usar goals.is_active: é coluna gerada como data_inativacao is null e não indica se a meta vale numa data. public.goals nunca é escrita por este fluxo, e erro de leitura é propagado, nunca convertido em painel vazio.

Contrato do adapter (Strategy + Registry)​

interface GoalMetricAdapter {
resolve(input: {
accountId: string;
dimensao: string;
subDimensao?: string;
containerId?: string;
filtro: Record<string, unknown>;
periodo: { inicio: string; fim: string };
}): Promise<{ valorAtual: number | null; referencia?: number | null }>;
}

goalAdapterRegistry (packages/features/goals/src/server/registry.ts) é um Map<productId, adapter> com register/get/has. get falha alto e cedo (Error explícito) se ninguém registrou adapter para o productId pedido: é erro de configuração, não de dado.

O adapter do Belite hoje é nulo: resolve() sempre devolve { valorAtual: null }, então todo painel do Belite mostra indisponivel até a TEC-1007 substituir pela consulta real ao DW.

Registro do adapter: cuidado com tree-shaking​

O composition root (apps/web/lib/goals/adapters.ts) expõe registerGoalAdapters() como função exportada. apps/web/package.json tem "sideEffects": false, e um import '~/lib/goals/adapters' sem binding pode ser eliminado pelo bundler; isso já foi reproduzido em produção como No GoalMetricAdapter registered for product "belite". Uma função chamada explicitamente não sofre esse problema.

Hoje o único consumidor é o harness de teste, que chama registerGoalAdapters() no topo das suas server actions. A chamada no app/layout.tsx foi removida. Todo consumidor novo (Monitor, TEC-1007) precisa chamar registerGoalAdapters() no próprio módulo antes de usar getPanel.

dimensao, sub_dimensao, container_id, filtro: quem passa, quem interpreta​

Todos opacos ao motor; só o adapter do produto dá significado.

  • dimensao: a métrica base (contratos, total_financiado...). Obrigatória.
  • sub_dimensao: refinamento da mesma dimensão (ex.: financiados vs à vista), sem precisar de uma dimensão nova.
  • container_id: um único identificador de recorte (ex.: o grupo_sk da conta).
  • filtro (jsonb): quando um único identificador não basta (múltiplos critérios simultâneos).

O motor só armazena e repassa; quem cria o objetivo escolhe os valores, quem lê (getPanel) repassa para o adapter, e é o adapter quem transforma isso em SQL real contra o DW.

Por que kind/dimensao são texto livre, não enum​

Se fossem enum do Postgres, o motor deixaria de ser agnóstico de produto: todo kind novo de qualquer produto exigiria ALTER TYPE no schema compartilhado. Como TEXT, o catálogo de valores válidos vive no código de cada produto (ainda não escrito para o motor novo; o precedente é KPI_KIND_LABELS do sistema antigo, public.goals).

Harness de teste (/dev-metas)​

Rota interna (apps/web/app/[locale]/(dev)/dev-metas/), fora do fluxo de qualquer produto, só para exercitar o motor ponta a ponta (criar, editar, listar, ver painel, simular cálculo, criar em lote) sem depender da UI real (TEC-1011) nem do adapter real (TEC-1007). Página e actions são restritas ao admin MeResolve (is_product_admin). kind/dimensao/filtro são texto livre por design.

Ciclo de vida​

Estados persistidos: rascunho | ativo | suspenso. expirado não é persistido: é derivado (data_inativacao < hoje) e calculado em listObjectives/listActiveObjectives. data_ativacao é gravada pelo gatilho na transição para ativo.

Pendências de decisão​

  • Regra dos degraus: hoje são valores absolutos comparados com >=, independentes do operador e do target_type da condição, e não há vínculo entre o target da condição e os degraus.
  • Retorno da criação: a RPC devolve objectiveId e conditionsCount, não os ids das condições.
  • Rotas HTTP (POST /api/goals, PATCH /api/goals/:id) versus server actions para o consumidor da TEC-1011.
  • Trava de vigência valendo para qualquer status (um rascunho bloqueia um ativo do mesmo kind).

Fora do escopo desta entrega​

Consulta real ao DW (TEC-1007) · dimensões vendedor/tipo_venda (TEC-1008) · UI de formulário e de painel (TEC-1011/TEC-1012) · retorno percentual em produção (depende de TEC-1013) · adapter Sempre completo · matriz booleana AND/OR por par de condições · migração de dados de public.goals (não vai acontecer; a retrocompatibilidade é só leitura).

Referências​

  • apps/web/supabase/tests/database/objectives-rpcs-tec1275.test.sql: suíte pgTAP (53 asserções) cobrindo replace-all, idempotência, ordem estável, isolamento do lote, forbidden/not_found, validações no banco, gatilho de ativação, FKs compostas, outra organização e admin sem membership.
  • packages/features/goals/src/**/*.test.ts: testes property-based (fast-check) do núcleo puro e unitários do service.