Pular para o conteúdo

L-2 — Georreferenciamento e Tipificação

Parte das Colônias de Lugares — Fase 1


A L-2 resolve a questão territorial dos lugares cadastrados pela L-1: a qual unidade cívica pertencem e em qual nível? Também enriquece a tipificação cruzando o subtipo declarado pelo cidadão com fontes abertas, para verificar se ele é plausível para aquelas coordenadas.

Consome lugar.cadastrado da L-1 e produz lugar.georreferenciado. O evento de saída é consumido por três colônias: a L-1 (atualiza o próprio status do registro), a E-1 (define a associação territorial de organizações) e a L-3 (ponto de partida para validação de qualidade, na Fase 2). É análoga à D-2 do fluxo de demandas, com a responsabilidade adicional da tipificação.

Não valida se o lugar existe de fato. Não aciona validação social. Apenas resolve geometria territorial e enriquece o tipo declarado. O dado bruto original (preservado pela L-1) nunca é alterado.

A L-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 L-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/lugar/l-2-georreferenciamento-tipificacao/
├── l2.module.ts # Module definition
├── l2.service.ts # Lógica de negócio: resolver UC, enriquecer tipo, publicar
├── l2.constants.ts # Constantes: raio OSM, TTLs, enums de confiança
├── config/
│ └── osm-tag-mapping.json # Mapeamento OSM tag → subtipo de lugar
├── repositories/
│ ├── resolucao.repository.ts # Acesso a l2.resolucoes (insert + query)
│ ├── consumer-offset.repository.ts # Acesso a l2.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)
├── osm-client.service.ts # Cliente HTTP para a Overpass API
├── osm-cache.service.ts # Cache de respostas OSM (in-memory)
├── osm-tag-mapping.ts # Carregamento do mapeamento e resolução de tags
└── tipificacao.service.ts # Comparação entre subtipo declarado e OSM

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

@Module({
imports: [],
controllers: [], // Colônia pura de eventos — sem REST
providers: [
L2Service,
ResolucaoRepository,
ConsumerOffsetRepository,
CoreGeoRepository,
PointInPolygonService,
GeoCacheService,
OsmCacheService,
OsmClientService,
TipificacaoService,
],
exports: [],
})
export class L2Module implements OnModuleInit {
constructor(private readonly l2Service: L2Service) {}
async onModuleInit(): Promise<void> {
await this.l2Service.iniciar();
}
}
  • O módulo não é @Global(). A L-2 não é dependência de nenhuma outra colônia. Outras colônias consomem seu evento (lugar.georreferenciado), 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 L-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 L-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.
// L2Service consome 'lugar.cadastrado', publica 'lugar.georreferenciado'
iniciar(): Promise<void>;
processarLugarCadastrado(evento: EventoConsultado): Promise<void>;
registrarConsumidores(): void;

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

A L-2 não tem BFF acoplado. É uma colônia de processamento puro: escuta, processa, publica. O input é sempre via barramento — exclusivamente lugar.cadastrado da L-1. A L-2 não sabe e não precisa saber se o lugar foi originado por um cidadão (D-1a) ou por uma organização (E-1).

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 L-2 acessa core.uc_polygons em modo leitura para realizar point-in-polygon. 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 L-2 residem no schema l2 do PostgreSQL. Este schema é de uso exclusivo do módulo L-2. Nenhuma outra colônia lê ou escreve nestas tabelas.

Registro do resultado do georreferenciamento e enriquecimento de tipificação para cada lugar processado. Uma linha por lugar_id.

CREATE SCHEMA IF NOT EXISTS l2;
CREATE TABLE l2.resolucoes (
id UUID NOT NULL,
lugar_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,
tipo_lugar VARCHAR(30) NOT NULL,
subtipo_declarado VARCHAR(50),
subtipo_confirmado VARCHAR(50),
confianca_tipificacao VARCHAR(20) NOT NULL,
enriquecimento_fonte VARCHAR(10),
enriquecimento_dados JSONB,
divergencia_tipificacao JSONB,
event_id UUID NOT NULL,
correlacao_id UUID NOT NULL,
criado_em TIMESTAMPTZ(2) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT resolucoes_pkey PRIMARY KEY (id)
);
CREATE UNIQUE INDEX resolucoes_lugar_id_key ON l2.resolucoes (lugar_id);
CREATE INDEX resolucoes_unidade_civica_id_idx ON l2.resolucoes (unidade_civica_id);
CREATE INDEX resolucoes_event_id_idx ON l2.resolucoes (event_id);
CREATE INDEX resolucoes_confianca_geo_confianca_tipificacao_idx
ON l2.resolucoes (confianca_geo, confianca_tipificacao);

O banco não tem CHECKs para confianca_geo, confianca_tipificacao, 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.
lugar_id UUID FK lógica para l1.lugares.id. 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). Informa a granularidade da resolução.
cadeia_ucs UUID[] Array ordenado de UCs pai, da menor resolvida até a nacional. [uc_nivel_N, uc_nivel_N+1, ..., uc_nivel_7].
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 (MVP), media para endereço textual, baixa para inferência.
tipo_lugar VARCHAR(30) residencia, organizacao ou equipamento_publico. Mantido do payload de entrada.
subtipo_declarado VARCHAR(50) Subtipo original do payload de lugar.cadastrado.
subtipo_confirmado VARCHAR(50) Subtipo após enriquecimento. Igual ao declarado se confirmado, diferente se houve divergência, NULL se enriquecimento não encontrou correspondência.
confianca_tipificacao VARCHAR(20) alta (OSM confirmou subtipo), media (OSM encontrou algo próximo mas diferente), baixa (OSM não encontrou nada no raio), nao_avaliada (OSM indisponível ou tipo_lugar = 'residencia').
enriquecimento_fonte VARCHAR(10) Fonte do enriquecimento: osm no MVP. cnpj, cnes, inep na Fase 2. NULL se enriquecimento não executado.
enriquecimento_dados JSONB Dados brutos do enriquecimento: nome, categoria, tags e distância da fonte. NULL se enriquecimento não executado ou não encontrou dados.
divergencia_tipificacao JSONB Presente quando OSM sugere tipo diferente do declarado: { "declarado": "farmacia", "osm": "supermercado", "distancia_metros": 5.2 }. NULL quando não há divergência.
event_id UUID event_id do evento lugar.cadastrado 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 lugar_id assume.
criado_em TIMESTAMPTZ(2) Timestamp de criação da resolução.

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 l2.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 = 'lugar.cadastrado'. A estrutura com PK em tipo_evento permite extensão futura sem alteração de schema.

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

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

A L-2 consulta esta tabela do schema core em modo somente leitura para realizar point-in-polygon. É uma simplificação de MVP (ver seção 1.6 e 8.2). A tabela é populada pelos scripts de seed territorial (descritos no SDS da D-2, 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 ({ "type": "Polygon", "coordinates": [...] }). 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 l2 e da base territorial:

  1. 20260809112210_d2_uc_polygons_resolucoes_geo — Cria core.uc_polygons (migration única compartilhada com o núcleo e a D-2).
  2. 20260811195030_add_fonte_metadados_populacao_uc_polygons — Adiciona fonte, metadados e populacao_estimada a core.uc_polygons.
  3. 20260813182624_create_l2_tables — Cria o schema l2, a tabela l2.resolucoes e a tabela l2.consumer_offset.
  4. 20260913120000_uc_polygons_nome_busca — Adiciona nome_busca e o trigger de normalização do nome.

Seed inicial do consumer_offset: no boot, offsetRepo.seed(['lugar.cadastrado'], 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, tabela l2.enriquecimentos para múltiplas fontes e partição por UC quando o volume escalar.

Não há foreign keys entre as tabelas do schema l2. O lugar_id em l2.resolucoes referencia l1.lugares.id, mas sem FK formal — schemas de colônias distintas não podem ter FKs entre si. A integridade é garantida em aplicação: o L2Service só processa lugares cujo lugar_id foi publicado pela L-1 no barramento.

Tabela única l2.resolucoes em vez de tabelas separadas para território e tipificação. A resolução territorial e o enriquecimento de tipificação são processados no mesmo fluxo, para o mesmo lugar_id, como uma unidade atômica de trabalho. Separar em duas tabelas criaria uma relação 1:1 desnecessária no MVP. Na Fase 2, quando o enriquecimento incluir múltiplas fontes (CNPJ, CNES, Inep) com esquemas distintos, uma tabela de enriquecimentos separada (l2.enriquecimentos) permitirá múltiplas linhas por lugar_id com fonte e dados próprios.

cadeia_ucs como UUID[] em vez de tabela de junção. O array de UUIDs é compacto, a consulta é sempre “dado um lugar, quais UCs o contêm?” e nunca “dada uma UC, quais lugares ela contém?” (essa é responsabilidade da L-4 na Fase 2). Um array PostgreSQL é suficiente e evita uma tabela de junção. A ordem é garantida: do menor nível resolvido até o nacional.

divergencia_tipificacao como JSONB separado de enriquecimento_dados. A divergência é um campo de negócio com semântica própria: sinaliza conflito entre o declarado e o observado. Mantê-la separada do enriquecimento bruto facilita queries de auditoria (WHERE divergencia_tipificacao IS NOT NULL) e alimenta a L-3 (Fase 2) com prioridade de validação.

confianca_geo sempre alta no MVP. Todos os lugares chegam à L-2 com coordenadas GPS do dispositivo — a L-1 já validou lat/lng, bounding box e descartou entradas inválidas. O campo existe para forward compatibility com endereços textuais (Fase 2), quando a D-1a ou E-1 poderão aceitar endereço em texto e a L-2 precisará geocodificar antes do point-in-polygon.


A L-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 L-2: o que espera receber, o que publica e em que ordem.

Propriedade Valor
Tipo lugar.cadastrado
Schema version 1.2.0 (o catálogo mantém 1.0.0 e 1.1.0)
Produtor L-1 (Cadastro de Lugares)
Consumidor L-2 (esta colônia)
Descrição Lugar validado e registrado pela L-1, pronto para georreferenciamento e enriquecimento de tipificação.

Payload esperado (conforme o schema lugar.cadastrado do Registry N-0b):

interface LugarCadastradoPayload {
lugar_id: string; // UUID v4 — gerado pela L-1
tipo_lugar: string; // 'residencia' | 'organizacao' | 'equipamento_publico' | 'poligono_uc'
subtipo?: string; // ex: 'farmacia', 'ubs', 'mercado'
nome?: string;
descricao?: string; // campo opcional da versão 1.2.0; ignorado pela L-2
coordenadas_brutas: {
lat: number;
lng: number;
};
alerta_proximidade?: { // presente apenas quando L-1 detectou duplicata
lugar_id_proximo: string;
distancia_metros: number;
};
cidadao_id: string;
canal: string;
horario_funcionamento?: string;
}
Propriedade Valor
Tipo lugar.georreferenciado
Schema version 1.0.0
Produtor L-2 (esta colônia)
Consumidores L-1 (atualização de status), E-1 (associação territorial de organizações), L-3 (validação, na Fase 2)
Descrição Lugar com UC resolvida, cadeia de UCs pai completa e tipificação enriquecida com nível de confiança.

Payload publicado (conforme o schema lugar.georreferenciado do Registry N-0b):

interface LugarGeorreferenciadoPayload {
lugar_id: string;
unidade_civica_id: string; // UC de menor nível resolvida
nivel_minimo_resolvido: number; // 1-7 — nível da UC resolvida
cadeia_ucs: string[]; // [uc_nivel_N, ..., uc_nivel_7]
metodo_resolucao: string; // 'gps' | 'endereco' | 'inferencia'
confianca_geo: string; // 'alta' | 'media' | 'baixa'
tipo_lugar: string;
subtipo_declarado?: string;
subtipo_confirmado?: string;
confianca_tipificacao: string; // 'alta' | 'media' | 'baixa' | 'nao_avaliada'
enriquecimento?: {
fonte: string; // 'osm' no MVP
dados?: Record<string, unknown>; // dados brutos da fonte
divergencia?: {
declarado: string;
encontrado: string;
distancia_metros: number;
};
};
}

O fluxo em L2Service.processarLugarCadastrado() segue esta ordem exata:

1. Verificar idempotência por event_id
→ consultar l2.resolucoes WHERE event_id = ?
→ se encontrado: republicar lugar.georreferenciado e avançar o cursor
2. Validar lugar_id
→ ausente: log.error, cursor avança sem processar
3. Validar tipo_lugar contra TIPOS_LUGAR_VALIDOS
→ ausente ou fora da lista: log.error, cursor avança sem processar
4. Polígono de UC (tipo_lugar = 'poligono_uc')
→ log "capturado e mantido em análise", cursor avança SEM georreferenciar
nem publicar (a inserção em core.uc_polygons é Fase 2)
5. Extrair coordenadas do payload
→ lat = payload.coordenadas_brutas.lat
→ lng = payload.coordenadas_brutas.lng
→ ausentes: log.error, cursor avança sem processar
6. Resolver território (point-in-polygon)
→ consultar cache geo: l2:geo:{lat}:{lng}
→ se cache hit: usar UC resolvida 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 UC contendo o ponto: parar (menor nível resolvido)
→ resolver cadeia de UCs pai
→ armazenar no cache geo
→ se nenhum polígono contiver o ponto: log.error, cursor avança sem processar
7. Enriquecer tipificação (pulado para tipo_lugar = 'residencia')
→ consultar cache OSM: l2:osm:{lat}:{lng}
→ se cache hit: usar resultado do cache
→ se cache miss:
→ chamar Overpass API com bounding box ao redor das coordenadas
→ armazenar resposta no cache OSM
→ mapear tags OSM para subtipos de lugar
→ comparar com subtipo_declarado:
→ match exato → confianca_tipificacao = 'alta', subtipo_confirmado = declarado
→ match próximo mas diferente → confianca_tipificacao = 'media', registrar divergência
→ sem match → confianca_tipificacao = 'baixa', subtipo_confirmado = null
→ se OSM indisponível (timeout/erro):
→ confianca_tipificacao = 'nao_avaliada', subtipo_confirmado = null
8. Persistir em l2.resolucoes
9. Publicar lugar.georreferenciado 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.
10. Atualizar o consumer offset

Persistir antes de publicar garante que a resolução esteja registrada mesmo se o barramento falhar. Se o passo 9 falhar, a resolução fica em l2.resolucoes mas o evento lugar.georreferenciado não foi publicado. A DLQ registra a falha. No replay, a idempotência por event_id no passo 1 detecta que a resolução já existe e apenas republica o evento.

Cenário Comportamento
Evento reentregue (replay DLQ) Detectado por event_id no passo 1. Registro existente encontrado. Log.info, republica lugar.georreferenciado e avança o cursor.
lugar_id ausente Log.error. Evento descartado. Cursor avança.
tipo_lugar ausente ou inválido Log.error. Evento descartado. Cursor avança.
tipo_lugar = 'poligono_uc' Log “capturado e mantido em análise”. Cursor avança sem georreferenciar nem publicar.
Coordenadas ausentes no payload Log.error. Evento descartado. Nenhum registro em l2.resolucoes. Cursor avança. A L-1 não deveria publicar lugar.cadastrado sem coordenadas. Se ocorrer, é bug na L-1 ou violação de schema.
Point-in-polygon não encontra UC (ponto fora de todos os polígonos) Log.error. Ponto pode estar em área não coberta (ex: zona rural sem polígono definido). Nenhum registro em l2.resolucoes. Cursor avança. Na Fase 2, tenta resolver no nível municipal por fallback (distância ao centroide mais próximo).
Overpass API timeout (5s) Log.warn. Enriquecimento marcado como nao_avaliada. Resolução territorial prossegue normalmente. O lugar é georreferenciado sem enriquecimento.
Overpass API retorna erro 5xx ou 429 Log.warn. Idem timeout. Sem retry — o enriquecimento é melhoria, não bloqueio. A L-3 (Fase 2) tentará novamente.
Overpass API retorna resposta vazia (sem elementos no raio) Log.info. confianca_tipificacao = 'baixa'. Nenhum erro — simplesmente não há dado OSM naquelas coordenadas.
INSERT falha (violação de unique em lugar_id) Log.error. Indica que o mesmo lugar_id foi processado duas vezes com event_id diferente — cenário impossível no MVP (a L-1 gera lugar_id único). Se ocorrer, o segundo INSERT falha e o erro é registrado na DLQ.
Publish de lugar.georreferenciado falha Log.error. Registro existe em l2.resolucoes. DLQ registrada. O handler de replay republica o evento ao encontrar o registro existente.
Caches em memória perdidos em reinicialização Degradação graciosa: point-in-polygon repete e Overpass é consultada novamente até o cache repopular. Performance reduzida temporariamente, funcionalidade preservada.

3.5 Idempotência na reentrega — republicação do evento de saída

Seção intitulada “3.5 Idempotência na reentrega — republicação do evento de saída”

Diferente da L-1, que apenas ignora eventos reentregues, a L-2 republica lugar.georreferenciado ao detectar um event_id já processado. Justificativa: o consumidor principal do evento é a E-1 (associação territorial de organizações). Se a E-1 estava fora do ar quando a L-2 publicou o evento pela primeira vez, a republicação no replay garante que a E-1 receba o dado. A L-1, que também consome este evento para atualizar status, é idempotente: se o status já for georreferenciado, apenas loga e retorna.

função processarLugarCadastrado(evento: EventoConsultado):
existente = resolucaoRepo.buscarPorEventId(evento.event_id)
se existente não é null:
logger.log("Evento já processado — republicando lugar.georreferenciado", {
event_id: evento.event_id,
lugar_id: existente.lugar_id,
})
await this.publicarLugarGeorreferenciado(existente, evento.correlacao_id)
await offsetRepo.upsert('lugar.cadastrado', evento.sequence_number)
retornar
// ... processamento normal ...

Persistir antes de publicar. Mesmo princípio da N-0a, do BFF e da L-1: o estado próprio é a memória da colônia. Se o barramento falhar após o INSERT, a resolução está salva e recuperável.

Republicar evento de saída no replay. A L-2 é um elo crítico na cadeia de lugares — sem lugar.georreferenciado, a E-1 não consegue associar organizações a UCs. A republicação no replay garante entrega at-least-once para consumidores que possam ter perdido o evento original. A L-1 e a E-1 devem ser idempotentes no consumo.

Enriquecimento OSM como melhoria, não bloqueio. Se a Overpass API estiver indisponível, o lugar é georreferenciado com confianca_tipificacao = 'nao_avaliada'. A resolução territorial (point-in-polygon) é local e determinística — nunca depende de serviço externo. O enriquecimento agrega valor, mas sua ausência não interrompe o pipeline.

Snapshot de polígonos com TTL de 6 horas. A lista de polígonos fica em memória no CoreGeoRepository com carregadoEm, e cada polígono leva o próprio bounding box. A recarga automática após 6 horas dispensa reiniciar a API depois de um novo seed IBGE. O pré-filtro do PointInPolygonService descarta pelo bbox antes de chamar o Turf.

Cache de duas camadas em memória: geo + OSM. O cache geo (l2:geo:*) evita point-in-polygon repetido para coordenadas idênticas ou muito próximas. O cache OSM (l2:osm:*) evita chamadas repetidas à Overpass API para a mesma região. Ambos são Map em memória no MVP. Redis (compartilhado com D-2 e D-4) entra na Fase 2, com múltiplas instâncias do monolito.


4.1 L2Service.processarLugarCadastrado() — pseudocódigo

Seção intitulada “4.1 L2Service.processarLugarCadastrado() — pseudocódigo”
função processarLugarCadastrado(evento: EventoConsultado):
eventId = evento.event_id
sequencia = evento.sequence_number
payload = evento.payload
// 1. Idempotência por event_id (com republicação)
existente = resolucaoRepo.buscarPorEventId(eventId)
se existente não é null:
logger.log("Evento já processado — republicando lugar.georreferenciado", {
event_id: eventId,
lugar_id: existente.lugar_id,
})
await this.publicarLugarGeorreferenciado(existente, evento.correlacao_id)
await offsetRepo.upsert('lugar.cadastrado', sequencia)
retornar
// 2. Validar lugar_id
lugarId = extrairTexto(payload.lugar_id)
se lugarId é null:
logger.error("lugar_id ausente em lugar.cadastrado — descartando")
await offsetRepo.upsert('lugar.cadastrado', sequencia)
retornar
// 3. Validar tipo_lugar
tipoLugar = extrairTexto(payload.tipo_lugar)
se tipoLugar é null ou não está em TIPOS_LUGAR_VALIDOS:
logger.error("tipo_lugar inválido em lugar.cadastrado — descartando")
await offsetRepo.upsert('lugar.cadastrado', sequencia)
retornar
// 4. Polígono de UC: capturado e mantido em análise (Fase 2)
se tipoLugar == 'poligono_uc':
logger.log("Lugar do tipo poligono_uc capturado e mantido em análise")
await offsetRepo.upsert('lugar.cadastrado', sequencia)
retornar
// 5. Extrair coordenadas
coordenadas = extrairCoordenadas(payload.coordenadas_brutas)
se coordenadas é null:
logger.error("Coordenadas ausentes em lugar.cadastrado — descartando")
await offsetRepo.upsert('lugar.cadastrado', sequencia)
retornar
lat = coordenadas.lat
lng = coordenadas.lng
// 6. Resolver território
resultado = geoCache.obter(lat, lng)
se resultado é null:
poligonos = await coreGeoRepo.buscarTodosOrdenadosPorNivel()
resultado = pointInPolygonService.resolverTerritorio(lat, lng, poligonos)
se resultado é null:
logger.error("Point-in-polygon falhou — ponto fora de todos os polígonos")
await offsetRepo.upsert('lugar.cadastrado', sequencia)
retornar
geoCache.definir(lat, lng, resultado)
// 7. Enriquecer tipificação
subtipoDeclarado = extrairTexto(payload.subtipo)
tipificacao = await tipificacaoService.enriquecer(lat, lng, tipoLugar, subtipoDeclarado)
// 8. Persistir
resolucao = await resolucaoRepo.inserir({
lugar_id: lugarId,
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',
tipo_lugar: tipoLugar,
subtipo_declarado: subtipoDeclarado,
subtipo_confirmado: tipificacao.subtipo_confirmado,
confianca_tipificacao: tipificacao.confianca,
enriquecimento_fonte: tipificacao.fonte,
enriquecimento_dados: tipificacao.dados,
divergencia_tipificacao: tipificacao.divergencia,
event_id: eventId,
correlacao_id: evento.correlacao_id ?? lugarId,
})
// 9. Publicar
await this.publicarLugarGeorreferenciado(resolucao, evento.correlacao_id)
// 10. Atualizar consumer offset
await offsetRepo.upsert('lugar.cadastrado', sequencia)

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 L2Service consulta o GeoCacheService e, no miss, carrega o snapshot pelo CoreGeoRepository e delega o cálculo ao PointInPolygonService.

função no L2Service (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 6: descarte com log.error
geoCache.definir(lat, lng, resultado)
função resolverTerritorio(lat, lng, poligonos) -> ResultadoTerritorio | null:
ponto = turf.point([lng, lat]) // Turf.js usa [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

Algoritmo de point-in-polygon: A implementação usa @turf/boolean-point-in-polygon (Turf.js). O algoritmo interno é ray casting: traça uma semirreta do ponto ao infinito e conta interseções com as arestas do polígono. Número ímpar de interseções = ponto dentro. A complexidade é O(v) por polígono, onde v é o número de vértices.

Cache de coordenadas com arredondamento: Coordenadas são arredondadas para 5 casas decimais (~1.1 metros de precisão no Equador). Dois lugares na mesma calçada produzem a mesma chave de cache. O TTL de 30 dias reflete a estabilidade dos polígonos de UC — eles só mudam quando há recorte territorial oficial (IBGE), o que ocorre a cada censo decenal.

Ordem de avaliação dos níveis: Os polígonos são carregados ordenados por nível (1 a 7). O algoritmo para no primeiro match — o nível mais granular que contém o ponto. Se não houver polígonos de níveis 1-3 (comum no MVP), o primeiro match será nível 4 (município). O campo nivel_minimo_resolvido informa ao consumidor qual granularidade foi alcançada.

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:
logger.warn("Hierarquia de UCs quebrada — parent_uc_id sem correspondência: ...")
break
cadeia.push(pai.unidade_civica_id)
parentId = pai.parent_uc_id
return cadeia

A cadeia é construída subindo de pais até o nível 7 (nacional). O resultado é um array ordenado do menor nível resolvido até o nacional: [uc_nivel_4, uc_nivel_5, uc_nivel_6, uc_nivel_7].

4.4 TipificacaoService.enriquecer() — OSM com cache

Seção intitulada “4.4 TipificacaoService.enriquecer() — OSM com cache”

O enriquecimento vive no TipificacaoService. O service do lugar só chama tipificacaoService.enriquecer(lat, lng, tipoLugar, subtipoDeclarado).

função enriquecer(lat, lng, tipoLugar, subtipoDeclarado) -> ResultadoTipificacao:
// Residências não passam por enriquecimento de tipificação
se tipoLugar == 'residencia':
return resultadoSemEnriquecimento('nao_avaliada')
// 1. Verificar cache OSM
elementosOsm = osmCache.obter(lat, lng)
se elementosOsm é null:
elementosOsm = await osmClient.consultar(lat, lng)
se elementosOsm não é null:
osmCache.definir(lat, lng, elementosOsm)
// 2. OSM indisponível
se elementosOsm é null:
return resultadoSemEnriquecimento('nao_avaliada')
// 3. OSM respondeu sem elementos
se elementosOsm.length == 0:
return {
confianca: 'baixa',
subtipo_confirmado: null,
fonte: 'osm',
dados: { elementos_encontrados: 0, raio_consulta: 50 },
divergencia: null,
}
// 4. Mapear o elemento mais próximo
maisProximo = elementosOsm[0] // o cliente ordena por distância
subtipoOsm = mapearTagOsmParaSubtipo(maisProximo.tags, carregarMapeamentoOsm())
// 5. Comparar com o subtipo declarado
se subtipoDeclarado é null:
return { confianca: 'media', subtipo_confirmado: subtipoOsm, fonte: 'osm',
dados: dadosDoElemento(maisProximo), divergencia: null }
se subtipoOsm == subtipoDeclarado:
return { confianca: 'alta', subtipo_confirmado: subtipoDeclarado, fonte: 'osm',
dados: dadosDoElemento(maisProximo), divergencia: null }
// Match diferente: registra divergência e mantém o declarado
return {
confianca: 'media',
subtipo_confirmado: subtipoDeclarado,
fonte: 'osm',
dados: dadosDoElemento(maisProximo),
divergencia: {
declarado: subtipoDeclarado,
encontrado: subtipoOsm,
distancia_metros: maisProximo.distancia,
},
}

dadosDoElemento monta { nome_osm, categoria_osm, distancia_metros, tags }, com categoria_osm vinda de amenity, shop ou office.

4.5 consultarOverpass() — cliente HTTP para Overpass API

Seção intitulada “4.5 consultarOverpass() — cliente HTTP para Overpass API”
função consultar(lat, lng) no OsmClientService -> ElementoOsm[] | null:
raio = 50 metros
query = Overpass com nodes e ways que tenham name nas chaves
amenity, shop, office, leisure e tourism, around:raio,lat,lng,
com [out:json][timeout:5] e out body center qt
tentar:
resposta = await fetch('https://overpass-api.de/api/interpreter', {
method: 'POST',
body: query,
headers: { 'Content-Type': 'text/plain' },
signal: AbortSignal.timeout(5000),
})
se resposta não é ok:
logger.warn("Overpass API retornou status não-200")
return null
dados = await resposta.json()
para cada elemento em dados.elements:
se elemento.tags.name existe:
elementoLat = elemento.lat ?? elemento.center.lat
elementoLng = elemento.lon ?? elemento.center.lon
distancia = haversine(lat, lng, elementoLat, elementoLng)
elementos.push({ osm_id, tipo, lat, lng, distancia, tags })
elementos.sort(por distancia ASC)
return elementos
capturar erro:
logger.warn("Overpass API indisponível ou timeout")
return null

4.6 mapearTagOsmParaSubtipo() — mapeamento configurável

Seção intitulada “4.6 mapearTagOsmParaSubtipo() — mapeamento configurável”
função mapearTagOsmParaSubtipo(tags, mapeamento) -> string | null:
// Prioridade: amenity > shop > office > leisure > tourism
para cada chaveOsm em ['amenity', 'shop', 'office', 'leisure', 'tourism']:
valorOsm = tags[chaveOsm]
se valorOsm existe e mapeamento[chaveOsm][valorOsm] existe:
return mapeamento[chaveOsm][valorOsm]
return null

O mapeamento é carregado na primeira chamada de config/osm-tag-mapping.json, relativo ao módulo, e permanece em memória de processo. Falha na leitura cai no MAPEAMENTO_PADRAO embutido em osm-tag-mapping.ts, com aviso no log.

Mapeamento OSM → subtipo padrão (MVP):

Tag OSM Subtipo
amenity=pharmacy farmacia
amenity=hospital hospital
amenity=clinic ubs
amenity=doctors consultorio
amenity=dentist consultorio_odontologico
amenity=school escola
amenity=university universidade
amenity=library biblioteca
amenity=marketplace mercado
amenity=police delegacia
amenity=fire_station bombeiros
amenity=townhall prefeitura
amenity=courthouse forum
amenity=post_office correios
amenity=bank banco
amenity=restaurant restaurante
amenity=cafe cafeteria
amenity=bar bar
amenity=place_of_worship templo_religioso
amenity=community_centre centro_comunitario
amenity=parking estacionamento
shop=supermarket supermercado
shop=convenience mercado
shop=bakery padaria
shop=hardware loja_construcao
shop=clothes loja_roupas
office=government orgao_publico
leisure=park parque
leisure=sports_centre centro_esportivo
leisure=fitness_centre academia
tourism=hotel hotel
tourism=museum museu

A L-2 não define política de categorização. O mapeamento é um parâmetro técnico, auditável e substituível sem recompilação.

4.7 L2Service.iniciar() — protocolo de inicialização

Seção intitulada “4.7 L2Service.iniciar() — protocolo de inicialização”
função iniciar():
se já iniciado: retornar
// 1. Iniciar os caches
geoCache.iniciar()
osmCache.iniciar()
// 2. Seed do cursor na maior sequência do log (sem sobrescrever)
maiorSequence = await eventBus.obterMaiorSequence()
await offsetRepo.seed(['lugar.cadastrado'], maiorSequence)
// 3. Replay de eventos perdidos durante inatividade
await this.reprocessarEventosPerdidos()
// lê o cursor em consumer_offset, chama replayDeSequence(cursor, ['lugar.cadastrado'])
// 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('lugar.cadastrado', 'L-2', this.processarLugarCadastrado.bind(this))
logger.log("L-2 Georreferenciamento e Tipificação inicializada")
Caso Comportamento
Lugar em coordenadas oceânicas ou de fronteira (fora de todos os polígonos) resolverTerritorio() retorna null. Evento descartado com log.error. Cursor avança. Na Fase 2, fallback para UC do centróide mais próximo.
Polígono com geometria inválida (self-intersection, anel externo não fechado) Turf.js lança erro. Capturado, log.warn com uc_id do polígono problemático. O polígono é pulado — a iteração continua no próximo. Se nenhum polígono válido contiver o ponto, evento descartado.
Hierarquia de UCs quebrada (parent_uc_id apontando para UC inexistente) resolverCadeiaUcs() para ao encontrar parent_uc_id sem correspondência. A cadeia retornada é parcial (do nível encontrado até onde a hierarquia existe). Log.warn com os IDs ausentes.
Lugar com subtipo que não existe no mapeamento OSM mapearTagOsmParaSubtipo() retorna null. Mesmo que OSM encontre elemento, a comparação falha (null ≠ declarado). confianca_tipificacao = 'media' com divergência registrada.
Lugar com tipo_lugar = 'organizacao' mas subtipo = null Enriquecimento OSM prossegue. Se encontrar elemento, subtipo_confirmado é preenchido com o mapeamento OSM e confianca = 'media'. Se não encontrar, confianca = 'baixa'.
Overpass API retorna elemento a 45 metros (dentro do raio de 50m) mas em outra rua O elemento é incluído na lista. Se for o mais próximo, é usado para comparação. A distância é registrada. A L-3 (Fase 2) pode usar a distância como fator adicional de confiança.
Dois handlers concorrentes processando o mesmo event_id Impossível no MVP monolito (single-threaded event loop). O unique de l2.resolucoes.lugar_id e a idempotência por event_id cobrem a reentrega.
Reinicialização — caches em memória vazios geoCache.iniciar() e osmCache.iniciar() criam Maps vazios. Caches repopulam com novas resoluções. Funcionalidade preservada.

Point-in-polygon com Turf.js, não PostGIS. Turf.js é uma biblioteca JavaScript madura para operações geoespaciais. booleanPointInPolygon é ray casting padrão, testado em produção. A alternativa PostGIS exigiria extensão no PostgreSQL, migração do campo geometria para GEOMETRY e queries SQL com ST_Contains. Para o volume do MVP (< 100 lugares/dia), a diferença de performance é irrelevante. Turf.js elimina a dependência da extensão PostGIS no MVP. A migração para PostGIS na Fase 2 é documentada e o contrato da função resolverTerritorio() permanece idêntico.

Cache geo com arredondamento de 5 casas decimais. 5 casas decimais correspondem a ~1.1 metros no Equador. Dois lugares na mesma fachada de um prédio compartilham a mesma chave de cache. A probabilidade de colisão entre lugares diferentes (ex: dois lados da mesma rua) é baixa — e mesmo se ocorrer, o cache armazena a UC, que é a mesma para ambos os lados da rua. O pior caso (falso positivo de cache) é inócuo: retorna a UC correta.

Enriquecimento apenas para organizações e equipamentos públicos. Residências (tipo_lugar = 'residencia') não têm correspondência significativa no OSM. Consultar a Overpass API para cada casa cadastrada seria desperdício de recursos e não produziria dados úteis. O handler pula o enriquecimento para este tipo, retornando confianca_tipificacao = 'nao_avaliada'.

Mapeamento OSM → subtipo como configuração externa, não hardcoded. O mapeamento entre tags OSM e subtipos de lugar é uma decisão de domínio, não de implementação. Carregá-lo de um arquivo de configuração permite ajuste sem recompilação e mantém a L-2 como aplicadora de regras, não definidora de política.

Timeout de 5 segundos na Overpass API. A Overpass API pública tem latência típica de 500ms-2s para queries simples. Um timeout de 5s cobre o caso normal com margem para picos. Se a API não responder em 5s, o enriquecimento é abortado — o lugar é georreferenciado sem tipificação. O timeout não é configurável no MVP para manter simplicidade; variável de ambiente na Fase 2.


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

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

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

@Injectable()
export class L2Service {
private readonly logger = new Logger(L2Service.name);
constructor(
private readonly eventBus: EventBusService,
private readonly resolucaoRepo: ResolucaoRepository,
private readonly offsetRepo: ConsumerOffsetRepository,
private readonly coreGeoRepo: CoreGeoRepository,
private readonly pointInPolygonService: PointInPolygonService,
private readonly geoCache: GeoCacheService,
private readonly osmCache: OsmCacheService,
private readonly tipificacaoService: TipificacaoService,
) {}
}

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

Cidadão (app)
→ POST /lugares (BFF D-1a)
→ lugar.recebido (barramento)
→ [L-1] → lugar.cadastrado
→ [L-2] — esta colônia → lugar.georreferenciado
→ [L-1] (atualiza status para 'georreferenciado')
→ [E-1] (associação territorial da organização, se tipo_lugar = 'organizacao')
→ [L-3] (validação de qualidade)
E-1 (cadastro de organização)
→ lugar.recebido (barramento, publicado pela E-1)
→ [L-1] → lugar.cadastrado
→ [L-2] — esta colônia → lugar.georreferenciado
→ [L-1] (atualiza status)
→ [E-1] → empresa.associação_territorial_definida

A L-2 é agnóstica em relação à origem do lugar. O mesmo handler processa lugar.cadastrado independentemente de o lugar ter sido originado por um cidadão (D-1a) ou por uma organização (E-1). A E-1, ao consumir lugar.georreferenciado, casa o lugar_id com a organização que o publicou e define a associação territorial.

private async publicarLugarGeorreferenciado(
resolucao: ResolucaoRegistro,
correlacaoId: string | null,
): Promise<void> {
const payload: Record<string, unknown> = {
lugar_id: resolucao.lugar_id,
unidade_civica_id: resolucao.unidade_civica_id,
nivel_minimo_resolvido: resolucao.nivel_minimo_resolvido,
cadeia_ucs: resolucao.cadeia_ucs,
metodo_resolucao: resolucao.metodo_resolucao,
confianca_geo: resolucao.confianca_geo,
tipo_lugar: resolucao.tipo_lugar,
confianca_tipificacao: resolucao.confianca_tipificacao,
};
if (resolucao.subtipo_declarado !== null) {
payload.subtipo_declarado = resolucao.subtipo_declarado;
}
if (resolucao.subtipo_confirmado !== null) {
payload.subtipo_confirmado = resolucao.subtipo_confirmado;
}
if (resolucao.enriquecimento_fonte !== null) {
const enriquecimento: Record<string, unknown> = {
fonte: resolucao.enriquecimento_fonte,
};
if (resolucao.enriquecimento_dados !== null) {
enriquecimento.dados = resolucao.enriquecimento_dados;
}
if (resolucao.divergencia_tipificacao !== null) {
enriquecimento.divergencia = resolucao.divergencia_tipificacao;
}
payload.enriquecimento = enriquecimento;
}
try {
await publicarComRetry(this.eventBus, {
tipo: 'lugar.georreferenciado',
versao_schema: '1.0.0',
origem: 'L-2',
event_id: uuidv4(),
correlacao_id: correlacaoId ?? resolucao.lugar_id,
payload,
});
} catch (erro: unknown) {
this.logger.error('Falha ao publicar lugar.georreferenciado', {
lugar_id: resolucao.lugar_id,
unidade_civica_id: resolucao.unidade_civica_id,
erro: erro instanceof Error ? erro.message : String(erro),
});
throw erro;
}
}

A publicação usa publicarComRetry (4 tentativas com backoff de 500, 1000 e 2000 ms). A falha definitiva relança a exceção: o registro já existe em l2.resolucoes e a DLQ registra a falha do handler.

A L-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 L-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 (ver seção 1.6).
  • Overpass API (serviço externo público, não é colônia).

A L-2 usa Map em memória para duas famílias de cache:

Chave Conteúdo TTL
l2:geo:{lat}:{lng} { unidade_civica_id, nivel_minimo_resolvido, cadeia_ucs } 30 dias (limpeza a cada hora por setInterval)
l2:osm:{lat}:{lng} Array de elementos OSM 7 dias (limpeza a cada hora por setInterval)

A chave geo usa 5 casas decimais e a chave OSM usa 4 casas. O snapshot de polígonos do CoreGeoRepository é um terceiro cache em memória, com TTL de 6 horas e recarga automática na primeira consulta após o vencimento.

Sem Redis no MVP — os caches não sobrevivem a reinicializações e são repopulados por novas resoluções. Redis (compartilhado com D-2 e D-4, isolamento por prefixo de chave) entra na Fase 2, com múltiplas instâncias do monolito.


A L-2 não aplica rate limiting. O volume de eventos que chega à L-2 é limitado indiretamente pelo rate limiting do BFF (D-1a) e pela frequência de cadastro de organizações na E-1. O barramento entrega o que recebe, e a L-2 processa.

Rate limiting de API externa: A Overpass API pública tem limite implícito (fair use policy). O cache OSM (TTL 7 dias por coordenada) reduz drasticamente as chamadas: uma vez consultada uma coordenada, lugares próximos usam o cache. O volume esperado no MVP (< 100 lugares/dia, muitos na mesma região) resulta em < 20 chamadas/dia à Overpass API — muito abaixo de qualquer limite.

Limite Valor Justificativa
Raio de consulta OSM 50 metros Suficiente para encontrar o estabelecimento na mesma calçada ou quarteirão. Uma farmácia a 200m não é a mesma.
Timeout Overpass API 5 segundos Latência típica 500ms-2s. Timeout de 5s cobre picos sem bloquear o pipeline.
Cache geo TTL 30 dias (2.592.000s) Polígonos de UC mudam a cada censo (~10 anos). 30 dias é conservador.
Cache OSM TTL 7 dias (604.800s) Estabelecimentos podem mudar, mas não diariamente. 7 dias equilibra frescor e economia de chamadas.
Arredondamento cache geo 5 casas decimais ~1.1m de precisão. Suficiente para agrupar lugares na mesma fachada.
Arredondamento cache OSM 4 casas decimais ~11m de precisão. A Overpass API retorna elementos em raio de 50m — arredondar para 11m agrupa consultas na mesma quadra.
Tamanho máximo da resposta OSM 100 elementos Limitado pela própria Overpass API (padrão). A query da L-2 não usa out body sem limite.
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.570 municípios + UFs + nacional No pior caso (sem índices de níveis 1-3), ~5.600 polígonos. Cada polígono GeoJSON tem em média 200 vértices (~3 KB). Total: ~17 MB em memória. Carregado uma vez a cada 6 horas.
Índice Query atendida
resolucoes_lugar_id_key (lugar_id, UNIQUE) Idempotência de negócio (lugar já processado com outro event_id?). Acesso por lugar_id.
resolucoes_event_id_idx (event_id) Idempotência por event_id — toda invocação do handler.
resolucoes_unidade_civica_id_idx (unidade_civica_id) “Lugares nesta UC” (dashboard L-4, Fase 2).
resolucoes_confianca_geo_confianca_tipificacao_idx (confianca_geo, confianca_tipificacao) Priorização da L-3 (Fase 2): lugares com baixa confiança primeiro.
uc_polygons_unidade_civica_id_idx (unidade_civica_id) Buscas por UC no snapshot e na D-7.
uc_polygons_nivel_idx (nivel) resolverTerritorio(): carregar polígonos ordenados por nível.

O padrão de acesso dominante é INSERT (resolução de lugar), com SELECT apenas para idempotência e cache:

  • buscarPorEventId(): 1 query por evento recebido (idempotência). Coberta pelo índice em event_id.
  • buscarTodosOrdenadosPorNivel(): 1 query a cada 6 horas (snapshot de polígonos), não por evento. Coberta pelo índice em nivel.
  • inserir(): 1 insert por lugar resolvido.
  • Cadeia de UCs: resolvida em memória sobre o snapshot, sem query por nível.

Volume esperado no MVP: < 100 lugares/dia. Tempo médio de processamento por lugar: < 200ms (point-in-polygon em memória + cache). Com OSM cache miss: < 2s adicionais (chamada à Overpass API). Com OSM cache hit: < 5ms adicional.

Cache geo (point-in-polygon):

  • Map<string, ResultadoTerritorio> com TTL de 30 dias, gerenciado por setInterval de limpeza a cada hora.

Cache OSM (enriquecimento):

  • Map<string, ElementoOsm[]> com TTL de 7 dias, gerenciado por setInterval de limpeza a cada hora.

Snapshot de polígonos (em memória):

  • Os polígonos de core.uc_polygons são carregados pelo CoreGeoRepository ordenados por nível e mantidos em memória com TTL de 6 horas. Após o vencimento, a próxima consulta recarrega o snapshot. O método invalidarCache() permite recarga manual. Não há dependência de reimplantação para atualizar a base.

Justificativa para caches em memória (sem Redis no MVP): O monolito roda em processo único — o Map é mais rápido que Redis (zero latência de rede) e elimina uma dependência de infraestrutura. A perda de cache em reinicializações resulta em mais chamadas à Overpass API e mais point-in-polygon repetidos temporariamente, mas a funcionalidade principal (georreferenciamento) não é afetada. Redis entra na Fase 2 quando houver múltiplas instâncias da API.

Cenário Lugares/dia Point-in-polygon/dia Chamadas OSM/dia (cache miss)
PoC (1 bairro, 10 entusiastas) ~20 ~20 (~5 únicos por cache geo) ~5 (mesma região, cache OSM cobre)
MVP (1 município, centenas de usuários) ~100 ~100 (~30 únicos) ~15
Fase 2 (regional) ~10.000 ~10.000 (~2.000 únicos) ~500

O cache reduz as operações mais caras (point-in-polygon e chamada OSM) em 70-85% no MVP, assumindo concentração geográfica típica de lugares cadastrados (bairros comerciais, corredores de serviços).


Teste unitário do L2Service:

beforeEach(async () => {
const module = await Test.createTestingModule({
providers: [
L2Service,
{ provide: EventBusService, useValue: mockEventBus },
{ provide: ResolucaoRepository, useValue: mockResolucaoRepo },
{ provide: ConsumerOffsetRepository, useValue: mockOffsetRepo },
{ provide: CoreGeoRepository, useValue: mockCoreGeoRepo },
PointInPolygonService,
GeoCacheService,
OsmCacheService,
{ provide: TipificacaoService, useValue: mockTipificacao },
],
}).compile();
service = module.get(L2Service);
geoCache = module.get(GeoCacheService);
osmCache = module.get(OsmCacheService);
mockEventBus.inscrever = jest.fn();
mockEventBus.publicar = jest.fn();
mockEventBus.replayDeSequence.mockResolvedValue([]);
mockEventBus.obterMaiorSequence.mockResolvedValue(41);
mockResolucaoRepo.buscarPorEventId.mockResolvedValue(null);
mockResolucaoRepo.inserir.mockImplementation((dados) =>
Promise.resolve({ id: 'uuid', ...dados, criado_em: new Date().toISOString() }),
);
mockOffsetRepo.seed.mockResolvedValue(undefined);
mockOffsetRepo.buscarTodos.mockResolvedValue([]);
mockOffsetRepo.upsert.mockResolvedValue(undefined);
mockCoreGeoRepo.buscarTodosOrdenadosPorNivel.mockResolvedValue([
// polígonos de exemplo com bbox calculado
]);
mockTipificacao.enriquecer.mockResolvedValue({
confianca: 'alta',
subtipo_confirmado: 'farmacia',
fonte: 'osm',
dados: { nome_osm: 'Farmácia São João', distancia_metros: 5.2 },
divergencia: null,
});
});

Happy path:

# Cenário Verificação
T1 processarLugarCadastrado() com payload completo, UC resolvida, OSM confirma subtipo INSERT em l2.resolucoes com confianca_geo = 'alta', confianca_tipificacao = 'alta'. publicar() chamado com tipo lugar.georreferenciado e versao_schema = '1.0.0'. Payload contém cadeia_ucs com array de 4 elementos. Consumer offset atualizado.
T2 iniciar() sem eventos perdidos seed() chamado com obterMaiorSequence(). replayDeSequence() chamado com o cursor. inscrever() registrado para lugar.cadastrado.
T3 iniciar() com eventos perdidos replayDeSequence() retorna 3 eventos. processarLugarCadastrado() chamado 3 vezes. Cache geo e OSM inicializados.
T4 Payload sem subtipo (nulo) Enriquecimento OSM prossegue. subtipo_confirmado preenchido a partir do OSM. confianca_tipificacao = 'media' (sem declaração para confirmar).
T5 tipo_lugar = 'residencia' Enriquecimento de tipificação pulado. confianca_tipificacao = 'nao_avaliada'. Resolução territorial prossegue normalmente.

Cache:

# Cenário Verificação
T6 Cache geo hit — coordenadas já resolvidas geoCache.obter() retorna resultado. Nenhum point-in-polygon executado. CoreGeoRepository não é chamado.
T7 Cache OSM hit — região já consultada osmCache.obter() retorna elementos. Nenhuma chamada à Overpass API. TipificacaoService usa o cache.

Falhas e bordas:

# Cenário Verificação
T8 Evento já processado (mesmo event_id) — com republicação buscarPorEventId() retorna registro. lugar.georreferenciado republicado. Log.info emitido. Cursor avança.
T9 Point-in-polygon não encontra UC (ponto fora de todos os polígonos) resolverTerritorio() retorna null. Handler retorna sem INSERT. Log.error emitido. Cursor avança.
T10 Polígono com geometria inválida Turf.js lança erro. Erro capturado. Polígono problemático é pulado. Se próximo polígono contiver o ponto, processamento prossegue.
T11 Hierarquia quebrada — parent_uc_id sem correspondência resolverCadeiaUcs() retorna cadeia parcial. Log.warn com IDs ausentes. Processamento prossegue com cadeia truncada.
T12 Overpass API timeout osmClient.consultar() retorna null. confianca_tipificacao = 'nao_avaliada'. Resolução territorial publicada normalmente.
T13 Overpass API retorna array vazio confianca_tipificacao = 'baixa'. enriquecimento_dados contém { elementos_encontrados: 0, raio_consulta: 50 }.
T14 OSM encontra elemento com subtipo diferente do declarado confianca_tipificacao = 'media'. divergencia_tipificacao preenchido. subtipo_confirmado mantém o declarado.
T15 OSM encontra elemento com tag não mapeada mapearTagOsmParaSubtipo() retorna null. confianca_tipificacao = 'media' com divergência (declarado vs. null).
T16 publicar() de lugar.georreferenciado falha nas 4 tentativas Registro persiste em l2.resolucoes. Erro relançado para DLQ. Consumer offset NÃO atualizado.
T17 INSERT falha com violação de unique (lugar_id duplicado) Erro capturado. DLQ registrada.
T18 Reinicialização — cache geo vazio geoCache.iniciar() cria Map em memória. Resoluções repetem point-in-polygon até repopular. Funcionalidade preservada.
T19 Reinicialização — cache OSM vazio osmCache.iniciar() cria Map em memória. Overpass é consultada novamente até repopular. Funcionalidade preservada.
T20 Evento com coordenadas ausentes Log.error. Handler retorna sem INSERT. Cursor avança. Defesa em profundidade — a L-1 não deveria publicar assim.
T21 Evento com lugar_id ausente Log.error. Handler retorna sem INSERT. Cursor avança.
T22 Evento com tipo_lugar inválido Log.error. Handler retorna sem INSERT. Cursor avança.
T23 Evento com tipo_lugar = 'poligono_uc' Log “capturado e mantido em análise”. Sem INSERT e sem publicação. Cursor avança.

Teste de integração (com PostgreSQL de teste e Event Bus real):

# Cenário Verificação
T24 Ciclo completo: lugar.cadastradolugar.georreferenciado 1 linha em l2.resolucoes. 1 evento em core.event_log com tipo lugar.georreferenciado. correlacao_id propagado. Cache geo populado.
T25 Replay após reinício: 3 eventos no log, 1 já processado iniciar() processa 2 eventos novos, republica evento do já processado. count(*) em l2.resolucoes = 3. lugar.georreferenciado publicado 3 vezes.
T26 Dois lugares na mesma coordenada (cache geo hit no segundo) Primeiro: point-in-polygon executado, cache geo populado. Segundo: cache hit, point-in-polygon pulado. Ambos georreferenciados com mesma UC.
T27 Lugar de organização com subtipo = 'farmacia', OSM confirma confianca_tipificacao = 'alta', subtipo_confirmado = 'farmacia', sem divergência.
T28 Lugar com subtipo = 'farmacia', OSM encontra supermercado confianca_tipificacao = 'media', subtipo_confirmado = 'farmacia' (mantido), divergencia_tipificacao registrada.

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. O SQL abaixo é ilustrativo de um cenário mínimo.

-- Polígonos de UC de exemplo (no schema core)
INSERT INTO core.uc_polygons (id, unidade_civica_id, nivel, parent_uc_id, nome, geometria, fonte, metadados, populacao_estimada)
VALUES
(
'a1b2c3d4-e5f6-7890-abcd-ef1234567890',
'uc-jardim-flores',
2,
'uc-municipio-sp',
'Jardim das Flores',
'{"type":"Polygon","coordinates":[[[-46.635,-23.553],[-46.633,-23.553],[-46.633,-23.551],[-46.635,-23.551],[-46.635,-23.553]]]}',
'ibge',
'{"codigo_bairro":"355030805","codigo_municipio":"3550308"}',
NULL
),
(
'b2c3d4e5-f6a7-8901-bcde-f12345678901',
'uc-municipio-sp',
4,
'uc-estado-sp',
'São Paulo',
'{"type":"Polygon","coordinates":[[[-47.0,-24.0],[-46.0,-24.0],[-46.0,-23.0],[-47.0,-23.0],[-47.0,-24.0]]]}',
'ibge',
'{"codigo_municipio":"3550308","codigo_uf":"35"}',
NULL
),
(
'c3d4e5f6-a7b8-9012-cdef-123456789012',
'uc-estado-sp',
5,
'uc-brasil',
'São Paulo (estado)',
'{"type":"Polygon","coordinates":[[[-53.0,-25.5],[-44.0,-25.5],[-44.0,-19.5],[-53.0,-19.5],[-53.0,-25.5]]]}',
'ibge',
'{"codigo_uf":"35"}',
NULL
),
(
'd4e5f6a7-b8c9-0123-defa-123456789abc',
'uc-brasil',
7,
NULL,
'Brasil',
'{"type":"Polygon","coordinates":[[[-74.0,-34.0],[-34.0,-34.0],[-34.0,6.0],[-74.0,6.0],[-74.0,-34.0]]]}',
'ibge',
'{}',
NULL
);
-- Resolução de exemplo (lugar já georreferenciado; o cursor do consumer_offset é semeado no boot)
INSERT INTO l2.resolucoes (id, lugar_id, unidade_civica_id, nivel_minimo_resolvido,
cadeia_ucs, metodo_resolucao, confianca_geo, tipo_lugar, subtipo_declarado,
subtipo_confirmado, confianca_tipificacao, enriquecimento_fonte,
enriquecimento_dados, divergencia_tipificacao, event_id, correlacao_id)
VALUES
(
'e5f6a7b8-c9d0-1234-efab-567890abcdef',
'd4e5f6a7-b8c9-0123-defa-123456789abc', -- lugar_id da farmácia do seed da L-1
'uc-jardim-flores',
2,
ARRAY['uc-jardim-flores', 'uc-municipio-sp', 'uc-estado-sp', 'uc-brasil'],
'gps',
'alta',
'organizacao',
'farmacia',
'farmacia',
'alta',
'osm',
'{"nome_osm":"Farmácia São João","categoria_osm":"pharmacy","distancia_metros":5.2,"tags":{"amenity":"pharmacy","name":"Farmácia São João"}}',
NULL,
'e3f4a5b6-c7d8-9012-efab-123456789abc',
'e3f4a5b6-c7d8-9012-efab-123456789abc'
);

Para testes de divergência, inserir um lugar com subtipo que conflite com o OSM (ex: declarado farmacia, OSM retorna supermercado). Para testes de OSM indisponível, mockar osmClient.consultar() para retornar null.

{
"amenity": {
"pharmacy": "farmacia",
"hospital": "hospital",
"clinic": "ubs",
"doctors": "consultorio",
"dentist": "consultorio_odontologico",
"school": "escola",
"university": "universidade",
"library": "biblioteca",
"marketplace": "mercado",
"police": "delegacia",
"fire_station": "bombeiros",
"townhall": "prefeitura",
"courthouse": "forum",
"post_office": "correios",
"bank": "banco",
"restaurant": "restaurante",
"cafe": "cafeteria",
"bar": "bar",
"place_of_worship": "templo_religioso",
"community_centre": "centro_comunitario",
"parking": "estacionamento"
},
"shop": {
"supermarket": "supermercado",
"convenience": "mercado",
"bakery": "padaria",
"hardware": "loja_construcao",
"clothes": "loja_roupas"
},
"office": {
"government": "orgao_publico"
},
"leisure": {
"park": "parque",
"sports_centre": "centro_esportivo",
"fitness_centre": "academia"
},
"tourism": {
"hotel": "hotel",
"museum": "museu"
}
}

Funcionalidade Status
inscrever('lugar.cadastrado') com handler idempotente por event_id 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
Enriquecimento de tipificação via Overpass API (OSM) para organizações e equipamentos públicos MVP obrigatório
Cache OSM de respostas da Overpass API (in-memory) MVP obrigatório
Mapeamento configurável OSM tag → subtipo de lugar MVP obrigatório
Cálculo de confianca_geo e confianca_tipificacao MVP obrigatório
Registro de divergência entre subtipo declarado e OSM MVP obrigatório
Captura de poligono_uc mantida em análise, sem publicação MVP obrigatório
Persistência em l2.resolucoes com todos os campos de resolução e enriquecimento MVP obrigatório
Publicação de lugar.georreferenciado com payload conforme Registry MVP obrigatório
Republicação de lugar.georreferenciado no replay para garantir entrega à E-1 MVP obrigatório
Consumer offset para replay após falha (l2.consumer_offset) MVP obrigatório
Propagação de correlacao_id do evento de origem para o evento de saída MVP obrigatório
Logs estruturados com lugar_id, event_id, correlacao_id, UC resolvida e confiança 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, análoga ao Registry. Migrar para colônia dedicada de geo na Fase 2. Cada colônia (D-2, L-2) mantém réplica local sincronizada por evento.
Polígono como GeoJSON em JSONB, sem PostGIS Instalar e manter a extensão PostGIS adiciona complexidade operacional. Turf.js resolve point-in-polygon com performance adequada para o volume do MVP. Migrar para GEOMETRY(POLYGON, 4326) + ST_Contains + índice GIST quando volume ou complexidade de queries espaciais exigir (Fase 2).
Polígonos carregados integralmente em memória, com TTL de 6 horas ~5.600 polígonos municipais ocupam ~17 MB. Cabem folgadamente na memória de qualquer servidor moderno, e a recarga periódica dispensa reiniciar a API após novo seed. Migrar para queries espaciais indexadas quando volume de polígonos crescer com níveis 1-3 detalhados (> 100K polígonos).
Enriquecimento apenas via OSM CNPJ (Receita Federal) e bases de equipamentos públicos (CNES, Inep) são fontes valiosas mas exigem integração adicional e manutenção de bases locais. O OSM cobre o caso mais comum (estabelecimentos comerciais e equipamentos públicos com nome). Adicionar fontes CNPJ, CNES e Inep na Fase 2, com tabela l2.enriquecimentos separada para múltiplas fontes.
Sem retry em falha de enriquecimento O enriquecimento é melhoria, não requisito funcional. Um lugar sem tipificação enriquecida ainda é georreferenciado e visível no sistema. Adicionar retry com backoff para fontes externas na Fase 2.
Timeout OSM fixo de 5s Cobrir 95% dos casos sem complexidade de configuração. Tornar configurável via variável de ambiente na Fase 2.
metodo_resolucao sempre gps Todos os lugares no MVP chegam com coordenadas GPS do dispositivo. Endereço textual não é suportado pela D-1a ou E-1 no MVP. Adicionar geocodificação de endereço textual (Nominatim/Photon) na Fase 2.
confianca_geo sempre alta Consequência do ponto anterior. O campo existe para forward compatibility. Passa a variar quando endereços textuais forem suportados.
Sem endpoint REST para consulta de resoluções A consulta é feita pela D-7 (Transparência) e L-4 (Fase 2) via projeções de leitura. A L-2 não serve queries. Se necessário para debug operacional, adicionar endpoint administrativo 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 sincronizadas por evento
  • PostGIS: GEOMETRY(POLYGON, 4326), índice GIST, ST_Contains para point-in-polygon, ST_DWithin para queries de proximidade
  • Enriquecimento por CNPJ (base da Receita Federal) para tipo_lugar = 'organizacao'
  • Enriquecimento por CNES (saúde) e Censo Escolar/Inep (educação) para tipo_lugar = 'equipamento_publico'
  • Tabela l2.enriquecimentos separada para múltiplas fontes de enriquecimento por lugar_id
  • Geocodificação de endereço textual (Nominatim/Photon) como fallback quando coordenadas GPS não disponíveis
  • Retry com backoff exponencial para APIs externas (Overpass, CNPJ, CNES, Inep)
  • Métricas Prometheus: l2_lugares_georreferenciados_total, l2_point_in_polygon_duration_ms, l2_osm_call_total, l2_osm_cache_hit_ratio, l2_divergencias_detectadas_total
  • Cache de polígonos com invalidação seletiva por evento de atualização de polígono
  • Inserção automática dos polígonos de poligono_uc em core.uc_polygons pela colônia de infraestrutura geoespacial
  • Suporte a re-georreferenciamento quando polígonos de UC são atualizados (poligono.uc_atualizado → recalcular lugares afetados)

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 — quem mantém a tabela? Avaliação: a tabela core.uc_polygons é criada por migration compartilhada e populada pelos scripts de seed territorial (IBGE e, nas fases seguintes, OSM e mapeamento coletivo). Nenhuma colônia de negócio escreve nela. Na Fase 2, uma colônia dedicada de infraestrutura geoespacial assume a manutenção e publica eventos de atualização. Sem conflito.

Conflito potencial: L-2 e D-2 implementam algoritmos de point-in-polygon ligeiramente diferentes, gerando UCs divergentes para a mesma coordenada. Avaliação: o algoritmo (ray casting sobre o mesmo polígono GeoJSON) deve produzir o mesmo resultado determinístico. Para garantir, ambas as colônias usam a mesma versão do Turf.js. Testes de integração cruzada (mesma coordenada → mesma UC em D-2 e L-2) devem fazer parte da suíte de regressão. Na Fase 2, com PostGIS, ST_Contains é o algoritmo canônico.

Conflito potencial: E-1 depende de lugar.georreferenciado para definir associação territorial, mas o evento pode chegar antes do cadastro da empresa estar completo. Avaliação: a E-1 publica lugar.recebido durante o cadastro. A L-1 processa e publica lugar.cadastrado. A L-2 processa e publica lugar.georreferenciado. A E-1 consome lugar.georreferenciado e casa com a organização pendente. Se o evento chegar antes do INSERT da organização na E-1, a E-1 deve armazenar temporariamente ou reconsultar. Isso é responsabilidade da E-1, não da L-2. Sem conflito para a L-2.

Conflito potencial: o CoreGeoRepository acessa tabela do schema core — violação da regra de isolamento? Avaliação: sim, é uma violação controlada e documentada. A regra geral é que colônias só dependem do núcleo (Event Bus + Registry). A base de polígonos é estendida ao núcleo como dependência de infraestrutura no MVP, sob a mesma justificativa do Registry: é um dado fundamental sem o qual point-in-polygon é impossível. A exceção é temporária e será removida na Fase 2 com a colônia dedicada de geo. Documentado nas seções 1.6 e 8.2.



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