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
| Camada | Local | Conhece produto? |
|---|---|---|
| Motor genérico | packages/features/goals/ | Não: zero import de apps/web |
| Composition root | apps/web/lib/goals/adapters.ts | Sim: é o único lugar que liga os dois lados |
| Adapter do Belite | apps/web/app/[locale]/belite/_lib/server/belite-goal-adapter.ts | Sim: 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 escrito | Sim |
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 quekind/dimensaosão texto" abaixo),label,operador_entre_condicoes(AND/OR),status(rascunho/ativo/suspenso), vigência (data_inicio/data_inativacao) edata_ativacao.objective_conditions: 1..N por objetivo.dimensao,sub_dimensao?,container_id?,filtro(jsonb),operadorde comparação,target(> 0),target_type(absolute/percent),created_at. Única por(objective_id, ordem).objective_condition_steps: 1..5 degraus por condição, comcreated_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 gistpor(account_id, kind, daterange(vigência)), mesmo padrão depublic.goals. Impede duas vigências sobrepostas do mesmokindna 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)eobjective_condition_steps (condition_id, account_id)→objective_conditions (id, account_id). Uma condição ou degrau não consegue ficar comaccount_iddiferente do pai. - Gatilho
set_data_ativacao: preenchedata_ativacaocomnow()sempre que o objetivo passa aativo(no insert ou quando o status anterior não eraativo), 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:
- Checa
can_manage_objectives. Sem permissão, retorna{"error": "forbidden"}antes de qualquer escrita ou lock. - 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 semlabel;degraus_invalidos: condição com 0 ou mais de 5 degraus;degraus_fora_de_ordem: degraus não estritamente crescentes;percentual_invalido:target_type = percentfora de 0.01 a 100.
- Toma um
pg_advisory_xact_lockpor(account_id, kind)para serializar upserts concorrentes. - Insere (se
p_objective_idénull) ou atualiza (todos os campos, incluindokind) o objetivo. Atualizar um id de outra conta retorna{"error": "not_found"}. - Faz replace-all dos filhos: apaga as condições do objetivo (os degraus caem em cascata) e reinsere a partir do
jsonbde entrada, preservando a ordem do array viajsonb_array_elements(...) with ordinality. - 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 semprevalorAtual >= degrau.evaluateCondition(cond, valorAtual, referencia): calculatarget_efetivo(direto emabsolute;(target/100) × referenciaempercent) e ostatusda condição, nesta ordem:indisponivel(dado ausente) →atingida→em_andamento→nao_atingida.progressoé clampado em[0, 1]; para<=é 1 dentro do limite etarget/valoracima dele. O operador=compara com tolerância de ponto flutuante.aggregate(results, operador): combina as condições emobjetivo_atingido: boolean | nullcom lógica de três valores.ORcom alguma condição atingida étrue;ANDcom alguma condição não atingida éfalse.nullsó quando a condição indisponível é a que decide o resultado.resolveObjectiveStatus(status, data_inativacao, hoje): devolveexpiradoquandodata_inativacao < hoje; caso contrário, o status gravado.legacyGoalToObjective(goal): converte uma linha depublic.goalsem 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 comstatus_calculado(rascunho/ativo/suspenso/expirado) eorigem(motor/legado).listActiveObjectives(...): só os comstatus_calculado = ativoedata_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 comaggregate. 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.:financiadosvsà vista), sem precisar de uma dimensão nova.container_id: um único identificador de recorte (ex.: ogrupo_skda 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 dotarget_typeda condição, e não há vínculo entre otargetda condição e os degraus. - Retorno da criação: a RPC devolve
objectiveIdeconditionsCount, 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.