SIMPE × SAGE/MGDI

Documentação da integração — piloto Saúde Bucal + expansão SGTES (Mais Médicos)

Visão Geral

Contexto, atores, arquitetura e estado atual da integração SIMPE × SAGE/MGDI.

O problema

O SIMPE precisa exibir indicadores oficiais (números de equipes de saúde, valores repassados, cobertura, etc.) dentro do plano estratégico. Esses dados já existem na SAGE. Em vez de duplicar a coleta, integramos via API do MGDI.

Escopo do piloto

16

Cards do mockup

7

Indicadores cobertos

~44%

Cobertura efetiva

35/35

Endpoints OK em 27/04

2

PMM · prontos pra renderizar

Saúde Bucal, 16 cards de indicadores nos mockups. Cobertura técnica atual: 7 indicadores com endpoint funcional. O restante depende de mapeamento ou novas fontes a confirmar com o DEMAS. A expansão para outras secretarias já começou: em 26/05/2026 chegaram 5 códigos novos de Saúde Bucal (CEO Tipo I/II/III, eSB Modalidade I/II) — registrados, mas ainda sem dado (carga/resultset retornam 500 ou vazio). E o SGTES (Mais Médicos) entrou em dois lotes: 09/06/2026 com 2 códigos MN prontos pra renderizar, e 22/06/2026 com 13 códigos que cobrem vagas ativas, vagas em processo de ocupação (BR/UF/DSEI) e as residências PRORES/PRAPS (anuais) — 12 com dado real até 2026; só MGDI_MS_XAW segue quebrado (HTTP 500). Detalhes em 🗺️ Cobertura e 🩺 SGTES.

Arquitetura

SIMPE Front Next.js Server actions persiste no Postgres (service_role) APISIX gateway apisix.demas... MGDI API Fastify ⚠ Rede interna do MS — APISIX e MGDI só resolvem dentro da VPN APISIX devolve CORS aberto → o fetch roda no navegador do admin (na VPN)
✅ Sync resolvido sem túnel cloud→VPN

A API MGDI só resolve dentro da VPN interna do MS, e Vercel/Supabase não estão na VPN. Um teste real mostrou que o APISIX devolve CORS aberto (ecoa o Origin + Vary: Origin). Logo, o fetch roda no navegador do administrador (que está na VPN) e os dados parseados são persistidos no Postgres via server actions com service_role. Não há túnel cloud→VPN, nem edge function, nem cron. Depois de persistido, todas as leituras do SIMPE são rápidas e independem da VPN. Fluxo completo em 💻 Implementação.

Stack do SIMPE

  • Frontend: Next.js 16 (App Router) + React 19 + TypeScript
  • Backend: server actions + camada rpc/ (lógica de banco)
  • Banco: Supabase (Postgres) — dados SAGE persistidos em 4 tabelas + 1 view (ver 🗄️ Schema)
  • Sync: client-side no navegador do admin (na VPN) → server actions → upsert via service_role
Status atual — junho/2026

A camada de backend/ingestão já está implementada: migration 20260608150050_create_indicadores_sage_schema.sql (4 tabelas + view), client MGDI (lib/sage/mgdi-client.ts), server actions/RPCs de sync e vínculo, e a aba Painel Admin → Indicadores SAGE. Os testes contra a rede interna do MS (27/04/2026) validaram 35/35 endpoints OK e identificaram 3 ressalvas tratadas no front: ano corrente parcial nos financeiros, agrupamento no resultset MN (resolvido por view), e inconsistência titulo × tituloCompleto. Detalhes em ⚠️ Ressalvas. O que falta é a UI dos cards — estratégia em 🎨 Estratégia de Frontend.

Cobertura

Cruzamento entre os 16 cards dos mockups e os endpoints disponíveis na API MGDI.

Cola rápida — o que cada endpoint retorna

São 5 padrões de URL. Cada um responde uma pergunta diferente sobre o indicador (independente do código):

EndpointPergunta que respondeO que retornaTamanho típicoUsa pra
GET /indicador/{codigo}
metadado
"Quem é esse indicador?" Objeto com titulo, tituloCompleto, descricao, fonte_dados, cache_ttl, unidade de medida, polaridade (maior=melhor ou menor=melhor), granularidade (BR/UF/MN), tags, responsáveis (técnico/gerencial) e visualização sugerida. ~25 KB Título do card e ficha (i)
GET /indicador/{codigo}/carga
sincronismo
"Pra que períodos existe dado?" Array enxuto de { co_anomes: YYYYMM } listando todas as competências carregadas. Funciona como índice de sincronismo — antes de pedir o resultset você sabe se há dado novo. É aqui que buracos aparecem (ex.: CEOVLR pula 202410). ~850 B Decidir invalidação de cache
GET /indicador/{codigo}/resultset?tipo=BR&anodata=-5
evolução Brasil
"Como o número evoluiu nos últimos 5 anos no país inteiro?" Array de { anomes, <CODIGO>: valor }. O nome do campo do valor é o próprio código do indicador (ex.: CEOIMP, CEOVLR). Não-financeiros vêm mensais; financeiros vêm com poucos pontos anuais + ano corrente parcial. ~1.2 KB (não-fin.) · ~150–200 B (fin.) Gráfico de evolução do card
GET /indicador/{codigo}/resultset?tipo=MN&anodata=-1
mapa por município
"Como o número se distribui nos ~5500 municípios no último ano?" Array com { anomes, uf, regiao, local, codigogeo, <CODIGO>: valor }. codigogeo é o IBGE de 6 dígitos sem dígito verificador (casa direto com GeoJSON). Pegadinha: o mesmo município pode aparecer em duas competências — agrupar por codigogeo mantendo o anomes máximo antes de renderizar. 500 KB – 1.1 MB Mapa coroplético do card
GET /ficha-qualificacao/{codigo}
ficha descritiva
"Onde está o documento explicativo?" Documento (HTML/JSON) com objetivo, conceituação, interpretação, fonte, limitações e notas técnicas — derivado dos mesmos campos do metadado, formatado como texto pronto pra exibir. ~3 KB Ícone (i) do card
GET /indicador?limit=N&ativos=true
bônus — listagem
"Que indicadores existem no catálogo?" Envelope { count, rows } com versão reduzida do metadado de cada um (sem blob HTML). Catálogo total: 1002 indicadores. variável Descoberta/busca — não pra renderizar card

Resumo mental: metadado = "quem é", carga = "quando tem", resultset BR = "linha do tempo nacional", resultset MN = "mapa", ficha = "documento". Os 4 primeiros bastam pra montar o card; a ficha é o (i).

4 elementos de cada card

RequisitoEndpointExemplo de retornoValidado
(1) Título descritivoGET /indicador/{codigo}tituloCompletometadado-ceoimp.json · metadado-ceovlr.json
(2) Evolução BR (5 anos)GET /indicador/{codigo}/resultset?tipo=BR&anodata=-5resultset-br-ceoimp.json · resultset-br-ceovlr.json
(3) Mapa por municípioGET /indicador/{codigo}/resultset?tipo=MN&anodata=-1resultset-mn-trecho.json
(4) Ficha (i)GET /ficha-qualificacao/{codigo}ficha-qualificacao-ceoimp.html
Auxiliar (sync)GET /indicador/{codigo}/cargacarga-anual.json · carga-financeiro.json

Fonte: _source/01-cobertura-api-simpe-saude-bucal.md §1

Cards × código MGDI × status

MockupCardCódigo MGDIEndpoints & samplesStatusObservação
4.1CEO cofinanciadosCEOIMP
5 endpoints
✅ cobertoSérie anual limpa, mapa ~1.1 MB
4.1Valor repassado CEOCEOVLR
5 endpoints
⚠️ ano parcialInclui 202602 misturado com anos fechados
4.2Novas UOM entregues❌ sem códigoPode ser derivado de SBUOMP — DEMAS
4.2UOM cofinanciadasSBUOMP
5 endpoints
✅ cobertoSérie anual limpa
4.2Valor repassado UOMSBRCUOM
5 endpoints
⚠️ ano parcialInclui 202602 parcial
4.3Equipes Saúde Bucal 40hSBESB40H
5 endpoints
✅ cobertoSérie anual limpa
4.3Carga Horária DiferenciadaSBESB - SBESB40H
derivado: 2× 5 endpoints
  • SBESB: GET /indicador/SBESB, /carga, /resultset?tipo=BR&anodata=-5, /resultset?tipo=MN&anodata=-1, GET /ficha-qualificacao/SBESB
  • SBESB40H: idem com código SBESB40H
  • Formato: ver samples metadado · resultset-br · resultset-mn · carga · ficha
⚠️ derivado20h + 30h. Validar definição com Mariana
4.4eSB cofinanciadas?⚠️ ambíguoPode ser SBESB com outra leitura
4.4Valor repassado equipesSBVRFAF
5 endpoints
⚠️ ano parcialInclui 202602 parcial
4.5Sesb cofinanciadas❌ sem código
4.5Equipamentos odontológicos❌ sem código
4.5Impressoras 3D e scanners❌ sem código
4.5Profissionais qualificados❌ sem código
4.5Municípios qualificados❌ sem código
4.5Mun. c/ LRPD cofinanciados❌ sem código
4.6Curso de gerentes CEO/SESB❌ sem códigoFora do piloto até definição
4.6Oferta técnica em saúde bucal❌ sem código
4.6Oferta pós-técnica saúde bucal❌ sem código

Fonte: _source/01-cobertura-api-simpe-saude-bucal.md §2

Resumo quantitativo

16

Totais

6

Cobertos diretos

1

Derivado

1

Ambíguo

8

Sem código

~44%

Cobertura efetiva

A API entrega 100% dos elementos visuais exigidos (título, evolução, mapa, ficha) para os indicadores que ela cobre. O gap é de cobertura de indicadores, não de funcionalidade da API.

Novos códigos SAPS (lote 26/05/2026) — dados pendentes

⚠️ Registrados no catálogo, mas sem dado carregado

Chegaram 5 códigos novos de Saúde Bucal. Probe real em 26/05/2026 (_source/reports/novos-codigos-report.csv): metadado e ficha respondem 200 em todos, mas /carga e /resultset ou retornam HTTP 500 ou array vazio. Ou seja, ainda não dá pra renderizar evolução nem mapa — confirmam o flag "ENCONTRAR O DADO" do CSV. Ficam fora do piloto até o DEMAS carregar os dados.

Correção de código no CSV de origem

Dois códigos vinham errados no novos-cods.csv e davam 404: SBCEOTIPOII → o real é SBCEOTIPII; SBCEOTIPOIII → o real é SBCEOTPIII. Além disso o CSV rotula as duas linhas como "Tipo III" (a primeira é Tipo II).

IndicadorCódigo MGDIEndpoints & samplesStatusObservação
CEO Tipo ISBCEOTIPO1
5 endpoints
❌ erro 500carga/resultset → HTTP 500
CEO Tipo IISBCEOTIPII
5 endpoints
  • GET /indicador/SBCEOTIPII → metadado 200 (formato: metadado-sbceotipo1.json)
  • GET /indicador/SBCEOTIPII/cargaHTTP 500
  • GET /indicador/SBCEOTIPII/resultset?tipo=BR&anodata=-5HTTP 500
  • GET /indicador/SBCEOTIPII/resultset?tipo=MN&anodata=-1HTTP 500
  • GET /ficha-qualificacao/SBCEOTIPII → ficha 200
❌ erro 500CSV trazia SBCEOTIPOII (404). carga/resultset → 500
CEO Tipo IIISBCEOTPIII
5 endpoints
  • GET /indicador/SBCEOTPIII → metadado 200
  • GET /indicador/SBCEOTPIII/carga[] (vazio)
  • GET /indicador/SBCEOTPIII/resultset?tipo=BR&anodata=-5resultset-vazio.json ([])
  • GET /indicador/SBCEOTPIII/resultset?tipo=MN&anodata=-1[] vazio
  • GET /ficha-qualificacao/SBCEOTPIII → ficha 200
⚠️ sem dadoCSV trazia SBCEOTIPOIII (404). carga/resultset → []
eSB Modalidade ISBESBMODI
5 endpoints
  • GET /indicador/SBESBMODI → metadado 200
  • GET /indicador/SBESBMODI/carga[] (vazio)
  • GET /indicador/SBESBMODI/resultset?tipo=BR&anodata=-5resultset-vazio.json ([])
  • GET /indicador/SBESBMODI/resultset?tipo=MN&anodata=-1[] vazio
  • GET /ficha-qualificacao/SBESBMODI → ficha 200
⚠️ sem dadocarga/resultset → []. Pode ser a leitura de "eSB cofinanciadas" (4.4)
eSB Modalidade IISBESBMODII
5 endpoints
  • GET /indicador/SBESBMODII → metadado 200
  • GET /indicador/SBESBMODII/cargacarga-erro-500.json (500)
  • GET /indicador/SBESBMODII/resultset?tipo=BR&anodata=-5resultset-erro-500.json (500)
  • GET /indicador/SBESBMODII/resultset?tipo=MN&anodata=-1HTTP 500
  • GET /ficha-qualificacao/SBESBMODII → ficha 200
❌ erro 500carga/resultset → HTTP 500

Fonte: _source/05-novos-codigos-2026-05.md · _source/reports/novos-codigos-report.csv

SGTES — Mais Médicos

Primeira secretaria além da Saúde Bucal. Dois lotes: 09/06/2026 (PMM, lote PPF*) e 22/06/2026 (vagas por granularidade + residências). Todos testados na VPN.

✅ PMM coberto — 2 códigos funcionais

A área enviou PPFVAPMM e PPFTVA para o Programa Mais Médicos. Probe real na VPN em 09/06/2026: os dois respondem 200 nos 5 endpoints, com dado real. Seguem exatamente o mesmo contrato do piloto Saúde Bucal — granularidade MN (município), carga mensal co_anomes, codigogeo IBGE de 6 dígitos, campo de valor = o próprio código. Logo, renderizam card completo (título + evolução BR + mapa MN + ficha) com o pipeline que já existe, sem tratamento especial. São os primeiros indicadores SGTES prontos pra renderizar.

Indicadores

Indicador (título API)CódigoGranul.CargaStatusObservação
Total de vagas ativas do Programa Mais MédicosPPFVAPMMMN201612 → 202509 (40 comp.)✅ cobertoSó PMM. BR 35 pontos (~15,8 mil vagas); mapa MN ~5,1 MB
Total de vagas ativas (PMM + PMpB)PPFTVAMN201612 → 202605 (48 comp.)✅ cobertoAgregado PMM + Programa Médicos pelo Brasil. BR 40 pontos (~27,6 mil vagas); mapa MN ~2,8 MB

Diferença entre os dois: PPFVAPMM conta apenas as vagas do Programa Mais Médicos; PPFTVA é o agregado que soma PMM + PMpB (Programa Médicos pelo Brasil), os dois programas de provimento federal em vigência.

Endpoints & samples

PPFVAPMM — Total de vagas ativas do PMM
PPFTVA — Total de vagas ativas (PMM + PMpB)
  • GET /indicador/PPFTVAmetadado-ppftva.json (200, granularidade MN)
  • GET /indicador/PPFTVA/carga(200 · co_anomes mensal · 201612 → 202605)
  • GET /indicador/PPFTVA/resultset?tipo=BR&anodata=-5resultset-br-ppftva.json (200 · {anomes, PPFTVA})
  • GET /indicador/PPFTVA/resultset?tipo=MN&anodata=-1(200 · ~2,8 MB · codigogeo IBGE)
  • GET /ficha-qualificacao/PPFTVA(200)
⚠️ Ressalvas (as mesmas do piloto Saúde Bucal)

(1) Ano corrente parcial e fora de ordem: o resultset BR mistura competências de anos diferentes sem ordenação (PPFTVA começa em 202601–202603, parciais) — ordenar por anomes antes de plotar. (2) Município repetido em várias competências no MN (ex.: Acrelândia em 202501–202509) — agrupar por codigogeo mantendo o anomes máximo, igual ao piloto. (3) PPFVAPMM veio sem responsáveis técnico/gerencial e sem polaridade; PPFTVA traz SAPS. Periodicidade é null em ambos.

Lote 22/06/2026 — vagas por granularidade + residências

Segundo lote do SGTES, vinculado a AE 11.5 RE 11.5.1 (Objetivo Estratégico 11) e ao eixo de residências (AE 1.12). 13 códigos testados na VPN em 22/06/2026.

🔑 Cada código MGDI_MS_* é uma fatia de granularidade, não um indicador municipal

Diferente dos PPF* (que são MN/município), os códigos MGDI_MS_* deste lote declaram uma granularidade fixa cadaBR (Brasil), UF (estadual) ou DS (DSEI indígena). Logo, o /resultset?tipo=MN deles retorna HTTP 500 — e isso é esperado, não é bug: eles simplesmente não têm recorte municipal. Cada um precisa ser consultado na sua granularidade (tipo=BR, tipo=UF ou tipo=DS). Testados assim, os indicadores de vagas têm dado real e atual (até 202606).

🗓️ Contrato dos parâmetros de data — anodata= (ano) vs data= (competência)

O /resultset aceita dois filtros com semânticas diferentes — confirmado em teste:

  • anodata= filtra por ano: anodata=2026 = todas as competências de 2026; anodata=-2 = últimos 2 anos.
  • data= filtra por competência (mês): data=202606 = só junho/2026 (1 linha); data=-3 = últimas 3 competências.

Regra prática: indicador mensal (carga traz co_anomes) → anodata=-N (janela de anos) ou data=<YYYYMM>/data=-N para precisão mensal. Indicador anual (carga traz co_ano) → iterar anodata=<ano> por ano listado na carga. Atenção: nos anuais a janela negativa (anodata=-5) não funciona (volta []) — só o ano explícito.

✅ 12 dos 13 códigos funcionais — vagas (ativas + em ocupação) e residências

Os dois indicadores de vagas ficam totalmente cobertos: somando o agregado municipal (PPF*) com as fatias BR/UF/DSEI (MGDI_MS_*), dá pra renderizar evolução nacional, ranking estadual e recorte indígena até junho/2026. As 4 residências também têm dado — pareciam vazias só por causa do parâmetro (eram anuais chamados com janela mensal). Único ainda quebrado: MGDI_MS_XAW (HTTP 500).

Vagas ativas, por tipo de equipe

Título (API)CódigoGranul.Resultset testadoStatus
Total de vagas ativas (PMM + PMpB)PPFTVAMNBR 40 pts → 202603 · MN ~2,8 MB✅ coberto
Total de vagas ativas BrasilMGDI_MS_32FBRBR 43 pts · 202212 → 202606✅ coberto
Total de vagas ativas EstadualMGDI_MS_SA4UFUF 270 linhas → 202606✅ coberto
Total de vagas ativas DSEIMGDI_MS_GIZDSDS 340 linhas → 202606✅ coberto

Vagas em processo de ocupação

Título (API)CódigoGranul.Resultset testadoStatus
Vagas em processo de ocupaçãoPPFVOCUPMNBR 31 pts → 202603 · MN ~3,4 MB✅ coberto
Vagas em processo de ocupação BrasilMGDI_MS_XVHBRBR 34 pts · 202309 → 202606✅ coberto
Vagas em processo de ocupação EstadualMGDI_MS_39QUFUF 270 linhas → 202606✅ coberto
Vagas em processo de ocupação DSEIMGDI_MS_WFSDSDS 340 linhas → 202606✅ coberto

Residências (PRORES / PRAPS) — eixo AE 1.12

✅ Corrigido — tinham dado o tempo todo, era o parâmetro

Estes 4 códigos são anuais (granularidade UF, carga com co_ano de 2010 a 2026). No primeiro probe voltaram [] porque foram chamados com anodata=-5 (janela mensal), que não funciona em série anual. Passando o ano explícito (?tipo=UF&anodata=2025), retornam as 27 UFs com dado. Estrutura da linha: {ano, uf, regiao, codigogeo, <código>}.

Indicador (CSV)CódigoGranul.Chamada corretaStatus
Nº de profissionais da saúde que ingressaram no PRORES (bolsas MS)MGDI_MS_3KPUF (anual)?tipo=UF&anodata=<ano> → 27 UFs✅ coberto
Total de residentes ativos no PRAPS (bolsas MS), por anoMGDI_MS_4RUUF (anual)?tipo=UF&anodata=<ano> → 27 UFs✅ coberto
Nº de profissionais da saúde no PRAPS (bolsas MS)MGDI_MS_543UF (anual)?tipo=UF&anodata=<ano> → 27 UFs✅ coberto
Nº de residentes médicos que ingressaram (bolsas MS), por anoMGDI_MS_XLJUF (anual)?tipo=UF&anodata=<ano> → 27 UFs✅ coberto
❌ 1 código realmente quebrado — MGDI_MS_XAW

Único do lote sem dado utilizável. Responde 200 em /indicador e /ficha-qualificacao, mas falha já no /carga com HTTP 500 (syntax error at or near "null", Postgres 42601) — e o erro persiste com qualquer parâmetro de data (anodata=2025, data=202505…). É o mesmo bug de backend que derruba 3 códigos de Saúde Bucal do lote 26/05, então não é caso de parâmetro. Bloqueia o indicador "Vagas ativas, por status" (cujo único código é o XAW). Pendência com o DEMAS.

Indicador (CSV)CódigoGranul.ProblemaStatus
Vagas ativas, por status (ocupadas/em ocupação/desocupadas/inativas) — PMMMGDI_MS_XAWBR/carga e /resultset → HTTP 500 em qualquer parâmetro❌ erro 500

Fonte: lote 22/06/2026 · _source/reports/codigos-22-06-report.csv · _source/reports/problema-indicadores-22-06.csv

Resumo SGTES

14

Códigos (2 lotes)

13

Com dado real

1

Quebrado (XAW · 500)

7

Indicadores cobertos

Cobertos: PMM (vagas ativas) via PPFVAPMM; vagas ativas por tipo de equipe e vagas em processo de ocupação, cada um com agregado MN + fatias BR/UF/DSEI; e as 4 residências PRORES/PRAPS (anuais, via anodata=<ano>). PPFTVA aparece nos dois lotes. Único bloqueado: vagas por statusMGDI_MS_XAW dá HTTP 500 (bug de backend).

Fonte: PPF* (09/06/2026) · lote 22/06/2026 · _source/reports/novos-codigos-report.csv · codigos-22-06-report.csv · problema-indicadores-22-06.csv

Endpoints

Catálogo dos 5 tipos de endpoint usados no piloto. Base: http://apisix.demas.saude.gov/api-dev (rede interna do MS).

Tipos de chamada

Os 7 indicadores cobertos (CEOIMP, CEOVLR, SBESB, SBESB40H, SBRCUOM, SBUOMP, SBVRFAF) usam os mesmos 5 padrões de URL. Tamanhos e tempos de resposta vêm dos testes reais executados em 27/04/2026 (_source/reports/saps-bucal-report.csv).

1. GET /indicador/{codigo}

Metadados completos do indicador: títulos, descrição, fórmula, fonte, periodicidade, unidade de medida, polaridade, responsáveis e indicadores relacionados.

Parâmetros: nenhum Tamanho típico: ~25 KB (6–31 KB) Tempo médio: ~0.3 s TTL recomendado: 24 h

// Trecho representativo de _source/samples/metadado-ceoimp.json

{
  "id": 1185,
  "codigo": "CEOIMP",
  "titulo": "Número de Centros de Especialidades Odontológicas implantado e custeado",
  "tituloCompleto": "Número de Centros de Especialidades Odontológicas pago no período",
  "descricao": "Quantidade de CEO - Centro de Especialidades Odontológicas, tipos I, II e III...",
  "fonte_dados": "<p>SISAB</p>",
  "ativo": true,
  "acumulativo": false,
  "cache_ttl": 43200,
  "co_unidade_medida": 2,
  "Tags": [
    { "codigo": 120, "descricao": "Política Nacional de Saúde Bucal (PNSB)",
      "TagCategoria": { "codigo": 1, "descricao": "Ações e Programas" } }
  ],
  "UnidadeMedida":   { "codigo": 2, "descricao": "Número", "fl_fator": 1 },
  "Polaridade":      { "codigo": 1, "descricao": "Maior - melhor" },
  "Granularidade":   { "codigo": 4, "sigla": "MN", "descricao": "Município" },
  "ParametroFonte":  null,
  "ResponsavelTecnico":   [{ "codigo": 17494, "sigla": "CGSB", "nome": "COORDENAÇÃO-GERAL DE SAÚDE BUCAL" }],
  "ResponsavelGerencial": [{ "codigo": 19174, "sigla": "SAPS", "nome": "SECRETARIA DE ATENÇÃO PRIMÁRIA À SAÚDE" }],
  "VisualizacaoIndicador": [{ "codigo": 5, "nome": "Gráfico de linha" }]
}
⚠️ Ressalva — campos titulo × tituloCompleto

Não seguem padrão consistente entre indicadores. Recomenda-se dicionário externo. Detalhes em Ressalva #3.

2. GET /indicador/{codigo}/carga

Lista de competências disponíveis (sincronismo). Retorna array de { co_anomes } no formato YYYYMM.

Parâmetros: nenhum Tamanho típico: ~850 B Tempo médio: ~0.2 s TTL recomendado: 6 h

// Trecho de _source/samples/carga-financeiro.json (CEOVLR)

[
  { "co_anomes": 201612 },
  { "co_anomes": 201712 },
  { "co_anomes": 201812 },
  { "co_anomes": 202112 },
  { "co_anomes": 202212 },
  { "co_anomes": 202301 },
  { "co_anomes": 202302 },
  ... // mensal a partir de 202301
  { "co_anomes": 202408 },
  { "co_anomes": 202409 },
  // ⚠️ falta 202410 — pendência aberta com DEMAS
  { "co_anomes": 202411 },
  { "co_anomes": 202412 },
  ...
  { "co_anomes": 202601 },
  { "co_anomes": 202602 }
]
⚠️ Anomalia confirmada

O CEOVLR pula a competência 202410 (vai de 202409 direto para 202411). Pode ser indisponibilidade real ou falha de carga. Pendência aberta — ver ✅ Pendências.

3. GET /indicador/{codigo}/resultset?tipo=BR&anodata=-5

Evolução agregada Brasil. Retorna array com anomes + campo de valor cujo nome é o próprio código do indicador (ex: CEOIMP).

Parâmetros: tipo=BR, anodata=-5 Tamanho típico: ~1.2 KB (não-financeiros) · ~150–200 B (financeiros) Tempo médio: ~0.7 s TTL recomendado: 6 h

// Trecho de _source/samples/resultset-br-ceoimp.json — não-financeiro, mensal

[
  { "anomes": 202601, "CEOIMP": 1154 },
  { "anomes": 202602, "CEOIMP": 1175 },
  { "anomes": 202501, "CEOIMP": 1209 },
  { "anomes": 202502, "CEOIMP": 1209 },
  { "anomes": 202503, "CEOIMP": 1210 },
  { "anomes": 202504, "CEOIMP": 1210 },
  { "anomes": 202505, "CEOIMP": 1211 },
  // ... 39 pontos mensais cobrindo 5 anos calendário
  { "anomes": 202412, "CEOIMP": 1209 },
  { "anomes": 202312, "CEOIMP": 1192 },
  { "anomes": 202212, "CEOIMP": 1158 }
]
⚠️ Ressalva — ano corrente parcial nos financeiros

Nos indicadores CEOVLR, SBRCUOM e SBVRFAF a série mistura anos fechados (dezembro) com o ano corrente parcial. Detalhes em Ressalva #1.

// _source/samples/resultset-br-ceovlr.json — financeiro com ano parcial visível

[
  { "anomes": 202602, "CEOVLR":  89421018.12 },  // PARCIAL: jan+fev/2026
  { "anomes": 202512, "CEOVLR": 506377601.91 },  // 2025 fechado
  { "anomes": 202412, "CEOVLR": 502460676.17 },  // 2024 fechado
  { "anomes": 202312, "CEOVLR": 282328339.49 },  // 2023 fechado
  { "anomes": 202212, "CEOVLR": 226805471.18 }   // 2022 fechado
]

4. GET /indicador/{codigo}/resultset?tipo=MN&anodata=-1

Distribuição por município (mapa). codigogeo é o código IBGE de 6 dígitos sem dígito verificador — casa com qualquer GeoJSON do Brasil. Campos uf, regiao e local já vêm preenchidos.

Parâmetros: tipo=MN, anodata=-1 Tamanho típico: ~1.1 MB (não-financeiros) · ~560 KB (financeiros) Tempo médio: 3–5 s (não-financeiros) · 1.5–2 s (financeiros) TTL recomendado: 24 h

// Trecho de _source/samples/resultset-mn-trecho.json (CEOIMP)

[
  { "anomes": 202601, "uf": "AC", "regiao": "Norte", "local": "Acrelândia",  "codigogeo": 120001, "CEOIMP": 0 },
  { "anomes": 202602, "uf": "AC", "regiao": "Norte", "local": "Acrelândia",  "codigogeo": 120001, "CEOIMP": 0 },
  { "anomes": 202601, "uf": "AC", "regiao": "Norte", "local": "Assis Brasil","codigogeo": 120005, "CEOIMP": 0 },
  { "anomes": 202602, "uf": "AC", "regiao": "Norte", "local": "Assis Brasil","codigogeo": 120005, "CEOIMP": 0 },
  // ... ~5500 municípios, cada um com múltiplas competências do ano corrente
]
⚠️ Ressalva — múltiplas competências por município

Acrelândia (codigogeo 120001) aparece duas vezes: anomes 202601 e 202602. É obrigatório agrupar por codigogeo mantendo o item com anomes máximo antes de renderizar — senão o mapa renderiza com valor errado de janeiro. Detalhes em Ressalva #2.

5. GET /ficha-qualificacao/{codigo}

Ficha de qualificação do indicador (formato HTML/JSON). Usada no ícone (i) ao lado do título do card.

Parâmetros: nenhum Tamanho típico: ~3.3 KB (2.3–4.3 KB) Tempo médio: ~0.3 s TTL recomendado: 7 dias

Conteúdo: texto descritivo (objetivo, conceituação, interpretação, fonte, limitações, notas) — derivado dos mesmos campos do metadado mas formatado como documento. Ver _source/samples/ficha-qualificacao-ceoimp.html.

Bônus — listagem paginada

GET /indicador?limit=N&ativos=true

Listagem paginada de indicadores. Útil para descoberta. Envelope: { count, rows }.

Total no catálogo: 1002 indicadores Resposta: array reduzido por indicador (sem o blob HTML dos campos descritivos)

// Trecho de _source/samples/listagem-paginada.json (limit=3)

{
  "count": 1002,
  "rows": [
    {
      "id": 2290,
      "codigo": "AIDSNCN",
      "titulo": "Número de casos de aids identificados no período",
      "tituloCompleto": "Número de casos de aids identificados no período",
      "ativo": true,
      "UnidadeMedida":   { "codigo": 2, "descricao": "Número" },
      "Granularidade":   { "codigo": 4, "descricao": "Município" },
      "ResponsavelTecnico": [{ "codigo": 7060, "sigla": "SVSA", "nome": "SECRETARIA DE VIGILÂNCIA EM SAÚDE E AMBIENTE" }]
    }
  ]
}

Tabela de testes (27/04/2026)

IndicadorMetadadoCargaResultset BRResultset MNFicha
CEOIMP 31 KB / 0.40 s904 B / 0.18 s1.2 KB / 0.97 s1.1 MB / 5.07 s2.9 KB / 0.24 s
CEOVLR 31 KB / 0.30 s904 B / 0.26 s200 B / 0.77 s562 KB / 1.91 s3.9 KB / 0.18 s
SBESB 31 KB / 0.30 s862 B / 0.26 s1.2 KB / 0.19 s1.1 MB / 3.96 s3.9 KB / 0.21 s
SBESB40H21 KB / 0.30 s946 B / 0.18 s1.4 KB / 0.81 s1.1 MB / 3.33 s3.9 KB / 0.49 s
SBRCUOM 18 KB / 0.30 s799 B / 0.19 s147 B / 0.73 s563 KB / 1.56 s4.3 KB / 0.19 s
SBUOMP 6 KB / 0.24 s 799 B / 0.18 s1.2 KB / 0.81 s1.1 MB / 3.20 s2.4 KB / 0.19 s
SBVRFAF 30 KB / 0.33 s946 B / 0.18 s197 B / 0.82 s583 KB / 1.57 s3.2 KB / 0.51 s

Fonte: _source/reports/saps-bucal-report.csv

Schema do Banco

Schema implementado na migration 20260608150050_create_indicadores_sage_schema.sql — catálogo, dados observados (série nacional + mapa municipal) e o vínculo com os itens estratégicos. Incorpora os indicadores SAGE/MGDI ao SIMPE em 4 tabelas + 1 view.

✅ Migrado e replicado localmente

As 4 tabelas + a view existem no Postgres e RLS restrita a Admin/SuperAdmin. Os exemplos de linha abaixo refletem o schema real.

Decisões fechadas
  • Persistir tudo no Postgres — a API MGDI só responde na VPN interna do MS; leitura ao vivo quebraria fora da VPN.
  • Vínculo N:N indicador↔item (tabela de junção), degrada para 1:1 naturalmente.
  • Mapa municipal com grão histórico completo (codigo, anomes, codigogeo); "mapa atual" = view.
  • Schema genérico + dimensão de origem (secretaria/tema/fonte); popular só os 7 do piloto Saúde Bucal.

Visão geral — 4 tabelas + 1 view

                     ┌───────────────────────────┐
                     │     indicadores_sage      │   catálogo (1 linha/indicador)
                     │  PK codigo  ('CEOIMP'...)  │
                     └────────────┬──────────────┘
            ┌─────────────────────┼───────────────────────┐
            │                     │                       │
 ┌──────────────────────┐ ┌──────────────────────────┐ ┌──────────────────────┐
 │ indicador_sage_      │ │ indicador_sage_valor_    │ │  item_indicador_sage │
 │   serie_br           │ │   municipio              │ │  (junção N:N)        │
 │ PK (codigo, anomes)  │ │ PK (codigo,anomes,       │ │ PK id                │
 │ → série nacional     │ │      codigogeo)          │ │ UQ (item_id, codigo) │
 │   (≈5 linhas)        │ │ → mapa, histórico        │ │        │             │
 └──────────────────────┘ │   (≈5500×comp×ind)       │ │        ▼             │
                          └────────────┬─────────────┘ │  itens_estrategicos  │
                                       │               │  (tabela existente)  │
                          ┌────────────▼─────────────┐ └──────────────────────┘
                          │ vw_indicador_sage_       │
                          │   mapa_atual  (view)     │  max(anomes) por município
                          └──────────────────────────┘

Mapa: endpoint MGDI → onde mora no schema

EndpointDestino no schema
GET /indicador/{codigo} (metadado)indicadores_sage · titulo, titulo_completo, unidade, metadado_raw
GET /indicador/{codigo}/cargaindicadores_sage.competencias_disponiveis
GET /indicador/{codigo}/resultset?tipo=BRindicador_sage_serie_br
GET /indicador/{codigo}/resultset?tipo=MNindicador_sage_valor_municipio (+ view do mapa atual)
GET /ficha-qualificacao/{codigo}indicadores_sage.ficha

1. indicadores_sage — catálogo

Uma linha por indicador MGDI. Espelha o metadado + a ficha + a dimensão de origem. Os campos titulo/titulo_completo da API são inconsistentes entre indicadores — os títulos de card vêm de um dicionário no front, não daqui.

PK: codigo (natural) Linhas: 1 por indicador (7 no piloto) Escrita: sync (service_role)
CampoTipoSignificado
codigotext PKCódigo MGDI único (ex. CEOIMP). FK em todas as outras tabelas.
titulo / titulo_completotextTítulos crus da API. Não usar direto na UI.
unidadetext'Número' ou 'Moeda'. Dirige formatação (R$) e tratamento de ano parcial.
periodicidade_cargatext'mensal' (financeiros) ou 'anual'.
fonte / secretaria / tematextDimensão de origem: 'SAGE/MGDI' · 'SAPS' · 'Saúde Bucal'.
fichajsonbPayload de /ficha-qualificacao.
metadado_rawjsonbMetadado completo (fidelidade / campos futuros sem migration).
competencias_disponiveisjsonbCompetências de /carga; o sync usa para detectar nova carga.
ativobooleanSoft-disable (sem hard delete).
*_synced_attimestamptzFreshness por dataset (metadado / série BR / município / ficha).

// Exemplo de linha (CEOIMP)

{
  "codigo": "CEOIMP",
  "titulo": "Número de Centros de Especialidades Odontológicas implantado e custeado",
  "titulo_completo": "Número de Centros de Especialidades Odontológicas pago no período",
  "unidade": "Número",
  "periodicidade_carga": "anual",
  "fonte": "SAGE/MGDI",
  "secretaria": "SAPS",
  "tema": "Saúde Bucal",
  "ficha": { "objetivo": "...", "metodo_calculo": "...", "fonte_dados": "..." },
  "competencias_disponiveis": [202412, 202512, 202601, 202602],
  "ativo": true,
  "serie_br_synced_at": "2026-06-03T09:00:05Z",
  "municipio_synced_at": "2026-06-03T09:01:10Z"
}

2. indicador_sage_serie_br — série nacional

Evolução anual nacional (resultset tipo=BR). Tabela minúscula. Sem coluna de "parcial": derivável de anomes (mês ≠ 12) + periodicidade_carga do catálogo.

PK: (codigo, anomes) Linhas: ≈5 por indicador
CampoTipoSignificado
codigotext FKindicadores_sage(codigo)
anomesintegerCompetência AAAAMM (ex. 202512).
valornumericValor nacional (contagem ou R$).

// Exemplo (CEOVLR — financeiro; note o ponto parcial 202602)

[
  { "codigo": "CEOVLR", "anomes": 202212, "valor": 226805471 },
  { "codigo": "CEOVLR", "anomes": 202312, "valor": 282328339 },
  { "codigo": "CEOVLR", "anomes": 202412, "valor": 502460676 },
  { "codigo": "CEOVLR", "anomes": 202512, "valor": 506377601 },
  { "codigo": "CEOVLR", "anomes": 202602, "valor":  89421018 }
]

3. indicador_sage_valor_municipio — mapa municipal

Valores por município (resultset tipo=MN), histórico completo. A API devolve várias cargas por município (mesmo codigogeo em competências diferentes) — persistimos todas; o "mapa atual" sai da view.

PK: (codigo, anomes, codigogeo) Linhas: ≈5500 × competências × indicador Índice: (codigo, anomes)
CampoTipoSignificado
codigotext FKindicadores_sage(codigo)
anomesintegerCompetência AAAAMM.
codigogeointegerCódigo IBGE do município, 6 dígitos (sem DV). Casa com GeoJSONs do Brasil.
uf / regiao / localchar(2) / text / textVêm preenchidos pela API (sem join auxiliar).
valornumericValor observado no município.

// Exemplo (CEOIMP — repare o mesmo município em 202601 e 202602)

[
  { "codigo": "CEOIMP", "anomes": 202601, "codigogeo": 120001, "uf": "AC", "regiao": "Norte", "local": "Acrelândia",   "valor": 0 },
  { "codigo": "CEOIMP", "anomes": 202602, "codigogeo": 120001, "uf": "AC", "regiao": "Norte", "local": "Acrelândia",   "valor": 0 },
  { "codigo": "CEOIMP", "anomes": 202601, "codigogeo": 120005, "uf": "AC", "regiao": "Norte", "local": "Assis Brasil", "valor": 0 },
  { "codigo": "CEOIMP", "anomes": 202602, "codigogeo": 355030, "uf": "SP", "regiao": "Sudeste", "local": "São Paulo",  "valor": 24 }
]

4. item_indicador_sage — vínculo N:N

Liga um indicador a um item estratégico (tipicamente um Resultado Esperado ou Ação). Guarda a proveniência do vínculo (a "árvore de vinculação" do CSV).

PK: id (uuid) Único: (item_id, codigo) FK: itens_estrategicos(id)
CampoTipoSignificado
iduuid PKIdentidade da linha de vínculo.
item_iduuid FKitens_estrategicos(id) (qualquer tipo).
codigotext FKindicadores_sage(codigo).
arvore_vinculacaotextToken cru do CSV, ex. 'AE 6.2 RE 6.2.2'.
numero_compostotextNúmero composto resolvido, ex. '6.2.2'.
criado_por / data_criacaouuid / timestamptzAuditoria.

// Exemplo de linha

{
  "id": "8f3a1c2e-...-uuid",
  "item_id": "c12d77ab-...-uuid",
  "codigo": "CEOIMP",
  "arvore_vinculacao": "AE 6.2 RE 6.2.2",
  "numero_composto": "6.2.2",
  "criado_por": "a91b...-uuid",
  "data_criacao": "2026-06-03T09:05:00Z"
}

view vw_indicador_sage_mapa_atual

Último valor por município (max anomes por codigogeo), por indicador. Resolve a pegadinha do resultset MN trazer várias cargas — o front consome a view e renderiza o mapa direto, sem agrupar.

create view public.vw_indicador_sage_mapa_atual as
select distinct on (codigo, codigogeo)
  codigo, codigogeo, uf, regiao, local, anomes, valor
from public.indicador_sage_valor_municipio
order by codigo, codigogeo, anomes desc;

// Saída (1 linha por município — só a competência mais recente)

[
  { "codigo": "CEOIMP", "codigogeo": 120001, "uf": "AC", "local": "Acrelândia",   "anomes": 202602, "valor": 0 },
  { "codigo": "CEOIMP", "codigogeo": 355030, "uf": "SP", "local": "São Paulo",    "anomes": 202602, "valor": 24 }
]

DDL completo

As tabelas, view, índices, GRANTs e políticas RLS vivem na migration supabase/migrations/20260608150050_create_indicadores_sage_schema.sql (imutável, já aplicável). Núcleo dos CREATE TABLE (idêntico ao migrado):

create table public.indicadores_sage (
  codigo                   text primary key,
  titulo                   text not null,
  titulo_completo          text,
  unidade                  text,                              -- 'Número' | 'Moeda'
  periodicidade_carga      text not null default 'anual',     -- 'mensal' | 'anual'
  fonte                    text not null default 'SAGE/MGDI',
  secretaria               text,
  tema                     text,
  ficha                    jsonb not null default '{}',
  metadado_raw             jsonb not null default '{}',
  competencias_disponiveis jsonb not null default '[]',
  ativo                    boolean not null default true,
  metadado_synced_at       timestamptz,
  serie_br_synced_at       timestamptz,
  municipio_synced_at      timestamptz,
  ficha_synced_at          timestamptz,
  data_criacao             timestamptz not null default now(),
  data_atualizacao         timestamptz not null default now()
);

create table public.indicador_sage_serie_br (
  codigo text not null references public.indicadores_sage(codigo) on delete cascade,
  anomes integer not null,
  valor  numeric,
  primary key (codigo, anomes)
);

create table public.indicador_sage_valor_municipio (
  codigo    text not null references public.indicadores_sage(codigo) on delete cascade,
  anomes    integer not null,
  codigogeo integer not null,
  uf        char(2),
  regiao    text,
  local     text,
  valor     numeric,
  primary key (codigo, anomes, codigogeo)
);

create table public.item_indicador_sage (
  id                uuid primary key default gen_random_uuid(),
  item_id           uuid not null references public.itens_estrategicos(id) on delete cascade,
  codigo            text not null references public.indicadores_sage(codigo) on delete cascade,
  arvore_vinculacao text,
  numero_composto   text,
  criado_por        uuid references auth.users(id),
  data_criacao      timestamptz not null default now(),
  unique (item_id, codigo)
);
-- + view, índices, GRANTs e RLS na migration

Fonte: supabase/migrations/20260608150050_create_indicadores_sage_schema.sql · docs-indicadores/modelagem-banco-indicadores-sage.md

Design do Card

Para a equipe de design: o shape composto de um card de indicador, montado a partir do banco/cache (leitura VPN-independente), e de onde cada parte vem no 🗄️ Schema. A estratégia de componentes (EUI/Elastic Charts) está em 🎨 Estratégia de Frontend.

4 elementos → origem no schema

Cada card no painel precisa de 4 elementos: título descritivo, gráfico de barras (evolução anual BR), mapa por município (escala de cor gradiente) e ícone (i) (ficha).

Parte do cardOrigem no schema
Título / subtítulo do cardDicionário de front (/lib/sage/titulos.ts) — não vem do banco
Formatação (R$ vs nº)indicadores_sage.unidade
Gráfico de barras (evolução BR)indicador_sage_serie_br + flag parcial derivada
Mapa coropléticovw_indicador_sage_mapa_atual
Ícone (i) / fichaindicadores_sage.ficha

Shape do payload

{
  "codigo": "CEOIMP",
  "card": {
    "titulo": "CEO cofinanciados",
    "subtitulo": "Nº de Centros de Especialidades Odontológicas",
    "unidade": "Número",
    "formato": "inteiro"
  },
  "evolucaoBR": [
    { "anomes": 202212, "ano": 2022, "valor": 1098, "parcial": false },
    { "anomes": 202312, "ano": 2023, "valor": 1142, "parcial": false },
    { "anomes": 202412, "ano": 2024, "valor": 1175, "parcial": false },
    { "anomes": 202512, "ano": 2025, "valor": 1209, "parcial": false },
    { "anomes": 202602, "ano": 2026, "valor": 1175, "parcial": true }
  ],
  "mapa": {
    "competencia": 202602,
    "municipios": [
      { "codigogeo": 120001, "uf": "AC", "local": "Acrelândia", "valor": 0 },
      { "codigogeo": 355030, "uf": "SP", "local": "São Paulo",  "valor": 24 }
    ]
  },
  "fichaUrl": "/api/sage/indicadores/CEOIMP/ficha"
}
⚠️ Ano corrente parcial — tratar no gráfico

Indicadores financeiros (unidade: "Moeda"CEOVLR, SBRCUOM, SBVRFAF) têm carga mensal e trazem o ano corrente parcial (ex. 202602 = só jan+fev/2026). A barra marcada "parcial": true deve ser renderizada listrada/hachurada com rótulo "fev/2026" (réplica do mockup da SAGE) — plotar direto mostraria uma falsa queda de ~82%.

✅ Mapa já desduplicado

O front consome vw_indicador_sage_mapa_atual — já vem 1 valor por município (competência mais recente). Não precisa agrupar por codigogeo no cliente. Escala de cor gradiente sobre valor; join com GeoJSON por codigogeo (IBGE 6 díg).

Guia de Implementação

A camada de ingestão já está construída. Esta seção descreve como o sync e o vínculo funcionam de fato — não é mais uma receita a escrever. A UI dos cards (o que ainda falta) tem sua estratégia em 🎨 Estratégia de Frontend.

✅ O contorno da VPN — resolvido client-side

A API MGDI (apisix.demas.saude.gov/api-dev) só resolve dentro da VPN interna do MS, e Vercel/Supabase cloud não estão na VPN. Um teste real mostrou que o APISIX devolve CORS aberto (ecoa o Origin + Vary: Origin). Logo: o fetch roda no navegador do administrador (que está na VPN). Os dados parseados são enviados a server actions que persistem no Postgres via service_role. Não há túnel cloud→VPN, nem edge function, nem cron. Depois de persistido, todas as leituras do SIMPE são rápidas e independem da VPN.

Fluxo de ponta a ponta

Navegador do admin (na VPN)                Supabase (Postgres)
┌──────────────────────────────┐           ┌───────────────────────────┐
│ lib/sage/mgdi-client.ts       │           │ indicadores_sage          │
│  fetch 5 endpoints (CORS open)│  payload  │ indicador_sage_serie_br   │
│  parse + chunk (MN)           │ ───────►  │ indicador_sage_valor_     │
│         │                     │  server   │   municipio               │
│         ▼                     │  actions  │ item_indicador_sage       │
│ actions/indicadores-sage/*    │ ───────►  │   (via service_role,      │
│  (re-checa papel Admin)       │   RPC     │    upsert/replace)        │
└──────────────────────────────┘           └───────────────────────────┘
                                                       │
                              leitura (cache/admin, VPN-independente) ▼
                                            SIMPE front renderiza os cards

Onde fica

A página é a aba Painel Admin → Indicadores SAGE (app/(main)/painel-admin/indicadores-sage/page.tsx) — Admin/SuperAdmin apenas (checado na server action e em RLS, defesa em profundidade). Dois painéis:

  • Sincronizar (IndicadoresSageSyncPanel) — puxa os 5 datasets da MGDI.
  • Vincular (VincularIndicadorPanel) — liga indicadores a itens do plano.

1. Sincronização (browser → server action → RPC)

O lib/sage/mgdi-client.ts roda no navegador (na VPN), busca e parseia cada endpoint, e chama as server actions de actions/indicadores-sage/sync.ts. Cada dataset persiste com a estratégia adequada e carimba seu próprio *_synced_at — freshness honesta mesmo em falha parcial.

Endpoint MGDIPersiste emEstratégia / RPC
GET /indicador/{c} (metadado)indicadores_sageupsert em codigoupsert-metadado.ts
GET /indicador/{c}/cargaindicadores_sage.competencias_disponiveisupdate — upsert-carga.ts
GET /indicador/{c}/resultset?tipo=BRindicador_sage_serie_brdelete-then-insert por código — replace-serie-br.ts
GET /indicador/{c}/resultset?tipo=MNindicador_sage_valor_municipioupsert por chunk em (codigo, anomes, codigogeo)upsert-municipio-chunk.ts
GET /ficha-qualificacao/{c}indicadores_sage.fichaupdate — upsert-ficha.ts
⚠️ MN é pesado → upload em chunks

O resultset MN traz ~1.1 MB / ~11k linhas por código. O client fatia em chunks (~2000 linhas, body < 1 MB) e o municipio_synced_at só é carimbado no último chunk — se o upload cair no meio, a freshness não mente. A pegadinha das "múltiplas cargas por município" é resolvida no banco pela view vw_indicador_sage_mapa_atual, não no app.

2. Vínculo indicador ↔ item estratégico

O VincularIndicadorPanel associa um indicador a um item do plano (tipicamente Resultado ou Ação), criando uma linha em item_indicador_sage. Dois caminhos:

  • Auto-sugestão pela "árvore de vinculação" do CSV: token 'AE 6.2 RE 6.2.2' → número composto '6.2.2' → casa contra as colunas numero denormalizadas dos *_dashboard_cache (escopo por plano) → item_id (resolve-arvore-vinculacao.ts) → o admin confirma em 1 clique.
  • Picker manual: cascata Objetivo → Ação → Resultado (IndicadorHierarchyPicker) quando não há sugestão ou se quer ajustar.

A proveniência (arvore_vinculacao cru + numero_composto resolvido + criado_por) fica gravada na linha para auditoria. O parser do número composto é coberto por testes (__tests__/rpc/indicadores-sage/resolve-arvore-vinculacao.test.ts).

3. Seed — dado real, sem VPN no dev local

Para desenvolver fora da VPN, o dado real da MGDI está commitado em seeds divididos:

  • supabase/seeds/indicadores-sage.sql — catálogo (7 códigos do piloto) + série BR + vínculos.
  • supabase/seeds/indicadores-sage-municipio.sql — mapa municipal (~4 MB, via COPY).

Gerados por scripts/gen-indicadores-sage-seed.mjs e referenciados em supabase/config.toml (sql_paths). Num supabase db reset o banco local já nasce populado.

Cloud: a primeira sync se auto-semeia

Na nuvem você só faz push da migration — sem seed. O upsert-metadado faz insert ... on conflict (codigo), então a primeira sync rodada por um admin na VPN cria as linhas do catálogo e preenche o resto. Banco cloud vazio se semeia sozinho no primeiro sync.

4. Leitura (o que a UI vai consumir)

Os cards leem do Postgres (cache/admin client), nunca da MGDI — por isso a leitura é VPN-independente. As helpers de tratamento já existem; ex. o ano corrente parcial dos financeiros é derivável do grão sem coluna extra:

// Marca o ponto parcial: mês ≠ 12 no ano corrente, em indicador mensal.
// `anomes` (AAAAMM) + `periodicidade_carga='mensal'` bastam — sem coluna "parcial".
export function isPontoParcial(anomes: number, anoCorrente: number): boolean {
  const ano = Math.floor(anomes / 100);
  const mes = anomes % 100;
  return ano === anoCorrente && mes !== 12;
}

Estratégia de Frontend

Recomendação para construir os cards de indicador com paridade visual e de UX com o stack Elastic/Kibana — Elastic UI (EUI) + Elastic Charts. Esta seção é estratégia: não há código de front implementado ainda.

Por que Elastic UI

Os mockups da SAGE espelham a linguagem visual do Kibana (cards densos, gráficos de série temporal, mapas coropléticos). Reproduzir isso com EUI + Elastic Charts dá paridade "de graça" — em vez de recriar o look do Kibana sobre shadcn/Tailwind, usamos a mesma biblioteca que o origina.

Realidade de compatibilidade (verificado jun/2026)

⚠️ EUI e Elastic Charts ainda não suportam React 19 oficialmente

O SIMPE roda Next.js 16.1.6, React 19.2.0, react-dom 19.2.0, Tailwind 4, Radix/shadcn; hoje sem Emotion, com gráficos via Recharts 3.7.0 e sem lib de mapa. Já o @elastic/eui mais recente (116.3.1) declara peerDependencies react: ^17 || ^18 (base Emotion 11) e o @elastic/charts (71.7.0) declara react: ^16.12 || ^17 || ^18. Nenhum dos dois lista React 19. O épico de suporte a React 19 (elastic/eui#8720) segue sem entregar em jun/2026 (bloqueado por refatorações de componentes legados incompatíveis com <StrictMode>). Premissa de versão: tratamos EUI/Charts como "React 18-target rodando sob React 19 com peer forçado", a validar componente a componente — não como suporte oficial.

Como introduzir no stack atual — honestamente

  • (a) Mismatch de peer com React 19. Instalar com overrides no package.json (ou --legacy-peer-deps) para destravar o peer, e validar os componentes específicos que usarmos — não assumir o pacote inteiro. Evitar montar a árvore EUI sob <StrictMode> (double-invoke quebra os componentes legados citados no épico). Travar versões exatas e cobrir os cards com smoke tests.
  • (b) Emotion/CSS-in-JS + SSR. EUI usa Emotion e exige <EuiProvider>. No App Router isso pede um cache Emotion próprio conectado via useServerInsertedHTML (de next/navigation) para o CSS sair no SSR sem flash — tudo atrás de fronteiras "use client" (EUI é client-only).
  • (c) Escopo restrito às superfícies de indicador. Montar EUI só numa ilha (subárvore de rota dos indicadores) com seu próprio cache + provider, para não brigar com o theming Tailwind/shadcn no resto do app. Conter/resetar estilos na fronteira da ilha (Tailwind preflight × reset do EUI). O dark/light do EUI (EuiProvider colorMode) deve seguir o tema já existente do SIMPE.
# Premissa: peer forçado até o suporte oficial a React 19 sair.
npm i @elastic/eui @elastic/eui-theme-borealis @elastic/charts \
      @emotion/react @emotion/css moment @elastic/datemath
# package.json → "overrides": { "react": "19.2.0", "react-dom": "19.2.0" }
# Escopar a uma ilha "use client" com EuiProvider + cache Emotion próprio.

4 elementos do card → componente Elastic + fonte de dado

ElementoComponente ElasticFonte de dado
(1) TítuloEuiTitle / EuiText dentro de EuiPanelDicionário curado lib/sage/titulos.ts (a criar) — lê o catálogo (metadado). Nunca exibir titulo/titulo_completo crus (inconsistentes — ver Ressalva #3).
(2) Evolução BRElastic Charts: Chart + LineSeries/BarSeries + Axisindicador_sage_serie_br (mensal). Marcar o ponto do ano corrente parcial nos "Moeda"/periodicidade_carga='mensal' (anomes mês ≠ 12 ⇒ parcial) com estilo distinto (hachura/rótulo "fev/2026") — ver Ressalva #1.
(3) Mapa municipalCoroplético leve (ver recomendação abaixo) com paleta EUIvw_indicador_sage_mapa_atual + GeoJSON municipal do Brasil, join por codigogeo (IBGE 6 díg, sem DV). View já desduplica.
(4) Ficha (i)EuiButtonIconEuiPopover/EuiFlyoutindicadores_sage.ficha (jsonb).

Mapa: por que NÃO o Elastic Maps

Recomendação: coroplético leve em vez do Elastic Maps

O Elastic Maps é um plugin do Kibana acoplado a um backend Elasticsearch — não um componente React standalone embutível num card do SIMPE. Recomenda-se um renderizador coroplético leve (estilo react-simple-maps / SVG+topojson com d3-geo), estilizado com a paleta sequencial do EUI para manter a paridade visual. Justificativa: (1) bundle muito menor; (2) sem dependência de ES — lê direto a view/GeoJSON; (3) embutível dentro do EuiPanel do card. O join é por codigogeo (6 dígitos), que já casa com os GeoJSONs municipais do IBGE. Para a legenda/escala, reusar euiPaletteForSequence() dá cor coerente com os gráficos.

Rollout em fases

  1. Prova de conceito (1 card): título + gráfico de evolução (Elastic Charts) numa ilha "use client" com EuiProvider, validando EUI/Charts sob React 19 (peer forçado, sem StrictMode). Mede risco real antes de investir.
  2. Um indicador completo: adicionar mapa coroplético + ficha (i) ao card de PoC, fechando os 4 elementos para 1 código (ex. CEOIMP).
  3. Galeria: replicar para os 7 do piloto, montar o dicionário lib/sage/titulos.ts e a grade de cards.
Onde isso pluga — e por que não depende da VPN

O dado já vive persistido nas tabelas/view (🗄️ Schema), populado pelo sync da 💻 Implementação. A UI lê pelo caminho normal de leitura (cache/admin client) — nunca chama a MGDI. Logo, a renderização dos cards é VPN-independente: a ilha EUI é puramente de apresentação sobre dados já no Postgres.

Ressalvas

Três comportamentos da API que precisam ser tratados no front. Ignorá-los gera bugs silenciosos — gráficos renderizam, mas com valor errado.

#1 — Ano corrente parcial nos indicadores financeiros

Afeta: CEOVLR, SBRCUOM, SBVRFAF (mockups 4.1, 4.2, 4.4).

A API tem cargas mensais pra esses 3 indicadores e cargas anuais pros outros 4. O parâmetro anodata=-5 retorna a competência mais recente de cada ano calendário, então pros financeiros vem 202602 (fev/2026, parcial) misturado com 202512, 202412, etc. (anos fechados em dezembro).

Exemplo real do CEOVLR (de _source/samples/resultset-br-ceovlr.json):

anomesValor (R$)Status
202212226.805.471ano fechado
202312282.328.339ano fechado
202412502.460.676ano fechado
202512506.377.601ano fechado
20260289.421.018apenas jan+fev/2026 — PARCIAL

Plotar direto vai mostrar 2026 como queda de 82%, o que é factualmente errado.

Solução técnica: derivar o flag de parcial do grão (isPontoParcial(), Implementação §4) — não filtra, só marca o ponto pro componente de gráfico aplicar tratamento visual (rótulo "fev/2026", padrão listrado, etc.). Estilização do ponto em 🎨 Estratégia de Frontend.

Decisão visual pendente com Luísa — ver ✅ Pendências.

#2 — Resultset MN retorna múltiplas competências por município

O parâmetro anodata=-1 no endpoint de mapa não retorna 1 ponto por município: retorna todas as cargas do último ano calendário. Em janeiro/2026, vieram 202601 E 202602 pro mesmo município de Acrelândia. Isso explica o tamanho do response (1.1 MB pros não-financeiros, 562 KB pros financeiros).

Exemplo real (de _source/samples/resultset-mn-trecho.json):

[
  { "anomes": 202601, "uf": "AC", "local": "Acrelândia", "codigogeo": 120001, "CEOIMP": 0 },
  { "anomes": 202602, "uf": "AC", "local": "Acrelândia", "codigogeo": 120001, "CEOIMP": 0 }
]

Sem agrupamento, um data.find(m => m.codigogeo === 120001) ingênuo vai pegar a primeira ocorrência (geralmente janeiro), não a mais recente. Bug silencioso clássico — o gráfico renderiza, mas com valor errado.

Solução técnica: agrupar por codigogeo mantendo o item com anomes máximo, e o cache deve guardar já a versão agrupada (não o response cru) — senão cada render do front faz o agrupamento de novo, desperdiçando CPU.

function ultimaPorMunicipio<C extends string>(rs: ResultsetMN<C>): ResultsetMN<C> {
  const acc = new Map<number, ResultsetMN<C>[number]>();
  for (const row of rs) {
    const atual = acc.get(row.codigogeo);
    if (!atual || row.anomes > atual.anomes) acc.set(row.codigogeo, row);
  }
  return Array.from(acc.values());
}

#3 — titulo × tituloCompleto inconsistentes

Após inspeção real da API (27/04/2026), confirmou-se que os campos titulo e tituloCompleto não seguem padrão consistente entre indicadores. Em alguns o titulo é a versão informativa e o tituloCompleto é uma definição técnica longa. Em outros é o oposto. E o SBESB chega a contradizer o próprio campo curto.

Comparação real dos 7 indicadores SAPS (de _source/01-cobertura-api-simpe-saude-bucal.md):

CódigotitulotituloCompleto
CEOIMPNúmero de Centros de Especialidades Odontológicas implantado e custeadoNúmero de Centros de Especialidades Odontológicas pago no período
CEOVLRValor repassado para custeio de Centros de Especialidades Odontológicas - CEOQuantidade de recursos (em real) do tipo custeio destinado aos municípios, pelo Ministério da Saúde, para o cofinanciamento dos Centros de Especialidades Odontológicas CEO.
SBESBNúmero de Equipes de Saúde Bucal (40h + carga horária diferenciada)Número de Equipes de Saúde Bucal – carga horária de 20, 30 e 40 horas ⚠️
SBESB40HNúmero de Equipes de Saúde Bucal 40hNúmero de Equipes de Saúde Bucal 40h custeadas pelo MS
SBRCUOMValor repassado para custeio UOMValor recurso de custeio destinado ao cofinanciamento das Unidades Odontológicas Móveis (UOM)
SBUOMPNúmero de Unidades Odontológicas Móveis - UOM pagasNúmero de Unidades Odontológicas Móveis - UOM pagas (idêntico)
SBVRFAFValor repassado para custeio das Equipes de Saúde BucalValor repassado referente ao custeio das Equipes de Saúde Bucal no mês de referência

Solução recomendada: não usar nenhum dos dois campos como fonte direta dos títulos do mockup. Mariana monta um dicionário de títulos por código MGDI alinhado com os títulos dos mockups. O front busca primeiro no dicionário (lib/sage/titulos.ts) e usa tituloCompleto como fallback. Ver 🎨 Estratégia de Frontend.

Pendências

Itens acionáveis até o piloto rodar em produção. Bloqueadores travam o lançamento; não-bloqueadores afetam qualidade ou completude visual.

✅ Resolvido (camada de ingestão e dados)
  • Schema migrado (4 tabelas + view, GRANTs + RLS) — 20260608150050_create_indicadores_sage_schema.sql.
  • Mecanismo de sync — antes uma pendência aberta (edge function/cron com acesso VPN); resolvido por sync client-side no navegador do admin (APISIX com CORS aberto) → server actions → upsert via service_role. Sem túnel, sem edge function, sem cron.
  • Aba Painel Admin → Indicadores SAGE com painéis de sync e de vínculo (auto-sugestão da árvore + picker manual).
  • Vínculo N:N indicador↔item com proveniência; seeds de dado real para dev local sem VPN.

Bloqueadores do piloto

  • 8 indicadores sem código MGDI
    Dono: DEMAS · Próxima ação: mapear oficialmente os indicadores dos mockups 4.5 e 4.6 + Novas UOM (4.2) + eSB cofinanciadas (4.4). Painéis das áreas têm endpoint público? Parcialmente endereçado: o lote de 26/05 já trouxe códigos para CEO Tipo I/II/III e eSB Modalidade I/II — mas sem dado (ver item abaixo).
  • Dados não carregados nos novos códigos de Saúde Bucal (lote 26/05/2026)
    Dono: DEMAS · Próxima ação: os 5 códigos de Saúde Bucal têm metadado e ficha, mas /carga e /resultset retornam HTTP 500 (SBCEOTIPO1, SBCEOTIPII, SBESBMODII — erro syntax error at or near "null") ou array vazio (SBCEOTPIII, SBESBMODI). Carregar os dados / corrigir o pipeline. Evidência: _source/reports/novos-codigos-report.csv. Nota: o PMM (SGTES) já foi resolvido pela área com os códigos PPFVAPMM e PPFTVA — ver 🩺 SGTES.
  • Endpoint quebrado: MGDI_MS_XAW (lote 22/06/2026)
    Dono: DEMAS · Próxima ação: MGDI_MS_XAW ("vagas por status — PMM") dá HTTP 500 já no /carga (syntax error at or near "null", Postgres 42601) — persiste com qualquer parâmetro de data, então é bug de backend, o mesmo que derruba 3 códigos de Saúde Bucal do lote 26/05. Corrigir o pipeline. Evidência: _source/reports/problema-indicadores-22-06.csv. Resolvido no mesmo lote: as 4 residências (3KP/4RU/543/XLJ) não estavam sem dado — eram anuais chamadas com janela mensal; com anodata=<ano> retornam as 27 UFs. Ver 🩺 SGTES.

Não-bloqueadores

  • Construir a UI dos cards de indicador
    Dono: front · Próxima ação: implementar os cards (título, evolução, mapa, ficha) seguindo a 🎨 Estratégia de Frontend (EUI + Elastic Charts em ilha, sob React 19 com peer forçado). É o principal trabalho restante; a leitura é VPN-independente.
  • Sync de produção depende de admin na VPN
    Dono: operação · Próxima ação: o app não precisa mais de acesso externo à API (leituras vêm do Postgres). Resta a operação: o sync precisa ser disparado por um Admin/SuperAdmin conectado à VPN interna do MS. Avaliar cadência (manual periódico) e quem opera.
  • Corrigir códigos e rótulos no CSV de Saúde Bucal
    Dono: Mariana / área · Próxima ação: SBCEOTIPOIISBCEOTIPII e SBCEOTIPOIIISBCEOTPIII (os do CSV dão 404), e as duas linhas estão rotuladas "Tipo III".
  • Tratamento visual do ano corrente parcial
    Dono: Luísa · Próxima ação: definir entre filtrar o ponto, mostrar com hash/listrado e label "fev/2026" (recomendado, replica mockup SAGE), ou substituir anodata=-5 por chamada explícita por competências.
  • Validar mapeamento "Carga Horária Diferenciada" = SBESB - SBESB40H
    Dono: Mariana · Próxima ação: confirmar se "diferenciada" = "20h + 30h" no contexto operacional da Saúde Bucal (subtração já confirmada na API: SBESB cobre 20h+30h+40h, SBESB40H só 40h).
  • Construir dicionário de títulos por código MGDI
    Dono: Mariana · Próxima ação: preencher 4 slots por indicador (cardTitulo, cardSubtitulo, graficoEvolucao, mapa) alinhados aos mockups, em lib/sage/titulos.ts. Contexto na 🎨 Estratégia de Frontend.
  • Confirmar política de rate-limit no APISIX
    Dono: DEMAS · Próxima ação: qual o limite efetivo por consumer? Cadastrar SIMPE com cota dedicada? Hoje não há cabeçalhos X-RateLimit-*.
  • Investigar gap de carga 202410 no CEOVLR
    Dono: DEMAS · Próxima ação: indisponibilidade real ou falha de carga? Afeta totalizador anual de 2024? (também aparece gap em 202509).