Pular para o conteúdo

D-2 — Georreferenciamento

Parte do Ciclo de Demandas — Fase 1


A D-2 resolve a questão territorial das demandas normalizadas: a qual unidade cívica essa demanda pertence? E a qual nível? Consome demanda.normalizada da D-1b e produz demanda.georreferenciada. O evento de saída é consumido pela D-3 (categorização), D-4 (priorização e ranking) e D-7 (transparência). Sem território resolvido, o restante do pipeline (ranquear, agendar, atribuir conselheiro) não funciona.

Não categoriza o conteúdo da demanda. Não prioriza. Não valida vínculo. Apenas resolve a geometria territorial: dado um ponto (coordenadas GPS), determina qual unidade cívica o contém e a cadeia completa de UCs pai.

É análoga à L-2 do pipeline de lugares, porém mais simples. Não faz enriquecimento de tipificação, não consulta bases externas e não lida com subtipos de lugar. Apenas point-in-polygon e cache.

A D-2 é uma colônia pura de eventos. Não tem BFF acoplado, não expõe endpoints REST e não faz chamadas síncronas a outras colônias.


A D-2 é um módulo NestJS com encapsulamento próprio dentro do monolito modular do MVP. Consome eventos do barramento via EventBusService (N-0a) e publica eventos ao final do processamento. Não expõe controllers REST.

src/demanda/d-2-georreferenciamento/
├── d2.module.ts # Module definition
├── d2.service.ts # Lógica de negócio: resolver UC, persistir, publicar
├── d2.constants.ts # Limites de coordenadas, TTLs e precisão do cache
├── repositories/
│ ├── resolucao-geo.repository.ts # Acesso a d2.resolucoes_geo e d2.revisoes_geo_pendentes
│ ├── evento-processado.repository.ts # Acesso a d2.eventos_processados (idempotência)
│ ├── consumer-offset.repository.ts # Acesso a d2.consumer_offset (cursor de replay)
│ └── core-geo.repository.ts # Leitura de core.uc_polygons com snapshot em memória
└── services/
├── point-in-polygon.service.ts # Point-in-polygon (Turf.js) com pré-filtro de bbox
└── geo-cache.service.ts # Cache de resoluções geo (in-memory)

Os testes ficam ao lado dos arquivos testados: d2.service.spec.ts, geo-cache.service.spec.ts, point-in-polygon.service.spec.ts e core-geo.repository.spec.ts.

@Module({
imports: [],
controllers: [], // Colônia pura de eventos — sem REST
providers: [
D2Service,
ResolucaoGeoRepository,
EventoProcessadoRepository,
ConsumerOffsetRepository,
CoreGeoRepository,
PointInPolygonService,
GeoCacheService,
],
exports: [],
})
export class D2Module implements OnModuleInit {
constructor(private readonly d2Service: D2Service) {}
async onModuleInit(): Promise<void> {
await this.d2Service.iniciar();
}
}
  • O módulo não é @Global(). A D-2 não é dependência de nenhuma outra colônia. Outras colônias consomem seu evento (demanda.georreferenciada), não seu código.
  • O módulo não importa EventBusModule explicitamente. EventBusModule é @Global(), e o EventBusService é injetável sem import.
  • O módulo não importa RegistryModule. A validação de schema dos eventos publicados é feita pelo próprio Event Bus (N-0a) no momento do publicar(). A D-2 apenas constrói o payload conforme o contrato do Registry.
  • O OnModuleInit dispara o protocolo de inicialização: seed do cursor, replay de eventos perdidos e registro de handlers.
  • O módulo não registra ThrottlerModule — rate limiting é responsabilidade exclusiva do BFF (D-1a), na borda HTTP. A D-2 processa o que recebe do barramento.
  • O CoreGeoRepository acessa core.uc_polygons em modo somente leitura e mantém um snapshot em memória com TTL de 6 horas. É uma simplificação de MVP (ver seção 8.2). Na Fase 2, a base de polígonos migra para uma colônia dedicada que publica eventos de sincronização.
// D2Service consome 'demanda.normalizada', publica 'demanda.georreferenciada'
iniciar(): Promise<void>;
processarDemandaNormalizada(evento: {
event_id: string;
sequence_number: bigint;
correlacao_id: string | null;
payload: Record<string, unknown>;
}): Promise<void>;
registrarConsumidores(): void;

O service não expõe interface formal. Nenhuma outra colônia injeta D2Service. A comunicação com o exterior é via barramento.

A D-2 não tem BFF acoplado. É uma colônia de processamento puro: escuta, processa, publica. O input é sempre via barramento — exclusivamente demanda.normalizada da D-1b.

1.6 Dependência de leitura do núcleo (simplificação MVP)

Seção intitulada “1.6 Dependência de leitura do núcleo (simplificação MVP)”

A D-2 acessa core.uc_polygons em modo leitura para realizar point-in-polygon. A mesma decisão se aplica à L-2 do pipeline de lugares — ambas compartilham a mesma base de polígonos via leitura do schema core. Esta é uma exceção controlada à regra de isolamento, válida apenas no MVP monolito. Justificativa: a base de polígonos de UCs é um dado de infraestrutura fundamental, tão essencial quanto o Registry. Duplicá-la em cada colônia que faz point-in-polygon (D-2 e L-2) no MVP geraria complexidade de sincronização desproporcional ao benefício.

Na Fase 2, a base de polígonos migra para uma colônia dedicada de infraestrutura geoespacial que publica eventos de atualização de polígonos. D-2 e L-2 mantêm réplicas locais sincronizadas por evento. A consulta direta ao core é removida.


Todas as tabelas da D-2 residem no schema d2 do PostgreSQL. Este schema é de uso exclusivo do módulo D-2. Nenhuma outra colônia lê ou escreve nestas tabelas.

Registro do resultado do georreferenciamento territorial para cada demanda processada. Uma linha por demanda_id.

CREATE SCHEMA IF NOT EXISTS d2;
CREATE TABLE d2.resolucoes_geo (
id UUID NOT NULL,
demanda_id UUID NOT NULL,
unidade_civica_id UUID NOT NULL,
nivel_minimo_resolvido INTEGER NOT NULL,
cadeia_ucs UUID[],
metodo_resolucao VARCHAR(20) NOT NULL,
confianca_geo VARCHAR(10) NOT NULL,
coordenadas_lat DOUBLE PRECISION NOT NULL,
coordenadas_lng DOUBLE PRECISION NOT NULL,
event_id UUID NOT NULL,
correlacao_id UUID NOT NULL,
criado_em TIMESTAMPTZ(2) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT resolucoes_geo_pkey PRIMARY KEY (id)
);
CREATE UNIQUE INDEX resolucoes_geo_demanda_id_key ON d2.resolucoes_geo (demanda_id);
CREATE INDEX resolucoes_geo_unidade_civica_id_idx ON d2.resolucoes_geo (unidade_civica_id);
CREATE INDEX resolucoes_geo_event_id_idx ON d2.resolucoes_geo (event_id);
CREATE INDEX resolucoes_geo_confianca_geo_idx ON d2.resolucoes_geo (confianca_geo);

O banco não tem CHECKs para confianca_geo, metodo_resolucao ou nivel_minimo_resolvido. Os valores são garantidos pelo código que preenche a linha e pelo schema do Registry na publicação.

Coluna Tipo Descrição
id UUID PK Identificador interno da resolução. Gerado pelo Prisma.
demanda_id UUID FK lógica para o registro de demanda na D-1a/D-1b. Sem constraint formal — regra de isolamento.
unidade_civica_id UUID UC de menor nível onde o ponto está contido. FK lógica para a base de UCs.
nivel_minimo_resolvido INTEGER 1-7 Nível da UC resolvida. Se não há polígono de bairro (nível 2), retorna nível 4 (município).
cadeia_ucs UUID[] Array ordenado de UCs pai, da menor resolvida até a nacional.
metodo_resolucao VARCHAR(20) gps para coordenadas diretas do dispositivo, endereco para geocodificação de texto, inferencia para estimativa. No MVP, sempre gps.
confianca_geo VARCHAR(10) alta para GPS, media para endereço textual, baixa para inferência ou falha de geocodificação.
coordenadas_lat / coordenadas_lng DOUBLE PRECISION Par usado na resolução. Preservado para auditoria.
event_id UUID event_id do evento demanda.normalizada que originou esta resolução. Para rastreabilidade.
correlacao_id UUID correlacao_id do evento de origem. Para trace distribuído. Quando o evento não traz o campo, o demanda_id assume.
criado_em TIMESTAMPTZ(2) Timestamp de criação da resolução.

Marca os eventos com caminho terminal no handler da D-2. A PK é o event_id, o que torna a gravação idempotente por construção.

CREATE TABLE d2.eventos_processados (
event_id UUID NOT NULL,
demanda_id UUID NOT NULL,
processado_em TIMESTAMPTZ(2) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT eventos_processados_pkey PRIMARY KEY (event_id)
);
CREATE INDEX eventos_processados_demanda_id_idx ON d2.eventos_processados (demanda_id);

A D-2 grava a linha em todo caminho terminal: demanda georreferenciada, demanda sem coordenadas, coordenadas inválidas e ponto fora de todos os polígonos. A idempotência de reprocessamento consulta d2.resolucoes_geo; a tabela de eventos processados registra a passagem do evento pelos caminhos que não geram resolução.

Fila de investigação manual para demandas cujo ponto não está contido em nenhum polígono. Uma linha por demanda_id.

CREATE TABLE d2.revisoes_geo_pendentes (
demanda_id UUID NOT NULL,
event_id UUID NOT NULL,
coordenadas_lat DOUBLE PRECISION NOT NULL,
coordenadas_lng DOUBLE PRECISION NOT NULL,
criado_em TIMESTAMPTZ(2) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT revisoes_geo_pendentes_pkey PRIMARY KEY (demanda_id)
);
CREATE UNIQUE INDEX revisoes_geo_pendentes_event_id_key ON d2.revisoes_geo_pendentes (event_id);

A D-2 insere a pendência, loga o aviso com o contador e avança o cursor. A resolução posterior depende da inclusão de novos polígonos em core.uc_polygons (mapeamento coletivo ou Fase 2).

Controla o cursor de processamento para replay seletivo após falha ou reinicialização. Segue o padrão definido pela N-0a (Event Bus).

CREATE TABLE d2.consumer_offset (
tipo_evento VARCHAR(255) PRIMARY KEY,
last_sequence BIGINT NOT NULL DEFAULT 0,
updated_at TIMESTAMPTZ(2) NOT NULL DEFAULT CURRENT_TIMESTAMP
);

No MVP, a tabela tem uma linha: tipo_evento = 'demanda.normalizada'.

2.6 Tabela core.uc_polygons (dependência de leitura do núcleo)

Seção intitulada “2.6 Tabela core.uc_polygons (dependência de leitura do núcleo)”

A D-2 consulta esta tabela do schema core em modo somente leitura para realizar point-in-polygon. A mesma tabela é usada pela L-2. É uma simplificação de MVP (ver seção 1.6 e 8.2). A tabela é populada pelos scripts de seed territorial (seção 8.5) e nenhuma colônia de negócio escreve nela.

Estrutura de referência:

CREATE TABLE core.uc_polygons (
id UUID NOT NULL,
unidade_civica_id UUID NOT NULL,
nivel INTEGER NOT NULL,
parent_uc_id UUID,
nome VARCHAR(255) NOT NULL,
nome_busca VARCHAR(255) NOT NULL DEFAULT '',
geometria JSONB NOT NULL, -- GeoJSON Polygon ou MultiPolygon
fonte VARCHAR(50) NOT NULL,
metadados JSONB NOT NULL DEFAULT '{}',
populacao_estimada INTEGER,
criado_em TIMESTAMPTZ(2) NOT NULL DEFAULT CURRENT_TIMESTAMP,
atualizado_em TIMESTAMPTZ(2) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT uc_polygons_pkey PRIMARY KEY (id)
);
CREATE INDEX uc_polygons_unidade_civica_id_idx ON core.uc_polygons (unidade_civica_id);
CREATE INDEX uc_polygons_nivel_idx ON core.uc_polygons (nivel);

As colunas fonte, metadados e populacao_estimada (migration 20260811195030_add_fonte_metadados_populacao_uc_polygons) são usadas pelos scripts de seed IBGE. populacao_estimada permanece NULL no MVP, porque nenhuma colônia consome população. A coluna nome_busca (migration 20260913120000_uc_polygons_nome_busca) mantém o nome em minúscula, sem acento e sem pontuação, por trigger, para a busca por nome na D-7.

O campo geometria armazena o polígono no formato GeoJSON. A escolha por JSONB em vez de PostGIS GEOMETRY é deliberada para o MVP: evita a instalação da extensão PostGIS e permite point-in-polygon via Turf.js. Na Fase 2, migra para GEOMETRY(POLYGON, 4326) com índice GIST e ST_Contains.

Migrations reais do schema d2 e da base territorial:

  1. 20260809112210_d2_uc_polygons_resolucoes_geo — Cria o schema d2, as tabelas d2.resolucoes_geo e d2.eventos_processados e a tabela core.uc_polygons (migration única compartilhada com o núcleo e a L-2).
  2. 20260811195030_add_fonte_metadados_populacao_uc_polygons — Adiciona fonte, metadados e populacao_estimada a core.uc_polygons.
  3. 20260813112406_add_consumer_offset_d1b_d2_d4 — Cria d2.consumer_offset (na mesma migration da D-1b e da D-4).
  4. 20260814202321_create_d2_revisoes_geo_pendentes — Cria d2.revisoes_geo_pendentes.
  5. 20260913120000_uc_polygons_nome_busca — Adiciona nome_busca e o trigger de normalização do nome.
  6. 20260918140000_d2_eventos_processados_limpeza — Remove a coluna redundante criado_em de d2.eventos_processados.

Seed inicial do consumer_offset: no boot, offsetRepo.seed(['demanda.normalizada'], obterMaiorSequence()) cria a linha com a maior sequência do log sem sobrescrever cursor existente.

Migrations futuras (Fase 2): migração de geometria JSONB para geometry GEOMETRY(POLYGON, 4326) com PostGIS, substituição do CoreGeoRepository por réplica local sincronizada via evento, índices GIST espaciais.

Não há foreign keys entre as tabelas do schema d2. O demanda_id em d2.resolucoes_geo referencia o identificador de demanda em schemas de outras colônias (D-1a, D-1b), mas sem FK formal — regra de isolamento.

Tabela única d2.resolucoes_geo sem colunas de enriquecimento. Diferente da L-2, a D-2 não faz enriquecimento de tipificação. A tabela contém apenas dados territoriais: UC resolvida, cadeia, método, confiança. A geocodificação de endereço textual (Fase 2) adicionará colunas como endereco_original e endereco_geocodificado sem alterar a estrutura fundamental.

coordenadas_lat e coordenadas_lng como DOUBLE PRECISION. Duas colunas separadas expressam o par ordenado usado na resolução e são mais diretas de ler do que um array. Índices não são necessários, porque a consulta é sempre por demanda_id ou event_id, nunca por coordenadas.

confianca_geo sempre alta no MVP. Todas as demandas chegam à D-2 com coordenadas GPS — a D-1a valida bounding box e a D-1b normaliza as coordenadas. O campo existe para forward compatibility com endereços textuais (Fase 2).

cadeia_ucs como UUID[] em vez de tabela de junção. Mesmo racional da L-2: o array é compacto, a consulta é sempre “dado um lugar, quais UCs o contêm?” e a ordem é garantida.

d2.eventos_processados separado de d2.resolucoes_geo. A idempotência de negócio vive na resolução, com unique em demanda_id. A marca de evento processado cobre também os descartes, que não geram resolução. As duas consultas têm propósitos distintos e não se sobrepõem.


A D-2 consome um tipo de evento e produz um. Os schemas completos (JSON Schema draft-2020-12) estão definidos no Registry (N-0b). Esta seção descreve os contratos do ponto de vista da D-2.

Propriedade Valor
Tipo demanda.normalizada
Schema version 1.2.0 (o catálogo mantém 1.0.0 e 1.1.0)
Produtor D-1b (Normalização)
Consumidor D-2 (esta colônia)
Descrição Demanda com campos estruturados, coordenadas validadas e score de confiança da normalização.

Payload esperado (conforme Registry N-0b):

interface DemandaNormalizadaPayload {
demanda_id: string;
titulo: string;
descricao_limpa: string;
tipo_midia_processada: string;
coordenadas_validadas: {
lat: number;
lng: number;
};
confianca_normalizacao: number; // 0-1
origem_normalizacao: string; // 'automatico' | 'hibrido'
conteudo_suspeito: boolean; // termo da denylist encontrado
entidades_extraidas?: {
endereco?: string;
cep?: string;
nomeRua?: string; // camelCase no payload real
};
idioma_detectado?: string;
termos_suspeitos?: string[]; // presente quando conteudo_suspeito = true
categoria_id?: string;
subcategoria_id?: string;
}

A D-2 lê demanda_id e coordenadas_validadas. Os demais campos seguem no evento para os outros consumidores.

Propriedade Valor
Tipo demanda.georreferenciada
Schema version 1.0.0
Produtor D-2 (esta colônia)
Consumidores D-3 (Categorização), D-4 (Priorização e Ranking), D-7 (Transparência)
Descrição Demanda com UC resolvida e cadeia de UCs pai completa.

Payload publicado (conforme Registry N-0b):

interface DemandaGeorreferenciadaPayload {
demanda_id: string;
unidade_civica_id: string; // UC de menor nível resolvida
nivel_minimo_resolvido: number; // 1-7
cadeia_ucs: string[]; // [uc_nivel_N, ..., uc_nivel_7]
metodo_resolucao: string; // 'gps' | 'endereco' | 'inferencia'
confianca_geo: string; // 'alta' | 'media' | 'baixa'
coordenadas_lat?: number; // opcionais no schema; publicados pela D-2
coordenadas_lng?: number;
}

O fluxo em D2Service.processarDemandaNormalizada() segue esta ordem exata:

1. Verificar idempotência por event_id
→ consultar d2.resolucoes_geo WHERE event_id = ?
→ se encontrado: republicar demanda.georreferenciada e avançar o cursor
2. Extrair coordenadas do payload
→ payload.coordenadas_validadas
→ se ausente: log.warn, registrar em d2.eventos_processados e avançar o cursor
3. Validar coordenadas (defesa em profundidade)
→ lat entre -90 e 90, lng entre -180 e 180
→ se inválidas: log.error, registrar em d2.eventos_processados e avançar o cursor
4. Resolver território (point-in-polygon)
→ consultar cache geo: d2:geo:{lat}:{lng}
→ se cache hit: usar o resultado do cache
→ se cache miss:
→ carregar o snapshot de polígonos (TTL de 6 horas)
→ executar point-in-polygon do menor para o maior nível
→ ao encontrar a UC: resolver a cadeia de UCs pai
→ armazenar no cache geo
→ se nenhum polígono contiver o ponto: inserir em d2.revisoes_geo_pendentes,
log.warn, registrar em d2.eventos_processados e avançar o cursor
5. Inserir a resolução em d2.resolucoes_geo
6. Publicar demanda.georreferenciada no barramento
→ se a publicação falhar: log.error e exceção relançada; o cursor não avança.
O replay seguinte encontra a resolução pela idempotência e republica a saída.
7. Registrar o evento em d2.eventos_processados e atualizar o consumer offset
Cenário Comportamento
Evento reentregue (replay DLQ) Detectado por event_id em d2.resolucoes_geo. Registro existente encontrado. Republica demanda.georreferenciada para garantir entrega a consumidores que possam ter perdido o evento original. Cursor avança.
Coordenadas ausentes no payload Log.warn. Evento descartado. Linha em d2.eventos_processados. Cursor avança. A D-1b deve normalizar coordenadas antes de publicar. Se ocorrer, é bug upstream.
Coordenadas inválidas (fora -90/+90, -180/+180) Log.error. Evento descartado. Linha em d2.eventos_processados. Cursor avança.
Point-in-polygon não encontra UC (ponto fora de todos os polígonos) Log.warn com o contador de pendências. Linha em d2.revisoes_geo_pendentes para investigação manual. Cursor avança sem publicação.
INSERT falha (violação de unique em demanda_id) Log.error e exceção relançada. Indica que o mesmo demanda_id foi processado duas vezes com event_id diferente — cenário impossível. O erro é registrado na DLQ e o cursor não avança.
Publish de demanda.georreferenciada falha Log.error. Registro existe em d2.resolucoes_geo. DLQ registrada. Cursor não avança.
Cache em memória perdido em reinicialização Degradação graciosa: point-in-polygon repete resoluções no boot seguinte. Performance reduzida temporariamente, funcionalidade preservada.

Persistir antes de publicar. Mesmo princípio de todas as colônias: o estado próprio é a memória do módulo. Se o barramento falhar após o INSERT, a resolução está salva e recuperável.

Republicar evento de saída no replay. A D-2 é o terceiro elo do pipeline de demandas. Sem demanda.georreferenciada, D-3, D-4 e D-7 não conseguem operar. A republicação no replay garante entrega at-least-once. As colônias consumidoras devem ser idempotentes.

Geocodificação de endereço textual adiada para a Fase 2. No MVP, a D-1a captura coordenadas GPS do dispositivo e a D-1b as valida e normaliza. O campo entidades_extraidas.endereco pode conter endereço textual extraído do texto, mas a D-2 não o processa no MVP — descarta o evento com log.warn se não houver coordenadas. A geocodificação (Nominatim/Photon) adiciona latência de rede e dependência de serviço externo que não se justificam para fechar o ciclo mínimo.


4.1 D2Service.processarDemandaNormalizada() — pseudocódigo

Seção intitulada “4.1 D2Service.processarDemandaNormalizada() — pseudocódigo”
função processarDemandaNormalizada(evento):
payload = evento.payload
// 1. Idempotência por event_id (com republicação)
existente = resolucaoGeoRepo.buscarPorEventId(evento.event_id)
se existente não é null:
logger.log("Evento já processado, republicando saída", {
event_id: evento.event_id,
demanda_id: existente.demanda_id,
})
await this.publicarDemandaGeorreferenciada(existente, evento.correlacao_id)
await offsetRepo.upsert('demanda.normalizada', evento.sequence_number)
retornar
// 2. Extrair coordenadas
coordenadas = payload.coordenadas_validadas
se coordenadas ausente:
logger.warn("Demanda sem coordenadas — descartando", {
event_id: evento.event_id,
demanda_id: payload.demanda_id,
})
await eventoProcessadoRepo.registrar(evento.event_id, payload.demanda_id)
await offsetRepo.upsert('demanda.normalizada', evento.sequence_number)
retornar
lat = coordenadas.lat
lng = coordenadas.lng
// 3. Validar coordenadas (defesa em profundidade)
se lat < -90 ou lat > 90 ou lng < -180 ou lng > 180:
logger.error("Coordenadas inválidas", {
event_id: evento.event_id,
demanda_id: payload.demanda_id,
lat, lng,
})
await eventoProcessadoRepo.registrar(evento.event_id, payload.demanda_id)
await offsetRepo.upsert('demanda.normalizada', evento.sequence_number)
retornar
// 4. Resolver território
resultado = geoCache.obter(lat, lng)
se resultado é null:
poligonos = await coreGeoRepo.buscarTodosOrdenadosPorNivel()
resultado = pointInPolygonService.resolverTerritorio(lat, lng, poligonos)
se resultado é null:
totalRevisoes = await resolucaoGeoRepo.contarRevisoesPendentes()
await resolucaoGeoRepo.inserirRevisaoPendente({
demanda_id: payload.demanda_id,
event_id: evento.event_id,
coordenadas_lat: lat,
coordenadas_lng: lng,
})
logger.warn("Ponto fora de todos os polígonos — demanda marcada para revisão manual", {
event_id: evento.event_id,
demanda_id: payload.demanda_id,
revisoes_pendentes: totalRevisoes + 1,
})
await eventoProcessadoRepo.registrar(evento.event_id, payload.demanda_id)
await offsetRepo.upsert('demanda.normalizada', evento.sequence_number)
retornar
geoCache.definir(lat, lng, resultado)
// 5. Persistir
resolucao = await resolucaoGeoRepo.inserir({
demanda_id: payload.demanda_id,
unidade_civica_id: resultado.unidade_civica_id,
nivel_minimo_resolvido: resultado.nivel_minimo_resolvido,
cadeia_ucs: resultado.cadeia_ucs,
metodo_resolucao: 'gps',
confianca_geo: 'alta',
coordenadas_lat: lat,
coordenadas_lng: lng,
event_id: evento.event_id,
correlacao_id: evento.correlacao_id ?? payload.demanda_id,
})
// 6. Publicar
await this.publicarDemandaGeorreferenciada(resolucao, evento.correlacao_id)
// 7. Fechar o ciclo
await eventoProcessadoRepo.registrar(evento.event_id, payload.demanda_id)
await offsetRepo.upsert('demanda.normalizada', evento.sequence_number)

4.2 resolverTerritorio() — point-in-polygon com cache

Seção intitulada “4.2 resolverTerritorio() — point-in-polygon com cache”

A busca no cache e o point-in-polygon vivem em pontos distintos do módulo. O D2Service consulta o GeoCacheService e, no miss, carrega o snapshot pelo CoreGeoRepository e delega o cálculo ao PointInPolygonService.

função no D2Service (trecho da resolução):
cacheHit = geoCache.obter(lat, lng)
se cacheHit não é null:
resultado = cacheHit
senão:
poligonos = await coreGeoRepo.buscarTodosOrdenadosPorNivel()
resultado = pointInPolygonService.resolverTerritorio(lat, lng, poligonos)
se resultado é null:
// seção 3.3, passo 4: revisão pendente
geoCache.definir(lat, lng, resultado)
função resolverTerritorio(lat, lng, poligonos) -> ResultadoTerritorio | null:
ponto = turf.point([lng, lat])
para cada poligono em poligonos:
se pontoDentroDoBbox(poligono.bbox, lat, lng) é falso:
continuar
tentar:
contido = turf.booleanPointInPolygon(ponto, poligono.geometria)
se contido:
return {
unidade_civica_id: poligono.unidade_civica_id,
nivel_minimo_resolvido: poligono.nivel,
cadeia_ucs: resolverCadeiaUcs(poligono, poligonos),
}
capturar erro:
logger.warn("Polígono inválido ignorado: uc_id=...")
return null

O snapshot de polígonos é carregado ordenado por nível e cada polígono leva o próprio bounding box. O PointInPolygonService descarta pelo bbox antes de chamar o Turf. O cache geo usa o prefixo d2:geo: e arredondamento de 5 casas decimais.

A cadeia é resolvida na lista de polígonos já carregada, sem consulta por nível.

função resolverCadeiaUcs(uc: UcPolygon, todosPoligonos: UcPolygon[]) -> UUID[]:
cadeia = [uc.unidade_civica_id]
parentId = uc.parent_uc_id
enquanto parentId não é null:
pai = todosPoligonos.find(p => p.unidade_civica_id === parentId)
se pai é undefined:
break
cadeia.push(pai.unidade_civica_id)
parentId = pai.parent_uc_id
return cadeia

4.4 D2Service.iniciar() — protocolo de inicialização

Seção intitulada “4.4 D2Service.iniciar() — protocolo de inicialização”
função iniciar():
se já iniciado: retornar
// 1. Iniciar o cache geo
geoCache.iniciar()
// 2. Seed do cursor na maior sequência do log (sem sobrescrever)
maiorSequence = await eventBus.obterMaiorSequence()
await offsetRepo.seed(['demanda.normalizada'], maiorSequence)
// 3. Replay de eventos perdidos durante inatividade
await this.reprocessarEventosPerdidos()
// lê o cursor em consumer_offset, chama replayDeSequence(cursor, ['demanda.normalizada'])
// e despacha cada evento pelo handler; falha por evento é logada e o evento
// permanece pendente para o próximo boot
// 4. Registrar handler para eventos futuros
this.registrarConsumidores()
// eventBus.inscrever('demanda.normalizada', 'D-2', this.processarDemandaNormalizada.bind(this))
logger.log("D-2 Georreferenciamento inicializado")
Caso Comportamento
Demanda com coordenadas_validadas = null (apenas endereço textual) MVP descarta com log.warn. Fase 2: geocodifica e prossegue.
Ponto fora de todos os polígonos (área não coberta) resolverTerritorio() retorna null. Linha em d2.revisoes_geo_pendentes com log.warn (não publicado).
Polígono com geometria inválida Turf.js lança erro. Capturado, log.warn com uc_id. Polígono pulado.
Hierarquia de UCs quebrada (parent_uc_id sem correspondência) resolverCadeiaUcs() interrompe a subida e retorna a cadeia parcial.
Cache em memória perdido em reinicialização Reconstrução natural: resoluções repetidas no boot. Funcionalidade preservada.
Dois handlers concorrentes processando o mesmo event_id Impossível no MVP monolito. A PK de d2.eventos_processados e o unique de d2.resolucoes_geo em demanda_id garantem a idempotência mesmo se ocorrer.

Point-in-polygon com Turf.js, não PostGIS. Mesmo racional da L-2: Turf.js é maduro, testado e não exige extensão PostgreSQL. Para o volume do MVP, a performance é adequada. A migração para PostGIS na Fase 2 é documentada.

Cache geo compartilhando padrão com a L-2, mas com prefixo independente. D-2 e L-2 usam o mesmo algoritmo e a mesma tabela core.uc_polygons, mas mantêm caches independentes (d2:geo:* vs. l2:geo:*). Isso preserva isolamento: cada colônia gerencia seu próprio ciclo de vida de cache (TTL, invalidação, métricas). A duplicação de cache é inofensiva — algumas dezenas de KB por colônia.

Sem enriquecimento de tipificação. A D-2 resolve apenas território. A tipificação (categorização do conteúdo) é responsabilidade da D-3 (Categorização), que opera sobre o texto da demanda, não sobre coordenadas. A L-2 faz enriquecimento de tipificação porque lugares têm tipo (farmácia, escola) associado à localização — demandas não.


5. Integração com o Barramento e Outras Colônias

Seção intitulada “5. Integração com o Barramento e Outras Colônias”

A D-2 injeta EventBusService (do módulo @Global() N-0a) para três operações: inscrever() (registrar handler para demanda.normalizada), publicar() (publicar demanda.georreferenciada) e replayDeSequence() (replay na inicialização).

@Injectable()
export class D2Service {
private readonly logger = new Logger(D2Service.name);
constructor(
private readonly eventBus: EventBusService,
private readonly resolucaoGeoRepo: ResolucaoGeoRepository,
private readonly eventoProcessadoRepo: EventoProcessadoRepository,
private readonly offsetRepo: ConsumerOffsetRepository,
private readonly coreGeoRepo: CoreGeoRepository,
private readonly pointInPolygonService: PointInPolygonService,
private readonly geoCache: GeoCacheService,
) {}
}

O logging usa o Logger do NestJS. A D-2 não injeta ObservabilityService nem MetricsService da N-0c. Nenhuma dependência além do núcleo e do CoreGeoRepository (simplificação MVP).

Cidadão (app)
→ POST /demandas (BFF D-1a)
→ demanda.recebida (barramento)
→ [D-1b] → demanda.normalizada
→ [D-2] — esta colônia → demanda.georreferenciada
→ [D-3] → demanda.categorizada
→ [D-4] → demanda.ranqueada (usa UC para posicionar no ranking)
→ [D-7] → timeline + dashboard (usa UC para agregação territorial)

A D-2 é o terceiro elo do pipeline. Sem seu evento de saída, a D-3 não consegue categorizar (precisa da UC para contexto territorial), a D-4 não consegue ranquear (cada UC tem seu próprio ranking) e a D-7 não consegue agregar territorialmente.

private async publicarDemandaGeorreferenciada(
demandaId: string,
unidadeCivicaId: string,
nivelMinimoResolvido: number,
cadeiaUcs: string[],
metodoResolucao: string,
confiancaGeo: string,
coordenadasLat: number,
coordenadasLng: number,
correlacaoId: string | null,
): Promise<void> {
try {
const novoEventId = uuidv4();
await publicarComRetry(this.eventBus, {
tipo: 'demanda.georreferenciada',
origem: 'D-2',
event_id: novoEventId,
correlacao_id: correlacaoId ?? demandaId,
payload: {
demanda_id: demandaId,
unidade_civica_id: unidadeCivicaId,
nivel_minimo_resolvido: nivelMinimoResolvido,
cadeia_ucs: cadeiaUcs,
metodo_resolucao: metodoResolucao,
confianca_geo: confiancaGeo,
coordenadas_lat: coordenadasLat,
coordenadas_lng: coordenadasLng,
},
});
} catch (erro: unknown) {
this.logger.error('Falha ao publicar demanda.georreferenciada', {
demanda_id: demandaId,
erro: erro instanceof Error ? erro.message : String(erro),
});
throw erro;
}
}

O versao_schema é omitido e o Event Bus publica a versão 1.0.0, padrão do tipo. A publicação usa publicarComRetry (4 tentativas, backoff de 500 ms), como o protocolo do AGENTS.md exige: a resolução já está persistida e o replay do próximo boot republica a saída pendente.

A D-2 não faz chamadas síncronas a outras colônias. Não expõe endpoints REST. Não atua como proxy. Toda comunicação é via barramento.

A D-2 não consome projeções de leitura de outras colônias. Os dados externos que acessa são:

  • core.event_log via EventBusService.replayDeSequence() — dependência do núcleo, permitida.
  • core.uc_polygons via CoreGeoRepository — simplificação MVP, será removida na Fase 2.

A D-2 usa Map em memória para cache de resoluções geo, com chave d2:geo:{lat}:{lng} (coordenadas arredondadas a 5 casas decimais):

Chave Conteúdo TTL
d2:geo:{lat}:{lng} { unidade_civica_id, nivel_minimo_resolvido, cadeia_ucs } 30 dias (limpeza a cada hora por setInterval)

O snapshot de polígonos do CoreGeoRepository é um segundo cache em memória, com TTL de 6 horas e recarga automática na primeira consulta após o vencimento.

Sem Redis no MVP — o cache não sobrevive a reinicializações e é simplesmente repopulado por novas resoluções. Redis com prefixo d2:geo:* entra na Fase 2, com múltiplas instâncias do monolito.


A D-2 não aplica rate limiting. O volume de eventos é limitado indiretamente pelo rate limiting do BFF.

Limite Valor Justificativa
Arredondamento cache geo 5 casas decimais ~1.1m de precisão. Idêntico ao padrão da L-2.
Cache geo TTL 30 dias (2.592.000s) Polígonos de UC mudam a cada censo (~10 anos).
Snapshot de polígonos TTL 6 horas (21.600.000ms) Recarga automática após novo seed, sem reiniciar a API.
Polígonos carregados em memória ~5.600 Pior caso: todos os municípios brasileiros. ~17 MB em memória.
Índice Query atendida
resolucoes_geo_demanda_id_key (demanda_id, UNIQUE) Deduplicação de negócio. Acesso por demanda_id.
resolucoes_geo_event_id_idx (event_id) Idempotência — toda invocação do handler.
resolucoes_geo_unidade_civica_id_idx (unidade_civica_id) “Demandas nesta UC” (dashboard D-7, ranking D-4).
resolucoes_geo_confianca_geo_idx (confianca_geo) Dashboard: demandas com localização incerta.
eventos_processados_pkey (event_id) Idempotência de descarte.
revisoes_geo_pendentes_pkey (demanda_id) e revisoes_geo_pendentes_event_id_key (event_id) Fila de investigação manual.
uc_polygons_unidade_civica_id_idx (unidade_civica_id) resolverCadeiaUcs() na L-2 e buscas por UC.
uc_polygons_nivel_idx (nivel) Carga de polígonos ordenados.
  • buscarPorEventId(): 1 query por evento recebido (idempotência).
  • buscarTodosOrdenadosPorNivel(): 1 query a cada 6 horas (snapshot de polígonos), não por evento.
  • inserir(): 1 insert por demanda resolvida.
  • inserirRevisaoPendente() e contarRevisoesPendentes(): apenas no caminho de ponto fora de todos os polígonos.
  • resolverCadeiaUcs(): resolvida em memória sobre o snapshot, sem query por nível.

Volume esperado no MVP: < 100 demandas/dia. Tempo médio de processamento: < 10ms com cache geo hit, < 50ms com cache miss.

  • Cache geo em memória: Map<string, EntradaCache> com TTL de 30 dias e limpeza periódica por setInterval de uma hora.
  • Snapshot de polígonos: CoreGeoRepository guarda a lista ordenada por nível com carregadoEm, invalida após 6 horas e expõe invalidarCache() para recarga manual.

A D-2 é mais simples que a L-2 — não tem cache OSM nem enriquecimento externo. Os únicos caches são o geo e o snapshot de polígonos.

Cenário Demandas/dia Point-in-polygon/dia (cache miss)
PoC (1 bairro, 10 entusiastas) ~20 ~10
MVP (1 município, centenas de usuários) ~100 ~40
Fase 2 (regional) ~10.000 ~3.000

Teste unitário do D2Service:

beforeEach(async () => {
const module = await Test.createTestingModule({
providers: [
D2Service,
{ provide: EventBusService, useValue: mockEventBus },
{ provide: ResolucaoGeoRepository, useValue: mockResolucaoGeoRepo },
{ provide: EventoProcessadoRepository, useValue: mockEventoProcessadoRepo },
{ provide: ConsumerOffsetRepository, useValue: mockOffsetRepo },
{ provide: CoreGeoRepository, useValue: mockCoreGeoRepo },
{ provide: PointInPolygonService, useValue: mockPointInPolygonService },
{ provide: GeoCacheService, useValue: mockGeoCache },
],
}).compile();
service = module.get(D2Service);
mockEventBus.inscrever = jest.fn();
mockEventBus.publicar = jest.fn();
mockEventBus.replayDeSequence.mockResolvedValue([]);
mockEventBus.obterMaiorSequence.mockResolvedValue(100);
mockResolucaoGeoRepo.buscarPorEventId.mockResolvedValue(null);
mockResolucaoGeoRepo.inserir.mockResolvedValue({ /* registro persistido */ });
mockResolucaoGeoRepo.inserirRevisaoPendente.mockResolvedValue(undefined);
mockResolucaoGeoRepo.contarRevisoesPendentes.mockResolvedValue(0);
mockEventoProcessadoRepo.registrar.mockResolvedValue(undefined);
mockOffsetRepo.seed.mockResolvedValue(undefined);
mockOffsetRepo.buscarTodos.mockResolvedValue([]);
mockOffsetRepo.upsert.mockResolvedValue(undefined);
mockGeoCache.obter.mockReturnValue(null);
mockGeoCache.definir = jest.fn();
mockCoreGeoRepo.buscarTodosOrdenadosPorNivel.mockResolvedValue([
// polígonos de exemplo com bbox calculado
]);
mockPointInPolygonService.resolverTerritorio.mockReturnValue({
unidade_civica_id: 'uc-municipio',
nivel_minimo_resolvido: 4,
cadeia_ucs: ['uc-municipio', 'uc-estado', 'uc-brasil'],
});
});

Happy path:

# Cenário Verificação
T1 processarDemandaNormalizada() com coordenadas válidas, UC resolvida INSERT em d2.resolucoes_geo com confianca_geo = 'alta'. publicar() chamado com demanda.georreferenciada. Consumer offset atualizado.
T2 iniciar() sem eventos perdidos seed() chamado com obterMaiorSequence(). replayDeSequence() chamado com o cursor. inscrever() registrado.
T3 iniciar() com eventos perdidos replayDeSequence() retorna 3 eventos. processarDemandaNormalizada() chamado 3 vezes.
T4 Cache geo hit — coordenadas já resolvidas geoCache.obter() retorna resultado. Nenhum point-in-polygon executado. CoreGeoRepository não chamado.

Falhas e bordas:

# Cenário Verificação
T5 Evento já processado (mesmo event_id) — com republicação buscarPorEventId() retorna registro. demanda.georreferenciada republicado.
T6 Coordenadas ausentes (coordenadas_validadas = null) Log.warn. Registrar em d2.eventos_processados. Handler retorna sem INSERT.
T7 Coordenadas inválidas (lat: 200) Log.error. Registrar em d2.eventos_processados. Handler retorna sem INSERT.
T8 Point-in-polygon não encontra UC resolverTerritorio() retorna null. Linha em d2.revisoes_geo_pendentes. Log.warn. Sem INSERT de resolução.
T9 publicar() de demanda.georreferenciada falha Registro persiste. Erro relançado para DLQ. Consumer offset NÃO atualizado.
T10 Reinicialização — cache geo vazio geoCache.iniciar() recria o Map. Resoluções repetem point-in-polygon até o cache repopular. Funcionalidade preservada.

Teste de integração:

# Cenário Verificação
T11 Ciclo completo: demanda.normalizadademanda.georreferenciada 1 linha em d2.resolucoes_geo. 1 evento em core.event_log tipo demanda.georreferenciada. correlacao_id propagado.
T12 Replay após reinício: 3 eventos, 1 já processado iniciar() processa 2 novos, república 1. count(*) = 3. 3 eventos publicados.
T13 Duas demandas na mesma coordenada Primeira: point-in-polygon + cache. Segunda: cache hit. Ambas georreferenciadas com mesma UC.
T14 Demanda em coordenada que cruza com UC de nível 4 (sem níveis 1-3 definidos) nivel_minimo_resolvido = 4. cadeia_ucs começa no nível 4.

O seed real do ambiente de desenvolvimento é o npx prisma db seed, que importa a malha municipal 2025 e a malha de bairros do Censo 2022 (seção 8.5). O SQL abaixo é ilustrativo de um cenário mínimo.

-- Resolução de exemplo (o cursor do consumer_offset é semeado no boot)
INSERT INTO d2.resolucoes_geo (id, demanda_id, unidade_civica_id, nivel_minimo_resolvido,
cadeia_ucs, metodo_resolucao, confianca_geo, coordenadas_lat, coordenadas_lng,
event_id, correlacao_id)
VALUES
(
'f6a7b8c9-d0e1-2345-fabc-678901abcdef',
'a1b2c3d4-e5f6-7890-abcd-ef1234567890',
'uc-jardim-flores',
2,
ARRAY['uc-jardim-flores', 'uc-municipio-sp', 'uc-estado-sp', 'uc-brasil'],
'gps',
'alta',
-23.552,
-46.634,
'b2c3d4e5-f6a7-8901-bcde-f12345678901',
'b2c3d4e5-f6a7-8901-bcde-f12345678901'
);

Funcionalidade Status
inscrever('demanda.normalizada') com handler idempotente por event_id MVP obrigatório
Extração de coordenadas do payload com validação de intervalo MVP obrigatório
Point-in-polygon via Turf.js contra core.uc_polygons MVP obrigatório
Resolução de UC de menor nível com cadeia completa de UCs pai MVP obrigatório
Cache geo de resoluções (in-memory) MVP obrigatório
Snapshot de polígonos com TTL e pré-filtro por bounding box MVP obrigatório
Persistência em d2.resolucoes_geo MVP obrigatório
Publicação de demanda.georreferenciada com payload conforme Registry MVP obrigatório
Republicação no replay para garantir entrega a D-3, D-4 e D-7 MVP obrigatório
Consumer offset para replay após falha MVP obrigatório
Propagação de correlacao_id MVP obrigatório
Fila de revisão manual para pontos fora de todos os polígonos (d2.revisoes_geo_pendentes) MVP obrigatório
Logs estruturados com demanda_id, event_id, correlacao_id e UC resolvida MVP obrigatório
Simplificação Justificativa Quando remover
CoreGeoRepository com leitura direta de core.uc_polygons Duplicar a base de polígonos em D-2 e L-2 no MVP geraria complexidade de sincronização sem benefício. A base no core é uma dependência de infraestrutura compartilhada com a L-2. Migrar para colônia dedicada de geo na Fase 2. D-2 e L-2 mantêm réplicas locais sincronizadas por evento.
Polígono como GeoJSON em JSONB, sem PostGIS Turf.js resolve point-in-polygon com performance adequada. PostGIS exigiria extensão PostgreSQL. Migrar para GEOMETRY(POLYGON, 4326) + ST_Contains + índice GIST na Fase 2.
Polígonos carregados integralmente em memória, com TTL de 6 horas ~5.600 polígonos ~= 17 MB. A recarga periódica dispensa reiniciar a API após novo seed. Migrar para queries espaciais indexadas com > 100K polígonos.
Sem geocodificação de endereço textual No MVP, a D-1a captura GPS e a D-1b normaliza coordenadas. Demandas sem coordenadas são descartadas com log.warn. Adicionar geocodificação (Nominatim/Photon) na Fase 2.
metodo_resolucao sempre gps, confianca_geo sempre alta Consequência da simplificação anterior. Campos existem para forward compatibility. Passam a variar quando geocodificação textual for suportada.
Sem endpoint REST para consulta A consulta é feita pela D-7 (Transparência) via projeções de leitura. Adicionar endpoint administrativo se necessário na Fase 2.
  • Migração de core.uc_polygons para colônia dedicada de infraestrutura geoespacial, com réplicas locais em D-2 e L-2
  • PostGIS: GEOMETRY(POLYGON, 4326), índice GIST, ST_Contains
  • Geocodificação de endereço textual via Nominatim/Photon (self-hosted)
  • metodo_resolucao = 'endereco' e confianca_geo = 'media' para demandas geocodificadas
  • metodo_resolucao = 'inferencia' e confianca_geo = 'baixa' para fallback por centróide
  • Métricas Prometheus: d2_demandas_georreferenciadas_total, d2_point_in_polygon_duration_ms, d2_cache_hit_ratio
  • Re-georreferenciamento quando polígonos de UC são atualizados
  • Tratamento automático (ou ferramenta dedicada) das revisões pendentes de geo

8.4 Verificação de conflitos com outras colônias

Seção intitulada “8.4 Verificação de conflitos com outras colônias”

Conflito potencial: D-2 e L-2 compartilham core.uc_polygons — divergência de algoritmo? Avaliação: ambas usam o mesmo algoritmo (ray casting via Turf.js, mesma versão) sobre a mesma tabela. O resultado é determinístico: mesma coordenada → mesma UC em ambas as colônias. Teste de regressão cruzada deve verificar isso. Na Fase 2, ST_Contains do PostGIS é o algoritmo canônico único.

Conflito potencial: D-2 e L-2 mantêm caches independentes (d2:geo:* vs. l2:geo:*) com os mesmos dados? Avaliação: sim, há duplicação de cache entre as colônias. É intencional: cada colônia gerencia seu próprio ciclo de vida de cache (TTL, invalidação, métricas) e o isolamento é preservado. A duplicação é inofensiva — algumas dezenas de KB. Se o volume de coordenadas únicas crescer a ponto de ser relevante (Fase 2), o cache pode ser unificado em uma camada de infraestrutura compartilhada.

Conflito potencial: D-1b publica demanda.normalizada com coordenadas que falharam na validação da D-2? Avaliação: a D-1b já valida bounding box e intervalo de coordenadas. Se coordenadas inválidas chegarem à D-2, é bug na D-1b. A D-2 mantém validação própria (defesa em profundidade) e descarta com log.error. Sem conflito — a D-2 é a última linha de defesa.

Conflito potencial: a D-3 (Categorização) depende de demanda.georreferenciada mas também consome demanda.normalizada. Se a D-2 falhar, a D-3 fica bloqueada? Avaliação: a D-3 deve consumir demanda.georreferenciada e usar demanda.normalizada apenas como dado complementar. Se a D-2 publicar com atraso (ex: replay após falha), a D-3 processa quando o evento chegar. O pipeline é assíncrono — a ordem de chegada dos eventos é garantida pelo sequence number, mas a D-3 não deve assumir que georreferenciada chega imediatamente após normalizada. Isso é responsabilidade da D-3, não da D-2. Sem conflito para a D-2.

8.5 Estratégia de seed da base territorial (core.uc_polygons)

Seção intitulada “8.5 Estratégia de seed da base territorial (core.uc_polygons)”

A tabela core.uc_polygons é a fundação geoespacial sobre a qual todas as colônias de demandas e lugares operam. Sem polígonos populados, nenhum point-in-polygon funciona e o pipeline de georreferenciamento retorna null para todas as coordenadas. A população inicial dessa tabela é, portanto, o primeiro passo operacional antes de qualquer ciclo de demanda.

Abordagem em três camadas — da mais automatizada para a mais comunitária:

Fonte para os níveis 2 (bairros) e 4 a 7 (municípios, UFs, regiões e país). Os shapefiles do IBGE são abertos, gratuitos e cobrem todo o território nacional.

  • Malhas: malha municipal 2025 (BR_Municipios_2025, BR_UF_2025, BR_Regioes_2025, BR_Pais_2025) e malha de bairros do Censo Demográfico 2022 (BR_bairros_CD2022), em geoftp.ibge.gov.br
  • Conversão: implementada em Node com shapefile e @turf/turf (scripts/seed-ibge/importar.ts), com encoding UTF-8 explícito e SIRGAS 2000 tratado como WGS84
  • Hierarquia: os bairros trazem o CD_MUN e o pai é resolvido pelo código oficial contra os municípios já importados. Fontes sem código, como o OSM, dependem de point-in-polygon
  • Idempotência: chave por codigo_municipio, codigo_uf, codigo_regiao e codigo_bairro nos metadados
  • Simplificação: por arquivo. 0,001 grau (cerca de 110 m) nos níveis macro e 0,0001 (cerca de 11 m) nos bairros. Nos bairros, quando a geometria simplificada falha na validação, a original é usada. Nos níveis macro, geometria inválida é descartada e o descarte aparece no resumo do importador
  • Volume: 17.575 bairros, 5.519 municípios, 25 UFs e 4 regiões, cerca de 66 MB em JSONB

A cobertura de bairros é parcial por natureza. Bairro é competência municipal: o IBGE representa apenas os legalmente instituídos, aproximados por setores censitários, em 895 municípios e 25 UFs, sem Tocantins e sem o Distrito Federal. Bairros cujo município não está no banco entram com parent_uc_id nulo; são 306, todos em municípios descartados pela simplificação municipal. Onde não há bairro, o sistema opera na granularidade disponível. Os níveis 1 (setores e micro-áreas) e as lacunas do nível 2 seguem para o OSM e o mapeamento coletivo.

Desempenho da resolução com os bairros: a D-2 e a L-2 mantêm um snapshot em memória da lista de polígonos (CoreGeoRepository, TTL de 6 horas) e cada polígono carrega o próprio bounding box. O PointInPolygonService descarta pelo bbox antes de chamar o Turf. Com 5.549 polígonos, a resolução caiu de até 7,99 ms para a faixa de 0,005 a 0,090 ms por ponto. A recarga do snapshot acontece na primeira consulta após o TTL, sem reiniciar a API.

Camada 2 — Mapeamento coletivo (cidadãos desenham polígonos)

Seção intitulada “Camada 2 — Mapeamento coletivo (cidadãos desenham polígonos)”

O nível 1 (micro-unidade cívica / setor) e o nível 2 (bairro) são populados por cidadãos desenhando os contornos no mapa. Este é o mecanismo alinhado com a filosofia do projeto — a mesma lógica de “cidadão reporta demanda” se aplica a “cidadão define o território”.

Fluxo:

Cidadão (front-end)
→ Abre ferramenta de desenho no mapa (Leaflet.Draw / react-leaflet-draw)
→ Desenha polígono fechado do bairro/setor
→ Submete como lugar com tipo_lugar = 'poligono_uc'
→ D-1a publica lugar.recebido no barramento
L-1 (Cadastro de Lugares)
→ Valida geometria: anel fechado, sem auto-intersecção, área > mínima
→ Deduplica com polígonos existentes da mesma UC (sobreposição > limiar = alerta)
→ Gera lugar_id e publica lugar.cadastrado
L-2 (Georreferenciamento e Tipificação)
→ Identifica tipo_lugar = 'poligono_uc'
→ Loga "capturado e mantido em análise" e avança o cursor sem georreferenciar
nem publicar (a inserção em core.uc_polygons é Fase 2)
Colônia de Infraestrutura Geoespacial (Fase 2 — Núcleo)
→ MVP: o seed manual direto em core.uc_polygons supre essa lacuna
→ Fase 2: colônia dedicada consome lugar.cadastrado com tipo_lugar = 'poligono_uc'
→ Valida contra polígonos existentes
→ Se sobreposição com polígono existente > 80%: ignora (já mapeado)
→ Se sobreposição entre 50-80%: cria versão alternativa, sinaliza para revisão
→ Se sem sobreposição significativa: insere como novo polígono em core.uc_polygons
→ Publica uc.poligono_adicionado no barramento

A validação fina da geometria pertence à L-1 no MVP e à colônia de infraestrutura na Fase 2. O caminho de captura do polígono fica inerte na L-2 até a Fase 2, quando a inserção em core.uc_polygons passa a ser automática.

Validação implícita por uso: Quanto mais demandas e lugares são georreferenciados dentro de um polígono desenhado pela comunidade, mais validação implícita ele recebe. Se o polígono estiver errado (ex: deslocado 500m do lugar real), as demandas daquela área cairão no polígono errado ou fora de todos os polígonos — o sistema acumulará evidência de que a geometria precisa de revisão.

Resolução de conflitos entre desenhos: Múltiplos cidadãos podem desenhar o mesmo bairro gerando polígonos ligeiramente diferentes. A heurística de consenso é simples: o polígono com maior área de intersecção com os demais vira o canônico. Versões alternativas são preservadas como histórico. A cada N submissões conflitantes, um alerta de moderação é gerado.

Camada 3 — OpenStreetMap como fallback (nível 2 apenas, planejada)

Seção intitulada “Camada 3 — OpenStreetMap como fallback (nível 2 apenas, planejada)”

Para bairros que já estão mapeados no OSM (relação admin_level=10), a Overpass API pode ser usada para importação automatizada do nível 2. A cobertura no Brasil é incompleta mas crescente — capitais e regiões metropolitanas tendem a ter bairros mapeados.

  • Query: rel["admin_level"="10"]["name"](area); sobre o município
  • Volume estimado: ~20% dos municípios brasileiros têm bairros mapeados no OSM
  • Quando usar: Como complemento à importação IBGE, antes de liberar o mapeamento coletivo. Reduz o esforço manual nas áreas já cobertas
1. Rodar o seed IBGE (malha municipal 2025 e bairros do Censo 2022) → 23.123 linhas em core.uc_polygons
2. Rodar script de importação OSM (nível 2, bairros disponíveis) → completa lacunas onde o IBGE não tem bairro
3. Liberar ferramenta de desenho no front-end (nível 1 e bairros restantes)
4. Cidadãos desenham os polígonos faltantes
5. Sistema opera com os polígonos disponíveis; áreas sem bairro caem no município (nível 4) ou na granularidade superior disponível

Onde não há polígono de bairro, o nivel_minimo_resolvido retorna o nível disponível, tipicamente 4 (município). O sistema não bloqueia — opera com a granularidade disponível e melhora conforme o território é mapeado.

O seed roda por npx prisma db seed, registrado em prisma.config.ts e orquestrado por prisma/seed-ibge.ts:

  1. scripts/seed-ibge/baixar.sh baixa e extrai os shapefiles de geoftp.ibge.gov.br em scripts/seed-ibge/data/ (diretório ignorado pelo Git).
  2. scripts/seed-ibge/importar.ts valida a geometria, converte para GeoJSON e insere em core.uc_polygons com fonte = 'ibge': municípios (nível 4), bairros do Censo 2022 (nível 2), UFs (nível 5), regiões (nível 6) e o polígono do Brasil (nível 7). Ao final, imprime o resumo por nível e por fonte.

O importador é idempotente pela chave fonte somada ao código da fonte nos metadados (codigo_municipio, codigo_uf, codigo_regiao e codigo_bairro). Rodar o mesmo seed duas vezes atualiza a geometria e o nome dos registros existentes, sem duplicar. Novo censo ou mudança de malha gera nova execução do seed, não uma migration. A validação de geometria (SRID 4326, anel fechado, auto-intersecção em anéis de até 2.000 vértices e área mínima de 100 m²) fica em scripts/utils/geo.ts.



Documento de especificação técnica de implementação.