Pular para o conteúdo

E-1 — Cadastro Institucional

Parte das Colônias de Empresas — Fase 1


A E-1 é o ponto de entrada de todas as colônias de empresa. Registra e mantém o perfil de cada organização — razão social, porte, setor, localização e associação às unidades cívicas do território de atuação. Sem registro, não há visibilidade no sistema.

No MVP, o cadastro é manual — preenchido diretamente pela empresa ou por um representante autenticado como cidadão. A adesão começa pelas empresas entusiastas: pequenas e médias que querem fazer parte do modelo de transparência.

A E-1 tem natureza dual: BFF acoplado para o formulário de cadastro (controllers REST) e consumidor puro de eventos para resolver a associação territorial assíncrona. A E-1 não faz georreferenciamento próprio: publica lugar.recebido(tipo=organizacao) no barramento para cada endereço de operação, a L-1/L-2 processam, e a E-1 consome lugar.georreferenciado de volta para definir as UCs de atuação. A associação é incremental — um evento empresa.associação_territorial_definida por endereço resolvido.

O cadastro é público no nível de razão social, setor e território. Dados financeiros e salariais ficam nas colônias E-2 e E-3.

A E-1 tem um documento irmão de outra camada:

Camada Responsabilidade Documento
Front-end App React: formulário de cadastro de empresa, painel de perfil, pin no mapa para endereços A definir — segue padrão de D-1a - Front-end.md
BFF + Consumidor Servidor NestJS: validação, persistência, publicação de eventos, consumo de georreferenciamento Este documento

A separação é de runtime. O front-end é uma aplicação cliente independente, e o BFF é um módulo NestJS dentro do monolito modular. Este documento especifica o BFF e o consumidor de eventos.


A E-1 é um módulo NestJS com encapsulamento próprio dentro do monolito modular do MVP. Expõe controllers REST para o front-end, publica eventos no barramento via EventBusService (N-0a) e consome lugar.georreferenciado para resolver a associação territorial.

src/empresa/e-1-cadastro-institucional/
├── e1.module.ts # Module definition + OnModuleInit
├── controllers/
│ ├── empresa.controller.ts # POST /empresas, GET /empresas, GET /empresas/:id
│ └── setor.controller.ts # GET /setores
├── services/
│ ├── empresa.service.ts # Cadastro, listagem, perfil, publicação e varredura de órfãos
│ ├── associacao-territorial.service.ts # Consumidor de lugar.georreferenciado + replay na inicialização
│ ├── setor.service.ts # Carga do config/setores.json
│ └── validacao-cnpj.service.ts # Máscara e dígitos verificadores do CNPJ
├── dto/
│ ├── criar-empresa.dto.ts # Contrato POST /empresas (inclui os endereços)
│ ├── listar-empresas.dto.ts # Query da listagem pública
│ └── listar-empresas-response.dto.ts # Shape da página de listagem
├── repositories/
│ ├── empresa.repository.ts # Acesso a e1.empresas
│ ├── empresa-endereco.repository.ts # Acesso a e1.empresa_enderecos
│ ├── empresa-uc.repository.ts # Acesso a e1.empresa_ucs
│ └── consumer-offset.repository.ts # Acesso a e1.consumer_offset
├── guards/
│ └── auth.guard.ts # Valida JWT, extrai cidadao_id como representante
├── config/
│ └── setores.json # Lista fechada CNAE macro (18 setores)
└── e1.constants.ts # Constantes: bounding box, limites de tamanho e nomes de evento
@Module({
imports: [],
controllers: [
EmpresaController,
SetorController,
],
providers: [
EmpresaService,
AssociacaoTerritorialService,
SetorService,
ValidacaoCnpjService,
EmpresaRepository,
EmpresaEnderecoRepository,
EmpresaUcRepository,
ConsumerOffsetRepository,
],
exports: [],
})
export class E1Module implements OnModuleInit {
constructor(
private readonly associacaoTerritorial: AssociacaoTerritorialService,
private readonly empresaService: EmpresaService,
) {}
async onModuleInit() {
await this.associacaoTerritorial.iniciar();
await this.empresaService.iniciar();
}
}
  • O módulo não é @Global(). A E-1 não é dependência de nenhuma outra colônia. Outras colônias consomem seus eventos, 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 da publicação.
  • O OnModuleInit dispara as duas rotinas de inicialização. O AssociacaoTerritorialService.iniciar() faz o seed do cursor, o replay de lugar.georreferenciado perdido e o registro do handler. O EmpresaService.iniciar() faz a varredura de órfãos, republicando empresas sem empresa.cadastrada e endereços sem lugar.recebido.
  • O módulo não importa ThrottlerModule.forRoot() — isso já é feito pela D-1a. O rate limiting usa @Throttle do NestJS direto nas rotas.
  • A E-1 publica lugar.recebido diretamente no barramento, sem passar pelo BFF da D-1a. Isso é intencional: a D-1a valida input de cidadão (bounding box, idempotência de conteúdo). A E-1 tem validações próprias de negócio. A L-1 é agnóstica em relação à origem do evento.

Os serviços são classes injetáveis, sem interfaces I* no código. Contratos reais:

  • EmpresaService.criar(dto, representanteId): valida, persiste em transação e publica os eventos. Retorna { empresa_id, endereco_ids }.
  • EmpresaService.listar(dto): página pública de empresas com busca e filtros.
  • EmpresaService.obterPerfil(empresaId): perfil público com endereços e UCs, ou null.
  • EmpresaService.iniciar(): varredura de órfãos no boot.
  • AssociacaoTerritorialService.iniciar(): seed do cursor, replay e registro do consumidor.
  • AssociacaoTerritorialService.processarLugarGeorreferenciado(evento): handler do evento.
  • SetorService.listar() e SetorService.existe(setorId): lista estática carregada do JSON.
  • ValidacaoCnpjService.validar(cnpj): máscara e dígitos verificadores.
Método Rota Controller Descrição
POST /api/empresas EmpresaController Cria empresa com endereços. Validação, persistência e publicação de eventos.
GET /api/empresas EmpresaController Listagem pública com busca por razão social ou nome fantasia, filtros de setor, porte, status e UC, paginação (page/limit, padrão 20 e máximo 50) e cidade/UF da sede.
GET /api/empresas/:id EmpresaController Perfil público da empresa com razão social, porte, setor, status, endereços e UCs. O id é validado como UUID v4 (ParseUUIDPipe), com HTTP 400 para identificador malformado. Sem representante_id, idempotencia_hash nem evento_publicado_em.
GET /api/setores SetorController Lista de setores CNAE macro para o dropdown do front-end.

A edição cadastral (PATCH /empresas/:id) e a inclusão de endereço após o cadastro não fazem parte do MVP. Todas as rotas passam pelo prefixo global /api.

A E-1 é uma colônia com BFF acoplado. Expõe endpoints REST diretamente para o front-end de cadastro de empresa. Esta é a natureza do ponto de entrada de organizações: ele precisa de interface síncrona para o formulário. As chamadas do BFF são operações de entrada (comandos do representante), não lógica de negócio delegada a outras colônias.

A E-1 também consome eventos do barramento (lugar.georreferenciado) como colônia pura. Essa face da E-1 não expõe endpoints e opera exclusivamente via handlers de evento. As duas faces coexistem no mesmo módulo, sem acoplamento interno: EmpresaService não chama AssociacaoTerritorialService e vice-versa. A comunicação entre elas é indireta, via banco de dados próprio e barramento.


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

Registro principal da organização. Contém os dados cadastrais e o status no modelo de transparência.

CREATE SCHEMA IF NOT EXISTS e1;
CREATE TABLE e1.empresas (
id UUID NOT NULL,
cnpj VARCHAR(18),
razao_social VARCHAR(300) NOT NULL,
nome_fantasia VARCHAR(300),
porte VARCHAR(20) NOT NULL,
setor_id VARCHAR(20) NOT NULL,
site VARCHAR(500),
termos_aceitos_em TIMESTAMPTZ(2) NOT NULL,
representante_id UUID NOT NULL,
status VARCHAR(30) NOT NULL DEFAULT 'cadastrada',
idempotencia_hash VARCHAR(64) NOT NULL,
evento_publicado_em TIMESTAMPTZ(2),
criado_em TIMESTAMPTZ(2) NOT NULL DEFAULT CURRENT_TIMESTAMP,
atualizado_em TIMESTAMPTZ(2) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT empresas_pkey PRIMARY KEY (id)
);
CREATE UNIQUE INDEX empresas_idempotencia_hash_key ON e1.empresas (idempotencia_hash);
CREATE INDEX empresas_setor_id_idx ON e1.empresas (setor_id);
CREATE INDEX empresas_status_idx ON e1.empresas (status);
CREATE INDEX empresas_representante_id_idx ON e1.empresas (representante_id);
CREATE INDEX empresas_cnpj_idx ON e1.empresas (cnpj);

Os limites de porte e status são validados em aplicação (PORTES_VALIDOS e STATUS_VALIDOS em e1.constants.ts), não no banco. O setor_id nasceu VARCHAR(10) e foi ampliado para VARCHAR(20) na migration 20260813185850_widen_e1_setor_id.

Coluna Tipo Descrição
id UUID PK empresa_id. Gerado pela aplicação. Identificador usado em todo o sistema de empresas.
cnpj VARCHAR(18) CNPJ formatado com máscara (XX.XXX.XXX/XXXX-XX). Opcional no MVP — sem validação na Receita Federal. Índice parcial cobre apenas não-nulos.
razao_social VARCHAR(300) NOT NULL Nome oficial da organização.
nome_fantasia VARCHAR(300) Nome fantasia. Opcional.
porte VARCHAR(20) NOT NULL micro, pequena, media ou grande. Autodeclarado, validado em aplicação.
setor_id VARCHAR(20) NOT NULL Identificador do setor (ex: comercio, industria, alimentacao, servicos_publicos). FK lógica para setores.json. IDs de até 17 caracteres; validação em aplicação contra o JSON.
site VARCHAR(500) URL do site da empresa. Opcional.
termos_aceitos_em TIMESTAMPTZ NOT NULL Timestamp de quando o representante aceitou os termos de transparência. Checkbox obrigatório no formulário.
representante_id UUID NOT NULL cidadão_id que cadastrou a empresa. Extraído do JWT validado pelo AuthGuard.
status VARCHAR(30) NOT NULL cadastrada (default — registro inicial), socializada (aderiu ao modelo de transparência completo), pendente (cadastro incompleto ou aguardando validação), recusada (não atende critérios mínimos).
idempotencia_hash VARCHAR(64) UNIQUE Hash SHA-256 do conteúdo determinístico. Garante idempotência.
evento_publicado_em TIMESTAMPTZ Preenchido após publicação bem-sucedida de empresa.cadastrada.
criado_em TIMESTAMPTZ Timestamp de criação.
atualizado_em TIMESTAMPTZ Timestamp da última alteração.

Endereços de operação da empresa. Cada endereço gera um lugar.recebido publicado no barramento. A correlação com o fluxo de lugares é mantida via correlacao_id_lugar.

CREATE TABLE e1.empresa_enderecos (
id UUID NOT NULL,
empresa_id UUID NOT NULL,
tipo VARCHAR(10) NOT NULL,
logradouro VARCHAR(300) NOT NULL,
numero VARCHAR(20),
complemento VARCHAR(200),
bairro VARCHAR(200),
cidade VARCHAR(200) NOT NULL,
estado CHAR(2) NOT NULL,
cep VARCHAR(9),
localizacao_lat DOUBLE PRECISION,
localizacao_lng DOUBLE PRECISION,
ordem SMALLINT NOT NULL DEFAULT 0,
status_geo VARCHAR(20) NOT NULL DEFAULT 'pendente',
correlacao_id_lugar UUID,
lugar_event_id UUID,
criado_em TIMESTAMPTZ(2) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT empresa_enderecos_pkey PRIMARY KEY (id),
CONSTRAINT empresa_enderecos_empresa_id_fkey
FOREIGN KEY (empresa_id) REFERENCES e1.empresas(id)
ON DELETE CASCADE ON UPDATE CASCADE
);
CREATE UNIQUE INDEX empresa_enderecos_correlacao_id_lugar_key
ON e1.empresa_enderecos (correlacao_id_lugar);
CREATE INDEX empresa_enderecos_empresa_id_idx
ON e1.empresa_enderecos (empresa_id);
CREATE INDEX empresa_enderecos_correlacao_id_lugar_idx
ON e1.empresa_enderecos (correlacao_id_lugar);
CREATE UNIQUE INDEX empresa_enderecos_empresa_id_localizacao_lat_localizacao_ln_key
ON e1.empresa_enderecos (empresa_id, localizacao_lat, localizacao_lng);
Coluna Tipo Descrição
id UUID PK endereco_id. Gerado pela aplicação.
empresa_id UUID NOT NULL FK FK para e1.empresas(id) com CASCADE. Um endereço pertence a uma única empresa.
tipo VARCHAR(10) NOT NULL sede (endereço principal, vira UC primária) ou filial (UC secundária), validado em aplicação.
logradouro VARCHAR(300) NOT NULL Rua, avenida, etc.
numero VARCHAR(20) Número. VARCHAR para aceitar formatos como “123-A” ou “S/N”.
complemento VARCHAR(200) Bloco, apto, sala, etc.
bairro VARCHAR(200) Bairro ou distrito.
cidade VARCHAR(200) NOT NULL Cidade.
estado CHAR(2) NOT NULL UF.
cep VARCHAR(9) CEP formatado (XXXXX-XXX).
localizacao_lat DOUBLE PRECISION Latitude fornecida pelo front-end via pin no mapa. Nula se endereço textual sem coordenadas.
localizacao_lng DOUBLE PRECISION Longitude fornecida pelo front-end via pin no mapa.
ordem SMALLINT Ordenação para exibição (sede primeiro, filiais em ordem de cadastro).
status_geo VARCHAR(20) NOT NULL pendente (aguardando L-2), georreferenciado (L-2 processou), falha (erro irrecuperável no georreferenciamento).
correlacao_id_lugar UUID correlacao_id usado na publicação de lugar.recebido para este endereço. Usado pelo consumidor de lugar.georreferenciado para lookup reverso. Índice parcial cobre apenas não-nulos.
lugar_event_id UUID event_id do evento lugar.recebido publicado para este endereço. Para rastreabilidade.
criado_em TIMESTAMPTZ Timestamp de criação.

A constraint empresa_enderecos_empresa_id_localizacao_lat_localizacao_ln_key em (empresa_id, localizacao_lat, localizacao_lng) impede que a mesma empresa cadastre duas vezes o mesmo ponto no mapa. Endereços textuais sem coordenadas (lat/lng nulos) não são cobertos pela constraint — o Postgres trata NULLs como distintos em UNIQUE.

Associações territoriais entre empresa e unidades cívicas, definidas quando lugar.georreferenciado é consumido da L-2. Uma empresa pode ter múltiplas UCs (primária da sede, secundárias das filiais).

CREATE TABLE e1.empresa_ucs (
id UUID NOT NULL,
empresa_id UUID NOT NULL,
endereco_id UUID NOT NULL,
unidade_civica_id UUID NOT NULL,
cadeia_ucs UUID[],
tipo_associacao VARCHAR(20) NOT NULL,
lugar_id UUID,
confianca_geo VARCHAR(10),
criado_em TIMESTAMPTZ(2) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT empresa_ucs_pkey PRIMARY KEY (id),
CONSTRAINT empresa_ucs_empresa_id_fkey
FOREIGN KEY (empresa_id) REFERENCES e1.empresas(id)
ON DELETE CASCADE ON UPDATE CASCADE,
CONSTRAINT empresa_ucs_endereco_id_fkey
FOREIGN KEY (endereco_id) REFERENCES e1.empresa_enderecos(id)
ON DELETE CASCADE ON UPDATE CASCADE
);
CREATE INDEX empresa_ucs_empresa_id_idx
ON e1.empresa_ucs (empresa_id);
CREATE INDEX empresa_ucs_unidade_civica_id_idx
ON e1.empresa_ucs (unidade_civica_id);
CREATE UNIQUE INDEX empresa_ucs_empresa_id_unidade_civica_id_key
ON e1.empresa_ucs (empresa_id, unidade_civica_id);
Coluna Tipo Descrição
id UUID PK Identificador da associação.
empresa_id UUID NOT NULL FK FK para e1.empresas(id) com CASCADE.
endereco_id UUID NOT NULL FK Qual endereço gerou esta associação. FK para e1.empresa_enderecos(id) com CASCADE.
unidade_civica_id UUID NOT NULL UC de menor nível resolvida. Extraída do payload de lugar.georreferenciado.
cadeia_ucs UUID[] NOT NULL Array de UUIDs com a cadeia completa de UCs pai, do menor ao maior nível.
tipo_associacao VARCHAR(20) NOT NULL primaria (endereço tipo sede) ou secundaria (endereço tipo filial), validado em aplicação.
lugar_id UUID lugar_id gerado pela L-1 que originou esta associação. Para rastreabilidade.
confianca_geo VARCHAR(10) alta, media ou baixa — extraída do payload da L-2.
criado_em TIMESTAMPTZ Timestamp de criação.

A constraint empresa_ucs_empresa_id_unidade_civica_id_key impede que a mesma UC seja associada duas vezes à mesma empresa. Se uma empresa tem sede e filial na mesma UC, apenas a primeira associação é registrada — a segunda é detectada e logada como redundante.

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 e1.consumer_offset (
tipo_evento VARCHAR(255) NOT NULL,
last_sequence BIGINT NOT NULL DEFAULT 0,
updated_at TIMESTAMPTZ(2) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT consumer_offset_pkey PRIMARY KEY (tipo_evento)
);

O cursor cobre um tipo no MVP: lugar.georreferenciado. O seed roda no boot com obterMaiorSequence(). A estrutura com PK em tipo_evento permite extensão futura sem alteração de schema.

  1. 20260813184759_create_e1_tables — Cria o schema e1, as tabelas empresas, empresa_enderecos, empresa_ucs e consumer_offset, com índices, uniques e as três FKs internas.
  2. 20260813185850_widen_e1_setor_id — Amplia empresas.setor_id de VARCHAR(10) para VARCHAR(20).

O schema e1 tem duas foreign keys internas:

  • empresa_enderecos.empresa_id → empresas.id (CASCADE)
  • empresa_ucs.empresa_id → empresas.id (CASCADE)
  • empresa_ucs.endereco_id → empresa_enderecos.id (CASCADE)

Estas são as únicas FKs permitidas no sistema — relações internas ao mesmo schema. FKs entre schemas de colônias distintas são proibidas pela regra de isolamento. O representante_id em e1.empresas referencia d1a.cidadaos(id), mas sem constraint formal. A integridade é garantida em aplicação: o AuthGuard valida o JWT e extrai o cidadao_id; se o cidadão não existir, o JWT é inválido.

cnpj como VARCHAR(18), não CHAR(14). Armazenar com máscara (XX.XXX.XXX/XXXX-XX) facilita a exibição sem formatação no front-end. A validação do dígito verificador é feita em aplicação, não no banco. Para buscas exatas, o índice parcial cobre. Para buscas sem máscara (futuro), uma coluna cnpj_limpo pode ser adicionada na Fase 2.

setor_id como VARCHAR(20), não FK para tabela de setores. A lista de setores é estática no MVP, versionada em setores.json. Uma tabela e1.setores com FK formal seria overengineering para 18 registros que mudam no máximo a cada ano. A validação é feita em aplicação contra o JSON. Na Fase 2, se os setores se tornarem dinâmicos, uma migration adiciona a tabela sem quebrar os dados existentes.

idempotencia_hash com UNIQUE, não o id. Mesmo padrão da D-1a. O id é UUID v4 (não determinístico). Retries do front-end gerariam UUIDs diferentes. O hash derivado do conteúdo determinístico detecta duplicatas independentemente do UUID.

localizacao_lat e localizacao_lng como DOUBLE PRECISION, não PostGIS geometry. A E-1 não faz queries geoespaciais — apenas armazena as coordenadas fornecidas pelo front-end. O georreferenciamento (point-in-polygon) é responsabilidade da L-2. Duas colunas numéricas são suficientes. Na Fase 2, se a E-1 precisar de queries espaciais (ex: “empresas em um raio de X km da UC Y”), adotar PostGIS.

Tabela empresa_ucs separada de empresa_enderecos. Um endereço pode gerar zero ou uma associação territorial (se o georreferenciamento falhar, zero). Uma empresa pode ter múltiplas UCs (sede + filiais). Tabela separada permite consultar “todas as UCs desta empresa” sem JOIN com endereços. Também permite que, no futuro, uma empresa seja associada a UCs por outros critérios além de endereço físico (ex: área de impacto declarada).

correlacao_id_lugar como chave de correlação reversa. O fluxo assíncrono E-1 → L-1 → L-2 → E-1 exige que a E-1 consiga mapear o lugar_id que chega em lugar.georreferenciado de volta ao seu endereco_id. A solução: a E-1 gera um correlacao_id distinto para cada lugar.recebido publicado, armazena em empresa_enderecos.correlacao_id_lugar, e esse correlacao_id é propagado por toda a cadeia (L-1, L-2). Quando lugar.georreferenciado chega de volta, o correlacao_id no envelope do evento é usado para lookup direto em empresa_enderecos. Sem depender de dados de outras colônias.


A E-1 produz três tipos de evento e consome 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 E-1: o que publica, o que espera receber e em que ordem.

Propriedade Valor
Tipo empresa.cadastrada
Schema version 1.0.0
Produtor E-1 (esta colônia)
Consumidores E-2 (Transparência Salarial), E-3 (Simulação Econômica), D-7 (Transparência), L-5 (Incubação Social — Fase 2)
Descrição Nova organização registrada no sistema. Publicado imediatamente após persistência, antes da associação territorial.

Payload publicado:

interface EmpresaCadastradaPayload {
empresa_id: string; // UUID v4
cnpj?: string; // com máscara, opcional no MVP
razao_social: string;
nome_fantasia?: string;
porte: string; // 'micro' | 'pequena' | 'media' | 'grande'
setor_id: string; // identificador do setor CNAE macro
site?: string;
quantidade_enderecos: number;
representante_id: string; // cidadão_id
termos_aceitos_em: string; // ISO-8601
status: string; // sempre 'cadastrada' no cadastro
}

3.2 Evento produzido: empresa.associação_territorial_definida

Seção intitulada “3.2 Evento produzido: empresa.associação_territorial_definida”
Propriedade Valor
Tipo empresa.associação_territorial_definida
Schema version 1.0.0
Produtor E-1 (esta colônia)
Consumidores E-4 (Impacto Territorial — Fase 2), D-7 (Transparência)
Descrição Empresa vinculada a uma unidade cívica. Publicado incrementalmente para cada endereço resolvido pela L-2.

Payload publicado:

interface EmpresaAssociacaoTerritorialDefinidaPayload {
empresa_id: string;
endereco_id: string;
unidade_civica_id: string; // UC de menor nível
cadeia_ucs: string[]; // array de UUIDs do menor ao maior nível
tipo_associacao: string; // 'primaria' (sede) | 'secundaria' (filial)
lugar_id: string; // lugar_id da L-1
confianca_geo: string; // 'alta' | 'media' | 'baixa'
quantidade_ucs_empresa: number; // total de UCs já associadas (incluindo esta)
}

O campo quantidade_ucs_empresa permite que colônias consumidoras saibam se a empresa já teve todos os seus endereços resolvidos, sem precisar consultar a E-1. A E-3 não consome este evento: o rateio por UC saiu do MVP e o excedente retorna ao sistema como fluxo único para o fomento. O campo permanece no payload para consumo da E-4 (Fase 2), que cruza o excedente que retorna ao sistema com o território da empresa.

Propriedade Valor
Tipo lugar.recebido
Schema version 1.0.0
Produtor E-1 (esta colônia)
Consumidor L-1 (Cadastro de Lugares)
Descrição Input bruto de endereço de organização. Mesmo schema da D-1a, com tipo_lugar = 'organizacao' e canal = 'web'.

Payload publicado:

interface LugarRecebidoPayload {
tipo_lugar: string; // sempre 'organizacao' quando publicado pela E-1
subtipo?: string; // não preenchido pela E-1 no MVP — vem do enriquecimento da L-2
nome?: string; // nome_fantasia ou razao_social da empresa
descricao?: string;
posicao: {
lat: number;
lng: number;
};
horario_funcionamento?: string; // não preenchido pela E-1 no MVP
cidadao_id: string; // representante_id
canal: string; // 'web'
timestamp_criacao?: string;
}

A E-1 publica lugar.recebido diretamente no barramento, sem passar pelo BFF da D-1a. O event_id do evento é o próprio endereco_id, armazenado em empresa_enderecos.lugar_event_id. O correlacao_id é um UUID v4 distinto para cada endereço, armazenado em empresa_enderecos.correlacao_id_lugar.

Propriedade Valor
Tipo lugar.georreferenciado
Schema version 1.0.0
Produtor L-2 (Georreferenciamento e Tipificação)
Consumidor E-1 (esta colônia — para definir associação territorial)
Descrição Lugar com UC resolvida e cadeia de UCs pai. A E-1 extrai a informação territorial e vincula ao endereço da empresa correspondente.

Payload esperado (conforme Registry N-0b seção 3.4.21):

interface LugarGeorreferenciadoPayload {
lugar_id: string;
unidade_civica_id: string;
nivel_minimo_resolvido: number;
cadeia_ucs: string[];
metodo_resolucao: string;
confianca_geo: string; // 'alta' | 'media' | 'baixa'
tipo_lugar?: string;
subtipo_declarado?: string;
subtipo_confirmado?: string;
confianca_tipificacao?: string;
enriquecimento?: object;
}

A E-1 usa apenas lugar_id, unidade_civica_id, cadeia_ucs e confianca_geo. Os demais campos são ignorados.

O fluxo em EmpresaService.criar() segue esta ordem exata:

1. Validar DTO de entrada
→ class-validator + class-transformer no pipe global do NestJS
→ campos mínimos: razao_social + porte + setor_id + ao menos 1 endereço tipo 'sede' + termos_aceitos_em
→ se inválido: HTTP 400, sem efeito colateral
2. Validar setor_id contra setores.json
→ SetorService.listar() retorna Setor[]
→ se setor_id não encontrado: HTTP 400 "Setor inválido"
→ sem efeito colateral
3. Validar endereços
→ ao menos 1 endereço com tipo 'sede'
→ cada endereço: se localizacao_lat e localizacao_lng informados, validar bounding box
(LAT_MIN <= lat <= LAT_MAX, LNG_MIN <= lng <= LNG_MAX)
→ se coordenadas fora: HTTP 400 "Coordenadas do endereço X fora da área de operação"
→ endereços sem coordenadas são aceitos (a L-2 tentará geocodificar)
4. Verificar idempotência
→ gerar idempotencia_hash = SHA-256(
(cnpj ?? '') + "|" +
razao_social.trim() + "|" +
representante_id
)
→ consultar e1.empresas WHERE idempotencia_hash = ?
→ se encontrado: HTTP 409 { empresa_id: existente.id, duplicata: true }, sem publicar evento
5. Gerar empresa_id e endereco_ids
→ empresa_id = UUID v4
→ para cada endereço: endereco_id = UUID v4
→ para cada endereço: correlacao_id_lugar = UUID v4 (um por endereço, para correlação reversa)
6. PERSISTIR no estado próprio
→ EM TRANSAÇÃO:
→ INSERT INTO e1.empresas (...)
→ se violação de UNIQUE(idempotencia_hash) por race condition: capturar, buscar existente, HTTP 409
→ para cada endereço: INSERT INTO e1.empresa_enderecos (..., correlacao_id_lugar)
7. PUBLICAR empresa.cadastrada no barramento
→ publicarComRetry(this.eventBus, {
tipo: 'empresa.cadastrada',
origem: 'E-1',
event_id: empresa_id,
correlacao_id: empresa_id,
payload: { empresa_id, cnpj, razao_social, nome_fantasia, porte, setor_id, site,
quantidade_enderecos, representante_id, termos_aceitos_em, status }
})
→ se a publicação falhar: log.error, HTTP 500
→ atualizar e1.empresas SET evento_publicado_em = NOW()
8. Para cada endereço com coordenadas: PUBLICAR lugar.recebido no barramento
→ apenas endereços com localizacao_lat E localizacao_lng preenchidos
→ publicarComRetry(this.eventBus, {
tipo: 'lugar.recebido',
origem: 'E-1',
event_id: endereco_id,
correlacao_id: endereco.correlacao_id_lugar, // distinto por endereço
payload: { tipo_lugar: 'organizacao', posicao: { lat, lng },
nome: nome_fantasia ?? razao_social, cidadao_id: representante_id, canal: 'web' }
})
→ armazenar o endereco_id em e1.empresa_enderecos.lugar_event_id
→ se a publicação falhar: log.error, sem interromper o fluxo. O endereço fica com status_geo = 'pendente'
e lugar_event_id = NULL, e a varredura de órfãos do boot republica.
→ endereços sem coordenadas: status_geo permanece 'pendente'. Nenhum evento publicado.
Na Fase 2, a L-2 pode geocodificar endereço textual se a E-1 enviar o logradouro.
9. Retornar 201 { empresa_id, endereco_ids }

Persistir antes de publicar garante que os dados estejam salvos mesmo se o barramento falhar. Se a publicação de empresa.cadastrada falhar, o registro existe em e1.empresas com evento_publicado_em = NULL e o fluxo responde HTTP 500. Se algum lugar.recebido falhar, o endereço correspondente fica com lugar_event_id = NULL e status_geo = 'pendente'. O EmpresaService.iniciar() varre os dois casos no boot e republica cada órfão com publicarComRetry, até o limite de LIMITE_VARREDURA_ORFAOS por varredura.

3.6 Ordem de operações — consumir lugar.georreferenciado

Seção intitulada “3.6 Ordem de operações — consumir lugar.georreferenciado”

O fluxo em AssociacaoTerritorialService.processarLugarGeorreferenciado() segue esta ordem exata:

1. Filtrar eventos relevantes para a E-1
→ extrair correlacao_id do envelope
→ se ausente: logger.warn("Evento sem correlacao_id"), avançar cursor, retornar
→ consultar e1.empresa_enderecos WHERE correlacao_id_lugar = correlacao_id
→ se não encontrado: logger.debug("lugar.georreferenciado sem endereço de empresa"), avançar cursor, retornar
→ É assim que a E-1 filtra eventos que lhe pertencem em um barramento compartilhado
2. Verificar idempotência
→ endereco.status_geo == 'georreferenciado': logger.log, avançar cursor, retornar
3. Validar o payload mínimo
→ lugar_id e unidade_civica_id são obrigatórios
→ se ausente: logger.error("Payload de lugar.georreferenciado incompleto — descartando"), avançar cursor, retornar
4. Extrair dados territoriais
→ lugar_id = payload.lugar_id
→ unidade_civica_id = payload.unidade_civica_id
→ cadeia_ucs = payload.cadeia_ucs ?? []
→ confianca_geo = payload.confianca_geo ?? 'media'
5. Determinar tipo_associacao
→ se endereco.tipo == 'sede': tipo_associacao = 'primaria'
→ se endereco.tipo == 'filial': tipo_associacao = 'secundaria'
6. PERSISTIR associação territorial
→ INSERT INTO e1.empresa_ucs (empresa_id, endereco_id, unidade_civica_id,
cadeia_ucs, tipo_associacao, lugar_id, confianca_geo)
→ se violação de UNIQUE(empresa_id, unidade_civica_id):
a empresa já tem associação com esta UC (sede e filial na mesma UC)
→ logger.warn("UC já associada à empresa"), prosseguir
→ erros de outra natureza relançam
7. Contar UCs associadas
→ quantidade_ucs = SELECT count(*) FROM e1.empresa_ucs WHERE empresa_id = endereco.empresa_id
8. PUBLICAR empresa.associação_territorial_definida
→ publicarComRetry(this.eventBus, {
tipo: 'empresa.associação_territorial_definida',
origem: 'E-1',
event_id: UUID v4,
correlacao_id: correlacao_id,
payload: {
empresa_id: endereco.empresa_id,
endereco_id: endereco.id,
unidade_civica_id,
cadeia_ucs,
tipo_associacao,
lugar_id,
confianca_geo,
quantidade_ucs_empresa: quantidade_ucs,
}
})
→ se a publicação falhar após as tentativas, o handler relança: o cursor não avança e o evento
fica pendente para o replay do próximo boot. O INSERT da UC já ocorreu; a reentrega cai na
violação de unicidade do passo 6.
9. Atualizar o endereço e o cursor
→ UPDATE e1.empresa_enderecos SET status_geo = 'georreferenciado' WHERE id = endereco.id
→ avançar o cursor de e1.consumer_offset para o sequence_number do evento

A E-1 implementa duas camadas de idempotência:

Camada 1 — Aplicação (E-1): O idempotencia_hash impede INSERTs duplicados em e1.empresas. O hash é SHA-256 de cnpj + razao_social + representante_id, sem componente temporal. A idempotência é permanente: qualquer reenvio do mesmo conteúdo pelo mesmo representante retorna 409, e empresas diferentes com a mesma razão social têm hashes diferentes porque o representante_id entra no input.

Camada 2 — Barramento (N-0a): O EventBusService.publicar() usa event_id como chave de idempotência. Para empresa.cadastrada, o event_id é o próprio empresa_id. Para lugar.recebido, o event_id é o endereco_id.

Cenário Comportamento
Falha de validação (campos mínimos, setor inválido, sem sede) HTTP 400. Nenhum efeito colateral.
CNPJ com formato inválido HTTP 400. “CNPJ em formato inválido”. Validação de dígito verificador em aplicação.
Rate limit excedido HTTP 429. Retry-After header com segundos restantes.
Duplicata detectada (idempotencia_hash) HTTP 409. Body: { empresa_id, duplicata: true }. Nenhum evento publicado.
INSERT no banco falha Erro de infraestrutura. HTTP 500. Nenhum evento publicado.
Publicação de empresa.cadastrada falha Erro logado. HTTP 500. Registro existe em e1.empresas com evento_publicado_em = NULL e a varredura de órfãos do boot republica.
Publicação de lugar.recebido falha (um ou mais endereços) Erro logado, sem interromper o fluxo. A empresa é criada e o HTTP 201 é retornado. O endereço fica com lugar_event_id = NULL e status_geo = 'pendente', e a varredura de órfãos do boot republica.
lugar.georreferenciado chega sem correlacao_id Log.warn. O cursor avança e o evento é descartado.
lugar.georreferenciado chega com correlacao_id desconhecido Log.debug. É um evento de lugar do cidadão, não de organização. O cursor avança e a E-1 não processa.
lugar.georreferenciado chega para endereço já georreferenciado Idempotência: status_geo já é georreferenciado. Log e retorno, com o cursor avançado.
lugar.georreferenciado chega com UC já associada à empresa UNIQUE constraint violada no INSERT de e1.empresa_ucs. Capturada com log.warn. O fluxo segue para a publicação e a atualização do status_geo.
Publicação de empresa.associação_territorial_definida falha Erro relançado. O cursor não avança e o evento fica pendente para o replay do próximo boot.

Persistir antes de publicar. Mesmo princípio da D-1a e L-1: o estado próprio é a memória da colônia. Se o barramento falhar após o INSERT, o dado está salvo e recuperável.

correlacao_id distinto por endereço como chave de correlação reversa. O fluxo assíncrono exige que a E-1 consiga identificar qual endereço gerou qual lugar_id. Publicar o correlacao_id distinto em lugar.recebido e armazená-lo em empresa_enderecos permite lookup direto quando lugar.georreferenciado chega de volta — sem depender de dados da L-1 ou L-2. A alternativa (rastrear lugar_id via event_id e consultar a L-1) violaria o isolamento de schemas. A solução adotada mantém a E-1 autossuficiente.

lugar.recebido publicado diretamente no barramento, sem passar pelo BFF da D-1a. A D-1a valida input de cidadão (rate limiting, idempotência de conteúdo, bounding box). A E-1 tem validações próprias de negócio e um ciclo de vida distinto. Publicar diretamente no barramento evita acoplamento HTTP entre colônias e mantém a E-1 como produtora independente. A L-1 é agnóstica em relação à origem do evento — processa lugar.recebido da mesma forma, venha da D-1a ou da E-1.

Incremental: um empresa.associação_territorial_definida por endereço. A alternativa batch (acumular todos os endereços e publicar um evento com a lista completa de UCs) exigiria estado de espera na E-1 (quantos endereços já foram resolvidos? quantos faltam?) e atrasaria a visibilidade para as colônias consumidoras. A abordagem incremental é mais simples e reativa. O campo quantidade_ucs_empresa no payload supre a informação de “já terminou?” sem estado adicional.

status_geo como coluna em empresa_enderecos, não como tabela de status. Três estados (pendente, georreferenciado, falha) validados em aplicação. Máquina de estados simples que não justifica tabela separada. Se a Fase 2 adicionar mais estados (ex: geocodificando, aguardando_enriquecimento), a validação em aplicação é estendida.


função criar(dto: CriarEmpresaDto, representanteId: string) -> { empresa_id: string, endereco_ids: string[] }:
// 1. Validar campos mínimos
se !dto.razao_social ou dto.razao_social.trim().length == 0:
lançar BadRequestException("Razão social é obrigatória")
se !dto.porte:
lançar BadRequestException("Porte é obrigatório")
se !dto.setor_id:
lançar BadRequestException("Setor é obrigatório")
se !dto.termos_aceitos_em:
lançar BadRequestException("Aceite dos termos é obrigatório")
// 2. Validar setor
setores = setorService.listar()
se !setores.find(s => s.id == dto.setor_id):
lançar BadRequestException("Setor inválido: " + dto.setor_id)
// 3. Validar endereços
se !dto.enderecos ou dto.enderecos.length == 0:
lançar BadRequestException("Ao menos um endereço é obrigatório")
temSede = false
para cada endereco em dto.enderecos:
se endereco.tipo == 'sede':
temSede = true
// validar coordenadas se fornecidas
se endereco.localizacao_lat não é null e endereco.localizacao_lng não é null:
se endereco.localizacao_lat < LAT_MIN ou endereco.localizacao_lat > LAT_MAX
ou endereco.localizacao_lng < LNG_MIN ou endereco.localizacao_lng > LNG_MAX:
lançar BadRequestException("Coordenadas do endereço fora da área de operação")
se !temSede:
lançar BadRequestException("Ao menos um endereço deve ser do tipo 'sede'")
// 4. Idempotência
hashInput = (dto.cnpj ?? '') + "|" +
dto.razao_social.trim() + "|" +
representanteId
idempotenciaHash = SHA256(hashInput)
existente = empresaRepo.buscarPorIdempotenciaHash(idempotenciaHash)
se existente não é null:
lançar ErroEmpresaDuplicada(existente.id)
// 5. Gerar IDs
empresaId = UUIDv4()
enderecoIds = []
correlacaoIds = []
para cada endereco em dto.enderecos:
enderecoId = UUIDv4()
correlacaoId = UUIDv4()
enderecoIds.push(enderecoId)
correlacaoIds.push(correlacaoId)
// 6. Persistir
EM TRANSAÇÃO (empresaRepo.criarComEnderecos):
empresa = INSERT INTO e1.empresas (..., status: 'cadastrada', idempotencia_hash)
para i de 0 até dto.enderecos.length - 1:
INSERT INTO e1.empresa_enderecos (..., ordem: i, status_geo: 'pendente',
correlacao_id_lugar: correlacaoIds[i])
// 7. Publicar empresa.cadastrada
tentar:
await publicarComRetry(this.eventBus, {
tipo: 'empresa.cadastrada',
origem: 'E-1',
event_id: empresaId,
correlacao_id: empresaId,
payload: {
empresa_id: empresaId,
cnpj: dto.cnpj,
razao_social: dto.razao_social.trim(),
nome_fantasia: dto.nome_fantasia?.trim(),
porte: dto.porte,
setor_id: dto.setor_id,
site: dto.site,
quantidade_enderecos: dto.enderecos.length,
representante_id: representanteId,
termos_aceitos_em: dto.termos_aceitos_em,
status: 'cadastrada',
},
})
empresaRepo.atualizarEventoPublicadoEm(empresaId, agora)
capturar erro:
logger.error("Falha ao publicar empresa.cadastrada", erro, {
empresa_id: empresaId,
representante_id: representanteId,
})
lançar ErroPublicacaoEvento()
// 8. Publicar lugar.recebido para cada endereço com coordenadas
nomeLugar = dto.nome_fantasia?.trim() ?? dto.razao_social.trim()
para i de 0 até dto.enderecos.length - 1:
endereco = dto.enderecos[i]
se endereco.localizacao_lat é null ou endereco.localizacao_lng é null:
continuar // endereço sem coordenadas — não publica lugar.recebido
tentar:
await publicarComRetry(this.eventBus, {
tipo: 'lugar.recebido',
origem: 'E-1',
event_id: enderecoIds[i],
correlacao_id: correlacaoIds[i],
payload: {
tipo_lugar: 'organizacao',
nome: nomeLugar,
posicao: {
lat: endereco.localizacao_lat,
lng: endereco.localizacao_lng,
},
cidadao_id: representanteId,
canal: 'web',
timestamp_criacao: new Date().toISOString(),
},
})
enderecoRepo.atualizarLugarEventId(enderecoIds[i], enderecoIds[i])
capturar erro:
logger.error("Falha ao publicar lugar.recebido para endereço", erro, {
empresa_id: empresaId,
endereco_id: enderecoIds[i],
})
// Não interrompe o fluxo — endereço fica com lugar_event_id = NULL
// e status_geo = 'pendente'. A varredura de órfãos do boot republica.
// 9. Retornar
retornar { empresa_id: empresaId, endereco_ids: enderecoIds }

4.2 AssociacaoTerritorialService.processarLugarGeorreferenciado() — pseudocódigo

Seção intitulada “4.2 AssociacaoTerritorialService.processarLugarGeorreferenciado() — pseudocódigo”
função processarLugarGeorreferenciado(evento: EventoConsultado):
payload = evento.payload
sequencia = BigInt(evento.sequence_number)
// 1. Filtrar eventos relevantes para a E-1
// O barramento entrega todos os lugar.georreferenciado.
// A E-1 só processa aqueles cujo correlacao_id está na tabela de endereços.
correlacaoId = evento.correlacao_id
se !correlacaoId:
logger.warn("Evento sem correlacao_id — ignorando")
avançarCursor(sequencia, evento)
retornar
endereco = enderecoRepo.buscarPorCorrelacaoIdLugar(correlacaoId)
se endereco é null:
// evento de lugar do cidadão — não pertence à E-1
logger.debug("lugar.georreferenciado sem endereço de empresa correspondente")
avançarCursor(sequencia, evento)
retornar
// 2. Idempotência
se endereco.status_geo == 'georreferenciado':
logger.log("Endereço já georreferenciado — ignorando")
avançarCursor(sequencia, evento)
retornar
// 3. Validar o payload mínimo
lugarId = payload.lugar_id
unidadeCivicaId = payload.unidade_civica_id
se !lugarId ou !unidadeCivicaId:
logger.error("Payload de lugar.georreferenciado incompleto — descartando")
avançarCursor(sequencia, evento)
retornar
// 4. Extrair dados territoriais
cadeiaUcs = payload.cadeia_ucs ?? []
confiancaGeo = payload.confianca_geo ?? 'media'
// 5. Determinar tipo de associação
tipoAssociacao = endereco.tipo == 'sede' ? 'primaria' : 'secundaria'
// 6. Persistir associação territorial
tentar:
empresaUcRepo.inserir({
id: UUIDv4(),
empresa_id: endereco.empresa_id,
endereco_id: endereco.id,
unidade_civica_id: unidadeCivicaId,
cadeia_ucs: cadeiaUcs,
tipo_associacao: tipoAssociacao,
lugar_id: lugarId,
confianca_geo: confiancaGeo,
})
capturar UniqueViolation:
// Sede e filial na mesma UC — associação já existe
logger.warn("UC já associada à empresa")
// 7. Contar UCs associadas
quantidadeUcs = empresaUcRepo.contarPorEmpresa(endereco.empresa_id)
// 8. Publicar empresa.associação_territorial_definida
await publicarComRetry(this.eventBus, {
tipo: 'empresa.associação_territorial_definida',
origem: 'E-1',
event_id: UUIDv4(),
correlacao_id: correlacaoId,
payload: {
empresa_id: endereco.empresa_id,
endereco_id: endereco.id,
unidade_civica_id: unidadeCivicaId,
cadeia_ucs: cadeiaUcs,
tipo_associacao: tipoAssociacao,
lugar_id: lugarId,
confianca_geo: confiancaGeo,
quantidade_ucs_empresa: quantidadeUcs,
},
})
// Falha após as tentativas relança; o cursor não avança e o evento fica pendente.
// 9. Atualizar o endereço e o cursor
enderecoRepo.atualizarStatusGeo(endereco.id, 'georreferenciado')
avançarCursor(sequencia, evento)

4.3 AssociacaoTerritorialService.iniciar() — protocolo de inicialização

Seção intitulada “4.3 AssociacaoTerritorialService.iniciar() — protocolo de inicialização”
função iniciar():
// 1. Garantir o cursor no boot; o seed não sobrescreve cursor existente
maiorSequence = await eventBus.obterMaiorSequence()
consumerOffsetRepo.seed(['lugar.georreferenciado'], maiorSequence)
// 2. Replay de eventos perdidos durante inatividade
offsets = consumerOffsetRepo.buscarTodos()
para cada tipo em TIPOS_EVENTO_CONSUMIDOS:
cursor = offsets.find(o => o.tipo_evento == tipo)?.last_sequence ?? 0
eventos = await eventBus.replayDeSequence(cursor, [tipo])
para cada evento em eventos:
// O handler processa apenas eventos com correlacao_id na tabela de endereços
tentar:
await this.processarLugarGeorreferenciado(evento)
capturar erro:
logger.error("Falha no replay — evento permanece pendente")
// 3. Registrar handler para eventos futuros
eventBus.inscrever(
'lugar.georreferenciado', 'E-1',
this.processarLugarGeorreferenciado.bind(this)
)
logger.log("E-1 inicializada — consumidor de lugar.georreferenciado")

4.4 EmpresaService.iniciar() — varredura de órfãos

Seção intitulada “4.4 EmpresaService.iniciar() — varredura de órfãos”
função iniciar():
// 1. Republicar empresas sem empresa.cadastrada publicado
empresas = empresaRepo.buscarSemEventoPublicado(LIMITE_VARREDURA_ORFAOS)
para cada empresa em empresas:
tentar:
await publicarComRetry(this.eventBus, {
tipo: 'empresa.cadastrada',
origem: 'E-1',
event_id: empresa.id,
correlacao_id: empresa.id,
payload: payloadDaEmpresa(empresa),
})
empresaRepo.atualizarEventoPublicadoEm(empresa.id, agora)
capturar erro:
logger.error("Falha ao republicar empresa.cadastrada")
// 2. Republicar lugar.recebido de endereços sem lugar_event_id
enderecos = enderecoRepo.buscarSemLugarEventId(LIMITE_VARREDURA_ORFAOS)
para cada endereco em enderecos:
se endereco.localizacao_lat é null ou endereco.localizacao_lng é null:
continuar
empresa = empresaRepo.buscarPorId(endereco.empresa_id)
se empresa é null:
logger.warn("Endereço órfão sem empresa correspondente — não republicável")
continuar
tentar:
await publicarComRetry(this.eventBus, {
tipo: 'lugar.recebido',
origem: 'E-1',
event_id: endereco.id,
correlacao_id: endereco.correlacao_id_lugar ?? endereco.id,
payload: payloadDoLugar(endereco, empresa),
})
enderecoRepo.atualizarLugarEventId(endereco.id, endereco.id)
capturar erro:
logger.error("Falha ao republicar lugar.recebido de endereço")

A varredura roda uma vez por boot, com o mesmo LIMITE_VARREDURA_ORFAOS (100) por consulta.

4.5 SetorService — carregamento da lista de setores

Seção intitulada “4.5 SetorService — carregamento da lista de setores”
O SetorService carrega config/setores.json no OnModuleInit.
O arquivo contém a lista de setores CNAE macro (~18 setores) para dropdown do front-end.
Estrutura do arquivo:
{
"setores": [
{ "id": "comercio", "nome": "Comércio" },
{ "id": "industria", "nome": "Indústria" },
{ "id": "servicos", "nome": "Serviços" },
{ "id": "tecnologia", "nome": "Tecnologia e TI" },
{ "id": "saude", "nome": "Saúde" },
{ "id": "educacao", "nome": "Educação" },
{ "id": "construcao", "nome": "Construção Civil" },
{ "id": "agropecuaria", "nome": "Agropecuária" },
{ "id": "alimentacao", "nome": "Alimentação" },
{ "id": "transporte", "nome": "Transporte e Logística" },
{ "id": "financeiro", "nome": "Serviços Financeiros" },
{ "id": "energia", "nome": "Energia e Saneamento" },
{ "id": "comunicacao", "nome": "Comunicação e Mídia" },
{ "id": "entretenimento", "nome": "Entretenimento e Cultura" },
{ "id": "imobiliario", "nome": "Mercado Imobiliário" },
{ "id": "servicos_publicos", "nome": "Serviços Públicos e Administração" },
{ "id": "terceiro_setor", "nome": "Terceiro Setor e ONGs" },
{ "id": "outros", "nome": "Outros" }
]
}
O endpoint GET /setores retorna o array de setores.
O front-end usa id + nome para o dropdown.
Caso Comportamento
Empresa sem CNPJ Aceito. cnpj = NULL. O campo é opcional no MVP.
CNPJ com formato inválido (tamanho, caracteres) HTTP 400. “CNPJ em formato inválido”. Validação de máscara + dígito verificador.
Empresa sem nome fantasia Aceito. nome_fantasia = NULL. O lugar.recebido usa razao_social como nome.
Dois endereços com mesmas coordenadas A unique empresa_enderecos_empresa_id_localizacao_lat_localizacao_ln_key bloqueia. HTTP 409. “Endereço duplicado para esta empresa”.
Endereço sem coordenadas (apenas textual) Aceito. status_geo = 'pendente'. Nenhum lugar.recebido publicado. Na Fase 2, a L-2 pode geocodificar o endereço textual.
Empresa cadastrada duas vezes pelo mesmo representante com o mesmo conteúdo Segunda tentativa retorna HTTP 409 com o empresa_id da primeira, sem janela de tempo.
Empresa cadastrada com mesma razão social por representante diferente Aceito (hash diferente porque representante_id é parte do input). Duas empresas distintas podem ter a mesma razão social.
Duas submissões idênticas simultâneas A transação de uma vence e a outra viola UNIQUE de idempotencia_hash. O erro é capturado, o registro existente é buscado e a resposta é HTTP 409.
Publicação de empresa.cadastrada falha Erro logado. HTTP 500. Registro no banco com evento_publicado_em = NULL.
Publicação de lugar.recebido falha para 1 de 3 endereços Erro logado, sem interromper o fluxo. O HTTP 201 é retornado. Os 2 endereços cuja publicação teve sucesso ficam com lugar_event_id preenchido; o terceiro fica com lugar_event_id = NULL e a varredura de órfãos do boot republica.
lugar.georreferenciado chega mas o correlacao_id não está na tabela de endereços Log.debug, cursor avançado. É um evento de lugar de cidadão.
lugar.georreferenciado chega sem correlacao_id ou com payload incompleto Log e cursor avançado. Evento descartado sem retrabalho no boot seguinte.
lugar.georreferenciado chega para endereço cujo status_geo já é georreferenciado Log e retorno sem publicar, com o cursor avançado. Idempotência.
Sede e filial na mesma UC INSERT em e1.empresa_ucs viola a unique empresa_ucs_empresa_id_unidade_civica_id_key. Capturado com log.warn. O fluxo segue para a publicação e a atualização do status_geo.
Empresa removida (CASCADE) enquanto lugar.georreferenciado está em trânsito FK com CASCADE: se a empresa for deletada, os endereços são deletados. O lugar.georreferenciado que chegar depois não encontrará o correlacao_id. O cursor avança e o evento é ignorado.
Empresa submete formulário sem termos_aceitos_em HTTP 400. “Aceite dos termos é obrigatório”. Checkbox required no front-end, validação no back-end.
@Injectable()
export class AuthGuard implements CanActivate {
canActivate(context: ExecutionContext): boolean {
const request = context.switchToHttp().getRequest();
const authHeader = request.headers.authorization;
se !authHeader ou !authHeader.startsWith('Bearer '):
lançar UnauthorizedException("Token não fornecido");
const token = authHeader.split(' ')[1];
tentar:
const payload = jwt.verify(token, obterSegredoJwt());
// payload: { sub: cidadao_id, ... }
request.representante_id = payload.sub;
retornar true;
capturar erro:
lançar UnauthorizedException("Token inválido ou expirado");
}
}

O JWT_SECRET é o mesmo usado pelo BFF D-1a para assinar tokens, lido em obterSegredoJwt(). A E-1 não emite tokens, apenas valida.

O rate limiting usa @Throttle do @nestjs/throttler direto nas rotas, sem guard customizado. A chave padrão do throttler é o IP do cliente.

// Nos controllers:
@Throttle({ default: { limit: 3, ttl: 86400000 } }) // 3 por dia
@Post()
async criar(...) { }
Rota Limite Janela Chave
POST /empresas 3 24 horas IP
GET /empresas 60 1 minuto IP
GET /empresas/:id 60 1 minuto IP
GET /setores 60 1 minuto IP

cnpj opcional no MVP. A validação na Receita Federal exige integração com API externa ou base local espelhada — complexidade que não se justifica no MVP de empresas entusiastas. O CNPJ é armazenado como metadado de autodeclaração. A pressão por veracidade vem da transparência radical: o CNPJ declarado fica visível publicamente no perfil da empresa. Na Fase 2, com integração à base da Receita, o CNPJ passa a ser validado e a constraint UNIQUE é adicionada.

setor_id como VARCHAR(20) com FK lógica para JSON. A lista de setores é estática e pequena (18 itens). Uma tabela de domínio com FK formal seria overengineering para este volume. O JSON versionado no repositório é a fonte da verdade. Na Fase 2, se os setores se tornarem dinâmicos ou exigirem metadados adicionais (ícone, descrição, categoria pai), uma migration adiciona a tabela e1.setores e uma FK formal sem quebrar dados existentes.

Publicação de lugar.recebido apenas para endereços com coordenadas. Endereços puramente textuais (sem pin no mapa) não geram evento. A L-1 exige posicao.lat e posicao.lng no payload. Se a empresa só informar logradouro sem coordenadas, o endereço fica registrado na E-1 mas não entra no fluxo de georreferenciamento. Na Fase 2, o front-end pode integrar geocodificação (Nominatim/Photon) para converter endereço textual em coordenadas antes de enviar ao BFF, ou a E-1 pode publicar lugar.recebido com posicao = null e a L-2 se encarregar da geocodificação.

Handler de lugar.georreferenciado registrado para todos os eventos do tipo, com filtro por correlacao_id. A E-1 não tem como se registrar seletivamente (“só me entregue eventos cujo correlacao_id está na minha tabela”). O barramento entrega todos os lugar.georreferenciado para todos os consumidores registrados. A E-1 recebe tanto eventos de lugares de organização quanto de lugares de cidadão. O filtro por correlacao_id no handler é a camada que separa o que pertence à E-1 do que não pertence. O custo é uma query simples (índice empresa_enderecos_correlacao_id_lugar_idx) por evento recebido. Para o volume do MVP (< 100 lugares/dia total), irrelevante.


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

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

A E-1 injeta EventBusService (do módulo @Global() N-0a) para três operações: publicar() dentro de publicarComRetry (três tipos de evento), inscrever() (um tipo, lugar.georreferenciado) e replayDeSequence() (um tipo na inicialização).

@Injectable()
export class EmpresaService {
constructor(
private readonly eventBus: EventBusService,
private readonly empresaRepo: EmpresaRepository,
private readonly enderecoRepo: EmpresaEnderecoRepository,
private readonly setorService: SetorService,
private readonly validacaoCnpj: ValidacaoCnpjService,
) {}
async criar(dto: CriarEmpresaDto, representanteId: string) {
// ... validação, persistência ...
await publicarComRetry(this.eventBus, {
tipo: 'empresa.cadastrada',
origem: 'E-1',
event_id: empresaId,
correlacao_id: empresaId,
payload: { /* ... */ },
});
}
}
@Injectable()
export class AssociacaoTerritorialService {
constructor(
private readonly eventBus: EventBusService,
private readonly enderecoRepo: EmpresaEnderecoRepository,
private readonly empresaUcRepo: EmpresaUcRepository,
private readonly offsetRepo: ConsumerOffsetRepository,
) {}
async iniciar(): Promise<void> {
const maiorSequence = await this.eventBus.obterMaiorSequence();
await this.offsetRepo.seed(TIPOS_EVENTO_CONSUMIDOS, maiorSequence);
await this.reprocessarEventosPerdidos();
this.registrarConsumidores();
}
registrarConsumidores(): void {
this.eventBus.inscrever(
'lugar.georreferenciado', 'E-1',
this.processarLugarGeorreferenciado.bind(this),
);
}
}

5.2 Fluxo de eventos — cadeia completa de empresa

Seção intitulada “5.2 Fluxo de eventos — cadeia completa de empresa”
Front-end (formulário de cadastro)
→ POST /empresas (BFF E-1)
→ empresa.cadastrada (barramento)
→ E-2 (Transparência Salarial)
→ E-3 (Simulação Econômica)
→ D-7 (Transparência)
→ lugar.recebido × N (barramento, um por endereço com coordenadas)
→ L-1 → lugar.cadastrado
→ L-2 → lugar.georreferenciado
→ E-1 → empresa.associação_territorial_definida (incremental)
→ E-4 (Impacto Territorial — Fase 2)
→ D-7 (Transparência)
→ L-1 (atualiza status)

A E-1 não invoca outras colônias diretamente. Apenas publica no barramento e consome de volta. A cadeia é orquestrada pelo consumo dos eventos. A inclusão de endereço após o cadastro não faz parte do MVP; o fluxo de endereços é o do POST /empresas.

A E-1 publica lugar.recebido(tipo=organizacao) diretamente no barramento. A L-1 processa sem saber se o evento veio do cidadão (D-1a) ou da empresa (E-1). A L-2 publica lugar.georreferenciado. A E-1 consome e filtra por correlacao_id. Nenhuma colônia escreve no banco da outra.

A E-2 consome empresa.cadastrada para habilitar o upload de folha salarial para aquela empresa. A E-2 não consulta a E-1 — o empresa_id no evento é suficiente para referência.

A E-3 consome empresa.cadastrada para manter a projeção local de empresas ativas. A E-3 não consome empresa.associação_territorial_definida: o rateio por UC saiu do MVP e o excedente retorna ao sistema como fluxo único para o fomento. O evento de associação territorial continua sendo produzido pela E-1 e permanece disponível para a E-4 (Fase 2).

A E-1 não faz chamadas HTTP para outras colônias. A validação de JWT é feita localmente com o mesmo JWT_SECRET do D-1a — sem chamada ao BFF de autenticação. A lista de setores é servida de arquivo estático local.

A E-1 não consome projeções de leitura de outras colônias. O único dado externo que acessa é o core.event_log via EventBusService.replayDeSequence(), dependência do núcleo permitida.

A E-1 e a D-1a compartilham a mesma variável de ambiente JWT_SECRET. A D-1a assina tokens com jsonwebtoken usando esse secret. A E-1 verifica tokens com o mesmo secret. Isso é dependência de infraestrutura, não de código — as duas colônias não importam código uma da outra. O secret é injetado via configuração de ambiente.


Rota Limite Janela Chave Biblioteca
POST /empresas 3 24 horas IP @nestjs/throttler in-memory
GET /empresas 60 1 minuto IP @nestjs/throttler in-memory
GET /empresas/:id 60 1 minuto IP @nestjs/throttler in-memory
GET /setores 60 1 minuto IP @nestjs/throttler in-memory

Os limites são parâmetros iniciais de referência, calibráveis com dados reais. O @nestjs/throttler armazena contadores em memória. Na Fase 2, migrar para Redis store.

Limite Valor Justificativa
Payload máximo do POST 1 MB (JSON) Suficiente para dados cadastrais + N endereços.
razao_social 300 caracteres Suficiente para nomes empresariais completos com natureza jurídica.
nome_fantasia 300 caracteres Mesmo limite de razao_social.
cnpj 18 caracteres (com máscara) Formato XX.XXX.XXX/XXXX-XX.
site 500 caracteres URL completa. Suficiente para domínios longos com path.
logradouro 300 caracteres Endereço completo de avenida.
numero 20 caracteres VARCHAR para aceitar “S/N”, “123-A”.
complemento 200 caracteres Bloco, apto, sala, etc.
bairro 200 caracteres
cidade 200 caracteres
estado 2 caracteres UF. Validado contra lista de UFs brasileiras.
cep 9 caracteres Formato XXXXX-XXX.
Número máximo de endereços por empresa 50 Suficiente para redes de filiais no MVP. Acima disso, paginação na Fase 2.
Índice Query atendida
empresas_pkey (id) buscarPorId() — GET /empresas/:id
empresas_idempotencia_hash_key (UNIQUE) Verificação de idempotência — todo POST /empresas
empresas_setor_id_idx “Empresas do setor X” — filtro da listagem pública
empresas_status_idx Filtro por status — listagem e dashboard
empresas_representante_id_idx “Minhas empresas” e autoria do cadastro
empresas_cnpj_idx Busca por CNPJ (quando informado)
empresa_enderecos_pkey (id) Acesso direto por endereco_id
empresa_enderecos_empresa_id_idx “Endereços da empresa X”
empresa_enderecos_correlacao_id_lugar_idx Lookup reverso no handler de lugar.georreferenciado — query mais crítica
empresa_enderecos_empresa_id_localizacao_lat_localizacao_ln_key (UNIQUE) Impede endereço duplicado da mesma empresa na mesma coordenada
empresa_ucs_pkey (id) Acesso direto
empresa_ucs_empresa_id_idx “UCs da empresa X”
empresa_ucs_unidade_civica_id_idx “Empresas na UC Y” — dashboard territorial
empresa_ucs_empresa_id_unidade_civica_id_key (UNIQUE) Impede associação duplicada
consumer_offset_pkey (tipo_evento) Inicialização: WHERE tipo_evento = ?

O padrão de acesso é misto: INSERTs no cadastro, SELECTs para leitura de perfil e no handler de eventos.

  • buscarPorIdempotenciaHash(): 1 query por POST /empresas. Coberta pela unique.
  • buscarPorCorrelacaoIdLugar(): 1 query por evento lugar.georreferenciado que chega ao barramento. É a query mais frequente em operação normal. O índice empresa_enderecos_correlacao_id_lugar_idx cobre. Para o volume do MVP (< 100 lugares/dia total entre cidadão e empresa), a carga é irrisória.
  • buscarPorId() (empresa): 1 query por GET /empresas/:id. Coberta pela PK.
  • listarPublicas() (empresas): 1 query + 1 count por GET /empresas, com busca, filtros e ordenação por razao_social e id.
  • contarPorEmpresa() (UCs): 1 query no handler de lugar.georreferenciado. Coberta por empresa_ucs_empresa_id_idx.

Sem cache na E-1. Justificativas:

  • A query de idempotência é por UNIQUE — < 1ms.
  • A query de correlação reversa (findByCorrelacaoId) é por índice parcial — < 1ms.
  • A lista de setores é carregada em memória no SetorService (readFileSync no OnModuleInit, disponível como array em memória).
  • O volume de leitura é baixo e a complexidade de cache não se justifica no MVP.
Cenário Empresas cadastradas Endereços UCs associadas Tamanho estimado do banco (ano)
PoC (1 bairro, 5 empresas entusiastas) ~5 ~10 ~5 < 1 MB
MVP (1 município, dezenas de empresas) ~50 ~100 ~50 < 5 MB
Fase 2 (regional) ~5.000 ~15.000 ~10.000 < 50 MB

O crescimento é linear com a base de empresas. As tabelas são compactas. A tabela empresa_ucs é a que mais cresce (N associações por empresa), mas com N pequeno (1-3 por empresa no caso típico). Particionamento não se justifica na Fase 1.


Teste unitário do EmpresaService:

beforeEach(async () => {
const module = await Test.createTestingModule({
providers: [
EmpresaService,
{ provide: EventBusService, useValue: mockEventBus },
{ provide: EmpresaRepository, useValue: mockEmpresaRepo },
{ provide: EmpresaEnderecoRepository, useValue: mockEnderecoRepo },
{ provide: SetorService, useValue: mockSetorService },
{ provide: ValidacaoCnpjService, useValue: mockValidacaoCnpj },
],
}).compile();
service = module.get(EmpresaService);
mockSetorService.existe.mockImplementation((id: string) => id === 'comercio');
mockValidacaoCnpj.validar.mockReturnValue(true);
mockEmpresaRepo.buscarPorIdempotenciaHash.mockResolvedValue(null);
mockEmpresaRepo.criarComEnderecos.mockImplementation((empresa, enderecos) =>
Promise.resolve({ ...empresa, enderecos }),
);
mockEventBus.publicar.mockResolvedValue({
sequence_number: 1,
event_id: 'test-uuid',
tipo: 'empresa.cadastrada',
});
});

Teste unitário do AssociacaoTerritorialService:

beforeEach(async () => {
const module = await Test.createTestingModule({
providers: [
AssociacaoTerritorialService,
{ provide: EventBusService, useValue: mockEventBus },
{ provide: EmpresaEnderecoRepository, useValue: mockEnderecoRepo },
{ provide: EmpresaUcRepository, useValue: mockUcRepo },
{ provide: ConsumerOffsetRepository, useValue: mockOffsetRepo },
],
}).compile();
service = module.get(AssociacaoTerritorialService);
mockEnderecoRepo.buscarPorCorrelacaoIdLugar.mockResolvedValue({
id: 'endereco-1',
empresa_id: 'empresa-1',
tipo: 'sede',
status_geo: 'pendente',
});
mockUcRepo.inserir.mockResolvedValue(undefined);
mockUcRepo.contarPorEmpresa.mockResolvedValue(1);
mockOffsetRepo.buscarTodos.mockResolvedValue([]);
mockEventBus.obterMaiorSequence.mockResolvedValue(100);
mockEventBus.replayDeSequence.mockResolvedValue([]);
mockEventBus.publicar.mockResolvedValue({
sequence_number: 10,
event_id: 'test-uuid',
tipo: 'empresa.associação_territorial_definida',
});
mockEventBus.inscrever.mockImplementation(() => {});
});

Teste e2e do controller (supertest):

const app = await Test.createTestingModule({
imports: [E1Module],
}).compile();
const httpServer = app.createNestApplication();
await httpServer.init();

Happy path:

# Cenário Verificação
T1 POST /empresas com dados válidos (1 sede + coordenadas) HTTP 201. Body contém empresa_id e endereco_ids[]. publicar() chamado com tipo empresa.cadastrada e lugar.recebido. Registro em e1.empresas com status cadastrada.
T2 POST /empresas com múltiplos endereços (1 sede + 2 filiais) HTTP 201. 3 endereços persistidos. 3 eventos lugar.recebido publicados, cada um com correlacao_id distinto e event_id igual ao endereco_id.
T3 GET /empresas/:id HTTP 200. Body: { id, razao_social, porte, setor_id, status, enderecos[], ucs[] }, sem campos internos.
T4 GET /empresas com busca e filtros HTTP 200. Página com itens, total, page e limit; busca sem diferenciar maiúsculas e filtros combinados.
T5 GET /setores HTTP 200. Retorna array com 18 setores.
T6 iniciar() sem eventos perdidos obterMaiorSequence() chamado, cursor seedado, replayDeSequence() chamado e inscrever() registrado para lugar.georreferenciado.
T7 processarLugarGeorreferenciado() com evento de organização buscarPorCorrelacaoIdLugar() retorna endereço. INSERT em e1.empresa_ucs. status_geo atualizado para georreferenciado. empresa.associação_territorial_definida publicado. Cursor avançado.
T8 iniciar() do EmpresaService com órfãos Empresa sem evento_publicado_em e endereço sem lugar_event_id são republicados com publicarComRetry e marcados.

Falhas e bordas:

# Cenário Verificação
T9 POST /empresas sem endereço tipo sede HTTP 400. “Ao menos um endereço deve ser do tipo ‘sede’”.
T10 POST /empresas sem razao_social HTTP 400. “Razão social é obrigatória”.
T11 POST /empresas com setor_id inválido HTTP 400. “Setor inválido”.
T12 POST /empresas sem termos_aceitos_em HTTP 400. “Aceite dos termos é obrigatório”.
T13 POST /empresas com coordenadas fora do bounding box HTTP 400. “Coordenadas do endereço fora da área de operação”.
T14 POST /empresas duplicada (mesmo conteúdo) HTTP 409. Body: { empresa_id, duplicata: true }. publicar() NÃO chamado. Retry do mesmo cadastro retorna 409 sem janela temporal.
T15 POST /empresas com CNPJ inválido (máscara ou dígito) HTTP 400. “CNPJ em formato inválido”.
T16 POST /empresas excedendo rate limit (4ª empresa no dia) HTTP 429.
T17 POST /empresas sem JWT HTTP 401. “Token não fornecido”.
T18 POST /empresas com JWT inválido HTTP 401. “Token inválido ou expirado”.
T19 Corrida de unicidade no INSERT Unique violado, registro existente buscado, HTTP 409.
T20 Publicação de empresa.cadastrada falha de forma definitiva Erro relançado. HTTP 500. Registro permanece com evento_publicado_em = NULL.
T21 Publicação de lugar.recebido falha Fluxo não interrompido. Endereço com lugar_event_id = NULL. A varredura de órfãos do boot republica.
T22 processarLugarGeorreferenciado() sem correlacao_id ou com payload incompleto Log e cursor avançado, sem efeito no estado.
T23 processarLugarGeorreferenciado() com correlacao_id de cidadão buscarPorCorrelacaoIdLugar() retorna null. Log.debug e cursor avançado.
T24 processarLugarGeorreferenciado() para endereço já georreferenciado status_geo já é georreferenciado. Log e retorno, com cursor avançado.
T25 processarLugarGeorreferenciado() com UC já associada Unique violada no INSERT de e1.empresa_ucs. Capturada com log.warn. Publicação e atualização de status_geo seguem.
T26 processarLugarGeorreferenciado() com publicação falhando de forma definitiva Registro em e1.empresa_ucs existe. Erro relançado. status_geo e cursor NÃO atualizados.
T27 Empresa sem CNPJ Aceito. cnpj = NULL no banco.

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

# Cenário Verificação
T26 Ciclo completo: POST empresa → empresa.cadastrada → lugar.recebido 1 linha em e1.empresas. N linhas em e1.empresa_enderecos. 1 evento empresa.cadastrada no event_log. N eventos lugar.recebido no event_log.
T27 Ciclo completo: lugar.recebido → L-1 → L-2 → E-1 lugar.georreferenciado processado. 1 linha em e1.empresa_ucs. status_geo atualizado. empresa.associação_territorial_definida no event_log.
T28 Idempotência com PostgreSQL real: dois POSTs idênticos Primeira: 201. Segunda: 409. count(*) em e1.empresas = 1. count(*) em event_log WHERE tipo = 'empresa.cadastrada' = 1.
T29 Replay após reinício: eventos no log, endereço pendente iniciar() processa lugar.georreferenciado pendente. status_geo atualizado. empresa.associação_territorial_definida publicado.
T30 Empresa com endereço sem coordenadas HTTP 201. status_geo = 'pendente'. Nenhum lugar.recebido publicado.
T31 DELETE empresa (CASCADE): UCs e endereços removidos count(*) em e1.empresa_enderecos = 0. count(*) em e1.empresa_ucs = 0.
-- Consumer offset inicial
INSERT INTO e1.consumer_offset (tipo_evento, last_sequence)
VALUES ('lugar.georreferenciado', 0);
-- Empresa de exemplo — comércio
INSERT INTO e1.empresas (id, cnpj, razao_social, nome_fantasia, porte, setor_id,
termos_aceitos_em, representante_id, status, idempotencia_hash, evento_publicado_em)
VALUES (
'a1b2c3d4-e5f6-7890-abcd-ef1234567890',
'12.345.678/0001-90',
'Padaria Pão Dourado Ltda',
'Pão Dourado',
'pequena',
'alimentacao',
'2026-06-15T10:00:00Z',
'b2c3d4e5-f6a7-8901-bcde-f12345678901', -- cidadão_id do seed D-1a
'cadastrada',
'abc123def456', -- substituir por hash real nos testes
'2026-06-15T10:00:01Z'
);
-- Endereço sede com coordenadas (SP, centro)
INSERT INTO e1.empresa_enderecos (id, empresa_id, tipo, logradouro, numero, bairro, cidade, estado,
cep, localizacao_lat, localizacao_lng, ordem, status_geo, correlacao_id_lugar, lugar_event_id)
VALUES (
'c3d4e5f6-a7b8-9012-cdef-123456789012',
'a1b2c3d4-e5f6-7890-abcd-ef1234567890',
'sede',
'Rua das Palmeiras',
'123',
'Jardim das Flores',
'São Paulo',
'SP',
'01234-567',
-23.55100,
-46.63400,
0,
'pendente',
'e3f4a5b6-c7d8-9012-efab-123456789abc',
NULL
);
-- Endereço filial com coordenadas
INSERT INTO e1.empresa_enderecos (id, empresa_id, tipo, logradouro, numero, bairro, cidade, estado,
cep, localizacao_lat, localizacao_lng, ordem, status_geo, correlacao_id_lugar)
VALUES (
'd4e5f6a7-b8c9-0123-defa-123456789abc',
'a1b2c3d4-e5f6-7890-abcd-ef1234567890',
'filial',
'Avenida Central',
'456',
'Vila Nova',
'São Paulo',
'SP',
'04567-890',
-23.56200,
-46.64500,
1,
'pendente',
'f2e3d4c5-b6a7-8901-cdef-1234567890ab'
);
-- Endereço já georreferenciado (com UC associada) — simula ciclo completo
INSERT INTO e1.empresa_enderecos (id, empresa_id, tipo, logradouro, numero, bairro, cidade, estado,
cep, localizacao_lat, localizacao_lng, ordem, status_geo, correlacao_id_lugar, lugar_event_id)
VALUES (
'e5f6a7b8-c9d0-1234-efab-123456789abc',
'a1b2c3d4-e5f6-7890-abcd-ef1234567890',
'sede',
'Rua das Oliveiras',
'789',
'Centro',
'São Paulo',
'SP',
'01000-000',
-23.55000,
-46.63300,
2,
'georreferenciado',
'a7b8c9d0-e1f2-3456-7890-abcdef123456',
'b8c9d0e1-f2a3-4567-8901-bcdef1234567'
);
-- UC associada para o endereço georreferenciado
INSERT INTO e1.empresa_ucs (id, empresa_id, endereco_id, unidade_civica_id, cadeia_ucs,
tipo_associacao, lugar_id, confianca_geo)
VALUES (
'f6a7b8c9-d0e1-2345-fabc-1234567890de',
'a1b2c3d4-e5f6-7890-abcd-ef1234567890',
'e5f6a7b8-c9d0-1234-efab-123456789abc',
'b1c2d3e4-f5a6-7890-1234-567890abcdef', -- UC de exemplo
ARRAY[
'b1c2d3e4-f5a6-7890-1234-567890abcdef', -- nível 1
'c2d3e4f5-a6b7-8901-2345-678901bcdef0', -- nível 2
'd3e4f5a6-b7c8-9012-3456-789012cdef01' -- nível 3
]::UUID[],
'primaria',
'd4e5f6a7-b8c9-0123-defa-123456789abc',
'alta'
);

Para testes de idempotência, usar idempotencia_hash gerado deterministicamente a partir dos mesmos dados do seed. Para testes de bounding box, configurar E1_LAT_MIN, E1_LAT_MAX, E1_LNG_MIN, E1_LNG_MAX nas variáveis de ambiente de teste cobrindo as coordenadas do seed.


Funcionalidade Status
POST /empresas com validação, persistência e publicação de empresa.cadastrada MVP obrigatório
POST /empresas com publicação de lugar.recebido para cada endereço com coordenadas MVP obrigatório
GET /empresas com listagem pública, busca, filtros e paginação MVP obrigatório
GET /empresas/:id com perfil público (razão social, porte, setor, status, endereços e UCs, sem identificadores internos) MVP obrigatório
GET /setores servindo lista CNAE macro estática MVP obrigatório
Consumo de lugar.georreferenciado com handler idempotente e cursor em todo caminho terminal MVP obrigatório
Publicação de empresa.associação_territorial_definida incremental por endereço MVP obrigatório
Consumer offset para replay após falha (e1.consumer_offset) MVP obrigatório
Propagação de correlacao_id distinto por endereço para correlação reversa MVP obrigatório
Validação de bounding box para coordenadas de endereços MVP obrigatório
Validação de CNPJ por máscara e dígitos verificadores MVP obrigatório
Idempotência por idempotencia_hash (SHA-256 de CNPJ + razão social + representante, sem janela) MVP obrigatório
Varredura de órfãos no boot (EmpresaService.iniciar()) MVP obrigatório
Autenticação via JWT do D-1a (AuthGuard) MVP obrigatório
Logs estruturados com empresa_id, endereco_id e correlacao_id MVP obrigatório
Rate limiting nas rotas públicas e autenticadas MVP obrigatório
Simplificação Justificativa Quando remover
CNPJ opcional, sem validação na Receita Federal Integração com API externa (Receita) ou base local espelhada é complexidade que não se justifica para empresas entusiastas. Adicionar validação e constraint UNIQUE na Fase 2 quando houver volume que justifique a integração.
Setores em JSON estático, sem tabela de domínio 18 setores que mudam no máximo a cada ano. Tabela seria overengineering. Migrar para tabela e1.setores com FK formal quando os setores exigirem metadados ou se tornarem dinâmicos.
Porte autodeclarado, sem validação cruzada com faturamento A validação de porte exigiria integração com SPED ou declaração de imposto de renda — fora do escopo do MVP. Adicionar validação na Fase 2 como parte da colônia de simulação econômica (E-3).
Endereços sem coordenadas aceitos mas sem publicação de lugar.recebido O front-end pode não ter pin no mapa para todos os endereços. A E-1 armazena o dado textual e não publica evento. Integrar geocodificação no front-end (Nominatim/Photon) ou permitir lugar.recebido com coordenadas nulas (L-2 geocodifica).
Sem endpoint de republicação de evento A varredura de órfãos do boot cobre a janela de falha publish-após-INSERT no monolito. Adicionar endpoint dedicado e scheduled job periódico na Fase 2.
idempotencia_hash sem componente temporal Retries de rede do mesmo conteúdo retornam 409 permanente, sem bloquear cadastros legítimos de empresas diferentes. Adotar idempotency key via header Idempotency-Key na Fase 2.
Rate limiting in-memory (@nestjs/throttler) Monolito de processo único. Volume baixo. Migrar para Redis store na Fase 2 quando houver múltiplas instâncias.
Sem cache Volume de leitura baixo. Queries por índice são < 1ms. Adicionar cache se o volume de GET /empresas/:id justificar.
  • Validação de CNPJ na base da Receita Federal com constraint UNIQUE
  • Tabela e1.setores com FK formal e metadados (ícone, descrição, categoria pai)
  • Validação de porte cruzada com faturamento declarado (E-3)
  • Geocodificação de endereços textuais (front-end integra Nominatim ou Photon)
  • Rotas de edição cadastral (PATCH /empresas/:id) e de inclusão de endereço pós-cadastro (POST /empresas/:id/enderecos)
  • Endpoint de republicação de eventos (POST /empresas/:id/republicar)
  • Scheduled job periódico de reconciliação (a varredura de boot já cobre o MVP)
  • Idempotency key via header Idempotency-Key
  • Rate limiting com Redis store
  • Métricas Prometheus: e1_empresas_cadastradas_total, e1_associacoes_territoriais_total, e1_lugares_publicados_total

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: E-1 publica lugar.recebido diretamente, sem passar pelo BFF D-1a. A L-1 documenta explicitamente que processa lugar.recebido vindo tanto da D-1a quanto da E-1 (seção 5.2 da L-1). A L-1 é agnóstica em relação à origem. O schema do evento é idêntico. A E-1 preenche tipo_lugar = 'organizacao' e canal = 'web'. Sem conflito.

Conflito potencial: correlacao_id_lugar como chave de correlação reversa vs. propagação de correlacao_id pela L-1. A L-1 propaga o correlacao_id do evento de origem para o evento de saída (seção 5.3 da L-1). A L-2 também. O correlacao_id que a E-1 define em lugar.recebido chega intacto em lugar.georreferenciado. Isso é suportado pelo contrato atual da L-1 e L-2. Sem conflito.

Conflito potencial: JWT_SECRET compartilhado entre D-1a e E-1. As duas colônias usam a mesma variável de ambiente. Isso é dependência de infraestrutura, não de código. Ambas leem process.env.JWT_SECRET e usam jsonwebtoken independentemente. A D-1a assina, a E-1 verifica. Nenhuma importa código da outra. Sem conflito com a regra de isolamento.

Conflito potencial: setores.json na E-1 vs. taxonomia de categorias na D-3. São domínios completamente distintos. A D-3 categoriza demandas de cidadãos (água, esgoto, iluminação). A E-1 classifica setores econômicos de empresas (comércio, indústria, saúde). Não há sobreposição. Sem conflito.

Conflito potencial: E-1 com BFF acoplado vs. regra de que apenas D-1a tem BFF. A regra do Formigueiro (Apêndice B, seção “Princípios herdados”) estabelece que “toda comunicação entre colônias ocorre exclusivamente via eventos no barramento” e que a exceção é “o BFF da D-1a pode chamar outras colônias via HTTP”. A E-1 ter BFF próprio não viola esta regra: o BFF da E-1 serve apenas o front-end de cadastro de empresa. Não faz chamadas HTTP para outras colônias. É um ponto de entrada independente, assim como o BFF da D-1a é ponto de entrada para cidadãos. A regra restringe comunicação entre colônias, não a existência de interfaces HTTP. Sem conflito.



Documento de especificação técnica de implementação. Aprovado e integrado.