E-1 — Cadastro Institucional
Parte das Colônias de Empresas — Fase 1
Propósito
Seção intitulada “Propósito”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.
1. Estrutura do Módulo NestJS
Seção intitulada “1. Estrutura do Módulo NestJS”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.
1.1 Árvore de diretórios
Seção intitulada “1.1 Árvore de diretórios”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 evento1.2 Module definition
Seção intitulada “1.2 Module definition”@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(); }}1.3 Pontos de atenção
Seção intitulada “1.3 Pontos de atenção”- 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
EventBusModuleexplicitamente.EventBusModuleé@Global(), e oEventBusServiceé 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
OnModuleInitdispara as duas rotinas de inicialização. OAssociacaoTerritorialService.iniciar()faz o seed do cursor, o replay delugar.georreferenciadoperdido e o registro do handler. OEmpresaService.iniciar()faz a varredura de órfãos, republicando empresas semempresa.cadastradae endereços semlugar.recebido. - O módulo não importa
ThrottlerModule.forRoot()— isso já é feito pela D-1a. O rate limiting usa@Throttledo NestJS direto nas rotas. - A E-1 publica
lugar.recebidodiretamente 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.
1.4 Serviços — responsabilidades e contratos
Seção intitulada “1.4 Serviços — responsabilidades e contratos”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, ounull.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()eSetorService.existe(setorId): lista estática carregada do JSON.ValidacaoCnpjService.validar(cnpj): máscara e dígitos verificadores.
1.5 Controllers — endpoints expostos
Seção intitulada “1.5 Controllers — endpoints expostos”| 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.
1.6 Colônia com BFF acoplado
Seção intitulada “1.6 Colônia com BFF acoplado”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.
2. Banco de Dados — Schema e Entidades
Seção intitulada “2. Banco de Dados — Schema e Entidades”2.1 Schema e1
Seção intitulada “2.1 Schema e1”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.
2.2 Tabela e1.empresas
Seção intitulada “2.2 Tabela e1.empresas”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.
Colunas — detalhamento
Seção intitulada “Colunas — detalhamento”| 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. |
2.3 Tabela e1.empresa_enderecos
Seção intitulada “2.3 Tabela e1.empresa_enderecos”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);Colunas — detalhamento
Seção intitulada “Colunas — detalhamento”| 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.
2.4 Tabela e1.empresa_ucs
Seção intitulada “2.4 Tabela e1.empresa_ucs”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);Colunas — detalhamento
Seção intitulada “Colunas — detalhamento”| 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.
2.5 Tabela e1.consumer_offset
Seção intitulada “2.5 Tabela e1.consumer_offset”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.
2.6 Migrations
Seção intitulada “2.6 Migrations”20260813184759_create_e1_tables— Cria o schemae1, as tabelasempresas,empresa_enderecos,empresa_ucseconsumer_offset, com índices, uniques e as três FKs internas.20260813185850_widen_e1_setor_id— Ampliaempresas.setor_iddeVARCHAR(10)paraVARCHAR(20).
2.7 Relações internas
Seção intitulada “2.7 Relações internas”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.
2.8 Decisões de schema
Seção intitulada “2.8 Decisões de schema”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.
3. Eventos — Contratos Detalhados
Seção intitulada “3. Eventos — Contratos Detalhados”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.
3.1 Evento produzido: empresa.cadastrada
Seção intitulada “3.1 Evento produzido: empresa.cadastrada”| 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.
3.3 Evento produzido: lugar.recebido
Seção intitulada “3.3 Evento produzido: lugar.recebido”| 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.
3.4 Evento consumido: lugar.georreferenciado
Seção intitulada “3.4 Evento consumido: lugar.georreferenciado”| 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.
3.5 Ordem de operações — criar empresa
Seção intitulada “3.5 Ordem de operações — criar empresa”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 evento3.7 Idempotência na publicação de eventos
Seção intitulada “3.7 Idempotência na publicação de eventos”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.
3.8 Tratamento de erro e reentrega
Seção intitulada “3.8 Tratamento de erro e reentrega”| 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. |
3.9 Decisões de design com justificativa
Seção intitulada “3.9 Decisões de design com justificativa”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.
4. Lógica de Negócio — Algoritmos e Fluxos
Seção intitulada “4. Lógica de Negócio — Algoritmos e Fluxos”4.1 EmpresaService.criar() — pseudocódigo
Seção intitulada “4.1 EmpresaService.criar() — pseudocódigo”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.4.6 Casos de borda
Seção intitulada “4.6 Casos de borda”| 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. |
4.7 AuthGuard — validação de JWT do D-1a
Seção intitulada “4.7 AuthGuard — validação de JWT do D-1a”@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.
4.8 Rate limiting
Seção intitulada “4.8 Rate limiting”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 |
4.9 Decisões de design com justificativa
Seção intitulada “4.9 Decisões de design com justificativa”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”5.1 Publicação e consumo via EventBusService
Seção intitulada “5.1 Publicação e consumo via EventBusService”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.
5.3 Relação com a L-1 e L-2
Seção intitulada “5.3 Relação com a L-1 e L-2”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.
5.4 Relação com a E-2 e E-3
Seção intitulada “5.4 Relação com a E-2 e E-3”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).
5.5 Chamadas síncronas via BFF
Seção intitulada “5.5 Chamadas síncronas via BFF”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.
5.6 Dependências de projeções de leitura
Seção intitulada “5.6 Dependências de projeções de leitura”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.
5.7 Compartilhamento de JWT_SECRET com o D-1a
Seção intitulada “5.7 Compartilhamento de JWT_SECRET com o D-1a”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.
6. Performance e Limites
Seção intitulada “6. Performance e Limites”6.1 Rate limiting
Seção intitulada “6.1 Rate limiting”| 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.
6.2 Cotas e limites de tamanho
Seção intitulada “6.2 Cotas e limites de tamanho”| 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. |
6.3 Índices e padrões de query
Seção intitulada “6.3 Índices e padrões de query”| Í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 = ? |
6.4 Padrões de query esperados
Seção intitulada “6.4 Padrões de query esperados”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 eventolugar.georreferenciadoque chega ao barramento. É a query mais frequente em operação normal. O índiceempresa_enderecos_correlacao_id_lugar_idxcobre. 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 porrazao_socialeid.contarPorEmpresa()(UCs): 1 query no handler delugar.georreferenciado. Coberta porempresa_ucs_empresa_id_idx.
6.5 Estratégia de cache
Seção intitulada “6.5 Estratégia de cache”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(readFileSyncnoOnModuleInit, disponível como array em memória). - O volume de leitura é baixo e a complexidade de cache não se justifica no MVP.
6.6 Projeção de volume
Seção intitulada “6.6 Projeção de volume”| 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.
7. Testabilidade
Seção intitulada “7. Testabilidade”7.1 Como testar o módulo isolado
Seção intitulada “7.1 Como testar o módulo isolado”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();7.2 Cenários de teste críticos
Seção intitulada “7.2 Cenários de teste críticos”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. |
7.3 Dados de seed para desenvolvimento local
Seção intitulada “7.3 Dados de seed para desenvolvimento local”-- Consumer offset inicialINSERT INTO e1.consumer_offset (tipo_evento, last_sequence)VALUES ('lugar.georreferenciado', 0);
-- Empresa de exemplo — comércioINSERT 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 coordenadasINSERT 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 completoINSERT 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 georreferenciadoINSERT 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.
8. Alinhamento com o MVP
Seção intitulada “8. Alinhamento com o MVP”8.1 O que é MVP obrigatório
Seção intitulada “8.1 O que é MVP obrigatório”| 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 |
8.2 Simplificações válidas no MVP
Seção intitulada “8.2 Simplificações válidas no MVP”| 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. |
8.3 O que vai para a Fase 2
Seção intitulada “8.3 O que vai para a Fase 2”- Validação de CNPJ na base da Receita Federal com constraint UNIQUE
- Tabela
e1.setorescom 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.
Referências
Seção intitulada “Referências”- Especificação de origem: Apêndice B - Colônias.md, seção “Colônia E-1 — Cadastro Institucional”
- Colônias vizinhas no pipeline:
- E-2 - Transparência Salarial e Folha.md — consome
empresa.cadastrada - E-3 - Simulação Econômica.md — consome
empresa.cadastrada - L-1 - Cadastro de Lugares.md — consome
lugar.recebidoda E-1 - L-2 - Georreferenciamento e Tipificação.md — produz
lugar.georreferenciadoconsumido pela E-1
- E-2 - Transparência Salarial e Folha.md — consome
- Schemas de eventos: N-0b - Registry.md
- Barramento de eventos: N-0a - Event Bus.md
- Observabilidade: N-0c - Observabilidade.md
- BFF de referência (formato e padrão): D-1a - BFF.md
- Stack de referência e arquitetura do MVP: Apêndice B - Colônias.md, seção “Arquitetura do MVP — Monolito Modular”
- Mapa de dependências de eventos: Apêndice B - Colônias.md, seção “Mapa de Dependências de Eventos entre Colônias”
- Princípios do Formigueiro: Apêndice B - Colônias.md, seção “Princípios herdados do Formigueiro”
- Contexto de gestão: contexto_IA.md, seção 7 (Gestão)
- Contexto de infraestrutura: contexto_IA.md, seção 10 (Infraestrutura cívica digital)
- Contexto do Formigueiro: contexto_IA.md, seção 22 (Arquitetura técnica — O Formigueiro)
- Contexto de empresas: contexto_IA.md, seção 15 (Relação com empresas)
Documento de especificação técnica de implementação. Aprovado e integrado.